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>
649 lines
28 KiB
HTML
649 lines
28 KiB
HTML
<title>VWE Scene Archive — 1994–1997</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…</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–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 <b>W A S D</b> fly <b>Q E</b> rise/sink <b>shift</b> fast <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’s hard
|
||
drive (“Glaze”, in use 1994–1999) and from the Tesla
|
||
Release 4.10 content tree. They were authored for <b>Division Ltd.’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> — unreleased pod prototype, spring 1996.</p>
|
||
<p><b>Hull Pressure</b> — unreleased submarine game, summer 1995.</p>
|
||
<p><b>BattleTech / Red Planet</b> — 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> — 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
|
||
— the binary layout was recovered from Division’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 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>
|