667 lines
29 KiB
JavaScript
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;
|