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>
This commit is contained in:
+46
-31
@@ -1,42 +1,57 @@
|
||||
# TREK.SCN Restoration
|
||||
# Scene Restorations
|
||||
|
||||
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.
|
||||
Modern re-renderings of the Division dVS scenes recovered from the Glaze
|
||||
developer drive (see [HISTORY.md](../HISTORY.md)) and this repo's CONTENT
|
||||
tree. The originals ran on Division Ltd.'s pixel-pipeline hardware in
|
||||
1994–1997; these tools parse the original data files and re-render them in
|
||||
WebGL, in the browser.
|
||||
|
||||
## Files
|
||||
## Viewers (open in any browser, fully self-contained)
|
||||
|
||||
- **`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`.
|
||||
- **`vwe-archive.html`** — the Scene Archive: 26 curated scenes across all
|
||||
projects — Star Trek (Enterprise-D, Klingon flyby), Hull Pressure (sea
|
||||
demos, fish schools, sunken temple), BattleTech (polar map with mech
|
||||
lineup, Ravine and desert maps, stadium), Red Planet (Blade arena, the
|
||||
Mars canal milestone demo), and the Canyon/Maya-temple Division demos.
|
||||
Fly camera: drag to look, WASD to fly, R resets.
|
||||
- **`trek-scn.html`** — the original standalone Star Trek restoration
|
||||
(TREK.SCN and KLNGVID.SCN).
|
||||
|
||||
## Toolkit
|
||||
|
||||
- **`divformats.py`** — parsers for every Division format found on the
|
||||
drive: text `VGF`/`VMF` (DIV-VIZ2 geometry/materials, both the 1995
|
||||
`VERSION 2:07` and 1996 `02:05` dialects, with header SCALE support),
|
||||
binary **`BGF`/`BMF` (DIV-BIZ2)** — the binary block-stream layout was
|
||||
recovered from Division's own reader source (`DPL3/BIZREAD.C`, 1994),
|
||||
including all 14 vertex layouts, pmesh/tristrip/polygon connectivity,
|
||||
LOD blocks (newer writers nest patches inside LODs; the largest LOD is
|
||||
taken), and embedded materials/textures/ramps — plus `TGA` and raw `SVT`
|
||||
textures, `SPL` splines, and `SCN` scene scripts.
|
||||
- **`audit.py`** — scans every `.SCN` on the drive and reports what
|
||||
fraction of its geometry survives, and in which format.
|
||||
- **`extract_all.py`** — extracts every scene with ≥60 % of its geometry
|
||||
recoverable (108 of 241 on the drive; the rest reference lost models)
|
||||
into `allscenes.json`, resolving models/materials/textures across the
|
||||
drive and this repo's CONTENT dirs.
|
||||
- **`gallery_build.py`** — curates scenes from `allscenes.json` into the
|
||||
self-contained `vwe-archive.html` (via `gallery_template.html`).
|
||||
- **`convert.py` / `viewer_template.html`** — the original single-scene
|
||||
Star Trek pipeline.
|
||||
|
||||
## 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()))"
|
||||
python extract_all.py # requires the sda4/ drive dump beside this repo
|
||||
python gallery_build.py
|
||||
```
|
||||
|
||||
## 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.
|
||||
Faithful: vertices, triangles, material colors and shading ramps, textures,
|
||||
fog and light parameters, spline motion, camera start positions. Approximated:
|
||||
Division's rasterizer behavior, point sizes, timing (originals ran at 30 Hz).
|
||||
Not simulated: event-driven SPECIALFX particles. A few dev scenes had camera
|
||||
start positions inside geometry; those get overridden or auto-framed cameras
|
||||
(see `START_OVERRIDE` in `gallery_build.py`). Scenes missing one or more
|
||||
models from the drive say so in their header.
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
"""Audit every .SCN on the drive: which geometry does it need, and in what form
|
||||
(VGF text / BGF binary / missing) does that geometry survive?"""
|
||||
import os, re, sys
|
||||
from collections import defaultdict
|
||||
|
||||
SDA4 = r"c:\VWE\TeslaRel410\sda4"
|
||||
|
||||
# index every VGF/BGF/VMF/BMF/TGA on the drive by lowercase basename
|
||||
index = defaultdict(list)
|
||||
for root, dirs, files in os.walk(SDA4):
|
||||
dirs[:] = [d for d in dirs if d.upper() not in ("EDITBACK", "RECYCLED")]
|
||||
for f in files:
|
||||
base, ext = os.path.splitext(f.lower())
|
||||
if ext in (".vgf", ".bgf", ".vmf", ".bmf", ".tga", ".spl", ".bsl"):
|
||||
index[(base, ext)].append(os.path.join(root, f))
|
||||
|
||||
def parse_scene_refs(path):
|
||||
geos, mats, spls = [], set(), []
|
||||
geodir = matdir = None
|
||||
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 == "GEOMETRY": geodir = t[1]
|
||||
elif k == "MATERIAL": matdir = t[1]
|
||||
elif k in ("STATIC", "SCHILD", "DCHILD"):
|
||||
geos.append(t[1].lower())
|
||||
elif k == "DYNAMIC":
|
||||
geos.append(t[1].lower())
|
||||
spls.append(os.path.basename(t[3].replace("\\", "/")).lower())
|
||||
elif k == "MORPH":
|
||||
geos.append(t[1].lower())
|
||||
except IndexError:
|
||||
pass
|
||||
return geos, spls, geodir, matdir
|
||||
|
||||
scenes = []
|
||||
for root, dirs, files in os.walk(SDA4):
|
||||
dirs[:] = [d for d in dirs if d.upper() not in ("EDITBACK", "RECYCLED")]
|
||||
for f in files:
|
||||
if f.lower().endswith(".scn"):
|
||||
scenes.append(os.path.join(root, f))
|
||||
|
||||
report = []
|
||||
for s in sorted(scenes):
|
||||
geos, spls, geodir, matdir = parse_scene_refs(s)
|
||||
if not geos: continue
|
||||
uniq = sorted(set(g.split(":")[0] for g in geos))
|
||||
n_vgf = sum(1 for g in uniq if (g, ".vgf") in index)
|
||||
n_bgf = sum(1 for g in uniq if (g, ".bgf") in index and (g, ".vgf") not in index)
|
||||
n_missing = len(uniq) - n_vgf - n_bgf
|
||||
spl_missing = sum(1 for x in set(spls) if (os.path.splitext(x)[0], ".spl") not in index)
|
||||
rel = os.path.relpath(s, SDA4)
|
||||
report.append((rel, len(uniq), n_vgf, n_bgf, n_missing, spl_missing))
|
||||
|
||||
print(f"{'scene':<44} {'geo':>3} {'vgf':>3} {'bgf':>3} {'mis':>3} {'spl-':>4}")
|
||||
full, partial, locked = 0, 0, 0
|
||||
for rel, n, v, b, m, sm in report:
|
||||
tag = "FULL-VGF" if v == n else ("HAS-BGF" if m == 0 else "MISSING")
|
||||
if v == n: full += 1
|
||||
elif m == 0: locked += 1
|
||||
else: partial += 1
|
||||
print(f"{rel:<44} {n:>3} {v:>3} {b:>3} {m:>3} {sm:>4} {tag}")
|
||||
print(f"\n{len(report)} scenes with geometry: {full} fully VGF, {locked} need BGF, {partial} have missing geometry")
|
||||
@@ -0,0 +1,411 @@
|
||||
"""Parsers for Division Ltd. dVS/DPL file formats (1994-1996), reverse-engineered
|
||||
from the VWE 'Glaze' drive. Binary BIZ2 layout comes from Division's own reader
|
||||
source (DPL3/BIZREAD.C, PJA 1994); text formats from surviving examples.
|
||||
|
||||
Formats:
|
||||
.VGF/.VMF DIV-VIZ2 text geometry / materials
|
||||
.BGF/.BMF DIV-BIZ2 binary geometry / materials (b2z block stream)
|
||||
.SVT raw square texture, 4 B/texel, size implies 64/128/256 edge
|
||||
.TGA type-2 uncompressed 24/32-bit
|
||||
.SPL text spline paths
|
||||
.SCN text scene scripts
|
||||
"""
|
||||
import os, re, struct, zlib, base64
|
||||
|
||||
# ================================================================ helpers
|
||||
def strip_comments(text):
|
||||
text = re.sub(r"/\*.*?\*/", " ", text, flags=re.S)
|
||||
text = re.sub(r"//[^\n]*", " ", text)
|
||||
return text
|
||||
|
||||
def png_datauri(w, h, rgb_rows):
|
||||
raw = b"".join(b"\x00" + r for r in rgb_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()
|
||||
|
||||
def r3(x): return round(x, 3)
|
||||
def r4(x): return round(x, 4)
|
||||
|
||||
# ================================================================ VGF (text geometry)
|
||||
def parse_vgf(path):
|
||||
text = strip_comments(open(path, "r", errors="replace").read())
|
||||
scale = 1.0
|
||||
hm = re.search(r"HEADER\s*\((.*?)\)", text, re.S)
|
||||
if hm:
|
||||
sm = re.search(r"SCALE\s*=\s*([\d.eE+-]+)", hm.group(1))
|
||||
if sm: scale = float(sm.group(1))
|
||||
groups = []
|
||||
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 or "3D_TEXTURE" in flags
|
||||
depth, i = 1, gm.end()
|
||||
while depth > 0 and i < len(text):
|
||||
if text[i] == "{": depth += 1
|
||||
elif text[i] == "}": depth -= 1
|
||||
i += 1
|
||||
body = text[gm.end():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([r3(v * scale) for v in 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([r3(v * scale) for v in nums[k:k+3]]); k += 3
|
||||
if has_n: norms.append([r3(v) for v in nums[k:k+3]]); k += 3
|
||||
if has_uv: uvs.append([r4(v) for v in 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*\}|(?=CONNECTION_LIST))", body, re.S):
|
||||
for row in re.finditer(r"\{([^}]*)\}", cm.group(1)):
|
||||
idx = [int(x) for x in re.findall(r"\d+", row.group(1))]
|
||||
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 (text materials)
|
||||
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)
|
||||
textures[name] = {"map": mmap.group(1).lower() if mmap else None,
|
||||
"slice": int(mslice.group(1)) if mslice else None}
|
||||
ramps = {}
|
||||
for rm in re.finditer(r"RAMP\s*\(\s*NAME\s*=\s*\"?([\w-]+)\"?\s*\)\s*\{(.*?)\n\}", text, re.S):
|
||||
nums = [float(x) for x in re.findall(r"[-\d.eE+]+", rm.group(2))]
|
||||
if len(nums) >= 6: ramps[rm.group(1)] = [nums[0:3], nums[3:6]]
|
||||
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, n=3):
|
||||
m = re.search(tag + r"\s*\{([^}]*)\}", body)
|
||||
return [float(x) for x in re.findall(r"[-\d.eE+]+", m.group(1))][:n] if m else None
|
||||
mtex = re.search(r'TEXTURE\s*\{\s*"([\w-]+)"\s*\}', body)
|
||||
mramp = re.search(r"RAMP\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,
|
||||
"ramp": mramp.group(1) if mramp else None,
|
||||
"immune": "IMMUNE" in params}
|
||||
return textures, materials, ramps
|
||||
|
||||
# ================================================================ BIZ2 (binary container)
|
||||
class B2Z:
|
||||
def __init__(self, path):
|
||||
self.d = open(path, "rb").read()
|
||||
self.p = 0
|
||||
def u8(self): v = self.d[self.p]; self.p += 1; return v
|
||||
def u16(self): v = struct.unpack_from("<H", self.d, self.p)[0]; self.p += 2; return v
|
||||
def i32(self): v = struct.unpack_from("<i", self.d, self.p)[0]; self.p += 4; return v
|
||||
def f32(self): v = struct.unpack_from("<f", self.d, self.p)[0]; self.p += 4; return v
|
||||
def floats(self, n):
|
||||
v = struct.unpack_from("<%df" % n, self.d, self.p); self.p += 4 * n; return v
|
||||
def ints(self, n):
|
||||
v = struct.unpack_from("<%di" % n, self.d, self.p); self.p += 4 * n; return v
|
||||
def cstr(self):
|
||||
e = self.d.index(0, self.p)
|
||||
s = self.d[self.p:e].decode("latin-1"); self.p = e + 1; return s
|
||||
def block(self):
|
||||
hdr = self.u16()
|
||||
mode = (hdr >> 12) & 0xC
|
||||
if mode == 0x8: ln = self.i32()
|
||||
elif mode == 0x4: ln = self.u16()
|
||||
elif mode == 0x0: ln = self.u8()
|
||||
else: raise ValueError("bad block header 0x%x @%d" % (hdr, self.p))
|
||||
return hdr & 0x3fff, ln # strip length-size bits, keep 0x2000 flag
|
||||
|
||||
VTX_LAYOUT = { # tag: (floats_per_vertex, n_off, uv_off, uv3_off, col_off, lum_off)
|
||||
0x80: (3, -1, -1, -1, -1, -1),
|
||||
0x81: (6, 3, -1, -1, -1, -1),
|
||||
0x82: (7, -1, -1, -1, 3, -1),
|
||||
0x83: (10, 3, -1, -1, 6, -1),
|
||||
0x84: (5, -1, -1, -1, -1, 3),
|
||||
0x85: (8, 3, -1, -1, -1, 6),
|
||||
0x88: (5, -1, 3, -1, -1, -1),
|
||||
0x89: (8, 3, 6, -1, -1, -1),
|
||||
0x8a: (9, -1, 7, -1, 3, -1),
|
||||
0x8c: (7, -1, 5, -1, -1, 3),
|
||||
0x90: (6, -1, -1, 3, -1, -1),
|
||||
0x91: (9, 3, -1, 6, -1, -1),
|
||||
0x92: (10, -1, -1, 7, 3, -1),
|
||||
0x94: (8, -1, -1, 5, 3, -1), # lum@3, 3dtex@5 per BIZREAD.C
|
||||
}
|
||||
|
||||
def _tri_from_strip(nv):
|
||||
out = []
|
||||
for t in range(nv - 2):
|
||||
if t % 2 == 0: out += [t, t + 1, t + 2]
|
||||
else: out += [t + 1, t, t + 2]
|
||||
return out
|
||||
|
||||
def _read_vertex_block(bz, tag, ln):
|
||||
fpv, n_off, uv_off, uv3_off, col_off, lum_off = VTX_LAYOUT[tag]
|
||||
if tag == 0x94: fpv, lum_off, uv3_off = 8, 3, 5
|
||||
nv = (ln // 4) // fpv
|
||||
verts, norms, uvs, cols = [], [], [], []
|
||||
for _ in range(nv):
|
||||
f = bz.floats(fpv)
|
||||
verts.append([r3(f[0]), r3(f[1]), r3(f[2])])
|
||||
if n_off >= 0: norms.append([r3(f[n_off]), r3(f[n_off+1]), r3(f[n_off+2])])
|
||||
if uv_off >= 0: uvs.append([r4(f[uv_off]), r4(f[uv_off+1])])
|
||||
if uv3_off >= 0: uvs.append([r4(f[uv3_off]), r4(f[uv3_off+1])])
|
||||
if col_off >= 0: cols.append([r3(f[col_off]), r3(f[col_off+1]), r3(f[col_off+2])])
|
||||
if lum_off >= 0: cols.append([r3(f[lum_off])] * 3)
|
||||
return verts, norms or None, uvs or None, cols or None
|
||||
|
||||
def _parse_bgf_vertices(bz, length, geotype, out_groups, material):
|
||||
end = bz.p + length
|
||||
pend_v = pend_n = pend_uv = pend_c = None
|
||||
conns = []
|
||||
while bz.p < end:
|
||||
tag, ln = bz.block()
|
||||
nxt = bz.p + ln
|
||||
if tag == 0x47: # triangle connectivity
|
||||
for _ in range(ln // 12):
|
||||
conns.append(list(bz.ints(3)))
|
||||
elif tag == 0x4d: # polygon connectivity
|
||||
vpp = bz.u8()
|
||||
n = (ln - 1) // (vpp * 4)
|
||||
for _ in range(n):
|
||||
conns.append(list(bz.ints(vpp)))
|
||||
elif tag in VTX_LAYOUT:
|
||||
v, n, uv, c = _read_vertex_block(bz, tag, ln)
|
||||
if geotype == "pmesh":
|
||||
if pend_v is None:
|
||||
pend_v, pend_n, pend_uv, pend_c = v, n, uv, c
|
||||
else:
|
||||
pend_v += v
|
||||
if pend_n and n: pend_n += n
|
||||
if pend_uv and uv: pend_uv += uv
|
||||
if pend_c and c: pend_c += c
|
||||
else: # strip / polygon: emit immediately
|
||||
idx = (_tri_from_strip(len(v)) if geotype == "tristrip"
|
||||
else [x for t in range(1, len(v)-1) for x in (0, t, t+1)])
|
||||
out_groups.append({"material": material, "mode": "mesh",
|
||||
"v": v, "n": n, "uv": uv, "c": c, "i": idx})
|
||||
bz.p = nxt
|
||||
if geotype == "pmesh" and pend_v is not None:
|
||||
idx = []
|
||||
for poly in conns:
|
||||
for t in range(1, len(poly) - 1):
|
||||
idx += [poly[0], poly[t], poly[t + 1]]
|
||||
out_groups.append({"material": material, "mode": "mesh",
|
||||
"v": pend_v, "n": pend_n, "uv": pend_uv,
|
||||
"c": pend_c, "i": idx})
|
||||
|
||||
def _parse_bgf_patch(bz, length, out_groups, obj_mtl):
|
||||
end = bz.p + length
|
||||
f_mtl = obj_mtl
|
||||
pend = [] # geometry blocks seen before the material tag
|
||||
while bz.p < end:
|
||||
tag, ln = bz.block()
|
||||
nxt = bz.p + ln
|
||||
if tag == 0x2008: bz.cstr()
|
||||
elif tag in (0x2030, 0x2031):
|
||||
t = bz.u8()
|
||||
name = bz.cstr() if t == 1 else (None if t != 2 else "DEFAULT")
|
||||
if tag == 0x2030: f_mtl = name or f_mtl
|
||||
elif tag == 0x43: _parse_bgf_vertices(bz, ln, "polygon", pend, None)
|
||||
elif tag == 0x44: _parse_bgf_vertices(bz, ln, "tristrip", pend, None)
|
||||
elif tag == 0x45: _parse_bgf_vertices(bz, ln, "tristrip", pend, None)
|
||||
elif tag == 0x46: _parse_bgf_vertices(bz, ln, "pmesh", pend, None)
|
||||
bz.p = nxt
|
||||
for g in pend:
|
||||
g["material"] = f_mtl
|
||||
out_groups.append(g)
|
||||
|
||||
def parse_bgf(path):
|
||||
"""Parse DIV-BIZ2 geometry -> groups like parse_vgf. Also returns any
|
||||
embedded materials/textures/ramps."""
|
||||
bz = B2Z(path)
|
||||
if bz.d[:8] != b"DIV-BIZ2":
|
||||
raise ValueError("not a DIV-BIZ2 file: " + path)
|
||||
bz.p = 8
|
||||
groups, materials, textures, ramps = [], {}, {}, {}
|
||||
while bz.p < len(bz.d) - 2:
|
||||
tag, ln = bz.block()
|
||||
nxt = bz.p + ln
|
||||
top = tag & 0xfff
|
||||
if top == 0x005: break # trailer
|
||||
elif top == 0x003 or top == 0x004: pass
|
||||
elif top == 0x010: _parse_biz_texture(bz, ln, textures)
|
||||
elif top == 0x020: _parse_biz_material(bz, ln, materials)
|
||||
elif top == 0x030: _parse_biz_ramp(bz, ln, ramps)
|
||||
elif top == 0x040: _parse_bgf_object(bz, ln, groups)
|
||||
bz.p = nxt
|
||||
return groups, materials, textures, ramps
|
||||
|
||||
def _parse_bgf_lod(bz, length, groups, obj_mtl):
|
||||
end = bz.p + length
|
||||
while bz.p < end:
|
||||
tag, ln = bz.block()
|
||||
nxt = bz.p + ln
|
||||
if tag == 0x42: _parse_bgf_patch(bz, ln, groups, obj_mtl)
|
||||
bz.p = nxt
|
||||
|
||||
def _parse_bgf_object(bz, length, groups):
|
||||
end = bz.p + length
|
||||
obj_mtl = None
|
||||
lods = [] # (size, offset) of nested LOD blocks
|
||||
while bz.p < end:
|
||||
tag, ln = bz.block()
|
||||
nxt = bz.p + ln
|
||||
if tag == 0x2008: bz.cstr()
|
||||
elif tag == 0x2030:
|
||||
t = bz.u8()
|
||||
if t == 1: obj_mtl = bz.cstr()
|
||||
elif tag == 0x2031:
|
||||
t = bz.u8()
|
||||
if t == 1: bz.cstr()
|
||||
elif tag == 0x42: _parse_bgf_patch(bz, ln, groups, obj_mtl)
|
||||
elif tag == 0x41: lods.append((ln, bz.p))
|
||||
bz.p = nxt
|
||||
if lods:
|
||||
# newer writers nest patches inside LODs (Division's 1994 reader
|
||||
# predates this); take the largest LOD = highest detail
|
||||
ln, off = max(lods)
|
||||
bz.p = off
|
||||
_parse_bgf_lod(bz, ln, groups, obj_mtl)
|
||||
bz.p = end
|
||||
|
||||
def _parse_biz_material(bz, length, materials):
|
||||
end = bz.p + length
|
||||
m = {"ambient": None, "diffuse": None, "emissive": None,
|
||||
"texture": None, "ramp": None, "immune": False}
|
||||
name = "unnamed"
|
||||
while bz.p < end:
|
||||
tag, ln = bz.block()
|
||||
nxt = bz.p + ln
|
||||
if tag == 0x2008: name = bz.cstr()
|
||||
elif tag == 0x0021:
|
||||
mode = bz.u8()
|
||||
if mode == 2: m["texture"] = bz.cstr()
|
||||
elif tag == 0x0023: m["ambient"] = [r3(x) for x in bz.floats(3)]
|
||||
elif tag == 0x0024: m["diffuse"] = [r3(x) for x in bz.floats(3)]
|
||||
elif tag == 0x0026: m["emissive"] = [r3(x) for x in bz.floats(3)]
|
||||
elif tag == 0x0028: m["ramp"] = bz.cstr()
|
||||
bz.p = nxt
|
||||
materials[name] = m
|
||||
|
||||
def _parse_biz_texture(bz, length, textures):
|
||||
end = bz.p + length
|
||||
name, mapname = None, None
|
||||
while bz.p < end:
|
||||
tag, ln = bz.block()
|
||||
nxt = bz.p + ln
|
||||
if tag == 0x2008: name = bz.cstr()
|
||||
elif tag == 0x0011: mapname = bz.cstr()
|
||||
bz.p = nxt
|
||||
if name: textures[name] = {"map": (mapname or "").lower() or None, "slice": None}
|
||||
|
||||
def _parse_biz_ramp(bz, length, ramps):
|
||||
end = bz.p + length
|
||||
name, data = None, None
|
||||
while bz.p < end:
|
||||
tag, ln = bz.block()
|
||||
nxt = bz.p + ln
|
||||
if tag == 0x2008: name = bz.cstr()
|
||||
elif tag == 0x0031:
|
||||
f = bz.floats(6); data = [list(f[0:3]), list(f[3:6])]
|
||||
bz.p = nxt
|
||||
if name and data: ramps[name] = data
|
||||
|
||||
# ================================================================ textures
|
||||
def tga_datauri(path):
|
||||
d = open(path, "rb").read()
|
||||
idlen, imgtype = d[0], d[2]
|
||||
w, h = struct.unpack("<HH", d[12:16])
|
||||
bpp, desc = d[16], d[17]
|
||||
if imgtype != 2 or bpp not in (24, 32): raise ValueError("TGA %s type=%d bpp=%d" % (path, imgtype, 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):
|
||||
row += bytes((d[base+x*nb+2], d[base+x*nb+1], d[base+x*nb]))
|
||||
rows.append(bytes(row))
|
||||
if not (desc & 0x20): rows.reverse()
|
||||
return png_datauri(w, h, rows)
|
||||
|
||||
def svt_datauri(path):
|
||||
d = open(path, "rb").read()
|
||||
edge = {16384: 64, 65536: 128, 262144: 256}.get(len(d))
|
||||
if not edge: raise ValueError("SVT %s bad size %d" % (path, len(d)))
|
||||
rows = []
|
||||
for y in range(edge):
|
||||
row = bytearray()
|
||||
base = y * edge * 4
|
||||
for x in range(edge):
|
||||
# texel layout: X R G B (first byte unused/alpha)
|
||||
row += d[base + x*4 + 1: base + x*4 + 4]
|
||||
rows.append(bytes(row))
|
||||
return png_datauri(edge, edge, rows)
|
||||
|
||||
# ================================================================ SPL / SCN
|
||||
def parse_spl(path):
|
||||
pts = []
|
||||
for l in open(path, errors="replace").read().splitlines()[1:]:
|
||||
try: nums = [float(x) for x in l.split()]
|
||||
except ValueError: continue
|
||||
if len(nums) >= 3: pts.append([r3(v) for v in nums[:3]])
|
||||
return pts
|
||||
|
||||
def parse_scn(path):
|
||||
scene = {"lights": [], "placements": []}
|
||||
last = None
|
||||
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 in ("GEOMETRY", "MATERIAL", "TEXTURE"): scene[k.lower()] = t[1]
|
||||
elif k == "STATIC":
|
||||
last = {"kind": "static", "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]],
|
||||
"children": []}
|
||||
scene["placements"].append(last)
|
||||
elif k == "DYNAMIC":
|
||||
last = {"kind": "dynamic", "geo": t[1].lower(), "scale": float(t[2]),
|
||||
"spl": os.path.basename(t[3].replace("\\", "/")).lower(),
|
||||
"phase": float(t[4]), "speed": float(t[5]), "children": []}
|
||||
scene["placements"].append(last)
|
||||
elif k in ("SCHILD", "DCHILD") and last is not None:
|
||||
last["children"].append({"geo": t[1].lower(), "scale": float(t[2])})
|
||||
except (ValueError, IndexError):
|
||||
pass
|
||||
return scene
|
||||
@@ -0,0 +1,231 @@
|
||||
"""Extract every recoverable scene on the Glaze drive to one master JSON."""
|
||||
import os, re, json, hashlib, traceback
|
||||
from collections import defaultdict
|
||||
import divformats as df
|
||||
|
||||
SDA4 = r"c:\VWE\TeslaRel410\sda4"
|
||||
MIN_COVERAGE = 0.6
|
||||
|
||||
# ---------------------------------------------------------------- file index
|
||||
# sda4 dev drive + the release repo's CONTENT (same-era Division files)
|
||||
ROOTS = [SDA4, r"c:\VWE\TeslaRel410\CONTENT"]
|
||||
index = defaultdict(list)
|
||||
for r in ROOTS:
|
||||
for root, dirs, files in os.walk(r):
|
||||
dirs[:] = [d for d in dirs if d.upper() not in ("EDITBACK", "RECYCLED")]
|
||||
for f in files:
|
||||
base, ext = os.path.splitext(f.lower())
|
||||
index[(base, ext)].append(os.path.join(root, f))
|
||||
|
||||
def nearest(cands, refdir):
|
||||
"""Pick candidate path sharing the longest prefix with refdir."""
|
||||
def score(p):
|
||||
a, b = p.lower(), refdir.lower()
|
||||
n = 0
|
||||
while n < min(len(a), len(b)) and a[n] == b[n]: n += 1
|
||||
return n
|
||||
return max(cands, key=score)
|
||||
|
||||
def find(base, exts, refdir):
|
||||
for ext in exts:
|
||||
c = index.get((base.lower(), ext))
|
||||
if c: return nearest(c, refdir)
|
||||
return None
|
||||
|
||||
# ---------------------------------------------------------------- caches
|
||||
model_cache = {} # abspath -> model id
|
||||
models = {} # id -> groups
|
||||
image_cache = {} # abspath -> image id
|
||||
images = {} # id -> datauri
|
||||
mat_file_cache = {} # abspath -> (textures, materials, ramps)
|
||||
|
||||
def load_model(path):
|
||||
if path in model_cache: return model_cache[path]
|
||||
mid = "m%d" % len(models)
|
||||
if path.lower().endswith(".vgf"):
|
||||
groups = df.parse_vgf(path)
|
||||
extra = ({}, {}, {})
|
||||
else:
|
||||
groups, m, t, r = df.parse_bgf(path)
|
||||
extra = (m, t, r)
|
||||
models[mid] = groups
|
||||
model_cache[path] = (mid, extra)
|
||||
return model_cache[path]
|
||||
|
||||
def load_image(path):
|
||||
if path in image_cache: return image_cache[path]
|
||||
try:
|
||||
uri = df.tga_datauri(path) if path.lower().endswith(".tga") else df.svt_datauri(path)
|
||||
except Exception:
|
||||
return None
|
||||
iid = "i%d" % len(images)
|
||||
images[iid] = uri
|
||||
image_cache[path] = iid
|
||||
return iid
|
||||
|
||||
def load_mat_file(path):
|
||||
if path in mat_file_cache: return mat_file_cache[path]
|
||||
if path.lower().endswith(".vmf"):
|
||||
t, m, r = df.parse_vmf(path, "x")
|
||||
m = {k.split(":", 1)[1]: v for k, v in m.items()}
|
||||
else:
|
||||
g, m, t, r = df.parse_bgf(path)
|
||||
mat_file_cache[path] = (t, m, r)
|
||||
return mat_file_cache[path]
|
||||
|
||||
def resolve_texture_image(tex, refdir):
|
||||
"""tex: {map, slice} -> image id or None"""
|
||||
if not tex or not tex.get("map"): return None
|
||||
m, sl = tex["map"], tex.get("slice")
|
||||
tries = []
|
||||
if sl is not None: tries.append(("%s%d" % (m, sl + 1), (".tga",)))
|
||||
dm = re.match(r"(.+?)(\d)$", m)
|
||||
tries.append((m, (".tga", ".svt")))
|
||||
if dm: tries.append((dm.group(1) + dm.group(2), (".tga", ".svt")))
|
||||
for base, exts in tries:
|
||||
p = find(base, exts, refdir)
|
||||
if p: return load_image(p)
|
||||
return None
|
||||
|
||||
def resolve_material(ref, refdir, embedded):
|
||||
"""ref like 'file:name' or 'name'. Returns resolved material dict or None."""
|
||||
if not ref: return None
|
||||
if ":" in ref: fbase, name = ref.split(":", 1)
|
||||
else: fbase, name = None, ref
|
||||
# 1) material file <fbase>.vmf/.bmf
|
||||
if fbase:
|
||||
p = find(fbase, (".vmf", ".bmf"), refdir)
|
||||
if p:
|
||||
t, m, r = load_mat_file(p)
|
||||
if name in m:
|
||||
return finish_material(m[name], t, r, refdir)
|
||||
# 2) embedded in the BGF itself
|
||||
em, et, er = embedded
|
||||
if name in em:
|
||||
return finish_material(em[name], et, er, refdir)
|
||||
if fbase: # sometimes embedded keyed with prefix
|
||||
if ref in em: return finish_material(em[ref], et, er, refdir)
|
||||
return None
|
||||
|
||||
def finish_material(m, texdict, rampdict, refdir):
|
||||
out = {"ambient": m.get("ambient"), "diffuse": m.get("diffuse"),
|
||||
"emissive": m.get("emissive"), "immune": m.get("immune", False)}
|
||||
tex = m.get("texture")
|
||||
if tex:
|
||||
tinfo = texdict.get(tex)
|
||||
if tinfo is None and re.match(r".*_tex$", tex):
|
||||
tinfo = {"map": tex[:-4], "slice": None}
|
||||
# heuristic: trailing digit in texture name selects TGA slice
|
||||
if tinfo and tinfo.get("slice") is None:
|
||||
dm = re.match(r"(.+?)(\d)_?tex$", tex)
|
||||
if dm and tinfo.get("map") and not re.search(r"\d$", tinfo["map"]):
|
||||
tinfo = {"map": tinfo["map"], "slice": int(dm.group(2)) - 1}
|
||||
out["img"] = resolve_texture_image(tinfo, refdir)
|
||||
rname = m.get("ramp")
|
||||
if rname and rname in rampdict:
|
||||
out["ramp"] = rampdict[rname]
|
||||
return out
|
||||
|
||||
# ---------------------------------------------------------------- scenes
|
||||
def dos_to_real(dospath, scndir):
|
||||
"""Map a scene's GEOMETRY/MATERIAL dir (DOS path) onto the dump."""
|
||||
if not dospath: return scndir
|
||||
p = dospath.replace("\\", "/").strip()
|
||||
p = re.sub(r"^[A-Za-z]:", "", p).lstrip("/")
|
||||
cand = os.path.join(SDA4, *p.split("/"))
|
||||
if os.path.isdir(cand): return cand
|
||||
return scndir
|
||||
|
||||
seen_hashes = set()
|
||||
scenes_out = {}
|
||||
skipped = []
|
||||
|
||||
PRIORITY = ["STDAVE", "HPDAVE", "CANYON", "BTDAVE", "BTRAVINE", "RPDAVE", "ERIC",
|
||||
"FX", "RD", "CONVERT", "DPL3RLS", "DPL3", "PROBLEMS", "SPHERES"]
|
||||
def prio(rel):
|
||||
top = rel.split(os.sep)[0].upper()
|
||||
return PRIORITY.index(top) if top in PRIORITY else 99
|
||||
|
||||
all_scn = []
|
||||
for root, dirs, files in os.walk(SDA4):
|
||||
dirs[:] = [d for d in dirs if d.upper() not in ("EDITBACK", "RECYCLED")]
|
||||
for f in files:
|
||||
if f.lower().endswith(".scn"):
|
||||
all_scn.append(os.path.join(root, f))
|
||||
all_scn.sort(key=lambda p: (prio(os.path.relpath(p, SDA4)), p))
|
||||
|
||||
for scn_path in all_scn:
|
||||
rel = os.path.relpath(scn_path, SDA4)
|
||||
try:
|
||||
content = open(scn_path, "rb").read()
|
||||
h = hashlib.md5(content).hexdigest()
|
||||
if h in seen_hashes:
|
||||
skipped.append((rel, "duplicate")); continue
|
||||
scene = df.parse_scn(scn_path)
|
||||
pls = scene["placements"]
|
||||
if not pls:
|
||||
skipped.append((rel, "no placements")); continue
|
||||
scndir = os.path.dirname(scn_path)
|
||||
geodir = dos_to_real(scene.get("geometry"), scndir)
|
||||
|
||||
needed = []
|
||||
for p in pls:
|
||||
needed.append(p["geo"].split(":")[0])
|
||||
for c in p["children"]: needed.append(c["geo"].split(":")[0])
|
||||
uniq = sorted(set(needed))
|
||||
found_models, missing = {}, []
|
||||
for g in uniq:
|
||||
fp = find(g, (".vgf",), geodir) or find(g, (".bgf",), geodir)
|
||||
if fp: found_models[g] = fp
|
||||
else: missing.append(g)
|
||||
if len(found_models) == 0 or len(found_models) / len(uniq) < MIN_COVERAGE:
|
||||
skipped.append((rel, "coverage %d/%d" % (len(found_models), len(uniq))))
|
||||
continue
|
||||
|
||||
# load models + materials
|
||||
used_models, used_mats = {}, {}
|
||||
for g, fp in found_models.items():
|
||||
mid, embedded = load_model(fp)
|
||||
used_models[g] = mid
|
||||
for grp in models[mid]:
|
||||
mref = grp.get("material")
|
||||
if mref and mref not in used_mats:
|
||||
r = resolve_material(mref, os.path.dirname(fp), embedded)
|
||||
if r is None:
|
||||
r = resolve_material(mref, dos_to_real(scene.get("material"), scndir), embedded)
|
||||
used_mats[mref] = r or {}
|
||||
# splines
|
||||
spls = {}
|
||||
for p in pls:
|
||||
if p["kind"] == "dynamic":
|
||||
sp = find(os.path.splitext(p["spl"])[0], (".spl",), scndir)
|
||||
if sp: spls[p["spl"]] = df.parse_spl(sp)
|
||||
placements = [p for p in pls if p["geo"].split(":")[0] in used_models
|
||||
and (p["kind"] != "dynamic" or p["spl"] in spls)]
|
||||
for p in placements:
|
||||
p["children"] = [c for c in p["children"] if c["geo"].split(":")[0] in used_models]
|
||||
|
||||
scenes_out[rel.replace(os.sep, "/")] = {
|
||||
"fov": scene.get("fov", 60), "clip": scene.get("clip", [1, 5000]),
|
||||
"fog": scene.get("fog"), "bg": scene.get("bg", [0, 0, 0]),
|
||||
"ambient": scene.get("ambient", [0.2, 0.2, 0.2]),
|
||||
"lights": scene.get("lights", []), "start": scene.get("start"),
|
||||
"placements": placements, "modelmap": used_models,
|
||||
"materials": used_mats, "splines": spls, "missing": missing,
|
||||
}
|
||||
seen_hashes.add(h)
|
||||
nv = sum(len(g.get("v", g.get("points", []))) for m in used_models.values() for g in models[m])
|
||||
print("OK %-46s models=%d/%d verts=%d" % (rel, len(found_models), len(uniq), nv))
|
||||
except Exception as e:
|
||||
skipped.append((rel, "ERROR " + str(e)))
|
||||
print("ERR %-46s %s" % (rel, e))
|
||||
traceback.print_exc()
|
||||
|
||||
out = {"models": models, "images": images, "scenes": scenes_out}
|
||||
dst = os.path.join(os.path.dirname(os.path.abspath(__file__)), "allscenes.json")
|
||||
with open(dst, "w") as fh:
|
||||
json.dump(out, fh, separators=(",", ":"))
|
||||
print("\n%d scenes extracted, %d skipped" % (len(scenes_out), len(skipped)))
|
||||
print("models=%d images=%d size=%.1f MB" % (len(models), len(images), os.path.getsize(dst) / 1e6))
|
||||
with open("extract_report.txt", "w") as fh:
|
||||
for rel, why in skipped: fh.write("%s\t%s\n" % (rel, why))
|
||||
@@ -0,0 +1,111 @@
|
||||
"""Build the gallery HTML: curated scenes from allscenes.json injected into
|
||||
gallery_template.html."""
|
||||
import json, os
|
||||
|
||||
CURATED = [
|
||||
# (scene key, project, display name, blurb)
|
||||
("STDAVE/TREK.SCN", "Star Trek", "Enterprise-D",
|
||||
"The Enterprise-D at rest among three parallax layers of drifting stars."),
|
||||
("STDAVE/KLNGVID.SCN", "Star Trek", "Klingon flyby",
|
||||
"A Klingon cruiser at rest while two more drift past on spline paths."),
|
||||
("STDAVE/TREKVID.SCN", "Star Trek", "Enterprise flyby",
|
||||
"Camera-pass variant of the Enterprise scene, built for video capture."),
|
||||
("HPDAVE/SDEMO.SCN", "Hull Pressure", "Sea demo",
|
||||
"The big Hull Pressure demo: seafloor, wrecks, and marine life on spline paths."),
|
||||
("HPDAVE/FISHSPLS.SCN", "Hull Pressure", "Fish schools",
|
||||
"Schools of fish and sharks swimming spline circuits over the seabed."),
|
||||
("HPDAVE/BDDEMO.SCN", "Hull Pressure", "Big demo",
|
||||
"Full underwater environment demo for the unreleased submarine game."),
|
||||
("HPDAVE/TMPRIGS.SCN", "Hull Pressure", "Temple rigs",
|
||||
"Sunken temple environment with rig structures."),
|
||||
("HPDAVE/DUANE.SCN", "Hull Pressure", "Duane's scene",
|
||||
"An underwater scene named for one of the team."),
|
||||
("HPDAVE/SANDTEST.SCN", "Hull Pressure", "Sand test",
|
||||
"Seafloor sand and lighting test."),
|
||||
("BTDAVE/MAPS/POLAR4.SCN", "BattleTech", "Polar map",
|
||||
"The polar arena environment, assembled from 99 models."),
|
||||
("BTRAVINE/RAVTEST.SCN", "BattleTech", "Ravine map",
|
||||
"The Ravine arena in development — this environment shipped as RAV/RAV1."),
|
||||
("BTRAVINE/DESTEST.SCN", "BattleTech", "Desert test",
|
||||
"Desert environment test with full map furniture."),
|
||||
("BTDAVE/MAPS/DTEST.SCN", "BattleTech", "Desert map test",
|
||||
"A smaller desert map assembly test."),
|
||||
("CONVERT/BTECH/STAD/STAD.SCN", "BattleTech", "Stadium",
|
||||
"Arena stadium model in the art-conversion workspace."),
|
||||
("DPL3RLS/SCENES/MISSILE.SCN", "BattleTech", "Missile test",
|
||||
"Weapon-effects staging scene with arena props."),
|
||||
("RPDAVE/SCENES/BLADE.SCN", "Red Planet", "Blade arena",
|
||||
"The Blade racing arena — late Red Planet development work."),
|
||||
("CONVERT/MILESTON/MILESTON.SCN", "Red Planet", "Milestone",
|
||||
"The Red Planet milestone demo: Mars mining-canal raceway."),
|
||||
("CONVERT/MILESTON/WELLENT.SCN", "Red Planet", "Well entrance",
|
||||
"Raceway well-entrance sequence from the milestone build."),
|
||||
("CONVERT/RD/CHOICE2.SCN", "Red Planet", "Choice point",
|
||||
"A raceway fork (“choice”) section of the Mars canals."),
|
||||
("RD/VTER.SCN", "Red Planet", "Terrain",
|
||||
"Generated Mars terrain heightfield test."),
|
||||
("CANYON/CANYON.SCN", "Canyon demo", "Canyon",
|
||||
"River-canyon flythrough environment — Division demo work."),
|
||||
("CANYON/CANABOVE.SCN", "Canyon demo", "Canyon from above",
|
||||
"The canyon seen from altitude."),
|
||||
("CANYON/TEMPLE.SCN", "Canyon demo", "Maya temple",
|
||||
"Mayan temple set piece at the canyon rim."),
|
||||
("ERIC/VANSMAK.SCN", "Misc", "Van's MAK",
|
||||
"A 31-model scene from developer Eric's directory."),
|
||||
("HPDAVE/BATEST.SCN", "Hull Pressure", "Bathysphere test",
|
||||
"Single-model test of the submersible."),
|
||||
("CONVERT/CALIB/DIVCAL.SCN", "Misc", "Division calibration",
|
||||
"Display-calibration model used to align the pod projectors."),
|
||||
]
|
||||
|
||||
# camera overrides for scenes whose START sits inside geometry (dev scenes);
|
||||
# None = use the viewer's auto-framing instead
|
||||
START_OVERRIDE = {
|
||||
"BTRAVINE/RAVTEST.SCN": [280, 30, 160, -8, 25, 0],
|
||||
"CANYON/CANYON.SCN": None,
|
||||
"CANYON/CANABOVE.SCN": None,
|
||||
"CANYON/TEMPLE.SCN": None,
|
||||
"BTDAVE/MAPS/DTEST.SCN": None,
|
||||
"CONVERT/CALIB/DIVCAL.SCN": None,
|
||||
}
|
||||
|
||||
here = os.path.dirname(os.path.abspath(__file__))
|
||||
data = json.load(open(os.path.join(here, "allscenes.json")))
|
||||
|
||||
out = {"models": {}, "images": {}, "scenes": {}, "catalog": []}
|
||||
for key, project, name, blurb in CURATED:
|
||||
sc = data["scenes"].get(key)
|
||||
if not sc:
|
||||
print("MISSING SCENE:", key)
|
||||
continue
|
||||
if key in START_OVERRIDE:
|
||||
sc = dict(sc); sc["start"] = START_OVERRIDE[key]
|
||||
if sc["start"] is None: del sc["start"]
|
||||
out["scenes"][key] = sc
|
||||
out["catalog"].append({"key": key, "project": project, "name": name, "blurb": blurb})
|
||||
for mid in sc["modelmap"].values():
|
||||
out["models"][mid] = data["models"][mid]
|
||||
for mat in sc["materials"].values():
|
||||
if mat and mat.get("img"):
|
||||
out["images"][mat["img"]] = data["images"][mat["img"]]
|
||||
|
||||
# per-model bounding radius for auto-framing
|
||||
for mid, groups in out["models"].items():
|
||||
lo = [1e9] * 3; hi = [-1e9] * 3
|
||||
for g in groups:
|
||||
for v in g.get("v", g.get("points", [])):
|
||||
for i in range(3):
|
||||
lo[i] = min(lo[i], v[i]); hi[i] = max(hi[i], v[i])
|
||||
if lo[0] > hi[0]: lo = hi = [0, 0, 0]
|
||||
for g in groups: pass
|
||||
c = [(lo[i] + hi[i]) / 2 for i in range(3)]
|
||||
r = max(hi[i] - lo[i] for i in range(3)) / 2 or 1
|
||||
out.setdefault("bounds", {})[mid] = {"c": [round(x, 2) for x in c], "r": round(r, 2)}
|
||||
|
||||
blob = json.dumps(out, separators=(",", ":"))
|
||||
print("gallery data: %.1f MB, %d scenes, %d models, %d images"
|
||||
% (len(blob) / 1e6, len(out["scenes"]), len(out["models"]), len(out["images"])))
|
||||
tpl = open(os.path.join(here, "gallery_template.html"), encoding="utf-8").read()
|
||||
open(os.path.join(here, "vwe-archive.html"), "w", encoding="utf-8").write(
|
||||
tpl.replace("%%DATA%%", blob))
|
||||
print("wrote vwe-archive.html", os.path.getsize(os.path.join(here, "vwe-archive.html")) / 1e6, "MB")
|
||||
@@ -0,0 +1,648 @@
|
||||
<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>
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user