Files

667 lines
29 KiB
JavaScript

"use strict";
// Small WebGL2 viewer for the model and map tools: textured meshes (positions can be updated
// every frame for CPU skinning), per-mesh position/scale, flat-coloured meshes (collision),
// wireframe, line / point overlays (grid, bones, boxes, markers), map lighting and fog, an orbit
// camera and picking rays.
// The game data is Direct3D left-handed (Y up); the scene is drawn through a Z mirror so it looks
// the same as in the client. Culling is off (many game materials are two-sided).
const GL3D = (() => {
// ---------- column-major mat4 (GL convention) ----------
const M = {
mul(a, b) {
const o = new Float32Array(16);
for (let c = 0; c < 4; c++)
for (let r = 0; r < 4; r++) o[c * 4 + r] = a[r] * b[c * 4] + a[4 + r] * b[c * 4 + 1] + a[8 + r] * b[c * 4 + 2] + a[12 + r] * b[c * 4 + 3];
return o;
},
persp(fovy, aspect, near, far) {
const f = 1 / Math.tan(fovy / 2);
const o = new Float32Array(16);
o[0] = f / aspect;
o[5] = f;
o[10] = (far + near) / (near - far);
o[11] = -1;
o[14] = (2 * far * near) / (near - far);
return o;
},
lookAt(eye, at, up) {
const z = norm(sub(eye, at));
const x = norm(cross(up, z));
const y = cross(z, x);
return new Float32Array([x[0], y[0], z[0], 0, x[1], y[1], z[1], 0, x[2], y[2], z[2], 0, -dot(x, eye), -dot(y, eye), -dot(z, eye), 1]);
},
};
const sub = (a, b) => [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
const dot = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
const cross = (a, b) => [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]];
const norm = (a) => {
const l = Math.hypot(a[0], a[1], a[2]) || 1;
return [a[0] / l, a[1] / l, a[2] / l];
};
const MIRROR = new Float32Array([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, -1, 0, 0, 0, 0, 1]); // D3D LH → GL RH
const IDENT3 = new Float32Array([1, 0, 0, 0, 1, 0, 0, 0, 1]);
/** Mirror · translate · rotate · scale, for a mesh placed at pos (game coordinates). */
const placed = (pos, scale, rotation = [0, 0, 0]) => {
const [x, y, z] = rotation.map((v) => (v * Math.PI) / 180);
const cx = Math.cos(x), sx = Math.sin(x), cy = Math.cos(y), sy = Math.sin(y), cz = Math.cos(z), sz = Math.sin(z);
const rx = new Float32Array([1, 0, 0, 0, 0, cx, sx, 0, 0, -sx, cx, 0, 0, 0, 0, 1]);
const ry = new Float32Array([cy, 0, -sy, 0, 0, 1, 0, 0, sy, 0, cy, 0, 0, 0, 0, 1]);
const rz = new Float32Array([cz, sz, 0, 0, -sz, cz, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]);
const r = M.mul(ry, M.mul(rx, rz));
const s = new Float32Array([scale[0], 0, 0, 0, 0, scale[1], 0, 0, 0, 0, scale[2], 0, 0, 0, 0, 1]);
const t = new Float32Array([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, pos[0], pos[1], -pos[2], 1]);
return M.mul(t, M.mul(MIRROR, M.mul(r, s)));
};
const placedQuaternion = (pos, scale, rotation) => {
const length = Math.hypot(...rotation) || 1;
const [x, y, z, w] = rotation.map((v) => v / length);
const r = new Float32Array([
1 - 2 * (y * y + z * z), 2 * (x * y + z * w), 2 * (x * z - y * w), 0,
2 * (x * y - z * w), 1 - 2 * (x * x + z * z), 2 * (y * z + x * w), 0,
2 * (x * z + y * w), 2 * (y * z - x * w), 1 - 2 * (x * x + y * y), 0,
0, 0, 0, 1,
]);
const s = new Float32Array([scale[0], 0, 0, 0, 0, scale[1], 0, 0, 0, 0, scale[2], 0, 0, 0, 0, 1]);
const t = new Float32Array([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, pos[0], pos[1], -pos[2], 1]);
return M.mul(t, M.mul(MIRROR, M.mul(r, s)));
};
const MESH_VS = `#version 300 es
layout(location=0) in vec3 aPos; layout(location=1) in vec3 aNrm;
layout(location=2) in vec2 aUv0; layout(location=3) in vec2 aUv1;
uniform mat4 uVP; uniform mat4 uModel;
out vec2 vUv0; out vec2 vUv1; out vec3 vN; out vec3 vW;
void main(){ vec4 w = uModel * vec4(aPos,1.0); vW = w.xyz; vN = mat3(uModel) * aNrm; vUv0 = aUv0; vUv1 = aUv1; gl_Position = uVP * w; }`;
const MESH_FS = `#version 300 es
precision highp float;
in vec2 vUv0; in vec2 vUv1; in vec3 vN; in vec3 vW;
uniform sampler2D uTex; uniform sampler2D uLm; uniform int uHasTex; uniform int uHasLm; uniform int uUvSet; uniform int uLmSet;
uniform int uLit; uniform vec3 uEye; uniform vec4 uTint; uniform float uAlphaCut; uniform int uHasNrm; uniform int uOpaque;
uniform int uFlat; uniform vec4 uColor; uniform mat3 uTexM; uniform mat3 uLmM; uniform float uColorMul; uniform float uEmit;
uniform int uLmAddDiffuse; uniform vec3 uDiffuse;
uniform int uEnv; uniform vec3 uAmbient; uniform vec3 uLightCol; uniform vec3 uLightDir;
uniform int uFog; uniform vec3 uFogCol; uniform vec2 uFogRange;
out vec4 o;
vec3 fogged(vec3 c){
if (uFog==0) return c;
float f = clamp((distance(vW, uEye) - uFogRange.x) / max(1.0, uFogRange.y - uFogRange.x), 0.0, 1.0);
return mix(c, uFogCol, f);
}
void main(){
vec3 n = uHasNrm==1 ? normalize(vN) : normalize(cross(dFdx(vW), dFdy(vW)));
if (uFlat==1) {
float d = abs(dot(n, normalize(vec3(0.3, 1.0, 0.2))));
o = vec4(uColor.rgb * (0.55 + 0.45 * d), uColor.a);
return;
}
vec2 tuv = (vec3(uUvSet==1 ? vUv1 : vUv0, 1.0) * uTexM).xy;
vec4 c = uHasTex==1 ? texture(uTex, tuv) : vec4(0.72,0.70,0.76,1.0);
if (c.a < uAlphaCut) discard;
float a = uOpaque==1 ? 1.0 : c.a;
vec3 emit = c.rgb * uEmit;
c.rgb *= uColorMul;
if (uHasLm==1) {
vec3 lm = texture(uLm, (vec3(uLmSet==1 ? vUv1 : vUv0, 1.0) * uLmM).xy).rgb;
c.rgb *= uLmAddDiffuse==1 ? min(vec3(1.0), lm + uDiffuse) : lm;
o = vec4(fogged(c.rgb + emit), a) * uTint; return;
}
vec3 light;
if (uEnv==1) {
float d = max(dot(n, -uLightDir), 0.0);
light = uAmbient + uLightCol * (0.35 + 0.65 * d);
} else {
vec3 l = normalize(uEye - vW + vec3(0.0, 60.0, 0.0));
light = vec3(uLit==1 ? 0.45 + 0.65 * abs(dot(n, l)) : 1.0);
}
o = vec4(fogged(c.rgb * light + emit), a) * uTint;
}`;
const LINE_VS = `#version 300 es
layout(location=0) in vec3 aPos; layout(location=1) in vec4 aCol;
uniform mat4 uVP; uniform mat4 uModel; uniform float uPoint;
out vec4 vCol;
void main(){ vCol = aCol; gl_Position = uVP * uModel * vec4(aPos,1.0); gl_PointSize = uPoint; }`;
const LINE_FS = `#version 300 es
precision mediump float; in vec4 vCol; uniform int uRound; out vec4 o;
void main(){
if (uRound==1) { float r = length(gl_PointCoord-vec2(0.5)); if (r>0.5) discard; o = vec4(r > 0.36 ? vCol.rgb*0.35 : vCol.rgb, vCol.a); return; }
o = vCol;
}`;
const PART_VS = `#version 300 es
layout(location=0) in vec3 aPos; layout(location=1) in vec2 aUv; layout(location=2) in vec4 aCol;
uniform mat4 uVP; uniform mat4 uModel;
out vec2 vUv; out vec4 vCol; out vec3 vW;
void main(){ vec4 w = uModel * vec4(aPos,1.0); vW = w.xyz; vUv = aUv; vCol = aCol; gl_Position = uVP * w; }`;
const PART_FS = `#version 300 es
precision mediump float;
in vec2 vUv; in vec4 vCol; in vec3 vW;
uniform sampler2D uTex; uniform int uHasTex; uniform float uMod;
out vec4 o;
void main(){
vec4 c = (uHasTex==1 ? texture(uTex, vUv) : vec4(1.0)) * vec4(vCol.rgb * uMod, vCol.a);
if (c.a <= 1.0/255.0) discard;
o = c;
}`;
function program(gl, vs, fs) {
const p = gl.createProgram();
for (const [type, src] of [[gl.VERTEX_SHADER, vs], [gl.FRAGMENT_SHADER, fs]]) {
const s = gl.createShader(type);
gl.shaderSource(s, src);
gl.compileShader(s);
if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) throw new Error(gl.getShaderInfoLog(s));
gl.attachShader(p, s);
}
gl.linkProgram(p);
if (!gl.getProgramParameter(p, gl.LINK_STATUS)) throw new Error(gl.getProgramInfoLog(p));
const u = {};
const n = gl.getProgramParameter(p, gl.ACTIVE_UNIFORMS);
for (let i = 0; i < n; i++) {
const name = gl.getActiveUniform(p, i).name;
u[name] = gl.getUniformLocation(p, name);
}
return { p, u };
}
class View {
constructor(canvas, options = {}) {
this.canvas = canvas;
const gl = (this.gl = canvas.getContext("webgl2", { antialias: true, preserveDrawingBuffer: true, alpha: options.alpha !== false, premultipliedAlpha: !!options.premultipliedAlpha }));
if (!gl) throw new Error("WebGL2 isn't available");
this.meshProg = program(gl, MESH_VS, MESH_FS);
this.lineProg = program(gl, LINE_VS, LINE_FS);
this.partProg = program(gl, PART_VS, PART_FS);
this.particles = []; // [{ tex, blend, depth, mod2x, data, count }]
this.partVao = gl.createVertexArray();
this.partBuf = gl.createBuffer();
gl.bindVertexArray(this.partVao);
gl.bindBuffer(gl.ARRAY_BUFFER, this.partBuf);
gl.enableVertexAttribArray(0);
gl.vertexAttribPointer(0, 3, gl.FLOAT, false, 36, 0);
gl.enableVertexAttribArray(1);
gl.vertexAttribPointer(1, 2, gl.FLOAT, false, 36, 12);
gl.enableVertexAttribArray(2);
gl.vertexAttribPointer(2, 4, gl.FLOAT, false, 36, 20);
gl.bindVertexArray(null);
this.meshes = new Map(); // id → gpu mesh
this.overlays = new Map(); // name → { vao, count, mode, depth, point }
this.opts = { textures: true, wire: false, lit: true, solid: true };
/** Map lighting: { ambient:[r,g,b], lightColor:[r,g,b], lightDir:[x,y,z] (game coords), fog:{color,start,end}|null, clear:[r,g,b]|null } */
this.env = null;
this.cam = { yaw: 0.6, pitch: 0.35, dist: 300, target: [0, 80, 0], fov: 0.8 };
this.white = this.texture(new ImageData(new Uint8ClampedArray([255, 255, 255, 255]), 1, 1));
this.onPointerDown = null; // (e) → true when the tool takes the drag (no orbit)
this.beforeDraw = null;
this.onCamera = null;
this.dirty = true;
this.controls();
new ResizeObserver(() => this.resize()).observe(canvas.parentElement);
const loop = () => {
if (this.dirty && this.canvas.offsetParent) this.draw();
requestAnimationFrame(loop);
};
requestAnimationFrame(loop);
}
resize() {
const r = this.canvas.parentElement.getBoundingClientRect();
const dpr = devicePixelRatio || 1;
this.canvas.width = Math.max(1, Math.round(r.width * dpr));
this.canvas.height = Math.max(1, Math.round(r.height * dpr));
this.canvas.style.width = `${r.width}px`;
this.canvas.style.height = `${r.height}px`;
this.dirty = true;
}
// ---------- resources ----------
texture(img) {
const gl = this.gl;
const t = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, t);
gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, false);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, img);
const pow2 = (n) => (n & (n - 1)) === 0;
if (pow2(img.width) && pow2(img.height)) {
gl.generateMipmap(gl.TEXTURE_2D);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR_MIPMAP_LINEAR);
} else gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.REPEAT);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.REPEAT);
return t;
}
/**
* mesh: { pos, normal?, uv0?, uv1?, indices, groups: [{ start, count, tex?, lmTex?, uvSet, lmSet, alphaCut, blend, color? }] }
* opts: { pos, scale } places it (map objects); { wireColor } draws its wireframe always.
*/
addMesh(id, m, opts = {}) {
const gl = this.gl;
this.removeMesh(id);
const vao = gl.createVertexArray();
gl.bindVertexArray(vao);
const bufs = [];
const buf = (loc, data, size, usage = gl.STATIC_DRAW) => {
const b = gl.createBuffer();
bufs.push(b);
gl.bindBuffer(gl.ARRAY_BUFFER, b);
gl.bufferData(gl.ARRAY_BUFFER, data, usage);
gl.enableVertexAttribArray(loc);
gl.vertexAttribPointer(loc, size, gl.FLOAT, false, 0, 0);
return b;
};
const V = m.pos.length / 3;
const g = { vao, bufs, groups: m.groups, visible: true, hasNormal: !!m.normal, model: MIRROR, wireColor: opts.wireColor || null };
if (opts.pos || opts.scale || opts.rotation) g.model = placed(opts.pos || [0, 0, 0], opts.scale || [1, 1, 1], opts.rotation || [0, 0, 0]);
g.pos = buf(0, m.pos, 3, gl.DYNAMIC_DRAW);
g.nrm = buf(1, m.normal || new Float32Array(V * 3), 3, gl.DYNAMIC_DRAW);
buf(2, m.uv0 || new Float32Array(V * 2), 2);
buf(3, m.uv1 || m.uv0 || new Float32Array(V * 2), 2);
const idx = m.indices instanceof Uint32Array ? m.indices : new Uint32Array(m.indices);
g.ibo = gl.createBuffer();
bufs.push(g.ibo);
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, g.ibo);
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, idx, gl.STATIC_DRAW);
gl.bindVertexArray(null);
// wireframe: every triangle edge once
const seen = new Set();
const edges = [];
for (let i = 0; i + 2 < idx.length; i += 3) {
for (const [a, b] of [[idx[i], idx[i + 1]], [idx[i + 1], idx[i + 2]], [idx[i + 2], idx[i]]]) {
const k = a < b ? a * 4194304 + b : b * 4194304 + a;
if (!seen.has(k)) {
seen.add(k);
edges.push(a, b);
}
}
}
g.wvao = gl.createVertexArray();
gl.bindVertexArray(g.wvao);
gl.bindBuffer(gl.ARRAY_BUFFER, g.pos);
gl.enableVertexAttribArray(0);
gl.vertexAttribPointer(0, 3, gl.FLOAT, false, 0, 0);
gl.disableVertexAttribArray(1);
g.wibo = gl.createBuffer();
bufs.push(g.wibo);
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, g.wibo);
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, new Uint32Array(edges), gl.STATIC_DRAW);
g.wcount = edges.length;
gl.bindVertexArray(null);
this.meshes.set(id, g);
this.dirty = true;
}
setTransform(id, pos, scale = [1, 1, 1], rotation = [0, 0, 0]) {
const g = this.meshes.get(id);
if (g) g.model = placed(pos, scale, rotation);
this.dirty = true;
}
setTransformQuaternion(id, pos, scale, rotation) {
const g = this.meshes.get(id);
if (g) g.model = placedQuaternion(pos, scale, rotation);
this.dirty = true;
}
updateMesh(id, pos, normal) {
const g = this.meshes.get(id);
if (!g) return;
const gl = this.gl;
gl.bindBuffer(gl.ARRAY_BUFFER, g.pos);
gl.bufferSubData(gl.ARRAY_BUFFER, 0, pos);
if (normal) {
gl.bindBuffer(gl.ARRAY_BUFFER, g.nrm);
gl.bufferSubData(gl.ARRAY_BUFFER, 0, normal);
}
this.dirty = true;
}
setGroupTexture(id, gi, tex, slot = "tex") {
const g = this.meshes.get(id);
if (g?.groups[gi]) g.groups[gi][slot] = tex;
this.dirty = true;
}
setVisible(id, v) {
const g = this.meshes.get(id);
if (g) g.visible = v;
this.dirty = true;
}
setTint(id, tint) {
const g = this.meshes.get(id);
if (g) g.tint = tint;
this.dirty = true;
}
setHighlight(id, on) {
const g = this.meshes.get(id);
if (g) g.highlight = on;
this.dirty = true;
}
removeMesh(id) {
const g = this.meshes.get(id);
if (!g) return;
const gl = this.gl;
gl.deleteVertexArray(g.vao);
gl.deleteVertexArray(g.wvao);
for (const b of g.bufs) gl.deleteBuffer(b);
this.meshes.delete(id);
this.dirty = true;
}
clear() {
for (const id of [...this.meshes.keys()]) this.removeMesh(id);
for (const n of [...this.overlays.keys()]) this.setOverlay(n, null);
}
/** Line / point overlay: data = Float32Array of x,y,z,r,g,b,a per vertex. */
setOverlay(name, data, { mode = "lines", depth = true, point = 6 } = {}) {
const gl = this.gl;
const old = this.overlays.get(name);
const visible = old ? old.visible : true;
if (old) {
gl.deleteVertexArray(old.vao);
gl.deleteBuffer(old.buf);
this.overlays.delete(name);
}
this.dirty = true;
if (!data || !data.length) return;
const vao = gl.createVertexArray();
gl.bindVertexArray(vao);
const buf = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, buf);
gl.bufferData(gl.ARRAY_BUFFER, data, gl.DYNAMIC_DRAW);
gl.enableVertexAttribArray(0);
gl.vertexAttribPointer(0, 3, gl.FLOAT, false, 28, 0);
gl.enableVertexAttribArray(1);
gl.vertexAttribPointer(1, 4, gl.FLOAT, false, 28, 12);
gl.bindVertexArray(null);
this.overlays.set(name, { vao, buf, count: data.length / 7, mode, depth, point, visible });
}
showOverlay(name, v) {
const o = this.overlays.get(name);
if (o) o.visible = v;
this.dirty = true;
}
// ---------- camera ----------
frame(min, max) {
const c = [(min[0] + max[0]) / 2, (min[1] + max[1]) / 2, (min[2] + max[2]) / 2];
const r = Math.max(1, Math.hypot(max[0] - min[0], max[1] - min[1], max[2] - min[2]) / 2);
this.cam.target = [c[0], c[1], -c[2]];
this.cam.dist = (r / Math.sin(this.cam.fov / 2)) * 1.05;
this.cam.radius = r;
this.home = { ...this.cam, target: this.cam.target.slice() };
this.moved();
}
reset() {
if (this.home) this.cam = { ...this.home, target: this.home.target.slice() };
this.moved();
}
/** Straight down (for placing things on the floor plan). */
topView() {
this.cam.pitch = 1.5;
this.cam.yaw = 0;
this.moved();
}
moved() {
this.dirty = true;
this.onCamera?.();
}
eye() {
const c = this.cam;
return [c.target[0] + c.dist * Math.cos(c.pitch) * Math.sin(c.yaw), c.target[1] + c.dist * Math.sin(c.pitch), c.target[2] + c.dist * Math.cos(c.pitch) * Math.cos(c.yaw)];
}
viewProj() {
const aspect = this.canvas.width / Math.max(1, this.canvas.height);
const r = this.cam.radius || 100;
const near = Math.max(0.05, this.cam.dist - r * 20) * 0.02 + 0.05;
const far = this.cam.dist + r * 40 + 1000;
return M.mul(M.persp(this.cam.fov, aspect, near, far), M.lookAt(this.eye(), this.cam.target, [0, 1, 0]));
}
/** Camera right / up / forward in game coordinates (for billboards). */
cameraAxes() {
const eye = this.eye();
const f = norm(sub(this.cam.target, eye));
const r = norm(cross(f, [0, 1, 0]));
const u = cross(r, f);
const g = (v) => [v[0], v[1], -v[2]];
return { right: g(r), up: g(u), dir: g(f) };
}
/** Particle quads to draw this frame (effects). */
setParticles(list) {
this.particles = list || [];
this.dirty = true;
}
/** Picking ray through a screen point (client px), in game coordinates. → { o, d } */
ray(clientX, clientY) {
const b = this.canvas.getBoundingClientRect();
const nx = ((clientX - b.left) / b.width) * 2 - 1;
const ny = 1 - ((clientY - b.top) / b.height) * 2;
const eye = this.eye();
const f = norm(sub(this.cam.target, eye));
const r = norm(cross(f, [0, 1, 0]));
const u = cross(r, f);
const t = Math.tan(this.cam.fov / 2);
const aspect = b.width / Math.max(1, b.height);
const d = norm([f[0] + nx * t * aspect * r[0] + ny * t * u[0], f[1] + nx * t * aspect * r[1] + ny * t * u[1], f[2] + nx * t * aspect * r[2] + ny * t * u[2]]);
return { o: [eye[0], eye[1], -eye[2]], d: [d[0], d[1], -d[2]] };
}
controls() {
const c = this.canvas;
let drag = null;
c.addEventListener("mousedown", (e) => {
e.preventDefault();
if (this.onPointerDown?.(e)) return;
drag = { x: e.clientX, y: e.clientY, pan: e.button !== 0 || e.shiftKey };
});
c.addEventListener("contextmenu", (e) => e.preventDefault());
window.addEventListener("mouseup", () => (drag = null));
window.addEventListener("mousemove", (e) => {
if (!drag) return;
const dx = e.clientX - drag.x;
const dy = e.clientY - drag.y;
drag.x = e.clientX;
drag.y = e.clientY;
if (drag.pan) {
const s = (this.cam.dist * Math.tan(this.cam.fov / 2) * 2) / c.clientHeight;
const yaw = this.cam.yaw;
const right = [Math.cos(yaw), 0, -Math.sin(yaw)];
const up = [-Math.sin(this.cam.pitch) * Math.sin(yaw), Math.cos(this.cam.pitch), -Math.sin(this.cam.pitch) * Math.cos(yaw)];
for (let i = 0; i < 3; i++) this.cam.target[i] += -dx * s * right[i] + dy * s * up[i];
} else {
this.cam.yaw -= dx * 0.008;
this.cam.pitch = Math.max(-1.55, Math.min(1.55, this.cam.pitch + dy * 0.008));
}
this.moved();
});
c.addEventListener(
"wheel",
(e) => {
e.preventDefault();
this.cam.dist = Math.max(0.5, this.cam.dist * (e.deltaY < 0 ? 0.88 : 1 / 0.88));
this.moved();
},
{ passive: false },
);
c.addEventListener("dblclick", (e) => !this.onPointerDown && this.reset());
}
// ---------- drawing ----------
draw() {
const gl = this.gl;
this.dirty = false;
this.beforeDraw?.();
gl.viewport(0, 0, this.canvas.width, this.canvas.height);
const env = this.env;
if (env?.clear) gl.clearColor(env.clear[0], env.clear[1], env.clear[2], 1);
else gl.clearColor(0, 0, 0, 0);
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
gl.enable(gl.DEPTH_TEST);
gl.disable(gl.CULL_FACE);
const vp = this.viewProj();
const eye = this.eye();
// meshes: opaque first, then blended
const mp = this.meshProg;
gl.useProgram(mp.p);
gl.uniformMatrix4fv(mp.u.uVP, false, vp);
gl.uniform3fv(mp.u.uEye, eye);
gl.uniform1i(mp.u.uLit, this.opts.lit ? 1 : 0);
gl.uniform1i(mp.u.uTex, 0);
gl.uniform1i(mp.u.uLm, 1);
gl.uniform1i(mp.u.uEnv, env?.ambient ? 1 : 0);
if (env?.ambient) {
gl.uniform3fv(mp.u.uAmbient, env.ambient);
gl.uniform3fv(mp.u.uLightCol, env.lightColor || [0.6, 0.6, 0.6]);
const d = norm(env.lightDir || [0, -1, 0]);
gl.uniform3fv(mp.u.uLightDir, [d[0], d[1], -d[2]]);
}
gl.uniform1i(mp.u.uFog, env?.fog ? 1 : 0);
if (env?.fog) {
gl.uniform3fv(mp.u.uFogCol, env.fog.color);
gl.uniform2fv(mp.u.uFogRange, [env.fog.start, env.fog.end]);
}
if (this.opts.solid) {
for (const pass of [0, 1]) {
if (pass) {
// blended sub-meshes (decals, glows, shadows): after the opaque ones, no depth writes,
// pulled slightly forward so coplanar layers don't flicker
gl.enable(gl.BLEND);
gl.depthMask(false);
gl.depthFunc(gl.LEQUAL);
gl.enable(gl.POLYGON_OFFSET_FILL);
gl.polygonOffset(-1, -2);
}
for (const g of this.meshes.values()) {
if (!g.visible) continue;
gl.bindVertexArray(g.vao);
gl.uniformMatrix4fv(mp.u.uModel, false, g.model);
gl.uniform1i(mp.u.uHasNrm, g.hasNormal ? 1 : 0);
for (const grp of g.groups) {
const blended = grp.blend || (grp.color && grp.color[3] < 1);
if (!!blended !== !!pass || !grp.count) continue;
if (pass) this.blendMode(grp.blendFunc);
if (grp.color) {
gl.uniform1i(mp.u.uFlat, 1);
gl.uniform4fv(mp.u.uColor, grp.color);
gl.drawElements(gl.TRIANGLES, grp.count, gl.UNSIGNED_INT, grp.start * 4);
continue;
}
gl.uniform1i(mp.u.uFlat, 0);
const tex = this.opts.textures && grp.tex;
const lm = this.opts.textures && grp.lmTex;
gl.activeTexture(gl.TEXTURE1);
gl.bindTexture(gl.TEXTURE_2D, lm || this.white);
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, tex || this.white);
gl.uniform1i(mp.u.uHasTex, tex ? 1 : 0);
gl.uniform1i(mp.u.uHasLm, lm ? 1 : 0);
gl.uniform1i(mp.u.uUvSet, grp.uvSet || 0);
gl.uniform1i(mp.u.uLmSet, grp.lmSet || 0);
gl.uniform1i(mp.u.uLmAddDiffuse, grp.lmAddDiffuse ? 1 : 0);
gl.uniform3fv(mp.u.uDiffuse, grp.diffuseColor || [1, 1, 1]);
gl.uniform1i(mp.u.uOpaque, pass ? 0 : 1);
gl.uniformMatrix3fv(mp.u.uTexM, false, grp.texM || IDENT3);
gl.uniformMatrix3fv(mp.u.uLmM, false, grp.lmM || IDENT3);
gl.uniform1f(mp.u.uColorMul, grp.colorMul ?? 1);
gl.uniform1f(mp.u.uEmit, grp.emissive || 0);
gl.uniform1f(mp.u.uAlphaCut, grp.blend ? grp.alphaCut ?? 0 : tex ? grp.alphaCut ?? 0.35 : 0);
gl.uniform4fv(mp.u.uTint, grp.highlight || g.highlight ? [1.3, 1.15, 0.7, 1] : g.tint || [1, 1, 1, 1]);
gl.drawElements(gl.TRIANGLES, grp.count, gl.UNSIGNED_INT, grp.start * 4);
}
}
gl.disable(gl.BLEND);
gl.depthMask(true);
gl.depthFunc(gl.LESS);
gl.disable(gl.POLYGON_OFFSET_FILL);
gl.blendEquation(gl.FUNC_ADD);
}
}
// particles: after the meshes, blended, no depth writes
if (this.particles.length) {
const pp = this.partProg;
gl.useProgram(pp.p);
gl.uniformMatrix4fv(pp.u.uVP, false, vp);
gl.uniformMatrix4fv(pp.u.uModel, false, MIRROR);
gl.uniform1i(pp.u.uTex, 0);
gl.bindVertexArray(this.partVao);
gl.bindBuffer(gl.ARRAY_BUFFER, this.partBuf);
gl.enable(gl.BLEND);
gl.depthMask(false);
gl.activeTexture(gl.TEXTURE0);
for (const b of this.particles) {
if (!b.count) continue;
if (b.depth) gl.enable(gl.DEPTH_TEST);
else gl.disable(gl.DEPTH_TEST);
this.blendMode(b.blend);
gl.bindTexture(gl.TEXTURE_2D, b.tex || this.white);
gl.uniform1i(pp.u.uHasTex, b.tex ? 1 : 0);
gl.uniform1f(pp.u.uMod, b.mod2x ? 2 : 1);
gl.bufferData(gl.ARRAY_BUFFER, b.data, gl.STREAM_DRAW);
gl.drawArrays(gl.TRIANGLES, 0, b.count);
}
gl.depthMask(true);
gl.enable(gl.DEPTH_TEST);
gl.disable(gl.BLEND);
gl.blendEquation(gl.FUNC_ADD);
gl.bindVertexArray(null);
}
// wireframe + overlays
const lp = this.lineProg;
gl.useProgram(lp.p);
gl.uniformMatrix4fv(lp.u.uVP, false, vp);
gl.enable(gl.BLEND);
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
gl.uniform1i(lp.u.uRound, 0);
for (const g of this.meshes.values()) {
if (!g.visible || (!this.opts.wire && !g.wireColor)) continue;
const w = g.wireColor || [0.55, 0.85, 1, this.opts.solid ? 0.45 : 0.8];
gl.vertexAttrib4f(1, w[0], w[1], w[2], w[3]); // constant colour (attribute 1 is off in the wire VAO)
gl.uniformMatrix4fv(lp.u.uModel, false, g.model);
gl.bindVertexArray(g.wvao);
gl.drawElements(gl.LINES, g.wcount, gl.UNSIGNED_INT, 0);
}
gl.uniformMatrix4fv(lp.u.uModel, false, MIRROR);
for (const o of this.overlays.values()) {
if (!o.visible) continue;
if (o.depth) gl.enable(gl.DEPTH_TEST);
else gl.disable(gl.DEPTH_TEST);
gl.uniform1f(lp.u.uPoint, o.point * (devicePixelRatio || 1));
gl.uniform1i(lp.u.uRound, o.mode === "points" ? 1 : 0);
gl.bindVertexArray(o.vao);
gl.drawArrays(o.mode === "points" ? gl.POINTS : gl.LINES, 0, o.count);
}
gl.disable(gl.BLEND);
gl.enable(gl.DEPTH_TEST);
gl.bindVertexArray(null);
}
/** Material scene_blend { op, src, dst } → GL state. Default: alpha blending. */
blendMode(b) {
const gl = this.gl;
const F = { zero: gl.ZERO, one: gl.ONE, src_color: gl.SRC_COLOR, inv_src_color: gl.ONE_MINUS_SRC_COLOR, src_alpha: gl.SRC_ALPHA, inv_src_alpha: gl.ONE_MINUS_SRC_ALPHA, dest_alpha: gl.DST_ALPHA, inv_dest_alpha: gl.ONE_MINUS_DST_ALPHA, dest_color: gl.DST_COLOR, inv_dest_color: gl.ONE_MINUS_DST_COLOR };
const O = { add: gl.FUNC_ADD, subtract: gl.FUNC_SUBTRACT, rev_subtract: gl.FUNC_REVERSE_SUBTRACT, min: gl.MIN, max: gl.MAX };
gl.blendEquationSeparate(O[b?.op] ?? gl.FUNC_ADD, gl.FUNC_ADD);
// Transparent views need accumulated alpha coverage. Effect preview uses
// an opaque clear target and keeps the native RGB blend factors above.
gl.blendFuncSeparate(F[b?.src] ?? gl.SRC_ALPHA, F[b?.dst] ?? gl.ONE_MINUS_SRC_ALPHA, gl.ONE, gl.ONE_MINUS_SRC_ALPHA);
}
/** Screen position (CSS px) of a game-space point, or null behind the camera. */
project(p) {
const vp = this.viewProj();
const x = p[0], y = p[1], z = -p[2];
const cx = vp[0] * x + vp[4] * y + vp[8] * z + vp[12];
const cy = vp[1] * x + vp[5] * y + vp[9] * z + vp[13];
const cw = vp[3] * x + vp[7] * y + vp[11] * z + vp[15];
if (cw <= 0) return null;
return [((cx / cw + 1) / 2) * this.canvas.clientWidth, ((1 - cy / cw) / 2) * this.canvas.clientHeight];
}
}
return { View };
})();
window.GL3D = GL3D;