import type { Camera } from "../camera.ts"; import { mat4 } from "gl-matrix"; import type { RenderLayer } from "$/common/everything_registry.ts"; import { TERRAIN_VERTEX_FLOATS } from "../workers/chunk_messages.ts"; export let device: GPUDevice; export let canvas: HTMLCanvasElement; const MAX_SPRITES = 100000; const VERTS_PER_SPRITE = 6; const FLOATS_PER_VERT = 9; const VERTEX_STRIDE = FLOATS_PER_VERT * 4; const SAMPLE_COUNT = 4; const DEPTH_FORMAT: GPUTextureFormat = "depth24plus"; // dynamic uniform offsets have to be 256 aligned const UNIFORM_SLOT_SIZE = 256; let context: GPUCanvasContext; let canvas_format: GPUTextureFormat; let pipeline_2d: GPURenderPipeline; let pipeline_3d: GPURenderPipeline; let terrain_pipelines: Record; // 0 1 2 0 2 3 for every quad, shared by all solid and cutout chunk meshes let quad_index_buffer: GPUBuffer | undefined; let quad_index_capacity = 0; let uniform_layout: GPUBindGroupLayout; let texture_layout: GPUBindGroupLayout; let sampler: GPUSampler; // minecraft's lightmap: the color for every block light (x) and sky light (y) pair, see update_lightmap let lightmap: GPUTexture; let lightmap_bind_group: GPUBindGroup; const vertex_data = new Float32Array(MAX_SPRITES * VERTS_PER_SPRITE * FLOATS_PER_VERT); let vert_index = 0; // every flush in a frame appends to this, so earlier draws dont get overwritten before the submit let stream_buffer: GPUBuffer; let stream_offset = 0; let uniform_buffer: GPUBuffer; let uniform_bind_group: GPUBindGroup; let uniform_slot = -1; const texture_bind_groups = new WeakMap(); let current_texture: GPUTexture | null = null; export let white_tex: GPUTexture | null = null; let mode3d = false; let camera: Camera | undefined; const ortho = mat4.create(); const proj = mat4.create(); const view = mat4.create(); const mvp = mat4.create(); // per frame state let encoder: GPUCommandEncoder | undefined; let pass: GPURenderPassEncoder | undefined; let frame_view: GPUTextureView; let msaa_texture: GPUTexture | undefined; let depth_texture: GPUTexture | undefined; let current_pipeline: GPURenderPipeline | undefined; let clear_color: GPUColor = { r: 0, g: 0, b: 0, a: 1 }; let pending_color_clear = false; let pending_depth_clear = false; let scissor: { x: number; y: number; width: number; height: number } | undefined; let pending_destroy: GPUBuffer[] = []; const shader_src = /* wgsl */ ` struct Uniforms { mvp: mat4x4, } @group(0) @binding(0) var uniforms: Uniforms; @group(1) @binding(0) var texture0: texture_2d; @group(1) @binding(1) var sampler0: sampler; struct VertexOut { @builtin(position) position: vec4, // centroid: with msaa, pixels on a triangle's edge would otherwise sample outside it, past the // sprite's edge in the atlas, which shows up as dark lines between blocks from far away @location(0) @interpolate(perspective, centroid) tex_coord: vec2, @location(1) @interpolate(perspective, centroid) color: vec4, } @vertex fn vs_main( @location(0) position: vec3, @location(1) tex_coord: vec2, @location(2) color: vec4, ) -> VertexOut { var out: VertexOut; out.position = uniforms.mvp * vec4(position, 1.0); out.tex_coord = tex_coord; out.color = color; return out; } // blended draws. fully clear texels don't write depth, or they would hide what's drawn behind them later @fragment fn fs_main(in: VertexOut) -> @location(0) vec4 { let color = textureSample(texture0, sampler0, in.tex_coord) * in.color; if (color.a < 0.01) { discard; } return color; } `; const terrain_shader_src = /* wgsl */ ` struct Uniforms { mvp: mat4x4, } @group(0) @binding(0) var uniforms: Uniforms; @group(1) @binding(0) var texture0: texture_2d; @group(1) @binding(1) var sampler0: sampler; @group(2) @binding(0) var lightmap: texture_2d; @group(2) @binding(1) var lightmap_sampler: sampler; struct VertexOut { @builtin(position) position: vec4, @location(0) @interpolate(perspective, centroid) tex_coord: vec2, // directional shade times ambient occlusion, and alpha @location(1) @interpolate(perspective, centroid) color: vec4, // block light, sky light, as lightmap coordinates @location(2) @interpolate(perspective, centroid) light: vec2, } @vertex fn vs_terrain( @location(0) position: vec3, @location(1) tex_coord: vec2, @location(2) color: vec4, @location(3) light: vec2, ) -> VertexOut { var out: VertexOut; out.position = uniforms.mvp * vec4(position, 1.0); out.tex_coord = tex_coord; out.color = color; out.light = light; return out; } fn lit(in: VertexOut) -> vec4 { let texel = textureSample(texture0, sampler0, in.tex_coord); let light = textureSample(lightmap, lightmap_sampler, in.light).rgb; return vec4(texel.rgb * in.color.rgb * light, texel.a * in.color.a); } // no discard, so the gpu can reject hidden fragments before running the shader @fragment fn fs_solid(in: VertexOut) -> @location(0) vec4 { return vec4(lit(in).rgb, 1.0); } // alpha tested instead of blended, so it doesn't need sorting @fragment fn fs_cutout(in: VertexOut) -> @location(0) vec4 { let color = lit(in); if (color.a < 0.1) { discard; } return vec4(color.rgb, 1.0); } @fragment fn fs_translucent(in: VertexOut) -> @location(0) vec4 { let color = lit(in); if (color.a < 0.01) { discard; } return color; } `; export async function init_window(canvas_element: HTMLCanvasElement) { canvas = canvas_element; canvas.width = 1800; canvas.height = 900; if (!navigator.gpu) { throw new Error("WebGPU not supported"); } const adapter = await navigator.gpu.requestAdapter(); if (!adapter) { throw new Error("No WebGPU adapter found"); } device = await adapter.requestDevice(); device.addEventListener("uncapturederror", (event) => { console.error("WebGPU error:", (event as GPUUncapturedErrorEvent).error.message); }); device.lost.then((info) => console.error(`WebGPU device lost: ${info.message}`)); // the dom typings dont know about the webgpu overload yet const ctx = canvas.getContext("webgpu") as GPUCanvasContext | null; if (!ctx) { throw new Error("WebGPU not supported"); } context = ctx; canvas_format = navigator.gpu.getPreferredCanvasFormat(); context.configure({ device, format: canvas_format, alphaMode: "opaque" }); create_pipelines(); sampler = device.createSampler({ magFilter: "nearest", minFilter: "nearest", addressModeU: "clamp-to-edge", addressModeV: "clamp-to-edge", }); stream_buffer = create_stream_buffer(8 * 1024 * 1024); create_uniform_buffer(64); create_white_texture(); create_lightmap(); } // how bright the sky is, 1 at noon. only the lightmap changes, the chunk meshes stay the same export function update_lightmap(daylight = 1) { // minecraft's LightTexture, with its default brightness setting and no flicker const gamma = 0.5; const brightness = (level: number) => { const f = level / 15; return f / (4 - 3 * f); }; const lerp = (from: number, to: number, t: number) => from + (to - from) * t; const clamp = (value: number) => Math.min(1, Math.max(0, value)); const not_gamma = (value: number) => 1 - (1 - value) ** 4; const sky_factor = daylight * 0.95 + 0.05; // sky light turns blue as it gets dark const sky_color = [lerp(daylight, 1, 0.35), lerp(daylight, 1, 0.35), 1]; const block_boost = 1.5; const pixels = new Uint8Array(16 * 16 * 4); for (let sky = 0; sky < 16; sky++) { for (let block = 0; block < 16; block++) { const s = brightness(sky) * sky_factor; // block light is warm, it loses blue and green faster as it dims const b = brightness(block) * block_boost; const color = [ b + sky_color[0] * s, b * ((b * 0.6 + 0.4) * 0.6 + 0.4) + sky_color[1] * s, b * (b * b * 0.6 + 0.4) + sky_color[2] * s, ].map((c) => clamp(lerp(c, 0.75, 0.04))) .map((c) => clamp(lerp(lerp(c, not_gamma(c), gamma), 0.75, 0.04))); const i = (sky * 16 + block) * 4; pixels[i] = Math.round(color[0] * 255); pixels[i + 1] = Math.round(color[1] * 255); pixels[i + 2] = Math.round(color[2] * 255); pixels[i + 3] = 255; } } device.queue.writeTexture({ texture: lightmap }, pixels, { bytesPerRow: 16 * 4 }, [16, 16]); } export function begin_drawing() { encoder = device.createCommandEncoder(); frame_view = context.getCurrentTexture().createView(); ensure_render_targets(); update_2d_mvp(); stream_offset = 0; uniform_slot = -1; write_mvp(); pending_color_clear = true; pending_depth_clear = true; vert_index = 0; } export function end_drawing() { flush_batch(); // make sure the frame gets cleared even if nothing was drawn ensure_pass(); pass!.end(); pass = undefined; device.queue.submit([encoder!.finish()]); encoder = undefined; for (const buffer of pending_destroy) { buffer.destroy(); } pending_destroy = []; } function update_2d_mvp() { const w = canvas.width; const h = canvas.height; const l = 0; const r = w; const t = 0; const b = h; const n = -1; const f = 1; mat4.orthoZO(ortho, l, r, b, t, n, f); } export function begin_clip(x: number, y: number, width: number, height: number) { flush_batch(); scissor = { x, y, width, height }; if (pass) { apply_scissor(pass); } } export function end_clip() { flush_batch(); scissor = undefined; if (pass) { apply_scissor(pass); } } export function begin_mode_3d(new_camera: Camera) { flush_batch(); mode3d = true; camera = new_camera; update_camera(); write_mvp(); } export function end_mode_3d() { if (!mode3d) { return; } flush_batch(); mode3d = false; write_mvp(); } export function clear_background(r: number, g: number, b: number, a = 1) { flush_batch(); clear_color = { r, g, b, a }; // the clear happens when a render pass starts, so start a new one if (pass) { pass.end(); pass = undefined; } pending_color_clear = true; } export function flush_batch() { if (vert_index === 0 || !current_texture) { return; } const byte_length = vert_index * 4; if (stream_offset + byte_length > stream_buffer.size) { // the old buffer might still be used by draws in this frame, destroy it after the submit pending_destroy.push(stream_buffer); stream_buffer = create_stream_buffer(Math.max(stream_buffer.size * 2, byte_length)); stream_offset = 0; } device.queue.writeBuffer(stream_buffer, stream_offset, vertex_data, 0, vert_index); draw(stream_buffer, stream_offset, vert_index / FLOATS_PER_VERT); stream_offset += byte_length; vert_index = 0; } // draws a chunk mesh made of quads (4 vertices each). without an index buffer the quads are drawn in order export function draw_terrain( layer: RenderLayer, vertex_buffer: GPUBuffer, quad_count: number, index_buffer?: GPUBuffer, ) { if (!current_texture || quad_count === 0) { return; } if (!index_buffer) { ensure_quad_indices(quad_count); index_buffer = quad_index_buffer!; } const render_pass = ensure_pass(); const pipeline = terrain_pipelines[layer]; if (current_pipeline !== pipeline) { render_pass.setPipeline(pipeline); current_pipeline = pipeline; } render_pass.setBindGroup(0, uniform_bind_group, [uniform_slot * UNIFORM_SLOT_SIZE]); render_pass.setBindGroup(1, get_texture_bind_group(current_texture)); render_pass.setBindGroup(2, lightmap_bind_group); render_pass.setVertexBuffer(0, vertex_buffer); render_pass.setIndexBuffer(index_buffer, "uint32"); render_pass.drawIndexed(quad_count * 6); } export function create_vertex_buffer(vertices: Float32Array): GPUBuffer { const buffer = device.createBuffer({ size: Math.max(4, vertices.byteLength), usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST, }); device.queue.writeBuffer(buffer, 0, vertices); return buffer; } export function create_index_buffer(indices: Uint32Array): GPUBuffer { const buffer = device.createBuffer({ size: Math.max(4, indices.byteLength), usage: GPUBufferUsage.INDEX | GPUBufferUsage.COPY_DST, }); device.queue.writeBuffer(buffer, 0, indices); return buffer; } export function destroy_buffer(buffer: GPUBuffer) { // it might be used by a draw thats not submitted yet if (encoder) { pending_destroy.push(buffer); } else { buffer.destroy(); } } export function resize_canvas() { const width = self.innerWidth; const height = self.innerHeight; if (canvas.width !== width || canvas.height !== height) { canvas.width = width; canvas.height = height; canvas.style.width = canvas.width + "px"; canvas.style.height = canvas.height + "px"; // render targets get recreated at the start of the next frame } } export function get_current_texture(): GPUTexture | null { return current_texture; } export function set_current_texture(texture: GPUTexture) { current_texture = texture; } export function create_texture(width: number, height: number): GPUTexture { return device.createTexture({ size: [width, height], format: "rgba8unorm", usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST | GPUTextureUsage.RENDER_ATTACHMENT, }); } export function push_vertex(px: number, py: number, pz: number, u: number, vv: number, r = 1, g = 1, b = 1, a = 1) { let i = vert_index; vertex_data[i++] = px; vertex_data[i++] = py; vertex_data[i++] = pz; vertex_data[i++] = u; vertex_data[i++] = vv; vertex_data[i++] = r; vertex_data[i++] = g; vertex_data[i++] = b; vertex_data[i++] = a; vert_index = i; } export function push_quad( dx: number, dy: number, dw: number, dh: number, u0: number, v0: number, u1: number, v1: number, r = 1, g = 1, b = 1, a = 1, ) { const x2 = dx + dw; const y2 = dy + dh; // triangle 1 push_vertex(dx, dy, 0, u0, v0, r, g, b, a); push_vertex(x2, dy, 0, u1, v0, r, g, b, a); push_vertex(dx, y2, 0, u0, v1, r, g, b, a); // triangle 2 push_vertex(x2, dy, 0, u1, v0, r, g, b, a); push_vertex(x2, y2, 0, u1, v1, r, g, b, a); push_vertex(dx, y2, 0, u0, v1, r, g, b, a); } export function push_quad_vertices( x0: number, y0: number, x1: number, y1: number, x2: number, y2: number, x3: number, y3: number, u0: number, v0: number, u1: number, v1: number, r = 1, g = 1, b = 1, a = 1, ) { // triangle 1 push_vertex(x0, y0, 0, u0, v0, r, g, b, a); push_vertex(x1, y1, 0, u1, v0, r, g, b, a); push_vertex(x2, y2, 0, u1, v1, r, g, b, a); // triangle 2 push_vertex(x0, y0, 0, u0, v0, r, g, b, a); push_vertex(x2, y2, 0, u1, v1, r, g, b, a); push_vertex(x3, y3, 0, u0, v1, r, g, b, a); } // internal function ensure_quad_indices(quad_count: number) { if (quad_count <= quad_index_capacity) { return; } if (quad_index_buffer) { destroy_buffer(quad_index_buffer); } quad_index_capacity = Math.max(quad_count, quad_index_capacity * 2, 16384); const indices = new Uint32Array(quad_index_capacity * 6); for (let q = 0; q < quad_index_capacity; q++) { const v = q * 4; const i = q * 6; indices[i] = v; indices[i + 1] = v + 1; indices[i + 2] = v + 2; indices[i + 3] = v; indices[i + 4] = v + 2; indices[i + 5] = v + 3; } quad_index_buffer = create_index_buffer(indices); } function draw(buffer: GPUBuffer, offset: number, vertex_count: number) { if (!current_texture || vertex_count === 0) { return; } const render_pass = ensure_pass(); const pipeline = mode3d ? pipeline_3d : pipeline_2d; if (current_pipeline !== pipeline) { render_pass.setPipeline(pipeline); current_pipeline = pipeline; } render_pass.setBindGroup(0, uniform_bind_group, [uniform_slot * UNIFORM_SLOT_SIZE]); render_pass.setBindGroup(1, get_texture_bind_group(current_texture)); render_pass.setVertexBuffer(0, buffer, offset); render_pass.draw(vertex_count); } function ensure_pass(): GPURenderPassEncoder { if (pass) { return pass; } pass = encoder!.beginRenderPass({ colorAttachments: [{ view: msaa_texture!.createView(), resolveTarget: frame_view, clearValue: clear_color, loadOp: pending_color_clear ? "clear" : "load", storeOp: "store", }], depthStencilAttachment: { view: depth_texture!.createView(), depthClearValue: 1, depthLoadOp: pending_depth_clear ? "clear" : "load", depthStoreOp: "store", }, }); pending_color_clear = false; pending_depth_clear = false; current_pipeline = undefined; apply_scissor(pass); return pass; } function apply_scissor(render_pass: GPURenderPassEncoder) { const target_width = msaa_texture!.width; const target_height = msaa_texture!.height; if (!scissor) { render_pass.setScissorRect(0, 0, target_width, target_height); return; } // webgpu errors on rects outside the target instead of clipping them const x0 = Math.min(target_width, Math.max(0, Math.floor(scissor.x))); const y0 = Math.min(target_height, Math.max(0, Math.floor(scissor.y))); const x1 = Math.min(target_width, Math.max(x0, Math.ceil(scissor.x + scissor.width))); const y1 = Math.min(target_height, Math.max(y0, Math.ceil(scissor.y + scissor.height))); render_pass.setScissorRect(x0, y0, x1 - x0, y1 - y0); } function ensure_render_targets() { const width = canvas.width; const height = canvas.height; if (msaa_texture && msaa_texture.width === width && msaa_texture.height === height) { return; } msaa_texture?.destroy(); depth_texture?.destroy(); msaa_texture = device.createTexture({ size: [width, height], format: canvas_format, sampleCount: SAMPLE_COUNT, usage: GPUTextureUsage.RENDER_ATTACHMENT, }); depth_texture = device.createTexture({ size: [width, height], format: DEPTH_FORMAT, sampleCount: SAMPLE_COUNT, usage: GPUTextureUsage.RENDER_ATTACHMENT, }); } // puts the current matrix in a new uniform slot, earlier draws in the frame keep using theirs function write_mvp() { uniform_slot += 1; if (uniform_slot * UNIFORM_SLOT_SIZE >= uniform_buffer.size) { pending_destroy.push(uniform_buffer); create_uniform_buffer((uniform_buffer.size / UNIFORM_SLOT_SIZE) * 2); uniform_slot = 0; } const matrix = mode3d ? mvp : ortho; device.queue.writeBuffer(uniform_buffer, uniform_slot * UNIFORM_SLOT_SIZE, matrix as Float32Array); } function update_camera() { if (!camera) { return; } mat4.perspectiveZO( proj, camera.fov, canvas.width / canvas.height, camera.near, camera.far, ); mat4.identity(view); mat4.rotateZ(view, view, -camera.roll); mat4.rotateX(view, view, -camera.pitch); mat4.rotateY(view, view, -camera.yaw); mat4.translate(view, view, [-camera.x, -camera.y, -camera.z]); mat4.multiply(mvp, proj, view); } function create_pipelines() { const module = device.createShaderModule({ code: shader_src }); uniform_layout = device.createBindGroupLayout({ entries: [{ binding: 0, visibility: GPUShaderStage.VERTEX, buffer: { type: "uniform", hasDynamicOffset: true, minBindingSize: 64 }, }], }); texture_layout = device.createBindGroupLayout({ entries: [ { binding: 0, visibility: GPUShaderStage.FRAGMENT, texture: {} }, { binding: 1, visibility: GPUShaderStage.FRAGMENT, sampler: {} }, ], }); const layout = device.createPipelineLayout({ bindGroupLayouts: [uniform_layout, texture_layout] }); const vertex: GPUVertexState = { module, entryPoint: "vs_main", buffers: [{ arrayStride: VERTEX_STRIDE, attributes: [ { shaderLocation: 0, offset: 0, format: "float32x3" }, { shaderLocation: 1, offset: 12, format: "float32x2" }, { shaderLocation: 2, offset: 20, format: "float32x4" }, ], }], }; const fragment: GPUFragmentState = { module, entryPoint: "fs_main", targets: [{ format: canvas_format, blend: { color: { srcFactor: "src-alpha", dstFactor: "one-minus-src-alpha", operation: "add" }, alpha: { srcFactor: "src-alpha", dstFactor: "one-minus-src-alpha", operation: "add" }, }, }], }; const multisample: GPUMultisampleState = { count: SAMPLE_COUNT }; pipeline_2d = device.createRenderPipeline({ layout, vertex, fragment, multisample, primitive: { topology: "triangle-list", cullMode: "none" }, depthStencil: { format: DEPTH_FORMAT, depthWriteEnabled: false, depthCompare: "always" }, }); const primitive_3d: GPUPrimitiveState = { topology: "triangle-list", cullMode: "back", frontFace: "ccw" }; const depth_3d: GPUDepthStencilState = { format: DEPTH_FORMAT, depthWriteEnabled: true, depthCompare: "less-equal", // same as the old polygonOffset(1, 1) depthBias: 1, depthBiasSlopeScale: 1, }; pipeline_3d = device.createRenderPipeline({ layout, vertex, fragment, multisample, primitive: primitive_3d, depthStencil: depth_3d, }); const terrain_module = device.createShaderModule({ code: terrain_shader_src }); const terrain_layout = device.createPipelineLayout({ bindGroupLayouts: [uniform_layout, texture_layout, texture_layout], }); const terrain_vertex: GPUVertexState = { module: terrain_module, entryPoint: "vs_terrain", buffers: [{ arrayStride: TERRAIN_VERTEX_FLOATS * 4, attributes: [ { shaderLocation: 0, offset: 0, format: "float32x3" }, { shaderLocation: 1, offset: 12, format: "float32x2" }, { shaderLocation: 2, offset: 20, format: "float32x4" }, { shaderLocation: 3, offset: 36, format: "float32x2" }, ], }], }; // like sodium and vanilla: solid and cutout don't blend, translucent blends and still writes depth // since it's drawn sorted back to front const terrain_pipeline = (entryPoint: string, blend?: GPUBlendState) => device.createRenderPipeline({ layout: terrain_layout, vertex: terrain_vertex, fragment: { module: terrain_module, entryPoint, targets: [{ format: canvas_format, blend }] }, multisample, primitive: primitive_3d, depthStencil: depth_3d, }); terrain_pipelines = { solid: terrain_pipeline("fs_solid"), cutout: terrain_pipeline("fs_cutout"), translucent: terrain_pipeline("fs_translucent", { color: { srcFactor: "src-alpha", dstFactor: "one-minus-src-alpha", operation: "add" }, alpha: { srcFactor: "src-alpha", dstFactor: "one-minus-src-alpha", operation: "add" }, }), }; } function create_stream_buffer(size: number) { return device.createBuffer({ size, usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST }); } function create_uniform_buffer(slots: number) { uniform_buffer = device.createBuffer({ size: slots * UNIFORM_SLOT_SIZE, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST, }); uniform_bind_group = device.createBindGroup({ layout: uniform_layout, entries: [{ binding: 0, resource: { buffer: uniform_buffer, size: 64 } }], }); } function get_texture_bind_group(texture: GPUTexture) { let bind_group = texture_bind_groups.get(texture); if (!bind_group) { bind_group = device.createBindGroup({ layout: texture_layout, entries: [ { binding: 0, resource: texture.createView() }, { binding: 1, resource: sampler }, ], }); texture_bind_groups.set(texture, bind_group); } return bind_group; } function create_lightmap() { lightmap = create_texture(16, 16); // linear, so light fades smoothly between levels across a face like in minecraft const lightmap_sampler = device.createSampler({ magFilter: "linear", minFilter: "linear", addressModeU: "clamp-to-edge", addressModeV: "clamp-to-edge", }); lightmap_bind_group = device.createBindGroup({ layout: texture_layout, entries: [ { binding: 0, resource: lightmap.createView() }, { binding: 1, resource: lightmap_sampler }, ], }); update_lightmap(); } function create_white_texture() { const tex = create_texture(1, 1); device.queue.writeTexture({ texture: tex }, new Uint8Array([255, 255, 255, 255]), { bytesPerRow: 4 }, [1, 1]); white_tex = tex; }