Files
CydandClaude Fable 5 326d29b72a Extract all recoverable Division scenes; add Scene Archive gallery
Cracks the DIV-BIZ2 binary format (BGF/BMF) using Division's own
reader source (DPL3/BIZREAD.C) as reference: block stream, 14 vertex
layouts, pmesh/strip connectivity, LOD nesting, embedded materials.
Adds SVT raw-texture decoding, 1995-dialect VGF support (implicit
CONNECTION_LIST, header SCALE), and a whole-drive scene audit.

108 of 241 scenes on the Glaze drive are recoverable; 26 are curated
into restoration/vwe-archive.html, a self-contained WebGL gallery:
Star Trek, Hull Pressure, BattleTech (incl. the polar-map mech
lineup), Red Planet's Mars-canal raceway, and the Canyon demos.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 18:03:12 -05:00

649 lines
28 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<title>VWE Scene Archive — 19941997</title>
<style>
:root {
--ink: #A8D8E4;
--amber: #D9A441;
--dim: #647486;
--panel: rgba(10, 17, 26, 0.85);
--line: #1B2836;
--mono: ui-monospace, Consolas, "Cascadia Mono", Menlo, monospace;
}
html, body { height: 100%; }
body {
margin: 0; background: #04060A; color: var(--ink);
font-family: var(--mono); font-size: 13px; line-height: 1.5;
overflow: hidden;
}
#gl { position: fixed; inset: 0; width: 100%; height: 100%; display: block; touch-action: none; cursor: grab; }
#gl.dragging { cursor: grabbing; }
.hud { position: fixed; z-index: 2; }
.panel {
background: var(--panel); border: 1px solid var(--line);
backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px);
}
#catalog {
top: 16px; left: 16px; bottom: 64px; width: 250px;
display: flex; flex-direction: column;
}
#catalog header { padding: 12px 14px 10px; border-bottom: 1px solid var(--line); }
#catalog .eyebrow {
color: var(--amber); font-size: 10px; letter-spacing: 0.2em;
text-transform: uppercase; margin: 0 0 3px;
}
#catalog h1 { font-size: 16px; font-weight: 500; letter-spacing: 0.04em; margin: 0; color: #DCEEF4; }
#catalog .sub { color: var(--dim); font-size: 10px; margin: 2px 0 0; }
#list { overflow-y: auto; flex: 1; padding: 6px 0; }
#list .proj {
color: var(--amber); font-size: 10px; letter-spacing: 0.18em;
text-transform: uppercase; padding: 10px 14px 4px;
}
#list button {
display: block; width: 100%; text-align: left; font: inherit; font-size: 12px;
color: var(--dim); background: none; border: 0; border-left: 2px solid transparent;
padding: 4px 12px; cursor: pointer;
}
#list button:hover { color: var(--ink); }
#list button.active { color: #DCEEF4; border-left-color: var(--amber); background: rgba(217,164,65,0.06); }
#list button:focus-visible, .ctl:focus-visible, #toggle:focus-visible { outline: 2px solid var(--amber); outline-offset: 1px; }
#scenehdr { top: 16px; left: 282px; padding: 10px 14px 8px; max-width: min(420px, calc(100vw - 300px)); }
#scenehdr h2 { font-size: 15px; font-weight: 500; margin: 0; color: #DCEEF4; }
#scenehdr .path { color: var(--amber); font-size: 10px; letter-spacing: 0.08em; }
#scenehdr .blurb { color: var(--dim); font-size: 11px; margin: 2px 0 0; }
#keys { bottom: 16px; left: 282px; padding: 7px 12px; color: var(--dim); font-size: 11px; }
#keys b { color: var(--ink); font-weight: 500; }
#foot { bottom: 16px; right: 16px; display: flex; gap: 6px; }
.ctl {
font: inherit; font-size: 11px; letter-spacing: 0.08em;
color: var(--ink); background: var(--panel); border: 1px solid var(--line);
padding: 7px 12px; cursor: pointer;
}
.ctl:hover { border-color: var(--dim); }
#info {
position: fixed; z-index: 3; top: 16px; right: 16px; bottom: 64px;
width: min(350px, calc(100vw - 32px));
padding: 16px 18px; overflow-y: auto; display: none;
}
#info.open { display: block; }
#info h2 { font-size: 11px; letter-spacing: 0.2em; text-transform: uppercase; color: var(--amber); margin: 14px 0 6px; font-weight: 500; }
#info h2:first-child { margin-top: 0; }
#info p { margin: 0 0 8px; color: #8FB4BE; font-size: 12px; }
#info p b { color: var(--ink); font-weight: 500; }
#toggle { display: none; }
#loading {
position: fixed; inset: 0; z-index: 5; display: flex; align-items: center; justify-content: center;
color: var(--dim); font-size: 12px; letter-spacing: 0.2em; background: #04060A;
}
@media (max-width: 760px) {
#catalog { display: none; }
#catalog.open { display: flex; width: min(250px, 80vw); z-index: 4; }
#scenehdr { left: 16px; top: 58px; }
#keys { display: none; }
#toggle {
display: block; position: fixed; top: 16px; left: 16px; z-index: 5;
font: inherit; font-size: 11px; color: var(--amber); background: var(--panel);
border: 1px solid var(--line); padding: 8px 12px; cursor: pointer;
}
}
</style>
<canvas id="gl"></canvas>
<div id="loading">RESTORING SCENES&hellip;</div>
<button id="toggle" aria-label="Toggle scene list">SCENES</button>
<nav id="catalog" class="hud panel" aria-label="Scene catalog">
<header>
<p class="eyebrow">Virtual World Entertainment</p>
<h1>Scene Archive</h1>
<p class="sub">Division dVS data, 1994&ndash;1997, re-rendered in WebGL</p>
</header>
<div id="list"></div>
</nav>
<div id="scenehdr" class="hud panel">
<p class="path" id="hdr-path"></p>
<h2 id="hdr-name"></h2>
<p class="blurb" id="hdr-blurb"></p>
</div>
<div id="keys" class="hud panel">
<b>drag</b> look &nbsp; <b>W A S D</b> fly &nbsp; <b>Q E</b> rise/sink &nbsp; <b>shift</b> fast &nbsp; <b>R</b> reset
</div>
<div id="foot" class="hud">
<button id="btn-pause" class="ctl" aria-pressed="false">PAUSE</button>
<button id="btn-info" class="ctl" aria-expanded="false">ABOUT</button>
</div>
<aside id="info" class="hud panel" aria-label="About this archive">
<h2>What this is</h2>
<p>Scenes recovered from a Virtual World Entertainment developer&rsquo;s hard
drive (&ldquo;Glaze&rdquo;, in use 1994&ndash;1999) and from the Tesla
Release 4.10 content tree. They were authored for <b>Division Ltd.&rsquo;s
dVS</b> pixel-pipeline hardware and are re-rendered here in WebGL from the
original data files.</p>
<h2>Projects</h2>
<p><b>Star Trek</b> &mdash; unreleased pod prototype, spring 1996.</p>
<p><b>Hull Pressure</b> &mdash; unreleased submarine game, summer 1995.</p>
<p><b>BattleTech / Red Planet</b> &mdash; development scenes for the two
shipped Tesla games: arena environments, map assembly tests, and the
Red Planet Mars-canal raceway milestone demo.</p>
<p><b>Canyon</b> &mdash; river-canyon and Maya-temple demo environments.</p>
<h2>Formats</h2>
<p>Geometry from text <b>VGF</b> and binary <b>BGF</b> (DIV-BIZ2) files
&mdash; the binary layout was recovered from Division&rsquo;s own reader
source (BIZREAD.C, 1994) found on the same drive. Materials from VMF/BMF,
textures from TGA and SVT, motion from SPL splines, scene scripts from SCN.</p>
<h2>Fidelity</h2>
<p>Faithful: vertices, triangles, material colors, textures, fog, lights,
spline motion, camera start positions. Approximated: shading ramps and
rasterizer behavior, point sizes, timing (original ran at 30&nbsp;Hz).
Event-driven particle effects are not simulated. Scenes missing a model
or two from the drive say so in their header.</p>
</aside>
<script type="application/json" id="data">%%DATA%%</script>
<script>
"use strict";
const DATA = JSON.parse(document.getElementById("data").textContent);
const canvas = document.getElementById("gl");
const gl = canvas.getContext("webgl", { antialias: true });
if (!gl) document.getElementById("loading").textContent = "WEBGL UNAVAILABLE — CANNOT RENDER";
// ---------------------------------------------------------------- matrices
function mat4mul(a, b) {
const o = new Float32Array(16);
for (let c = 0; c < 4; c++) for (let r = 0; r < 4; r++) {
let s = 0;
for (let k = 0; k < 4; k++) s += a[k * 4 + r] * b[c * 4 + k];
o[c * 4 + r] = s;
}
return o;
}
function mat4identity() { const m = new Float32Array(16); m[0] = m[5] = m[10] = m[15] = 1; return m; }
function mat4translate(x, y, z) { const m = mat4identity(); m[12] = x; m[13] = y; m[14] = z; return m; }
function mat4scale(s) { const m = mat4identity(); m[0] = m[5] = m[10] = s; return m; }
function mat4rotY(a) { const m = mat4identity(), c = Math.cos(a), s = Math.sin(a); m[0] = c; m[8] = s; m[2] = -s; m[10] = c; return m; }
function mat4rotX(a) { const m = mat4identity(), c = Math.cos(a), s = Math.sin(a); m[5] = c; m[9] = -s; m[6] = s; m[10] = c; return m; }
function mat4rotZ(a) { const m = mat4identity(), c = Math.cos(a), s = Math.sin(a); m[0] = c; m[4] = -s; m[1] = s; m[5] = c; return m; }
function mat4perspective(fovy, aspect, near, far) {
const f = 1 / Math.tan(fovy / 2), m = new Float32Array(16);
m[0] = f / aspect; m[5] = f; m[10] = (far + near) / (near - far); m[11] = -1;
m[14] = 2 * far * near / (near - far);
return m;
}
const D2R = Math.PI / 180;
// ---------------------------------------------------------------- shaders
function compile(vsrc, fsrc) {
const p = gl.createProgram();
for (const [t, src] of [[gl.VERTEX_SHADER, vsrc], [gl.FRAGMENT_SHADER, fsrc]]) {
const s = gl.createShader(t);
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 = {}, n = gl.getProgramParameter(p, gl.ACTIVE_UNIFORMS);
for (let i = 0; i < n; i++) { const info = gl.getActiveUniform(p, i); u[info.name.replace("[0]", "")] = gl.getUniformLocation(p, info.name); }
return { prog: p, u };
}
const meshShader = compile(`
attribute vec3 aPos; attribute vec3 aNorm; attribute vec2 aUV; attribute vec3 aCol;
uniform mat4 uProj, uView, uModel;
varying vec3 vN; varying vec2 vUV; varying vec3 vCol; varying float vDist;
void main() {
vec4 v = uView * uModel * vec4(aPos, 1.0);
vDist = length(v.xyz);
vN = mat3(uModel[0].xyz, uModel[1].xyz, uModel[2].xyz) * aNorm;
vUV = aUV; vCol = aCol;
gl_Position = uProj * v;
}`, `
precision mediump float;
varying vec3 vN; varying vec2 vUV; varying vec3 vCol; varying float vDist;
uniform vec3 uAmbScene, uMatAmb, uMatDiff, uMatEmis, uFogColor, uViewFwd;
uniform vec3 uLightDir[2]; uniform vec3 uLightCol[2];
uniform vec3 uRamp0, uRamp1;
uniform vec3 uFog; /* start, end, immune */
uniform float uHasTex, uCooked; uniform sampler2D uTex;
void main() {
vec3 N = normalize(vN);
if (dot(N, uViewFwd) > 0.0) N = -N;
vec3 acc = vec3(0.0);
for (int i = 0; i < 2; i++) acc += uLightCol[i] * max(dot(N, -uLightDir[i]), 0.0);
vec3 lit = mix(uRamp0, uRamp1, clamp(acc, 0.0, 1.0));
vec3 tex = mix(vec3(1.0), texture2D(uTex, vUV).rgb, uHasTex);
vec3 shaded = (uMatAmb * uAmbScene + uMatDiff * lit) * tex + uMatEmis;
vec3 cooked = vCol * tex + uMatEmis;
vec3 c = mix(shaded, cooked, uCooked);
float f = clamp((uFog.y - vDist) / (uFog.y - uFog.x), 0.0, 1.0);
f = max(f, uFog.z);
gl_FragColor = vec4(mix(uFogColor, c, f), 1.0);
}`);
const pointShader = compile(`
attribute vec3 aPos;
uniform mat4 uProj, uView, uModel;
uniform float uRadius, uProjScale;
varying float vDist;
void main() {
vec4 v = uView * uModel * vec4(aPos, 1.0);
vDist = length(v.xyz);
gl_Position = uProj * v;
gl_PointSize = clamp(uRadius * uProjScale / max(vDist, 0.001), 1.5, 40.0);
}`, `
precision mediump float;
uniform vec3 uColor; uniform float uFar;
varying float vDist;
void main() {
vec2 d = gl_PointCoord - vec2(0.5);
float a = smoothstep(0.5, 0.08, length(d));
a *= clamp((uFar - vDist) / (uFar * 0.25), 0.0, 1.0);
gl_FragColor = vec4(uColor * a, 1.0);
}`);
// ---------------------------------------------------------------- geometry
function computeNormals(v, idx) {
const n = new Float32Array(v.length);
for (let i = 0; i < idx.length; i += 3) {
const a = idx[i] * 3, b = idx[i + 1] * 3, c = idx[i + 2] * 3;
const ux = v[b] - v[a], uy = v[b + 1] - v[a + 1], uz = v[b + 2] - v[a + 2];
const wx = v[c] - v[a], wy = v[c + 1] - v[a + 1], wz = v[c + 2] - v[a + 2];
const nx = uy * wz - uz * wy, ny = uz * wx - ux * wz, nz = ux * wy - uy * wx;
for (const o of [a, b, c]) { n[o] += nx; n[o + 1] += ny; n[o + 2] += nz; }
}
for (let i = 0; i < n.length; i += 3) {
const l = Math.hypot(n[i], n[i + 1], n[i + 2]) || 1;
n[i] /= l; n[i + 1] /= l; n[i + 2] /= l;
}
return n;
}
function buf(target, data) { const b = gl.createBuffer(); gl.bindBuffer(target, b); gl.bufferData(target, data, gl.STATIC_DRAW); return b; }
const glTextures = {};
function getTexture(iid) {
if (!iid || !DATA.images[iid]) return null;
if (glTextures[iid]) return glTextures[iid];
const tex = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, tex);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGB, 1, 1, 0, gl.RGB, gl.UNSIGNED_BYTE, new Uint8Array([128, 128, 128]));
const img = new Image();
img.onload = () => {
gl.bindTexture(gl.TEXTURE_2D, tex);
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGB, gl.RGB, gl.UNSIGNED_BYTE, img);
gl.generateMipmap(gl.TEXTURE_2D);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR_MIPMAP_LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.REPEAT);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.REPEAT);
};
img.src = DATA.images[iid];
glTextures[iid] = tex;
return tex;
}
const builtModels = {}; // mid -> {meshes, points}
function buildModel(mid, mats) {
const cacheKey = mid;
if (builtModels[cacheKey]) return builtModels[cacheKey];
const m = { meshes: [], points: [] };
for (const g of DATA.models[mid]) {
const mat = (mats && mats[g.material]) || {};
if (g.mode === "points") {
m.points.push({
vbo: buf(gl.ARRAY_BUFFER, new Float32Array(g.points.flat())),
count: g.points.length,
color: mat.emissive || mat.diffuse || [1, 1, 1],
});
continue;
}
if (!g.i || !g.i.length) continue;
const v = new Float32Array(g.v.flat());
const big = g.v.length > 65535;
const idx = big ? new Uint32Array(g.i) : new Uint16Array(g.i);
const n = g.n ? new Float32Array(g.n.flat()) : computeNormals(v, g.i);
const uv = g.uv ? new Float32Array(g.uv.flat()) : new Float32Array(g.v.length * 2);
const col = g.c ? new Float32Array(g.c.flat()) : null;
m.meshes.push({
vbo: buf(gl.ARRAY_BUFFER, v), nbo: buf(gl.ARRAY_BUFFER, n),
tbo: buf(gl.ARRAY_BUFFER, uv),
cbo: col ? buf(gl.ARRAY_BUFFER, col) : null,
ibo: buf(gl.ELEMENT_ARRAY_BUFFER, idx), count: g.i.length, big,
amb: mat.ambient || [0.6, 0.6, 0.6], diff: mat.diffuse || [0.6, 0.6, 0.6],
emis: mat.emissive || [0, 0, 0], immune: mat.immune ? 1 : 0,
ramp: mat.ramp || [[0, 0, 0], [1, 1, 1]],
tex: getTexture(mat.img),
cooked: col ? 1 : 0,
});
}
builtModels[cacheKey] = m;
return m;
}
// ---------------------------------------------------------------- scene state
function dirFromAngles(pitchDeg, yawDeg) {
const p = pitchDeg * D2R, y = yawDeg * D2R;
return [-Math.sin(y) * Math.cos(p), Math.sin(p), -Math.cos(y) * Math.cos(p)];
}
const cam = { pos: [0, 0, 50], yaw: 0, pitch: 0 };
let scene = null, sceneKey = null, autoClipFar = 5000;
let simTime = 0, paused = matchMedia("(prefers-reduced-motion: reduce)").matches;
const uint32ok = !!gl.getExtension("OES_element_index_uint");
function frameScene(sc) {
// bounding sphere over static placements, excluding outliers (sky domes,
// ground planes) whose radius dwarfs the median
const radii = [];
for (const p of sc.placements) {
const b = DATA.bounds[sc.modelmap[p.geo.split(":")[0]]];
if (b) radii.push(b.r * (p.scale || 1));
}
radii.sort((a, b) => a - b);
const median = radii[Math.floor(radii.length / 2)] || 50;
const cap = Math.max(median * 8, 100);
let lo = [1e8, 1e8, 1e8], hi = [-1e8, -1e8, -1e8];
for (const p of sc.placements) {
const mid = sc.modelmap[p.geo.split(":")[0]];
const b = DATA.bounds[mid];
if (!b) continue;
const s = p.scale || 1;
if (b.r * s > cap && radii.length > 2) continue;
const pos = p.pos || [0, 0, 0];
const rot = p.rot && (p.rot[0] || p.rot[1] || p.rot[2]);
// rotated placements: bound conservatively by |center|+r around pos
const cr = rot ? Math.hypot(b.c[0], b.c[1], b.c[2]) + b.r : 0;
for (let i = 0; i < 3; i++) {
const clo = rot ? -cr : b.c[i] - b.r;
const chi = rot ? cr : b.c[i] + b.r;
lo[i] = Math.min(lo[i], pos[i] + clo * s);
hi[i] = Math.max(hi[i], pos[i] + chi * s);
}
}
if (lo[0] > hi[0]) { lo = [-50, -50, -50]; hi = [50, 50, 50]; }
const c = [(lo[0] + hi[0]) / 2, (lo[1] + hi[1]) / 2, (lo[2] + hi[2]) / 2];
const r = Math.max(hi[0] - lo[0], hi[1] - lo[1], hi[2] - lo[2]) / 2 || 50;
return { center: c, radius: r };
}
function loadScene(key) {
sceneKey = key;
scene = DATA.scenes[key];
const fr = frameScene(scene);
autoClipFar = Math.max((scene.clip || [1, 5000])[1], fr.radius * 4);
if (scene.start) {
const s = scene.start;
cam.pos = [s[0], s[1], s[2]]; cam.pitch = s[3] || 0; cam.yaw = s[4] || 0;
} else {
let d = fr.radius * 1.6;
if (scene.fog) d = Math.min(d, scene.fog[1] * 0.4); // stay inside the fog
cam.pos = [fr.center[0], fr.center[1] + d * 0.3, fr.center[2] + d];
cam.pitch = -14; cam.yaw = 0;
}
simTime = 0;
const meta = DATA.catalog.find(x => x.key === key) || { name: key, project: "", blurb: "" };
document.getElementById("hdr-path").textContent =
meta.project.toUpperCase() + " · " + key + (scene.missing.length ? " · " + scene.missing.length + " MODEL(S) LOST" : "");
document.getElementById("hdr-name").textContent = meta.name;
document.getElementById("hdr-blurb").textContent = meta.blurb;
for (const b of document.querySelectorAll("#list button"))
b.classList.toggle("active", b.dataset.key === key);
document.getElementById("catalog").classList.remove("open");
location.hash = encodeURIComponent(key);
}
function pathPoint(spl, u) {
const n = spl.length;
if (!n) return { p: [0, 0, 0], d: [0, 0, -1] };
if (n < 2) return { p: spl[0], d: [0, 0, -1] };
const span = n - 1;
let t = u % span; if (t < 0) t += span;
const i = Math.min(Math.floor(t), span - 1), f = t - i;
const a = spl[i], b = spl[i + 1];
return {
p: [a[0] + (b[0] - a[0]) * f, a[1] + (b[1] - a[1]) * f, a[2] + (b[2] - a[2]) * f],
d: [b[0] - a[0], b[1] - a[1], b[2] - a[2]],
};
}
function instanceMatrix(pos, rotDeg, scale, faceDir) {
if (faceDir) {
const l = Math.hypot(faceDir[0], faceDir[1], faceDir[2]);
if (l > 1e-6) {
const yaw = Math.atan2(-faceDir[0], -faceDir[2]);
const pitch = Math.asin(faceDir[1] / l);
return mat4mul(mat4mul(mat4translate(pos[0], pos[1], pos[2]),
mat4mul(mat4rotY(yaw), mat4rotX(-pitch))), mat4scale(scale));
}
}
if (rotDeg && (rotDeg[0] || rotDeg[1] || rotDeg[2])) {
const r = mat4mul(mat4rotY((rotDeg[1] || 0) * D2R),
mat4mul(mat4rotX((rotDeg[0] || 0) * D2R), mat4rotZ((rotDeg[2] || 0) * D2R)));
return mat4mul(mat4mul(mat4translate(pos[0], pos[1], pos[2]), r), mat4scale(scale));
}
return mat4mul(mat4translate(pos[0], pos[1], pos[2]), mat4scale(scale));
}
// ---------------------------------------------------------------- controls
const keys = {};
addEventListener("keydown", e => {
keys[e.key.toLowerCase()] = true;
if (e.key.toLowerCase() === "r") loadScene(sceneKey);
});
addEventListener("keyup", e => { keys[e.key.toLowerCase()] = false; });
let dragging = false, lastX = 0, lastY = 0, pinchDist = 0;
canvas.addEventListener("pointerdown", e => {
dragging = true; lastX = e.clientX; lastY = e.clientY;
canvas.classList.add("dragging"); canvas.setPointerCapture(e.pointerId);
});
canvas.addEventListener("pointermove", e => {
if (!dragging) return;
cam.yaw += (e.clientX - lastX) * 0.22;
cam.pitch = Math.max(-89, Math.min(89, cam.pitch - (e.clientY - lastY) * 0.22));
lastX = e.clientX; lastY = e.clientY;
});
canvas.addEventListener("pointerup", () => { dragging = false; canvas.classList.remove("dragging"); });
canvas.addEventListener("wheel", e => {
e.preventDefault();
const f = dirFromAngles(cam.pitch, cam.yaw), step = e.deltaY * -0.002 * autoClipFar / 40;
for (let i = 0; i < 3; i++) cam.pos[i] += f[i] * step;
}, { passive: false });
canvas.addEventListener("touchmove", e => {
if (e.touches.length === 2) {
const d = Math.hypot(e.touches[0].clientX - e.touches[1].clientX, e.touches[0].clientY - e.touches[1].clientY);
if (pinchDist) {
const f = dirFromAngles(cam.pitch, cam.yaw), step = (d - pinchDist) * 0.002 * autoClipFar / 40;
for (let i = 0; i < 3; i++) cam.pos[i] += f[i] * step;
}
pinchDist = d;
}
}, { passive: true });
canvas.addEventListener("touchend", () => { pinchDist = 0; });
const btnInfo = document.getElementById("btn-info");
btnInfo.onclick = () => {
const p = document.getElementById("info").classList.toggle("open");
btnInfo.setAttribute("aria-expanded", p);
};
const btnPause = document.getElementById("btn-pause");
function setPaused(p) { paused = p; btnPause.textContent = p ? "RESUME" : "PAUSE"; btnPause.setAttribute("aria-pressed", p); }
btnPause.onclick = () => setPaused(!paused);
setPaused(paused);
document.getElementById("toggle").onclick = () =>
document.getElementById("catalog").classList.toggle("open");
// catalog list
{
const list = document.getElementById("list");
let lastProj = null;
for (const it of DATA.catalog) {
if (it.project !== lastProj) {
const h = document.createElement("div");
h.className = "proj"; h.textContent = it.project;
list.appendChild(h);
lastProj = it.project;
}
const b = document.createElement("button");
b.textContent = it.name;
b.dataset.key = it.key;
b.onclick = () => loadScene(it.key);
list.appendChild(b);
}
}
// ---------------------------------------------------------------- render
function resize() {
const dpr = Math.min(devicePixelRatio || 1, 2);
canvas.width = innerWidth * dpr; canvas.height = innerHeight * dpr;
}
addEventListener("resize", resize); resize();
gl.enable(gl.DEPTH_TEST);
gl.disable(gl.CULL_FACE);
let lastT = performance.now();
function frame(now) {
requestAnimationFrame(frame);
const dt = Math.min((now - lastT) / 1000, 0.1); lastT = now;
if (!scene) return;
if (!paused) simTime += dt;
const moveScale = autoClipFar / 250;
const speed = (keys.shift ? 5 : 1.2) * moveScale * dt * 10;
const f = dirFromAngles(cam.pitch, cam.yaw);
const right = [-f[2], 0, f[0]];
const rl = Math.hypot(right[0], right[2]) || 1; right[0] /= rl; right[2] /= rl;
if (keys.w || keys.arrowup) for (let i = 0; i < 3; i++) cam.pos[i] += f[i] * speed;
if (keys.s || keys.arrowdown) for (let i = 0; i < 3; i++) cam.pos[i] -= f[i] * speed;
if (keys.a || keys.arrowleft) { cam.pos[0] -= right[0] * speed; cam.pos[2] -= right[2] * speed; }
if (keys.d || keys.arrowright) { cam.pos[0] += right[0] * speed; cam.pos[2] += right[2] * speed; }
if (keys.q) cam.pos[1] += speed;
if (keys.e) cam.pos[1] -= speed;
gl.viewport(0, 0, canvas.width, canvas.height);
const bg = scene.bg || [0, 0, 0];
gl.clearColor(bg[0], bg[1], bg[2], 1);
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
const aspect = canvas.width / canvas.height;
const hfov = (scene.fov || 60) * D2R;
const vfov = 2 * Math.atan(Math.tan(hfov / 2) / Math.max(aspect, 1));
const clip = scene.clip || [1, 5000];
const proj = mat4perspective(vfov, aspect, Math.max(clip[0], autoClipFar / 50000), autoClipFar);
const view = mat4mul(mat4mul(mat4rotX(-cam.pitch * D2R), mat4rotY(-cam.yaw * D2R)),
mat4translate(-cam.pos[0], -cam.pos[1], -cam.pos[2]));
const projScale = canvas.height / (2 * Math.tan(vfov / 2));
// instances: [geoName, matrix, worldScale]
const inst = [];
const tick = simTime * 30;
for (const p of scene.placements) {
let mm, base;
if (p.kind === "static") {
mm = instanceMatrix(p.pos, p.rot, p.scale);
} else {
const spl = scene.splines[p.spl];
const { p: pp, d } = pathPoint(spl, p.phase + p.speed * tick);
const mid = scene.modelmap[p.geo.split(":")[0]];
const isPoints = DATA.models[mid] && DATA.models[mid].every(g => g.mode === "points");
mm = instanceMatrix(pp, null, p.scale, isPoints ? null : d);
}
inst.push([p.geo.split(":")[0], mm, p.scale]);
for (const c of p.children) {
const cm = mat4mul(mm, mat4scale(c.scale || 1));
inst.push([c.geo.split(":")[0], cm, p.scale * (c.scale || 1)]);
}
}
// meshes
const ms = meshShader; gl.useProgram(ms.prog);
gl.uniformMatrix4fv(ms.u.uProj, false, proj);
gl.uniformMatrix4fv(ms.u.uView, false, view);
gl.uniform3fv(ms.u.uAmbScene, scene.ambient || [0.2, 0.2, 0.2]);
const fogSE = scene.fog || [autoClipFar * 0.6, autoClipFar, bg[0], bg[1], bg[2]];
gl.uniform3fv(ms.u.uFogColor, fogSE.slice(2, 5));
gl.uniform3fv(ms.u.uViewFwd, f);
const ldirs = [], lcols = [];
for (let i = 0; i < 2; i++) {
const L = (scene.lights || [])[i];
if (L) { ldirs.push(...dirFromAngles(L[3], L[4])); lcols.push(L[0], L[1], L[2]); }
else if (i === 0 && !(scene.lights || []).length) { ldirs.push(...dirFromAngles(-50, 30)); lcols.push(1, 1, 1); }
else { ldirs.push(0, -1, 0); lcols.push(0, 0, 0); }
}
gl.uniform3fv(ms.u.uLightDir, ldirs);
gl.uniform3fv(ms.u.uLightCol, lcols);
const aPos = gl.getAttribLocation(ms.prog, "aPos");
const aNorm = gl.getAttribLocation(ms.prog, "aNorm");
const aUV = gl.getAttribLocation(ms.prog, "aUV");
const aCol = gl.getAttribLocation(ms.prog, "aCol");
gl.enableVertexAttribArray(aPos); gl.enableVertexAttribArray(aNorm); gl.enableVertexAttribArray(aUV);
gl.depthMask(true);
for (const [name, mm] of inst) {
const mid = scene.modelmap[name];
if (!mid) continue;
const model = buildModel(mid, scene.materials);
gl.uniformMatrix4fv(ms.u.uModel, false, mm);
for (const g of model.meshes) {
if (g.big && !uint32ok) continue;
gl.uniform3fv(ms.u.uMatAmb, g.amb);
gl.uniform3fv(ms.u.uMatDiff, g.diff);
gl.uniform3fv(ms.u.uMatEmis, g.emis);
gl.uniform3fv(ms.u.uRamp0, g.ramp[0]);
gl.uniform3fv(ms.u.uRamp1, g.ramp[1]);
gl.uniform3f(ms.u.uFog, fogSE[0], fogSE[1], g.immune);
gl.uniform1f(ms.u.uHasTex, g.tex ? 1 : 0);
gl.uniform1f(ms.u.uCooked, g.cooked);
if (g.tex) { gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D, g.tex); gl.uniform1i(ms.u.uTex, 0); }
gl.bindBuffer(gl.ARRAY_BUFFER, g.vbo); gl.vertexAttribPointer(aPos, 3, gl.FLOAT, false, 0, 0);
gl.bindBuffer(gl.ARRAY_BUFFER, g.nbo); gl.vertexAttribPointer(aNorm, 3, gl.FLOAT, false, 0, 0);
gl.bindBuffer(gl.ARRAY_BUFFER, g.tbo); gl.vertexAttribPointer(aUV, 2, gl.FLOAT, false, 0, 0);
if (g.cbo) { gl.enableVertexAttribArray(aCol); gl.bindBuffer(gl.ARRAY_BUFFER, g.cbo); gl.vertexAttribPointer(aCol, 3, gl.FLOAT, false, 0, 0); }
else { gl.disableVertexAttribArray(aCol); gl.vertexAttrib3f(aCol, 1, 1, 1); }
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, g.ibo);
gl.drawElements(gl.TRIANGLES, g.count, g.big ? gl.UNSIGNED_INT : gl.UNSIGNED_SHORT, 0);
}
}
gl.disableVertexAttribArray(aNorm); gl.disableVertexAttribArray(aUV);
// points
const ps = pointShader; gl.useProgram(ps.prog);
gl.uniformMatrix4fv(ps.u.uProj, false, proj);
gl.uniformMatrix4fv(ps.u.uView, false, view);
gl.uniform1f(ps.u.uProjScale, projScale);
gl.uniform1f(ps.u.uFar, autoClipFar);
const pPos = gl.getAttribLocation(ps.prog, "aPos");
gl.enableVertexAttribArray(pPos);
gl.enable(gl.BLEND);
gl.blendFunc(gl.ONE, gl.ONE);
gl.depthMask(false);
for (const [name, mm, ws] of inst) {
const mid = scene.modelmap[name];
if (!mid) continue;
const model = buildModel(mid, scene.materials);
if (!model.points.length) continue;
const b = DATA.bounds[mid] || { r: 10 };
gl.uniformMatrix4fv(ps.u.uModel, false, mm);
gl.uniform1f(ps.u.uRadius, Math.max(0.002 * (ws || 1) * b.r, 0.05));
for (const g of model.points) {
gl.uniform3fv(ps.u.uColor, g.color);
gl.bindBuffer(gl.ARRAY_BUFFER, g.vbo);
gl.vertexAttribPointer(pPos, 3, gl.FLOAT, false, 0, 0);
gl.drawArrays(gl.POINTS, 0, g.count);
}
}
gl.depthMask(true);
gl.disable(gl.BLEND);
}
const startKey = decodeURIComponent(location.hash.slice(1));
loadScene(DATA.scenes[startKey] ? startKey : DATA.catalog[0].key);
document.getElementById("loading").remove();
requestAnimationFrame(frame);
</script>