"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;