// Copyright (c) 2026 Dominic Masters // // This software is released under the MIT License. // https://opensource.org/licenses/MIT "use strict"; // MapRenderer - a minimal, dependency-free WebGL renderer for the chunk // preview pane. The camera formula is ported 1:1 from // src/dusk/scene/overworld/sceneoverworld.c (lines 62-81) so the preview // approximates the game's own fixed-angle 3rd-person camera: the eye sits // worldH units "behind" the target along Y and a proportional distance // "above" along Z, always looking at the target, up = (0, 1, 0). // // The material model mirrors SHADER_UNLIT: a texture sampler multiplied by // a flat RGBA tint, with a 1x1 white fallback texture standing in for // untextured models. const MapRenderer = (() => { const VERT_SRC = ` attribute vec2 aUV; attribute vec3 aPos; uniform mat4 uModel; uniform mat4 uView; uniform mat4 uProjection; varying vec2 vUV; void main() { vUV = aUV; gl_Position = uProjection * uView * uModel * vec4(aPos, 1.0); } `; const FRAG_SRC = ` precision mediump float; varying vec2 vUV; uniform sampler2D uTexture; uniform vec4 uColor; void main() { gl_FragColor = texture2D(uTexture, vUV) * uColor; } `; function mat4Identity(out) { out.fill(0); out[0] = out[5] = out[10] = out[15] = 1; return out; } function mat4Translation(out, v) { mat4Identity(out); out[12] = v[0]; out[13] = v[1]; out[14] = v[2]; return out; } function mat4Perspective(out, fovy, aspect, near, far) { const f = 1.0 / Math.tan(fovy / 2); out.fill(0); out[0] = f / aspect; out[5] = f; out[10] = (far + near) / (near - far); out[11] = -1; out[14] = (2 * far * near) / (near - far); return out; } function vec3Sub(a, b) { return [a[0] - b[0], a[1] - b[1], a[2] - b[2]]; } function vec3Cross(a, b) { return [ a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0], ]; } function vec3Normalize(a) { const len = Math.hypot(a[0], a[1], a[2]) || 1; return [a[0] / len, a[1] / len, a[2] / len]; } function mat4LookAt(out, eye, center, up) { const zAxis = vec3Normalize(vec3Sub(eye, center)); const xAxis = vec3Normalize(vec3Cross(up, zAxis)); const yAxis = vec3Cross(zAxis, xAxis); out[0] = xAxis[0]; out[1] = yAxis[0]; out[2] = zAxis[0]; out[3] = 0; out[4] = xAxis[1]; out[5] = yAxis[1]; out[6] = zAxis[1]; out[7] = 0; out[8] = xAxis[2]; out[9] = yAxis[2]; out[10] = zAxis[2]; out[11] = 0; out[12] = -(xAxis[0] * eye[0] + xAxis[1] * eye[1] + xAxis[2] * eye[2]); out[13] = -(yAxis[0] * eye[0] + yAxis[1] * eye[1] + yAxis[2] * eye[2]); out[14] = -(zAxis[0] * eye[0] + zAxis[1] * eye[1] + zAxis[2] * eye[2]); out[15] = 1; return out; } // Ported from sceneoverworld.c:62-81. Returns { eye, view }. function computeCamera(target, worldH) { const fov = Math.PI / 4; // glm_rad(45.0f) const zDist = (worldH * 0.5) / Math.tan(fov * 0.5); const offset = -worldH; const eye = [target[0], target[1] + offset, target[2] + zDist]; const view = mat4LookAt(new Float32Array(16), eye, target, [0, 1, 0]); return { eye, view, fov }; } function compileShader(gl, type, src) { const shader = gl.createShader(type); gl.shaderSource(shader, src); gl.compileShader(shader); if(!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) { const info = gl.getShaderInfoLog(shader); gl.deleteShader(shader); throw new Error(`Shader compile failed: ${info}`); } return shader; } function create(canvas) { const gl = canvas.getContext("webgl") || canvas.getContext("experimental-webgl"); if(!gl) throw new Error("WebGL is not available in this browser"); const program = gl.createProgram(); gl.attachShader(program, compileShader(gl, gl.VERTEX_SHADER, VERT_SRC)); gl.attachShader(program, compileShader(gl, gl.FRAGMENT_SHADER, FRAG_SRC)); gl.linkProgram(program); if(!gl.getProgramParameter(program, gl.LINK_STATUS)) { throw new Error(`Program link failed: ${gl.getProgramInfoLog(program)}`); } const attribs = { aUV: gl.getAttribLocation(program, "aUV"), aPos: gl.getAttribLocation(program, "aPos"), }; const uniforms = { uModel: gl.getUniformLocation(program, "uModel"), uView: gl.getUniformLocation(program, "uView"), uProjection: gl.getUniformLocation(program, "uProjection"), uTexture: gl.getUniformLocation(program, "uTexture"), uColor: gl.getUniformLocation(program, "uColor"), }; const whiteTexture = gl.createTexture(); gl.bindTexture(gl.TEXTURE_2D, whiteTexture); gl.texImage2D( gl.TEXTURE_2D, 0, gl.RGBA, 1, 1, 0, gl.RGBA, gl.UNSIGNED_BYTE, new Uint8Array([255, 255, 255, 255]), ); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST); gl.enable(gl.DEPTH_TEST); gl.enable(gl.BLEND); gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA); // Terrain/mesh winding isn't guaranteed consistent relative to this // preview's fixed camera angle, so backface culling stays off - this // is an authoring preview, not a performance-sensitive renderer, and // an invisible mesh is a worse failure mode than a visible backface. // Uploads interleaved [u, v, x, y, z] float data (DMF/terrain layout) // into a GL buffer. Returns { buffer, vertexCount }. function createMesh(floats) { const buffer = gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER, buffer); gl.bufferData(gl.ARRAY_BUFFER, floats, gl.STATIC_DRAW); return { buffer, vertexCount: floats.length / 5 }; } function createTextureFromImage(image) { const texture = gl.createTexture(); gl.bindTexture(gl.TEXTURE_2D, texture); gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true); gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, image); gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, false); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); return texture; } function drawMesh(mesh, model, texture, color, depthWrite, mode) { gl.depthMask(depthWrite !== false); gl.bindBuffer(gl.ARRAY_BUFFER, mesh.buffer); gl.enableVertexAttribArray(attribs.aUV); gl.vertexAttribPointer(attribs.aUV, 2, gl.FLOAT, false, 20, 0); gl.enableVertexAttribArray(attribs.aPos); gl.vertexAttribPointer(attribs.aPos, 3, gl.FLOAT, false, 20, 8); gl.uniformMatrix4fv(uniforms.uModel, false, model); gl.uniform4f(uniforms.uColor, color[0], color[1], color[2], color[3]); gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D, texture || whiteTexture); gl.uniform1i(uniforms.uTexture, 0); gl.drawArrays(mode || gl.TRIANGLES, 0, mesh.vertexCount); } // Builds a 4-vertex outline of the tile at (x, y, z), lifted slightly // to avoid z-fighting with the terrain surface beneath it. function buildHighlightMesh(x, y, z) { const eps = 0.02; return createMesh(new Float32Array([ 0, 0, x, y, z + eps, 0, 0, x + 1, y, z + eps, 0, 0, x + 1, y + 1, z + eps, 0, 0, x, y + 1, z + eps, ])); } // Neighboring chunks are drawn dimmer and semi-transparent, so they // read as context rather than part of the chunk being edited. const NEIGHBOR_ALPHA = 0.35; const NEIGHBOR_DARKEN = 0.5; // scene = { // target: [x, y, z], worldH: number, // terrain: { mesh, texture } | null, // gridLines: { mesh } | null, // models: [{ mesh, texture, color: [r,g,b,a in 0..1], offset: [x,y,z] }], // neighbors: [{ // offset: [x,y,z], // terrain: { mesh, texture } | null, // models: [{ mesh, texture, color, offset }], // }], // highlight: { x, y, z } | null, // } function render(scene) { const width = canvas.width, height = canvas.height; gl.viewport(0, 0, width, height); gl.clearColor(0.16, 0.18, 0.22, 1); gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT); gl.useProgram(program); gl.depthMask(true); const { view, fov } = computeCamera(scene.target, scene.worldH); const projection = mat4Perspective(new Float32Array(16), fov, width / height, 0.1, 1000); gl.uniformMatrix4fv(uniforms.uProjection, false, projection); gl.uniformMatrix4fv(uniforms.uView, false, view); const identity = mat4Identity(new Float32Array(16)); if(scene.terrain) { drawMesh(scene.terrain.mesh, identity, scene.terrain.texture, [1, 1, 1, 1], true); } if(scene.gridLines) { drawMesh(scene.gridLines.mesh, identity, null, [0, 0, 0, 0.35], false, gl.LINES); } for(const model of scene.models) { const modelMatrix = mat4Translation(new Float32Array(16), model.offset); drawMesh(model.mesh, modelMatrix, model.texture, model.color, true); } for(const neighbor of scene.neighbors || []) { if(neighbor.terrain) { const modelMatrix = mat4Translation(new Float32Array(16), neighbor.offset); drawMesh( neighbor.terrain.mesh, modelMatrix, neighbor.terrain.texture, [NEIGHBOR_DARKEN, NEIGHBOR_DARKEN, NEIGHBOR_DARKEN, NEIGHBOR_ALPHA], false, ); } for(const model of neighbor.models) { const worldOffset = [ neighbor.offset[0] + model.offset[0], neighbor.offset[1] + model.offset[1], neighbor.offset[2] + model.offset[2], ]; const modelMatrix = mat4Translation(new Float32Array(16), worldOffset); const color = [ model.color[0] * NEIGHBOR_DARKEN, model.color[1] * NEIGHBOR_DARKEN, model.color[2] * NEIGHBOR_DARKEN, model.color[3] * NEIGHBOR_ALPHA, ]; drawMesh(model.mesh, modelMatrix, model.texture, color, false); } } if(scene.highlight) { const { x, y, z } = scene.highlight; const highlightMesh = buildHighlightMesh(x, y, z); drawMesh(highlightMesh, identity, null, [1, 0.92, 0.23, 1], false, gl.LINE_LOOP); } } return { gl, createMesh, createTextureFromImage, render }; } return Object.freeze({ create, computeCamera }); })();