299 lines
10 KiB
JavaScript
299 lines
10 KiB
JavaScript
// 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 });
|
|
})();
|