Add TREK.SCN restoration: converter and WebGL viewer

Parses the DIV-VIZ2 formats (VGF geometry, VMF materials, TGA
textures, SPL splines, SCN scenes) from the STDAVE directory of the
Glaze drive and re-renders VWE's unreleased 1996 Star Trek pod
scenes in a self-contained WebGL page: the Enterprise-D among
drifting starfields (TREK.SCN) and the Klingon flyby (KLNGVID.SCN).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Cyd
2026-07-02 15:19:52 -05:00
co-authored by Claude Fable 5
parent ce1b4ca9de
commit 5e9f191e0b
5 changed files with 1354 additions and 1 deletions
+2 -1
View File
@@ -84,7 +84,8 @@ The most historically significant material on the drive:
A working Division scene (`TREK.SCN`) running on the Tesla renderer: layered
starfield splines, an **Enterprise-D model** (`EPRISED.VGF`), and **Klingon
ship models and textures** (`KLNGN*.VGF`, `KLNG16.TGA`). A VWE Star Trek pod
experience that never shipped.
experience that never shipped. These scenes have been restored and can be
viewed in a browser — see [restoration/](restoration/).
### Hull Pressure (`HPDAVE/`, August 1995)
A **submarine game** prototype: sharks, submarines, Atlantis geometry, fish
+42
View File
@@ -0,0 +1,42 @@
# TREK.SCN Restoration
A modern re-rendering of the unreleased **Star Trek** simulator-pod scenes found
in the `STDAVE` directory of the Glaze developer drive (see
[HISTORY.md](../HISTORY.md)). The original scenes ran on Division Ltd.'s dVS
pixel-pipeline hardware in 1996; this restoration parses the original data
files and re-renders them in WebGL, in the browser.
## Files
- **`trek-scn.html`** — the finished, self-contained viewer (open in any
browser; no server needed). Renders `TREK.SCN` (the Enterprise-D among
three parallax layers of drifting stars) and `KLNGVID.SCN` (a Klingon
cruiser at rest while two more drift past on spline paths). Fly camera:
drag to look, WASD to fly.
- **`convert.py`** — the converter. Parses the DIV-VIZ2 text formats from
the drive dump (expects it at `..\sda4\STDAVE`) and emits `trekdata.json`:
- `.VGF` geometry (vertex pools + polygon connection lists, converted from
Wavefront OBJ in 1996 by Ken Olsen's `obj2vgf`)
- `.VMF` materials (ambient/diffuse/emissive, shading ramps, texture
bit-slice references)
- `.TGA` textures (decoded and re-encoded as PNG data URIs)
- `.SPL` splines (starfield drift paths, ship flyby paths)
- `.SCN` scene scripts (camera, lights, fog, object placements)
- **`viewer_template.html`** — the viewer source with a `%%DATA%%`
placeholder; `convert.py`'s JSON output is injected to produce
`trek-scn.html`.
## Rebuild
```
python convert.py
python -c "open('trek-scn.html','w',encoding='utf-8').write(open('viewer_template.html',encoding='utf-8').read().replace('%%DATA%%',open('trekdata.json',encoding='utf-8').read()))"
```
## Fidelity notes
Faithful: vertices, triangles, material colors, textures, fog and light
parameters, star paths and layer speeds, camera start positions — all from
the original files. Approximated: Division's shading ramps and rasterizer
behavior, point-sprite star sizes, ship motion timing (original simulation
ran at 30 Hz). Not simulated: the event-driven SPECIALFX particle effects.
+230
View File
@@ -0,0 +1,230 @@
"""Convert Division DIV-VIZ2 assets (VGF/VMF/TGA/SPL/SCN) to JSON for the web viewer.
Formats reverse-engineered from the VWE 'Glaze' drive (STDAVE, 1996):
- VGF: text geometry. OBJECT { GEOGROUP(F_MATERIAL=..;VERTEX=flags) {
PMESH { VERTEX_POOL {..} CONNECTION_LIST (PCOUNT=n) {..} .. }
| SPHERELIST {..} } }
Vertex tuple = pos(3) [+ normal(3) if NORMALS] [+ uv(2) if 2D_TEXTURE].
- VMF: text materials. TEXTURE(NAME=..){MAP{"base"} BITSLICE{n}} selects
grayscale TGA "BASE<n+1>.TGA"; MATERIAL(NAME=..){AMBIENT/DIFFUSE/EMISSIVE
{r,g,b} TEXTURE{"t"}}.
- SPL: first line = knot count, rows = x y z (+3 unused here).
- SCN: scene script (camera, lights, fog, STATIC/DYNAMIC placements).
- TGA: type-2 uncompressed 24bpp, bottom-up.
"""
import json, re, struct, sys, zlib, base64, os
ROOT = r"c:\VWE\TeslaRel410\sda4\STDAVE"
BUILD = os.path.join(ROOT, "VIDEO", "BUILD")
# ---------------------------------------------------------------- tokenizer
def strip_comments(text):
text = re.sub(r"/\*.*?\*/", " ", text, flags=re.S)
text = re.sub(r"//[^\n]*", " ", text)
return text
# ---------------------------------------------------------------- VGF
def parse_vgf(path):
"""Return list of groups: {material, mode, verts/normals/uvs/indices or points}."""
text = strip_comments(open(path, "r", errors="replace").read())
groups = []
pos = 0
for gm in re.finditer(r"GEOGROUP\s*\(([^)]*)\)\s*\{", text):
params = gm.group(1)
mmat = re.search(r'F_MATERIAL\s*=\s*"([^"]+)"', params)
material = mmat.group(1) if mmat else None
vflags = re.search(r"VERTEX\s*=\s*([^;]+);", params)
flags = vflags.group(1).upper() if vflags else ""
has_n = "NORMALS" in flags
has_uv = "2D_TEXTURE" in flags
body_start = gm.end()
# find matching close brace for the group body
depth, i = 1, body_start
while depth > 0 and i < len(text):
if text[i] == "{": depth += 1
elif text[i] == "}": depth -= 1
i += 1
body = text[body_start:i - 1]
g = {"material": material}
sph = re.search(r"SPHERELIST\s*\{(.*?)\n\s*\}", body, re.S)
if sph:
pts = []
for row in re.finditer(r"\{([^}]*)\}", sph.group(1)):
nums = [float(x) for x in re.findall(r"[-\d.eE]+", row.group(1))]
pts.append(nums[:3])
g["mode"] = "points"
g["points"] = pts
groups.append(g)
continue
vp = re.search(r"VERTEX_POOL\s*\{(.*?)\n\s*\}", body, re.S)
if not vp:
continue
verts, norms, uvs = [], [], []
for row in re.finditer(r"\{([^}]*)\}", vp.group(1)):
nums = [float(x) for x in re.findall(r"[-\d.eE+]+", row.group(1))]
k = 0
verts.append(nums[k:k+3]); k += 3
if has_n:
norms.append(nums[k:k+3]); k += 3
if has_uv:
uvs.append(nums[k:k+2]); k += 2
indices = []
for cm in re.finditer(r"CONNECTION_LIST\s*\(\s*PCOUNT\s*=\s*(\d+)\s*\)\s*\{(.*?)\n\s*\}", body, re.S):
pc = int(cm.group(1))
for row in re.finditer(r"\{([^}]*)\}", cm.group(2)):
idx = [int(x) for x in re.findall(r"\d+", row.group(1))]
# fan-triangulate polygons
for t in range(1, len(idx) - 1):
indices += [idx[0], idx[t], idx[t + 1]]
g["mode"] = "mesh"
g["v"] = verts
g["n"] = norms if has_n else None
g["uv"] = uvs if has_uv else None
g["i"] = indices
groups.append(g)
return groups
# ---------------------------------------------------------------- VMF
def parse_vmf(path, prefix):
text = strip_comments(open(path, "r", errors="replace").read())
textures, materials = {}, {}
for tm in re.finditer(r"TEXTURE\s*\(\s*NAME\s*=\s*\"?([\w]+)\"?\s*;?([^)]*)\)\s*\{(.*?)\n\}", text, re.S):
name, params, body = tm.group(1), tm.group(2), tm.group(3)
mmap = re.search(r'MAP\s*\{\s*"(\w+)"\s*\}', body)
mslice = re.search(r"BITSLICE\s*\{\s*(\d+)\s*\}", body)
scroll = re.search(r"SCROLL\s+([-\d. ]+)", params + body)
textures[name] = {
"map": mmap.group(1) if mmap else None,
"slice": int(mslice.group(1)) if mslice else 0,
"scroll": [float(x) for x in scroll.group(1).split()] if scroll else None,
}
for mm in re.finditer(r"MATERIAL\s*\(\s*NAME\s*=\s*\"?([\w]+)\"?\s*;?([^)]*)\)\s*\{(.*?)\n\}", text, re.S):
name, params, body = mm.group(1), mm.group(2), mm.group(3)
def col(tag):
m = re.search(tag + r"\s*\{([^}]*)\}", body)
return [float(x) for x in re.findall(r"[-\d.eE]+", m.group(1))][:3] if m else None
mtex = re.search(r'TEXTURE\s*\{\s*"(\w+)"\s*\}', body)
materials[prefix + ":" + name] = {
"ambient": col("AMBIENT"), "diffuse": col("DIFFUSE"),
"emissive": col("EMISSIVE"),
"texture": mtex.group(1) if mtex else None,
"immune": "IMMUNE" in params, # fog-immune (self-lit stars, running lights)
}
return textures, materials
# ---------------------------------------------------------------- TGA -> PNG data URI
def tga_to_png_datauri(path):
d = open(path, "rb").read()
idlen, cmap, imgtype = d[0], d[1], d[2]
w, h = struct.unpack("<HH", d[12:16])
bpp, desc = d[16], d[17]
assert imgtype == 2 and bpp in (24, 32), f"unsupported TGA {path} type={imgtype} bpp={bpp}"
off = 18 + idlen
nb = bpp // 8
rows = []
for y in range(h):
row = bytearray()
base = off + y * w * nb
for x in range(w):
b, g, r = d[base + x*nb], d[base + x*nb + 1], d[base + x*nb + 2]
row += bytes((r, g, b))
rows.append(bytes(row))
if not (desc & 0x20): # bottom-up -> flip to top-down
rows.reverse()
raw = b"".join(b"\x00" + r for r in rows)
def chunk(tag, data):
c = struct.pack(">I", len(data)) + tag + data
return c + struct.pack(">I", zlib.crc32(tag + data) & 0xffffffff)
png = (b"\x89PNG\r\n\x1a\n"
+ chunk(b"IHDR", struct.pack(">IIBBBBB", w, h, 8, 2, 0, 0, 0))
+ chunk(b"IDAT", zlib.compress(raw, 9))
+ chunk(b"IEND", b""))
return "data:image/png;base64," + base64.b64encode(png).decode()
# ---------------------------------------------------------------- SPL
def parse_spl(path):
lines = [l for l in open(path).read().splitlines() if l.strip()]
n = int(lines[0].split()[0])
pts = []
for l in lines[1:]:
try:
nums = [float(x) for x in l.split()]
except ValueError:
continue
if len(nums) >= 3:
pts.append(nums[:3])
return pts
# ---------------------------------------------------------------- SCN
def parse_scn(path):
scene = {"lights": [], "statics": [], "dynamics": []}
for raw in open(path, errors="replace"):
line = raw.split("//")[0].split("#")[0].strip()
if not line: continue
t = line.split()
k = t[0].upper()
try:
if k == "VIEWANGLE": scene["fov"] = float(t[1])
elif k == "CLIP": scene["clip"] = [float(t[1]), float(t[2])]
elif k == "FOG": scene["fog"] = [float(x) for x in t[1:6]]
elif k == "BACKGND": scene["bg"] = [float(x) for x in t[1:4]]
elif k == "AMBIENT": scene["ambient"] = [float(x) for x in t[1:4]]
elif k == "LIGHT": scene["lights"].append([float(x) for x in t[1:7]])
elif k == "START": scene["start"] = [float(x) for x in t[1:7]]
elif k == "STATIC":
scene["statics"].append({"geo": t[1].lower(), "scale": float(t[2]),
"pos": [float(x) for x in t[3:6]], "rot": [float(x) for x in t[6:9]]})
elif k == "DYNAMIC":
scene["dynamics"].append({"geo": t[1].lower(), "scale": float(t[2]),
"spl": os.path.basename(t[3].replace("\\", "/")).lower(),
"phase": float(t[4]), "speed": float(t[5])})
except (ValueError, IndexError):
pass
return scene
# ---------------------------------------------------------------- drive it
def main():
out = {"models": {}, "materials": {}, "textures": {}, "images": {},
"splines": {}, "scenes": {}}
tex1, mat1 = parse_vmf(os.path.join(BUILD, "STSHIPS.VMF"), "stships")
tex2, mat2 = parse_vmf(os.path.join(BUILD, "STAR.VMF"), "star")
out["textures"] = {**tex1, **tex2}
out["materials"] = {**mat1, **mat2}
for name, f in [("tesall", "TESALL.VGF"), ("tesall_lp", "TESALL_L.VGF"),
("klngn", "KLNGN.VGF"), ("klngn_lp", "KLNGN_LP.VGF"),
("stars", "STARS.VGF")]:
gs = parse_vgf(os.path.join(BUILD, f))
out["models"][name] = gs
nv = sum(len(g.get("v", g.get("points", []))) for g in gs)
nt = sum(len(g.get("i", [])) // 3 for g in gs)
print(f"model {name}: {len(gs)} groups, {nv} verts, {nt} tris")
# texture maps: map base 'steh'/'klng', slice n -> BUILD/<BASE><n+1>.TGA
used_maps = {(t["map"], t["slice"]) for t in out["textures"].values() if t["map"]}
for base, sl in sorted(used_maps):
f = os.path.join(BUILD, f"{base.upper()}{sl+1}.TGA")
if os.path.exists(f):
out["images"][f"{base}{sl}"] = tga_to_png_datauri(f)
print(f"texture {base} slice {sl}: {os.path.basename(f)} ok")
else:
print(f"texture MISSING: {f}")
for i in range(1, 7):
out["splines"][f"field{i}.spl"] = parse_spl(os.path.join(ROOT, f"FIELD{i}.SPL"))
for f in ["KLNGN2.SPL", "KLNGN3.SPL"]:
out["splines"][f.lower()] = parse_spl(os.path.join(ROOT, f))
out["scenes"]["TREK.SCN"] = parse_scn(os.path.join(ROOT, "TREK.SCN"))
out["scenes"]["KLNGVID.SCN"] = parse_scn(os.path.join(ROOT, "KLNGVID.SCN"))
dst = os.path.join(os.path.dirname(os.path.abspath(__file__)), "trekdata.json")
with open(dst, "w") as fh:
json.dump(out, fh, separators=(",", ":"))
print("wrote", dst, f"{os.path.getsize(dst)/1e6:.2f} MB")
if __name__ == "__main__":
main()
File diff suppressed because one or more lines are too long
+540
View File
@@ -0,0 +1,540 @@
<title>TREK.SCN — VWE Star Trek Prototype (1996)</title>
<style>
:root {
--ink: #A8D8E4;
--amber: #D9A441;
--dim: #647486;
--panel: rgba(10, 17, 26, 0.82);
--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);
}
#masthead { top: 16px; left: 16px; padding: 12px 16px 10px; max-width: min(480px, calc(100vw - 32px)); }
#masthead .eyebrow {
color: var(--amber); font-size: 10px; letter-spacing: 0.22em;
text-transform: uppercase; margin: 0 0 4px;
}
#masthead h1 { font-size: 22px; font-weight: 500; letter-spacing: 0.04em; margin: 0; color: #DCEEF4; }
#masthead .sub { color: var(--dim); font-size: 11px; margin: 2px 0 0; }
#tabs { top: 118px; left: 16px; display: flex; flex-direction: column; gap: 6px; }
#tabs button {
font: inherit; font-size: 11px; letter-spacing: 0.08em; text-align: left;
color: var(--dim); background: var(--panel); border: 1px solid var(--line);
padding: 7px 12px; cursor: pointer;
}
#tabs button:hover { color: var(--ink); }
#tabs button.active { color: var(--amber); border-color: var(--amber); }
#tabs button:focus-visible, .ctl:focus-visible { outline: 2px solid var(--amber); outline-offset: 1px; }
#keys { bottom: 16px; left: 16px; padding: 8px 12px; color: var(--dim); font-size: 11px; }
#keys b { color: var(--ink); font-weight: 500; }
#foot { bottom: 16px; right: 16px; display: flex; gap: 6px; align-items: flex-end; }
.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(360px, calc(100vw - 32px));
padding: 16px 18px; overflow-y: auto; display: none;
}
#info.open { display: block; }
#info h2 { font-size: 11px; letter-spacing: 0.22em; 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; }
#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: 640px) {
#masthead h1 { font-size: 17px; }
#keys { display: none; }
#tabs { top: 148px; }
}
@media (prefers-reduced-motion: reduce) { * { scroll-behavior: auto; } }
</style>
<canvas id="gl"></canvas>
<div id="loading">RESTORING TREK.SCN&hellip;</div>
<header id="masthead" class="hud panel">
<p class="eyebrow">Virtual World Entertainment &middot; unreleased prototype &middot; 1996</p>
<h1 id="title">TREK.SCN</h1>
<p class="sub" id="subtitle">Star Trek scene &mdash; Division dVS data, re-rendered in WebGL</p>
</header>
<nav id="tabs" class="hud" aria-label="Scene">
<button id="tab-trek" class="active">TREK.SCN &mdash; Enterprise-D</button>
<button id="tab-klng">KLNGVID.SCN &mdash; Klingon flyby</button>
</nav>
<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 THIS SCENE</button>
</div>
<aside id="info" class="hud panel" aria-label="About this scene">
<h2>What you are looking at</h2>
<p>An unreleased <b>Star Trek</b> scene built at Virtual World Entertainment for the
Tesla simulator pods, recovered from a developer&rsquo;s hard drive
(<b>C:\STDAVE</b>, spring 1996). It never shipped.</p>
<p>The Enterprise&nbsp;D and Klingon ship were modeled in Wavefront OBJ, converted with
Ken Olsen&rsquo;s <b>obj2vgf</b>, and rendered on Division Ltd.&rsquo;s dVS pixel-pipeline
hardware &mdash; an i860-powered board descended from UNC&rsquo;s Pixel-Planes&nbsp;5.</p>
<h2>How this restoration works</h2>
<p>The original DIV-VIZ2 geometry (VGF), materials (VMF), textures (TGA) and scene
script (SCN) were parsed from the drive and are re-rendered here in WebGL:
same vertices, same triangles, same material colors, same fog and lights,
same drifting starfield paths.</p>
<p>Approximated: Division&rsquo;s shading ramps and rasterizer behavior, star point
sizes, and ship motion timing. The scene&rsquo;s weapon/impact particle effects
(SPECIALFX) are event-driven and are not simulated. The original ran at
30&nbsp;Hz over NTSC or SVGA.</p>
<h2>Scenes</h2>
<p><b>TREK.SCN</b> &mdash; the Enterprise&nbsp;D at rest among three parallax layers of
drifting stars, with her running lights on.</p>
<p><b>KLNGVID.SCN</b> &mdash; a Klingon ship at rest while two more drift past on
spline paths.</p>
<h2>Credits</h2>
<p>Models &amp; scene: Virtual World Entertainment, Inc., 1996 (developer
directory &ldquo;STDAVE&rdquo;). Renderer &amp; formats: Division Ltd. Restoration:
converted &amp; re-rendered 2026. Star Trek is a trademark of its respective
owners; this is a non-commercial preservation exhibit.</p>
</aside>
<script type="application/json" id="data">%%DATA%%</script>
<script>
"use strict";
const DATA = JSON.parse(document.getElementById("data").textContent);
const RAMPS = { softer: [0.3, 0.99], klingon: [0.75, 0.99], _default: [0.3, 0.99] };
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;
uniform mat4 uProj, uView, uModel;
varying vec3 vN; varying vec2 vUV; varying float vDist;
void main() {
vec4 w = uModel * vec4(aPos, 1.0);
vec4 v = uView * w;
vDist = length(v.xyz);
vN = mat3(uModel[0].xyz, uModel[1].xyz, uModel[2].xyz) * aNorm;
vUV = aUV;
gl_Position = uProj * v;
}`, `
precision mediump float;
varying vec3 vN; varying vec2 vUV; varying float vDist;
uniform vec3 uAmbScene, uMatAmb, uMatDiff, uMatEmis, uFogColor, uViewFwd;
uniform vec3 uLightDir[2]; uniform vec3 uLightCol[2];
uniform vec2 uRamp; uniform vec3 uFog; /* start, end, immune */
uniform float uHasTex; uniform sampler2D uTex;
void main() {
vec3 N = normalize(vN);
if (dot(N, uViewFwd) > 0.0) N = -N; /* double-sided */
vec3 lit = vec3(0.0);
for (int i = 0; i < 2; i++) {
float d = max(dot(N, -uLightDir[i]), 0.0);
lit += uLightCol[i] * d;
}
lit = uRamp.x + (uRamp.y - uRamp.x) * clamp(lit, 0.0, 1.0);
vec3 tex = mix(vec3(1.0), texture2D(uTex, vUV).rgb, uHasTex);
vec3 c = (uMatAmb * uAmbScene + uMatDiff * lit) * tex + uMatEmis;
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); /* soften far-plane pop */
gl_FragColor = vec4(uColor * a, 1.0);
}`);
// ---------------------------------------------------------------- geometry upload
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 textures = {}; // texname -> WebGLTexture (from DATA.images map+slice)
function getTexture(texName) {
if (!texName || !DATA.textures[texName]) return null;
const t = DATA.textures[texName];
const key = t.map + t.slice;
if (!DATA.images[key]) return null;
if (textures[key]) return textures[key];
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[key];
textures[key] = tex;
return tex;
}
const models = {}; // name -> {meshes:[], points:[]}
for (const [name, groups] of Object.entries(DATA.models)) {
const m = { meshes: [], points: [] };
for (const g of groups) {
const mat = DATA.materials[g.material] || {};
if (g.mode === "points") {
const flat = new Float32Array(g.points.flat());
m.points.push({
vbo: buf(gl.ARRAY_BUFFER, flat), count: g.points.length,
color: mat.emissive || mat.diffuse || [1, 1, 1],
});
continue;
}
if (!g.i.length) continue;
const v = new Float32Array(g.v.flat());
const idx = new Uint16Array(g.i);
const n = g.n ? new Float32Array(g.n.flat()) : computeNormals(v, idx);
const uv = g.uv ? new Float32Array(g.uv.flat()) : new Float32Array(g.v.length * 2);
const rampName = (g.material || "").includes("klng") ? "klingon" : "softer";
m.meshes.push({
vbo: buf(gl.ARRAY_BUFFER, v), nbo: buf(gl.ARRAY_BUFFER, n), tbo: buf(gl.ARRAY_BUFFER, uv),
ibo: buf(gl.ELEMENT_ARRAY_BUFFER, idx), count: idx.length,
amb: mat.ambient || [0.5, 0.5, 0.5], diff: mat.diffuse || [0.5, 0.5, 0.5],
emis: mat.emissive || [0, 0, 0], immune: mat.immune ? 1 : 0,
ramp: RAMPS[rampName] || RAMPS._default,
tex: getTexture(mat.texture),
});
}
models[name] = 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, sceneName = null;
let simTime = 0, paused = matchMedia("(prefers-reduced-motion: reduce)").matches;
function loadScene(name) {
sceneName = name;
scene = DATA.scenes[name];
const s = scene.start || [0, 10, 60, 0, 0, 0];
cam.pos = [s[0], s[1], s[2]];
cam.pitch = s[3]; cam.yaw = s[4];
simTime = 0;
document.getElementById("title").textContent = name;
document.getElementById("tab-trek").classList.toggle("active", name === "TREK.SCN");
document.getElementById("tab-klng").classList.toggle("active", name === "KLNGVID.SCN");
document.getElementById("subtitle").textContent = name === "TREK.SCN"
? "Star Trek scene — Division dVS data, re-rendered in WebGL"
: "Klingon flyby scene — Division dVS data, re-rendered in WebGL";
}
function pathPoint(spl, u) {
const n = spl.length;
if (n < 2) return { p: spl[0] || [0, 0, 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];
const p = [a[0] + (b[0] - a[0]) * f, a[1] + (b[1] - a[1]) * f, a[2] + (b[2] - a[2]) * f];
const d = [b[0] - a[0], b[1] - a[1], b[2] - a[2]];
return { p, d };
}
// instance transforms for this frame
function instanceMatrix(pos, rotDeg, scale, faceDir) {
let m = mat4mul(mat4translate(pos[0], pos[1], pos[2]), mat4scale(scale));
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);
m = mat4mul(mat4mul(mat4translate(pos[0], pos[1], pos[2]), mat4mul(mat4rotY(yaw), mat4rotX(-pitch))), mat4scale(scale));
}
} else if (rotDeg && (rotDeg[0] || rotDeg[1] || rotDeg[2])) {
const r = mat4mul(mat4rotY(rotDeg[1] * D2R), mat4mul(mat4rotX(rotDeg[0] * D2R), mat4rotZ(rotDeg[2] * D2R)));
m = mat4mul(mat4mul(mat4translate(pos[0], pos[1], pos[2]), r), mat4scale(scale));
}
return m;
}
// ---------------------------------------------------------------- controls
const keys = {};
addEventListener("keydown", e => {
keys[e.key.toLowerCase()] = true;
if (e.key.toLowerCase() === "r") loadScene(sceneName);
});
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.05;
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.15;
for (let i = 0; i < 3; i++) cam.pos[i] += f[i] * step;
}
pinchDist = d;
}
}, { passive: true });
canvas.addEventListener("touchend", () => { pinchDist = 0; });
document.getElementById("tab-trek").onclick = () => loadScene("TREK.SCN");
document.getElementById("tab-klng").onclick = () => loadScene("KLNGVID.SCN");
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);
// ---------------------------------------------------------------- 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;
// fly
const speed = (keys.shift ? 60 : 18) * dt;
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, 2700];
const proj = mat4perspective(vfov, aspect, clip[0], clip[1] * 4);
// view = R^-1 T^-1
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));
// gather instances: [modelName, matrix]
const inst = [];
for (const st of scene.statics) inst.push([st.geo, instanceMatrix(st.pos, st.rot, st.scale)]);
const tick = simTime * 30; // original ran at 30 Hz
for (const dy of scene.dynamics) {
const spl = DATA.splines[dy.spl];
if (!spl) continue;
const { p, d } = pathPoint(spl, dy.phase + dy.speed * tick);
const ship = dy.geo !== "stars";
inst.push([dy.geo, instanceMatrix(p, null, dy.scale, ship ? d : null)]);
}
// 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.1, 0.1, 0.1]);
gl.uniform3fv(ms.u.uFogColor, (scene.fog || [0,0,0,0,0]).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 { ldirs.push(0, -1, 0); lcols.push(0, 0, 0); }
}
gl.uniform3fv(ms.u.uLightDir, ldirs);
gl.uniform3fv(ms.u.uLightCol, lcols);
const fogSE = scene.fog || [clip[1] * 0.5, clip[1], 0, 0, 0];
const aPos = gl.getAttribLocation(ms.prog, "aPos");
const aNorm = gl.getAttribLocation(ms.prog, "aNorm");
const aUV = gl.getAttribLocation(ms.prog, "aUV");
gl.enableVertexAttribArray(aPos); gl.enableVertexAttribArray(aNorm); gl.enableVertexAttribArray(aUV);
gl.depthMask(true);
for (const [name, mm] of inst) {
const model = models[name];
if (!model) continue;
gl.uniformMatrix4fv(ms.u.uModel, false, mm);
for (const g of model.meshes) {
gl.uniform3fv(ms.u.uMatAmb, g.amb);
gl.uniform3fv(ms.u.uMatDiff, g.diff);
gl.uniform3fv(ms.u.uMatEmis, g.emis);
gl.uniform2fv(ms.u.uRamp, g.ramp);
gl.uniform3f(ms.u.uFog, fogSE[0], fogSE[1], g.immune);
gl.uniform1f(ms.u.uHasTex, g.tex ? 1 : 0);
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);
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, g.ibo);
gl.drawElements(gl.TRIANGLES, g.count, gl.UNSIGNED_SHORT, 0);
}
}
gl.disableVertexAttribArray(aNorm); gl.disableVertexAttribArray(aUV);
// points: stars + running lights (additive, no depth write)
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, clip[1] * 4);
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] of inst) {
// point groups of the model itself, plus its "_lp" running-light sibling
for (const src of [models[name], models[name + "_lp"]]) {
if (!src || !src.points.length) continue;
const isStars = name === "stars";
gl.uniformMatrix4fv(ps.u.uModel, false, mm);
gl.uniform1f(ps.u.uRadius, isStars ? 3.0 : 0.15);
for (const g of src.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);
}
loadScene(location.hash === "#klng" ? "KLNGVID.SCN" : "TREK.SCN");
document.getElementById("loading").remove();
requestAnimationFrame(frame);
</script>