Bring the graphics-dev collaborator's dpl3-revive into the repo as first-class
project code (they've handed it off; it's ours now). This is the proven
Division renderer that our in-process rt_draw has been trying to be.
What's here:
- parser/ B2Z/V2Z/SVT/SCN/SPL/BGF/BMF/BSL decoders (pure Python).
- spec/ reverse-engineered format + the definitive VelociRender wire
protocol (from the original DIVISION source, matches our live
VPX node/action tables exactly).
- source-ref/ read-only copies of the original DIVISION C (BIZREAD.C,
DPLTYPES.H, DPL.H) that define the formats.
- patha/ the "virtual VelociRender board": vrboard.py (24-action protocol
server), vrview.py (numpy software rasterizer, the reference),
vrview_gl.py (moderngl GPU backend, 832x512@60Hz), plus the
run/replay/regress tooling and evidence renders. Drives FLYK/BLADE/
Star Trek demos AND our btl4opt/rpl4opt game binaries.
- viewer/ WebGL archive generators (.py); prebuilt HTML/data regeneratable.
- samples/ small test models/textures.
- bt*.raw.bin real BTL4OPT arena wire captures (kept for offline renderer
testing/regression against OUR game).
.gitignore keeps the multi-hundred-MB demo capture dumps + debug logs +
regeneratable viewer bundles out of history (they stay on disk).
Phase 0 of the integration is validated: their board decodes our bt8 capture
with zero errors (3748 nodes, 507 instances, 4 mechs) and renders our arena
(terrain/dome/sky, correct Division DAC gamma). Plan + status in memory;
integration continues in emulator/RENDERER-COLLAB.md.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
948 lines
43 KiB
Python
948 lines
43 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
bundle.py -- pack DPL3 models (.B2Z) + textures (.SVT) into a single, self-contained
|
||
WebGL viewer HTML. No server, no dependencies, no external assets: geometry is inlined
|
||
as JSON and textures as base64 RGBA. Open the output in any browser.
|
||
|
||
python viewer/bundle.py # builds viewer/dpl3-viewer.html from SHOWCASE
|
||
|
||
The viewer proves the whole recovered art pipeline end-to-end: B2Z geometry + UVs
|
||
rendered on modern hardware, textured with decoded SVT bitmaps.
|
||
"""
|
||
import base64
|
||
import glob
|
||
import json
|
||
import math
|
||
import os
|
||
import re
|
||
import sys
|
||
|
||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||
ROOT = os.path.dirname(HERE)
|
||
sys.path.insert(0, os.path.join(ROOT, "parser"))
|
||
import b2z
|
||
import scn
|
||
import spl
|
||
import bsl
|
||
import gamemap
|
||
import svt
|
||
|
||
# the read-only source archive (models/textures are resolved by name from here)
|
||
ARCHIVE = r"s:/OneDrive/Tesla III/DaveMcCoy/sda4"
|
||
|
||
|
||
# ---- what to put in the viewer ---------------------------------------------
|
||
# Each model: file + per-material {color | texture}. Material is matched by the
|
||
# f_material name carried in the B2Z; "*" is the fallback for any other material.
|
||
SAMPLES = os.path.join(ROOT, "samples")
|
||
SHOWCASE = [
|
||
{
|
||
"name": "Raptor terrain flyover (fly-through)",
|
||
"scene": "DPL3RLS/SCENES/RAPTOR.SCN",
|
||
"spline": "DPL3RLS/EXAMPLES/CAMERA.SPL",
|
||
"up": "y",
|
||
},
|
||
{
|
||
"name": "Red Planet canal (assembled scene, 47 objects)",
|
||
"scene": "DPL3RLS/SCENES/CANAL.SCN",
|
||
"up": "y",
|
||
},
|
||
{
|
||
"name": "Aquarium reef (underwater scene)",
|
||
"scene": "DPL3/EXAMPLES/SHARKS.SCN",
|
||
"up": "y",
|
||
},
|
||
{
|
||
"name": "Star Trek — the Tesla (TREK.SCN)",
|
||
"scene": "STDAVE/TREK.SCN",
|
||
"up": "y",
|
||
},
|
||
{
|
||
"name": "Red Planet canyon (textured terrain)",
|
||
"file": "CANYONS1.B2Z",
|
||
"up": "z",
|
||
"materials": {
|
||
"redrock_mtl": {"texture": "ROCKNOIZ.SVT", "tint": [1.0, 0.55, 0.4]},
|
||
"water1_mtl": {"color": [0.15, 0.28, 0.5]},
|
||
"*": {"texture": "ROCKNOIZ.SVT", "tint": [1.0, 0.6, 0.45]},
|
||
},
|
||
},
|
||
{
|
||
"name": "Test box (multi-material, per-part colour)",
|
||
"file": "TESTBOX.B2Z",
|
||
"up": "y",
|
||
"materials": {"*": {"from_material": True}},
|
||
},
|
||
{
|
||
"name": "Sphere (smooth-shaded)",
|
||
"file": "BALL.B2Z",
|
||
"up": "y",
|
||
"materials": {"*": {"color": [0.72, 0.74, 0.8]}},
|
||
},
|
||
]
|
||
|
||
|
||
def compute_normals(positions, indices):
|
||
n = [0.0] * len(positions)
|
||
for t in range(0, len(indices), 3):
|
||
i, j, k = indices[t] * 3, indices[t + 1] * 3, indices[t + 2] * 3
|
||
ax, ay, az = positions[i], positions[i + 1], positions[i + 2]
|
||
bx, by, bz = positions[j], positions[j + 1], positions[j + 2]
|
||
cx, cy, cz = positions[k], positions[k + 1], positions[k + 2]
|
||
ux, uy, uz = bx - ax, by - ay, bz - az
|
||
vx, vy, vz = cx - ax, cy - ay, cz - az
|
||
nx, ny, nz = uy * vz - uz * vy, uz * vx - ux * vz, ux * vy - uy * vx
|
||
for base in (i, j, k):
|
||
n[base] += nx; n[base + 1] += ny; n[base + 2] += nz
|
||
for o in range(0, len(n), 3):
|
||
L = math.sqrt(n[o]**2 + n[o+1]**2 + n[o+2]**2) or 1.0
|
||
n[o] /= L; n[o+1] /= L; n[o+2] /= L
|
||
return n
|
||
|
||
|
||
_TEX_INFO = {}
|
||
|
||
|
||
def load_texture_rgba(path):
|
||
"""Decode a texture ref to (edge, rgba, is_luminance).
|
||
Refs are .SVT paths or ("bsl", path, slice) bit-slice picks.
|
||
Full-colour painted skins (vehicle textures) are shown as-is. Greyscale 'noise'
|
||
maps, bit-slices, and broken intensity maps (a dead colour channel) are rebuilt
|
||
as luminance and flagged -- those get coloured by the material's diffuse."""
|
||
if path in _TEX_INFO:
|
||
return _TEX_INFO[path]
|
||
if isinstance(path, tuple) and path[0] == "bsl":
|
||
b = bsl.load(path[1])
|
||
rgba = bsl.slice_plane(b, path[2])
|
||
res = (b["edge"], rgba, True)
|
||
_TEX_INFO[path] = res
|
||
return res
|
||
data, edge = svt.load(path)
|
||
rgba = bytearray(svt.to_rgba(data, "xbgr"))
|
||
chan_max = [max(rgba[c::4]) for c in range(3)]
|
||
is_lum = False
|
||
if min(chan_max) < 32: # a dead channel -> intensity map
|
||
for i in range(0, len(rgba), 4):
|
||
L = max(rgba[i], rgba[i+1], rgba[i+2])
|
||
rgba[i] = rgba[i+1] = rgba[i+2] = L
|
||
is_lum = True
|
||
else: # inherently greyscale (low saturation)?
|
||
sat = n = 0
|
||
for i in range(0, len(rgba), 4 * 17):
|
||
sat += max(rgba[i], rgba[i+1], rgba[i+2]) - min(rgba[i], rgba[i+1], rgba[i+2])
|
||
n += 1
|
||
if n and sat / n < 22:
|
||
is_lum = True
|
||
res = (edge, bytes(rgba), is_lum)
|
||
_TEX_INFO[path] = res
|
||
return res
|
||
|
||
|
||
def build_texture(svt_path, cache):
|
||
if svt_path in cache:
|
||
return cache[svt_path]
|
||
edge, rgba, _is_lum = load_texture_rgba(svt_path)
|
||
idx = len(cache)
|
||
cache[svt_path] = idx
|
||
build_texture.records.append({
|
||
"w": edge, "h": edge,
|
||
"rgba": base64.b64encode(rgba).decode("ascii"),
|
||
})
|
||
return idx
|
||
|
||
|
||
# ---- scene assembly --------------------------------------------------------
|
||
_INDEX = None
|
||
_MODEL_CACHE = {}
|
||
_MATS_CACHE = {}
|
||
FLIP_V = False # The original renderer indexed texture rows directly: v=0 = SVT
|
||
# row 0 (image top). Verified by A/B renders: mech pod vents vs
|
||
# missile tubes, the MADCOL "08" insignia, and the shark's
|
||
# back/belly shading are all correct UNFLIPPED and inverted when
|
||
# flipped. GL's texImage2D also puts the first uploaded row at
|
||
# t=0, so v passes through unchanged.
|
||
|
||
|
||
def load_model_cached(path):
|
||
if path not in _MODEL_CACHE:
|
||
_MODEL_CACHE[path] = b2z.load(path)
|
||
return _MODEL_CACHE[path]
|
||
|
||
|
||
def load_mats_cached(geo_path):
|
||
if geo_path not in _MATS_CACHE:
|
||
v = v2z_for(geo_path)
|
||
_MATS_CACHE[geo_path] = parse_v2z_materials(v) if v else {}
|
||
return _MATS_CACHE[geo_path]
|
||
|
||
|
||
def archive_index():
|
||
"""basename(lower, no ext) -> path indexes over the read-only archive:
|
||
geo : geometry (.B2Z/.BIZ first, then game-side .BGF)
|
||
tex : .SVT texture maps
|
||
bsl : .BSL bit-slice texture packs
|
||
v2z : .V2Z text model sources (materials)
|
||
mtl : material libraries (.VMF text preferred over .BMF binary)"""
|
||
global _INDEX
|
||
if _INDEX is None:
|
||
geo, tex, v2z, bsl, mtl = {}, {}, {}, {}, {}
|
||
for ext in ("B2Z", "BIZ", "BGF"):
|
||
for f in glob.glob(ARCHIVE + "/**/*." + ext, recursive=True):
|
||
geo.setdefault(os.path.basename(f)[:-4].lower(), f)
|
||
for f in glob.glob(ARCHIVE + "/**/*.SVT", recursive=True):
|
||
tex.setdefault(os.path.basename(f)[:-4].lower(), f)
|
||
for f in glob.glob(ARCHIVE + "/**/*.BSL", recursive=True):
|
||
bsl.setdefault(os.path.basename(f)[:-4].lower(), f)
|
||
for f in glob.glob(ARCHIVE + "/**/*.V2Z", recursive=True):
|
||
v2z.setdefault(os.path.basename(f)[:-4].lower(), f)
|
||
for f in glob.glob(ARCHIVE + "/**/*.VMF", recursive=True):
|
||
mtl.setdefault(os.path.basename(f)[:-4].lower(), f)
|
||
for f in glob.glob(ARCHIVE + "/**/*.BMF", recursive=True):
|
||
mtl.setdefault(os.path.basename(f)[:-4].lower(), f)
|
||
mod = {}
|
||
for f in glob.glob(ARCHIVE + "/**/*.MOD", recursive=True):
|
||
mod.setdefault(os.path.basename(f)[:-4].lower(), f)
|
||
_INDEX = {"geo": geo, "tex": tex, "v2z": v2z, "bsl": bsl, "mtl": mtl, "mod": mod}
|
||
return _INDEX
|
||
|
||
|
||
def v2z_for(geo_path):
|
||
"""The material text source matching a geometry file -- SAME directory/basename
|
||
first (geometry and materials must come from one source; different asset dirs
|
||
define the same material name with different colours), else any same-name V2Z.
|
||
Game-side .BGF models pair with a .VGF text twin the same way."""
|
||
if not geo_path:
|
||
return None
|
||
base = os.path.splitext(geo_path)[0]
|
||
for ext in (".V2Z", ".v2z", ".VGF", ".vgf"):
|
||
if os.path.exists(base + ext):
|
||
return base + ext
|
||
return archive_index()["v2z"].get(os.path.basename(base).lower())
|
||
|
||
|
||
_NAME_RE = re.compile(r'NAME\s*=\s*"?([\w.]+)"?', re.I)
|
||
_SPECIAL_RE = re.compile(r'SPECIAL\s*=\s*"([^"]*)"', re.I)
|
||
_SCROLL_RE = re.compile(r"SCROLL\s+(-?[\d.]+)\s+(-?[\d.]+)\s+(-?[\d.]+)\s+(-?[\d.]+)")
|
||
_MAP_RE = re.compile(r"MAP\s*\{\"?(\w+)\"?\}")
|
||
_DIF_RE = re.compile(r"DIFFUSE\s*\{([^}]*)\}")
|
||
_MTLTEX_RE = re.compile(r"TEXTURE\s*\{\"?([\w.]+)\"?\}")
|
||
_SLICE_RE = re.compile(r"BITSLICE\s*\{(\d+)\}")
|
||
|
||
|
||
def _blocks(text, kw):
|
||
"""yield (header, body) for `KW(header) {body}` blocks, brace-matched.
|
||
Two source dialects exist: modeller exports (NAME=x, `// End of ...` marker
|
||
comments) and the compact game libraries (NAME="x", no markers, SPECIAL=
|
||
hooks in the header). Matching braces handles both; `KW {...}` references
|
||
(no parens) inside other bodies are not block starts and are skipped."""
|
||
pos = 0
|
||
while True:
|
||
j = text.find(kw + "(", pos)
|
||
if j < 0:
|
||
return
|
||
if j and (text[j - 1].isalnum() or text[j - 1] == "_"):
|
||
pos = j + len(kw)
|
||
continue
|
||
h_end = text.find(")", j)
|
||
if h_end < 0:
|
||
return
|
||
b = text.find("{", h_end)
|
||
nxt = text.find(kw + "(", h_end)
|
||
if b < 0 or (0 <= nxt < b): # headerless block (no body braces)
|
||
yield text[j + len(kw) + 1:h_end], ""
|
||
pos = h_end + 1
|
||
continue
|
||
depth, e = 0, b
|
||
for e in range(b, len(text)):
|
||
c = text[e]
|
||
if c == "{":
|
||
depth += 1
|
||
elif c == "}":
|
||
depth -= 1
|
||
if depth == 0:
|
||
break
|
||
yield text[j + len(kw) + 1:h_end], text[b + 1:e]
|
||
pos = e + 1
|
||
|
||
|
||
def _parse_scroll(special):
|
||
"""texture SPECIAL hook ' SCROLL u0 v0 du dv' -> [u0,v0,du,dv] or None."""
|
||
if not special:
|
||
return None
|
||
m = _SCROLL_RE.search(special)
|
||
return [float(m.group(i)) for i in range(1, 5)] if m else None
|
||
|
||
|
||
def parse_v2z_materials(path):
|
||
"""Authoritative materials from a .V2Z/.VGF/.VMF text source:
|
||
name -> (diffuse[3], map_name_or_None, bitslice, scroll_or_None, texname).
|
||
scroll is the texture entity's SPECIAL " SCROLL u0 v0 du dv" hook (UV drift
|
||
rates -- rivers, smoke, laser beams); texname is the TEXTURE entity name a
|
||
scene-side SCROLL override addresses."""
|
||
try:
|
||
t = open(path, encoding="latin-1").read()
|
||
except OSError:
|
||
return {}
|
||
texmap = {}
|
||
for header, body in _blocks(t, "TEXTURE"):
|
||
nm = _NAME_RE.search(header)
|
||
if not nm:
|
||
continue
|
||
mm = _MAP_RE.search(body)
|
||
sl = _SLICE_RE.search(body)
|
||
sp = _SPECIAL_RE.search(header)
|
||
texmap[nm.group(1).lower()] = (mm.group(1).lower() if mm else None,
|
||
int(sl.group(1)) if sl else 0,
|
||
_parse_scroll(sp.group(1) if sp else None))
|
||
out = {}
|
||
for header, body in _blocks(t, "MATERIAL"):
|
||
nm = _NAME_RE.search(header)
|
||
if not nm:
|
||
continue
|
||
dif = _DIF_RE.search(body)
|
||
diffuse = [float(x) for x in dif.group(1).split(",")] if dif else [0.7, 0.7, 0.7]
|
||
tx = _MTLTEX_RE.search(body)
|
||
texname = tx.group(1).lower() if tx else None
|
||
tmap, sl, scroll = texmap.get(texname, (None, 0, None)) if texname else (None, 0, None)
|
||
out[nm.group(1).lower()] = (diffuse, tmap, sl, scroll, texname)
|
||
return out
|
||
|
||
|
||
_LIB_CACHE = {}
|
||
|
||
|
||
def load_material_library(libname):
|
||
"""Load a named material library (`stships` in `stships:eblu4_mtl`).
|
||
Text .VMF preferred; binary .BMF parsed with the b2z reader (which now
|
||
captures the 0x018 BITSLICE tag)."""
|
||
key = libname.lower()
|
||
if key in _LIB_CACHE:
|
||
return _LIB_CACHE[key]
|
||
path = archive_index()["mtl"].get(key)
|
||
mats = {}
|
||
if path and path.lower().endswith((".vmf", ".vgf", ".v2z")):
|
||
mats = parse_v2z_materials(path)
|
||
elif path:
|
||
try:
|
||
lib = b2z.load(path)
|
||
for name, mt in lib.materials.items():
|
||
tx = lib.textures.get(mt.texture) if mt.texture else None
|
||
mats[name.lower()] = (list(mt.diffuse),
|
||
tx.mapfile.lower() if tx and tx.mapfile else None,
|
||
tx.bitslice if tx else 0,
|
||
_parse_scroll(tx.special) if tx else None,
|
||
mt.texture.lower() if mt.texture else None)
|
||
except Exception:
|
||
mats = {}
|
||
_LIB_CACHE[key] = mats
|
||
return mats
|
||
|
||
|
||
# fallback surface colours when a material has no texture (keyed by name substring)
|
||
COLOR_HINTS = [
|
||
("water", [0.15, 0.28, 0.5]), ("redrock", [0.55, 0.32, 0.24]),
|
||
("rednoi", [0.58, 0.3, 0.24]), ("rock", [0.5, 0.42, 0.38]),
|
||
("techy", [0.5, 0.52, 0.57]), ("grey", [0.55, 0.55, 0.58]),
|
||
("metal", [0.55, 0.57, 0.6]), ("sky", [0.4, 0.6, 0.8]),
|
||
]
|
||
|
||
|
||
# when a material has no exact <stem>.SVT, fall back to a representative texture
|
||
# for common terrain surfaces (the authoritative map lives in material libraries
|
||
# we haven't decoded; this is a documented plausibility fallback -- see SCN spec).
|
||
# keyword -> (texture stem, tint). Grey noise textures are tinted by the material
|
||
# colour, as the original renderer did (e.g. red rock = grey rock-noise * red diffuse).
|
||
TEXTURE_HINTS = [
|
||
("redrock", ("rocknoiz", [1.0, 0.50, 0.38])),
|
||
("rednoi", ("rocknoiz", [1.0, 0.50, 0.38])),
|
||
("cliff", ("rocknoiz", [1.0, 0.50, 0.38])),
|
||
("rock", ("rocknoiz", [0.95, 0.62, 0.52])),
|
||
("techy", ("generic", [0.78, 0.80, 0.85])),
|
||
]
|
||
|
||
|
||
def surface_for(matname, has_uv, idx, tint=(1.0, 1.0, 1.0)):
|
||
"""Resolve a material name to (texture_path_or_None, color).
|
||
First the exact stem==texture convention (e.g. 'vtvcab_mtl' -> VTVCAB.SVT),
|
||
then a keyword texture fallback (tinted), then a flat colour."""
|
||
stem = (matname or "").lower()
|
||
if stem.endswith("_mtl"):
|
||
stem = stem[:-4]
|
||
if has_uv and stem in idx["tex"]:
|
||
return idx["tex"][stem], list(tint)
|
||
if has_uv:
|
||
for key, (tname, ttint) in TEXTURE_HINTS:
|
||
if key in stem and tname in idx["tex"]:
|
||
return idx["tex"][tname], list(ttint)
|
||
for key, col in COLOR_HINTS:
|
||
if key in stem:
|
||
return None, col
|
||
return None, [0.62, 0.6, 0.6]
|
||
|
||
|
||
def bake_instances(instances, idx, scrolls=None):
|
||
"""Bake a list of (model, materials, matrix, model_name) into merged, textured
|
||
surfaces in world space. Shared by whole scenes and single-model previews.
|
||
`scrolls` is the scene's SCROLL override map: texture entity id ("lib:name"
|
||
or plain name) -> [u0,v0,du,dv]; a texture's own SPECIAL scroll applies
|
||
even without an override. Returns (groups, tex_records, bounds, tris)."""
|
||
surfaces = {} # (texpath, colour) -> dict(pos,normal,uv,idx,color,texture)
|
||
tex_ids = {}
|
||
tex_records = []
|
||
|
||
def resolve(matname, has_uv, mats, model_name=None):
|
||
"""-> (texture_ref|None, colour, from_material, scroll|None, texid|None).
|
||
A texture_ref is an .SVT path or ("bsl", path, slice) for bit-slice packs;
|
||
texid is the addressable texture entity ("lib:name" / plain name).
|
||
|
||
Namespaced names (`stships:eblu4_mtl`, the game-side convention) resolve
|
||
through the named material library (.VMF/.BMF) and use its declared map
|
||
directly. Plain names use the model's own materials with candidates:
|
||
per-part skin <stem>.SVT, the declared map, then the model-name skin."""
|
||
mname = (matname or "").lower()
|
||
if ":" in mname:
|
||
lib, sub = mname.split(":", 1)
|
||
info = load_material_library(lib).get(sub)
|
||
if info is not None:
|
||
diffuse, tmap, sl, scroll, texname = info
|
||
tref = None
|
||
if has_uv and tmap:
|
||
if tmap in idx["bsl"]:
|
||
tref = ("bsl", idx["bsl"][tmap], sl)
|
||
elif tmap in idx["tex"]:
|
||
tref = idx["tex"][tmap]
|
||
return tref, list(diffuse), True, scroll, \
|
||
(lib + ":" + texname) if texname else None
|
||
return None, [0.62, 0.6, 0.6], True, None, None
|
||
info = mats.get(mname)
|
||
stem = mname[:-4] if mname.endswith("_mtl") else mname
|
||
tmap, sl, scroll, texname = (info[1], info[2], info[3], info[4]) if info \
|
||
else (None, 0, None, None)
|
||
tref = None
|
||
if has_uv:
|
||
for cand in (stem, tmap, model_name):
|
||
if cand and cand in idx["tex"]:
|
||
tref = idx["tex"][cand]
|
||
break
|
||
if tref is None and tmap and tmap in idx["bsl"]:
|
||
tref = ("bsl", idx["bsl"][tmap], sl)
|
||
if info is not None:
|
||
return tref, list(info[0]), True, scroll, texname
|
||
if tref is not None: # generic material, but a model skin exists
|
||
return tref, [1.0, 1.0, 1.0], True, None, None
|
||
tp, color = surface_for(matname, has_uv, idx)
|
||
return tp, color, False, None, None
|
||
|
||
def get_surface(matname, has_uv, mats, model_name=None, anim=None, akey=None):
|
||
tpath, color, from_mat, scroll, texid = resolve(matname, has_uv, mats, model_name)
|
||
if anim is None and tpath is not None:
|
||
rates = (scrolls or {}).get(texid, scroll)
|
||
if rates and (rates[2] or rates[3]):
|
||
anim = {"type": "scroll", "rate": [rates[2], rates[3]],
|
||
"phase": [rates[0], rates[1]]}
|
||
akey = ("scroll", texid, rates[2], rates[3])
|
||
tid = -1
|
||
if tpath is not None:
|
||
edge, rgba, is_lum = load_texture_rgba(tpath)
|
||
if from_mat and not is_lum:
|
||
color = [1.0, 1.0, 1.0] # full-colour skin: show texture as-is
|
||
if tpath not in tex_ids:
|
||
tex_ids[tpath] = len(tex_records)
|
||
tex_records.append({"w": edge, "h": edge,
|
||
"rgba": base64.b64encode(rgba).decode("ascii")})
|
||
tid = tex_ids[tpath]
|
||
# key on texture AND colour so materials sharing a texture but differing
|
||
# in tint stay separate; animated parts get their own surfaces (akey) so
|
||
# the viewer can move them independently of the static batch.
|
||
key = (tpath, tuple(round(c, 4) for c in color), akey)
|
||
if key not in surfaces:
|
||
surfaces[key] = {"positions": [], "normals": [], "uvs": [],
|
||
"indices": [], "color": color, "texture": tid}
|
||
if anim:
|
||
surfaces[key]["anim"] = anim
|
||
return surfaces[key]
|
||
|
||
for inst in instances:
|
||
model, mats, M, mname = inst[0], inst[1], inst[2], inst[3]
|
||
anim = inst[4] if len(inst) > 4 else None
|
||
if model is None:
|
||
continue
|
||
akey = id(anim) if anim else None
|
||
for g in model.geogroups:
|
||
uv_present = any(geom.verts and geom.verts[0].uv for geom in g.geoms)
|
||
surf = get_surface(g.f_material, uv_present, mats, mname, anim, akey)
|
||
for geom in g.geoms:
|
||
gbase = len(surf["positions"]) // 3
|
||
for v in geom.verts:
|
||
wx, wy, wz = scn.xform_point(M, v.pos[0], v.pos[1], v.pos[2])
|
||
surf["positions"] += [wx, wy, wz]
|
||
if v.normal:
|
||
nx, ny, nz = scn.xform_dir(M, *v.normal)
|
||
else:
|
||
nx, ny, nz = 0.0, 1.0, 0.0
|
||
surf["normals"] += [nx, ny, nz]
|
||
if v.uv:
|
||
surf["uvs"] += [v.uv[0], (1.0 - v.uv[1]) if FLIP_V else v.uv[1]]
|
||
else:
|
||
surf["uvs"] += [0.0, 0.0]
|
||
for (a, b, c) in b2z.triangles(geom):
|
||
surf["indices"] += [gbase + a, gbase + b, gbase + c]
|
||
|
||
groups = []
|
||
for s in surfaces.values():
|
||
if not s["indices"]:
|
||
continue
|
||
g = {
|
||
"positions": [round(x, 3) for x in s["positions"]],
|
||
"normals": [round(x, 4) for x in s["normals"]],
|
||
"uvs": [round(x, 4) for x in s["uvs"]],
|
||
"indices": s["indices"], "color": s["color"], "texture": s["texture"],
|
||
}
|
||
if s.get("anim"):
|
||
g["anim"] = s["anim"]
|
||
groups.append(g)
|
||
xs = [g["positions"][i] for g in groups for i in range(0, len(g["positions"]), 3)]
|
||
ys = [g["positions"][i+1] for g in groups for i in range(0, len(g["positions"]), 3)]
|
||
zs = [g["positions"][i+2] for g in groups for i in range(0, len(g["positions"]), 3)]
|
||
bounds = {"min": [min(xs), min(ys), min(zs)], "max": [max(xs), max(ys), max(zs)]} if xs \
|
||
else {"min": [-1, -1, -1], "max": [1, 1, 1]}
|
||
tris = sum(len(g["indices"]) for g in groups) // 3
|
||
return groups, tex_records, bounds, tris
|
||
|
||
|
||
def build_model_archive(geo_path, name):
|
||
"""Build one standalone model (identity transform) with authoritative materials."""
|
||
idx = archive_index()
|
||
model = load_model_cached(geo_path)
|
||
mats = load_mats_cached(geo_path)
|
||
groups, tex_records, bounds, tris = bake_instances(
|
||
[(model, mats, scn.ident(), name.lower())], idx)
|
||
return {"name": name.upper(), "up": "y", "groups": groups,
|
||
"textures": tex_records, "bounds": bounds, "tris": tris}
|
||
|
||
|
||
def _tree_file(map_path, subdir, base, exts):
|
||
"""Look for <maproot>/../<subdir>/<base>.<ext> -- assets from the SAME game
|
||
tree as the map (BTDAVE maps use BTDAVE models, not BTLIVE's)."""
|
||
root = os.path.dirname(os.path.dirname(map_path))
|
||
for d in (os.path.join(root, subdir), os.path.join(root, subdir, "EDITBACK")):
|
||
for ext in exts:
|
||
p = os.path.join(d, base + ext)
|
||
if os.path.exists(p):
|
||
return p
|
||
return None
|
||
|
||
|
||
def _dropzone_marker(x, y, z):
|
||
"""A small red pyramid marking a pod dropzone."""
|
||
s, h = 1.5, 4.0
|
||
pos = [x, y + h, z, x - s, y, z - s, x + s, y, z - s,
|
||
x + s, y, z + s, x - s, y, z + s]
|
||
idx = [0, 1, 2, 0, 2, 3, 0, 3, 4, 0, 4, 1, 1, 3, 2, 1, 4, 3]
|
||
return pos, idx
|
||
|
||
|
||
def build_map(cfg):
|
||
"""Assemble a game .MAP (arena / track) into a renderable scene."""
|
||
map_path = os.path.join(ARCHIVE, cfg["map"])
|
||
idx = archive_index()
|
||
|
||
def mod_resolver(modfile):
|
||
"""-> [(bgf_name, local_anim_or_None), ...] for every video object in the
|
||
MOD. Door leaves get a data-driven slide from the .SUB (MotionExtent /
|
||
TravelTime / DeadTime); laser-drill objects ('laz*') get a glow pulse."""
|
||
base = os.path.splitext(os.path.basename(modfile or ""))[0].lower()
|
||
if not base:
|
||
return []
|
||
p = _tree_file(map_path, "MODELS", base, (".MOD",)) or idx["mod"].get(base)
|
||
if not p:
|
||
return []
|
||
names, gclass, subfile = gamemap.parse_mod_objects(p)
|
||
doors = {}
|
||
if subfile:
|
||
sp = os.path.join(os.path.dirname(p), os.path.basename(subfile))
|
||
if os.path.exists(sp):
|
||
doors = gamemap.parse_sub(sp)
|
||
out = []
|
||
for n in names:
|
||
anim = None
|
||
if n in doors:
|
||
d = doors[n]
|
||
anim = {"type": "slide", "extent": d["extent"],
|
||
"travel": d["travel"], "dead": d["dead"]}
|
||
elif n.startswith("laz"):
|
||
anim = {"type": "pulse"} # laser drill: engine-driven; approximated
|
||
out.append((n, anim))
|
||
return out
|
||
|
||
placements, dropzones = gamemap.expand(map_path, mod_resolver)
|
||
instances, missing = [], set()
|
||
for (bgf, anim), M in placements:
|
||
p = _tree_file(map_path, "VIDEO", bgf, (".BGF",)) or idx["geo"].get(bgf)
|
||
if not p:
|
||
missing.add(bgf)
|
||
continue
|
||
wanim = None
|
||
if anim and anim.get("type") == "slide":
|
||
ex, ey, ez = scn.xform_dir(M, *anim["extent"]) # local -> world axis
|
||
wanim = {"type": "slide", "extent": [round(ex, 3), round(ey, 3), round(ez, 3)],
|
||
"travel": anim["travel"], "dead": anim["dead"]}
|
||
elif anim:
|
||
wanim = dict(anim)
|
||
instances.append((load_model_cached(p), load_mats_cached(p), M, bgf, wanim))
|
||
groups, tex_records, bounds, tris = bake_instances(instances, idx)
|
||
|
||
if dropzones:
|
||
pos, nrm, uv, ind = [], [], [], []
|
||
for d in dropzones:
|
||
mp, mi = _dropzone_marker(d[0], d[1], d[2])
|
||
base = len(pos) // 3
|
||
pos += mp
|
||
nrm += [0.0, 1.0, 0.0] * (len(mp) // 3)
|
||
uv += [0.0, 0.0] * (len(mp) // 3)
|
||
ind += [base + i for i in mi]
|
||
groups.append({"positions": [round(v, 3) for v in pos], "normals": nrm,
|
||
"uvs": uv, "indices": ind,
|
||
"color": [0.9, 0.1, 0.1], "texture": -1})
|
||
|
||
out = {"name": cfg["name"], "up": "y", "groups": groups,
|
||
"textures": tex_records, "bounds": bounds, "tris": tris,
|
||
"background": [0.05, 0.05, 0.08]}
|
||
print(" map %-22s %d placements (%d missing: %s), %d dropzones, %d surfaces"
|
||
% (os.path.basename(cfg["map"]), len(placements), len(missing),
|
||
sorted(missing)[:4], len(dropzones), len(groups)))
|
||
return out
|
||
|
||
|
||
def build_scene(cfg):
|
||
sc = scn.load(os.path.join(ARCHIVE, cfg["scene"]))
|
||
idx = archive_index()
|
||
cache, mat_cache = {}, {}
|
||
for inst in sc.instances:
|
||
n = inst.name.lower()
|
||
if n not in cache:
|
||
path = idx["geo"].get(n)
|
||
cache[n] = load_model_cached(path) if path else None
|
||
mat_cache[n] = load_mats_cached(path) if path else {}
|
||
|
||
instances = [(cache.get(i.name.lower()), mat_cache.get(i.name.lower(), {}),
|
||
i.matrix, i.name.lower()) for i in sc.instances]
|
||
placed = sum(1 for m, _, _, _ in instances if m is not None)
|
||
groups, tex_records, bounds, tris = bake_instances(instances, idx, sc.scrolls)
|
||
|
||
out = {
|
||
"name": cfg["name"], "up": cfg.get("up", "y"), "groups": groups,
|
||
"textures": tex_records, "bounds": bounds, "tris": tris,
|
||
"background": list(sc.background),
|
||
}
|
||
if sc.fog:
|
||
z0, z1, r, g, b = sc.fog
|
||
out["fog"] = [r, g, b, z0, z1]
|
||
if cfg.get("spline"):
|
||
frames = spl.sample_flythrough(os.path.join(ARCHIVE, cfg["spline"]))
|
||
out["flythrough"] = [{"eye": [round(x, 2) for x in f["eye"]],
|
||
"center": [round(x, 2) for x in f["center"]],
|
||
"up": [round(x, 3) for x in f["up"]]} for f in frames]
|
||
print(" scene %-22s %d/%d objects placed, %d surfaces, %d textures%s"
|
||
% (os.path.basename(cfg["scene"]), placed, len(sc.instances),
|
||
len(groups), len(tex_records),
|
||
", %d camera frames" % len(out["flythrough"]) if "flythrough" in out else ""))
|
||
return out
|
||
|
||
|
||
def build_model(cfg):
|
||
if "scene" in cfg:
|
||
return build_scene(cfg)
|
||
model = b2z.load(os.path.join(SAMPLES, cfg["file"]))
|
||
idx = archive_index()
|
||
v2z = idx["v2z"].get(os.path.splitext(cfg["file"])[0].lower())
|
||
mats = parse_v2z_materials(v2z) if v2z else {}
|
||
tex_cache = {}
|
||
build_texture.records = []
|
||
groups = []
|
||
for g in model.geogroups:
|
||
# merge every geometry in the geogroup into one indexed mesh
|
||
pos, uv, ind = [], [], []
|
||
have_uv = False
|
||
for geom in g.geoms:
|
||
base = len(pos) // 3
|
||
for v in geom.verts:
|
||
pos += [v.pos[0], v.pos[1], v.pos[2]]
|
||
if v.uv:
|
||
have_uv = True
|
||
uv += [v.uv[0], 1.0 - v.uv[1]] # flip V for GL origin
|
||
else:
|
||
uv += [0.0, 0.0]
|
||
for (a, b, c) in b2z.triangles(geom):
|
||
ind += [base + a, base + b, base + c]
|
||
if not ind:
|
||
continue
|
||
|
||
# resolve material -> colour / texture. Prefer the model's own authoritative
|
||
# material (.V2Z: diffuse x texture); fall back to the showcase cfg.
|
||
color = [0.7, 0.7, 0.72]
|
||
tex = -1
|
||
info = mats.get((g.f_material or "").lower())
|
||
if info is not None:
|
||
diffuse, tmap = info[0], info[1]
|
||
color = list(diffuse)
|
||
if have_uv and tmap and tmap in idx["tex"]:
|
||
tex = build_texture(idx["tex"][tmap], tex_cache) # modulated by diffuse
|
||
else:
|
||
spec = cfg.get("materials", {}).get(g.f_material) or cfg.get("materials", {}).get("*", {})
|
||
tint = spec.get("tint", [1.0, 1.0, 1.0])
|
||
if spec.get("from_material") and g.f_material in model.materials:
|
||
d = model.materials[g.f_material].diffuse
|
||
color = [d[0], d[1], d[2]]
|
||
elif "color" in spec:
|
||
color = spec["color"]
|
||
if "texture" in spec and have_uv:
|
||
tex = build_texture(os.path.join(SAMPLES, spec["texture"]), tex_cache)
|
||
color = tint
|
||
|
||
groups.append({
|
||
"positions": [round(x, 4) for x in pos],
|
||
"normals": [round(x, 4) for x in compute_normals(pos, ind)],
|
||
"uvs": [round(x, 4) for x in uv],
|
||
"indices": ind,
|
||
"color": color,
|
||
"texture": tex,
|
||
})
|
||
|
||
# bounds
|
||
xs = [g["positions"][i] for g in groups for i in range(0, len(g["positions"]), 3)]
|
||
ys = [g["positions"][i+1] for g in groups for i in range(0, len(g["positions"]), 3)]
|
||
zs = [g["positions"][i+2] for g in groups for i in range(0, len(g["positions"]), 3)]
|
||
bounds = {"min": [min(xs), min(ys), min(zs)], "max": [max(xs), max(ys), max(zs)]}
|
||
tris = sum(len(g["indices"]) for g in groups) // 3
|
||
return {
|
||
"name": cfg["name"], "up": cfg["up"], "groups": groups,
|
||
"textures": build_texture.records, "bounds": bounds, "tris": tris,
|
||
}
|
||
|
||
|
||
def main():
|
||
scene = {"models": [build_model(c) for c in SHOWCASE]}
|
||
payload = json.dumps(scene, separators=(",", ":"))
|
||
html = TEMPLATE.replace("/*__SCENE__*/null", payload)
|
||
out = os.path.join(HERE, "dpl3-viewer.html")
|
||
with open(out, "w", encoding="utf-8") as fp:
|
||
fp.write(html)
|
||
kb = len(html) / 1024
|
||
print("wrote %s (%.0f KB, %d models)" % (out, kb, len(scene["models"])))
|
||
for m in scene["models"]:
|
||
print(" - %-42s %5d tris, %d textures" % (m["name"], m["tris"], len(m["textures"])))
|
||
|
||
|
||
# ---- the viewer (content-only: no <html>/<head>/<body> so it doubles as an
|
||
# Artifact source; browsers render it fine opened directly too) ----------
|
||
TEMPLATE = r"""<title>DPL3 Model Viewer</title>
|
||
<style>
|
||
:root{--bg:#0d0b09;--panel:#17130d;--hair:#3a3126;--amber:#f2a73b;--ink:#ece2d0;--muted:#9a8d76;}
|
||
*{box-sizing:border-box;margin:0;padding:0}
|
||
html,body{height:100%}
|
||
body{background:var(--bg);color:var(--ink);font:14px/1.5 ui-monospace,Consolas,monospace;overflow:hidden}
|
||
#c{position:fixed;inset:0;width:100%;height:100%;display:block;touch-action:none;cursor:grab}
|
||
#c:active{cursor:grabbing}
|
||
.panel{position:fixed;left:16px;top:16px;background:rgba(23,19,13,.88);border:1px solid var(--hair);
|
||
border-radius:10px;padding:14px 16px;max-width:340px;backdrop-filter:blur(4px)}
|
||
.eyebrow{color:var(--amber);font-size:10px;letter-spacing:.18em;text-transform:uppercase;margin-bottom:8px}
|
||
h1{font:600 16px/1.2 system-ui,sans-serif;margin-bottom:2px}
|
||
.sub{color:var(--muted);font-size:12px;margin-bottom:12px}
|
||
select{width:100%;background:#0d0b09;color:var(--ink);border:1px solid var(--hair);border-radius:6px;
|
||
padding:7px 9px;font:13px ui-monospace,monospace;margin-bottom:10px}
|
||
.stat{display:flex;justify-content:space-between;color:var(--muted);font-size:12px}
|
||
.stat b{color:var(--ink);font-weight:600}
|
||
.hint{color:var(--muted);font-size:11px;margin-top:10px;border-top:1px solid var(--hair);padding-top:9px}
|
||
label.row{display:flex;align-items:center;gap:7px;color:var(--muted);font-size:12px;margin-top:8px;cursor:pointer}
|
||
.err{position:fixed;inset:0;display:none;place-items:center;text-align:center;padding:40px;color:var(--muted)}
|
||
</style>
|
||
<canvas id="c"></canvas>
|
||
<div class="panel">
|
||
<div class="eyebrow">DPL3 · recovered art</div>
|
||
<h1>Model Viewer</h1>
|
||
<div class="sub">Virtual World Entertainment, c.1994 — on modern hardware</div>
|
||
<select id="pick"></select>
|
||
<div class="stat"><span>triangles</span><b id="tris">—</b></div>
|
||
<div class="stat"><span>geo​groups</span><b id="grps">—</b></div>
|
||
<div class="stat"><span>textures</span><b id="texs">—</b></div>
|
||
<label class="row" id="flyrow" style="display:none"><input type="checkbox" id="fly"> play fly‑through</label>
|
||
<label class="row"><input type="checkbox" id="spin" checked> auto-rotate</label>
|
||
<label class="row"><input type="checkbox" id="wire"> wireframe</label>
|
||
<div class="hint">drag to orbit · scroll to zoom</div>
|
||
</div>
|
||
<div class="err" id="err"></div>
|
||
<script>
|
||
"use strict";
|
||
var SCENE=/*__SCENE__*/null;
|
||
|
||
// ---- tiny mat4 -------------------------------------------------------------
|
||
var M={
|
||
mul:function(a,b){var o=new Float32Array(16);for(var r=0;r<4;r++)for(var c=0;c<4;c++){var s=0;
|
||
for(var k=0;k<4;k++)s+=a[k*4+r]*b[c*4+k];o[c*4+r]=s;}return o;},
|
||
persp:function(f,asp,n,fr){var t=1/Math.tan(f/2),o=new Float32Array(16);
|
||
o[0]=t/asp;o[5]=t;o[10]=(fr+n)/(n-fr);o[11]=-1;o[14]=2*fr*n/(n-fr);return o;},
|
||
look:function(e,c,u){var z=nrm(sub(e,c)),x=nrm(crs(u,z)),y=crs(z,x);
|
||
return new Float32Array([x[0],y[0],z[0],0, x[1],y[1],z[1],0, x[2],y[2],z[2],0,
|
||
-dot(x,e),-dot(y,e),-dot(z,e),1]);},
|
||
norm3:function(m){return new Float32Array([m[0],m[1],m[2],m[4],m[5],m[6],m[8],m[9],m[10]]);}
|
||
};
|
||
function sub(a,b){return[a[0]-b[0],a[1]-b[1],a[2]-b[2]];}
|
||
function crs(a,b){return[a[1]*b[2]-a[2]*b[1],a[2]*b[0]-a[0]*b[2],a[0]*b[1]-a[1]*b[0]];}
|
||
function dot(a,b){return a[0]*b[0]+a[1]*b[1]+a[2]*b[2];}
|
||
function nrm(a){var l=Math.hypot(a[0],a[1],a[2])||1;return[a[0]/l,a[1]/l,a[2]/l];}
|
||
|
||
var gl,prog,loc,canvas=document.getElementById("c");
|
||
var VS="attribute vec3 aPos;attribute vec3 aNrm;attribute vec2 aUV;"+
|
||
"uniform mat4 uMVP;uniform mat4 uView;uniform mat3 uN;uniform vec2 uUVOff;"+
|
||
"varying vec3 vN;varying vec2 vUV;varying float vDist;"+
|
||
"void main(){vN=uN*aNrm;vUV=aUV+uUVOff;"+
|
||
"vDist=length((uView*vec4(aPos,1.0)).xyz);gl_Position=uMVP*vec4(aPos,1.0);}";
|
||
var FS="precision mediump float;varying vec3 vN;varying vec2 vUV;varying float vDist;"+
|
||
"uniform vec3 uColor;uniform sampler2D uTex;uniform float uUseTex;"+
|
||
"uniform vec3 uFog;uniform vec2 uFogRange;uniform float uFogOn;"+
|
||
"void main(){vec3 L=normalize(vec3(0.4,0.7,0.6));"+
|
||
"float d=abs(dot(normalize(vN),L));float lit=0.35+0.65*d;"+ // two-sided
|
||
"vec3 base=(uUseTex>0.5?texture2D(uTex,vUV).rgb*uColor:uColor)*lit;"+
|
||
"if(uFogOn>0.5){float f=clamp((vDist-uFogRange.x)/(uFogRange.y-uFogRange.x),0.0,1.0);"+
|
||
"base=mix(base,uFog,f);}"+
|
||
"gl_FragColor=vec4(pow(base,vec3(1.0/1.7)),1.0);}"; // Division DAC gamma 1.7
|
||
|
||
function sh(t,s){var o=gl.createShader(t);gl.shaderSource(o,s);gl.compileShader(o);
|
||
if(!gl.getShaderParameter(o,gl.COMPILE_STATUS))throw gl.getShaderInfoLog(o);return o;}
|
||
|
||
function b64bytes(b64){var s=atob(b64),n=s.length,a=new Uint8Array(n);
|
||
for(var i=0;i<n;i++)a[i]=s.charCodeAt(i);return a;}
|
||
|
||
var models=[],cur=0,az=0.7,el=0.5,dist=3,target=[0,0,0],flyPos=0;
|
||
|
||
function initGL(){
|
||
gl=canvas.getContext("webgl")||canvas.getContext("experimental-webgl");
|
||
if(!gl)throw"WebGL is not available in this browser.";
|
||
prog=gl.createProgram();gl.attachShader(prog,sh(gl.VERTEX_SHADER,VS));
|
||
gl.attachShader(prog,sh(gl.FRAGMENT_SHADER,FS));gl.linkProgram(prog);gl.useProgram(prog);
|
||
loc={pos:gl.getAttribLocation(prog,"aPos"),nrm:gl.getAttribLocation(prog,"aNrm"),
|
||
uv:gl.getAttribLocation(prog,"aUV"),mvp:gl.getUniformLocation(prog,"uMVP"),
|
||
view:gl.getUniformLocation(prog,"uView"),
|
||
n:gl.getUniformLocation(prog,"uN"),color:gl.getUniformLocation(prog,"uColor"),
|
||
tex:gl.getUniformLocation(prog,"uTex"),useTex:gl.getUniformLocation(prog,"uUseTex"),
|
||
fog:gl.getUniformLocation(prog,"uFog"),fogRange:gl.getUniformLocation(prog,"uFogRange"),
|
||
fogOn:gl.getUniformLocation(prog,"uFogOn"),uvoff:gl.getUniformLocation(prog,"uUVOff")};
|
||
gl.enable(gl.DEPTH_TEST);
|
||
}
|
||
|
||
function buildModel(md){
|
||
var texs=md.textures.map(function(t){
|
||
var tx=gl.createTexture();gl.bindTexture(gl.TEXTURE_2D,tx);
|
||
gl.texImage2D(gl.TEXTURE_2D,0,gl.RGBA,t.w,t.h,0,gl.RGBA,gl.UNSIGNED_BYTE,b64bytes(t.rgba));
|
||
gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_WRAP_S,gl.REPEAT);
|
||
gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_WRAP_T,gl.REPEAT);
|
||
gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_MIN_FILTER,gl.LINEAR_MIPMAP_LINEAR);
|
||
gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_MAG_FILTER,gl.LINEAR);
|
||
gl.generateMipmap(gl.TEXTURE_2D);return tx;});
|
||
var groups=md.groups.map(function(g){
|
||
function buf(arr,tp){var b=gl.createBuffer();gl.bindBuffer(tp,b);
|
||
gl.bufferData(tp,arr,gl.STATIC_DRAW);return b;}
|
||
return {pos:buf(new Float32Array(g.positions),gl.ARRAY_BUFFER),
|
||
nrm:buf(new Float32Array(g.normals),gl.ARRAY_BUFFER),
|
||
uv:buf(new Float32Array(g.uvs),gl.ARRAY_BUFFER),
|
||
idx:buf(new Uint16Array(g.indices),gl.ELEMENT_ARRAY_BUFFER),
|
||
n:g.indices.length,color:g.color,tex:g.texture,anim:g.anim||null,
|
||
line:buf(new Uint16Array(edges(g.indices)),gl.ELEMENT_ARRAY_BUFFER),nline:edges(g.indices).length};});
|
||
var b=md.bounds,ctr=[(b.min[0]+b.max[0])/2,(b.min[1]+b.max[1])/2,(b.min[2]+b.max[2])/2];
|
||
var rad=Math.hypot(b.max[0]-b.min[0],b.max[1]-b.min[1],b.max[2]-b.min[2])/2||1;
|
||
return {groups:groups,textures:texs,center:ctr,radius:rad,up:md.up,
|
||
bg:md.background||[0.05,0.045,0.04],fog:md.fog||null,frames:md.flythrough||null};
|
||
}
|
||
function lerp3(a,b,f){return [a[0]+(b[0]-a[0])*f,a[1]+(b[1]-a[1])*f,a[2]+(b[2]-a[2])*f];}
|
||
function edges(idx){var o=[];for(var i=0;i<idx.length;i+=3){o.push(idx[i],idx[i+1],idx[i+1],idx[i+2],idx[i+2],idx[i]);}return o;}
|
||
|
||
function select(i){cur=i;var md=SCENE.models[i];
|
||
document.getElementById("tris").textContent=md.tris.toLocaleString();
|
||
document.getElementById("grps").textContent=md.groups.length;
|
||
document.getElementById("texs").textContent=md.textures.length;
|
||
var m=models[i];target=m.center;dist=m.radius*2.6;az=0.7;el=0.5;flyPos=0;
|
||
var hasFly=!!m.frames;
|
||
document.getElementById("flyrow").style.display=hasFly?"flex":"none";
|
||
document.getElementById("fly").checked=hasFly;
|
||
document.getElementById("spin").checked=!hasFly;}
|
||
|
||
function draw(){
|
||
var w=canvas.clientWidth,h=canvas.clientHeight;
|
||
if(canvas.width!==w||canvas.height!==h){canvas.width=w;canvas.height=h;}
|
||
var m=models[cur];var ig=1.0/1.7;
|
||
gl.viewport(0,0,w,h);gl.clearColor(Math.pow(m.bg[0],ig),Math.pow(m.bg[1],ig),Math.pow(m.bg[2],ig),1);
|
||
gl.clear(gl.COLOR_BUFFER_BIT|gl.DEPTH_BUFFER_BIT);
|
||
var eye,center,camUp,near,far;
|
||
var flying=m.frames&&document.getElementById("fly").checked;
|
||
if(flying){
|
||
var N=m.frames.length;flyPos=(flyPos+0.15)%N;
|
||
var i=Math.floor(flyPos),f=flyPos-i,a=m.frames[i],b=m.frames[(i+1)%N];
|
||
eye=lerp3(a.eye,b.eye,f);center=lerp3(a.center,b.center,f);camUp=lerp3(a.up,b.up,f);
|
||
near=50;far=m.radius*5+2000;
|
||
}else{
|
||
var up=m.up==="z"?[0,0,1]:[0,1,0];
|
||
var ce=Math.cos(el);
|
||
if(m.up==="z"){eye=[target[0]+dist*ce*Math.sin(az),target[1]+dist*ce*Math.cos(az),target[2]+dist*Math.sin(el)];}
|
||
else{eye=[target[0]+dist*ce*Math.sin(az),target[1]+dist*Math.sin(el),target[2]+dist*ce*Math.cos(az)];}
|
||
center=target;camUp=up;near=dist*0.02;far=dist*10+m.radius*4;
|
||
}
|
||
var proj=M.persp(1.0,w/h,near,far);
|
||
var view=M.look(eye,center,camUp);var mvp=M.mul(proj,view);
|
||
gl.uniformMatrix4fv(loc.mvp,false,mvp);
|
||
gl.uniformMatrix4fv(loc.view,false,view);
|
||
gl.uniformMatrix3fv(loc.n,false,M.norm3(view));
|
||
if(m.fog){gl.uniform1f(loc.fogOn,1);gl.uniform3fv(loc.fog,[m.fog[0],m.fog[1],m.fog[2]]);
|
||
gl.uniform2fv(loc.fogRange,[m.fog[3],m.fog[4]]);}else{gl.uniform1f(loc.fogOn,0);}
|
||
var wire=document.getElementById("wire").checked;
|
||
var now=performance.now()/1000;
|
||
for(var k=0;k<m.groups.length;k++){var g=m.groups[k];
|
||
gl.bindBuffer(gl.ARRAY_BUFFER,g.pos);gl.enableVertexAttribArray(loc.pos);gl.vertexAttribPointer(loc.pos,3,gl.FLOAT,false,0,0);
|
||
gl.bindBuffer(gl.ARRAY_BUFFER,g.nrm);gl.enableVertexAttribArray(loc.nrm);gl.vertexAttribPointer(loc.nrm,3,gl.FLOAT,false,0,0);
|
||
gl.bindBuffer(gl.ARRAY_BUFFER,g.uv);gl.enableVertexAttribArray(loc.uv);gl.vertexAttribPointer(loc.uv,2,gl.FLOAT,false,0,0);
|
||
var uvo=[0,0];
|
||
if(g.anim&&g.anim.type==="scroll"){var ph=g.anim.phase||[0,0],rt=g.anim.rate;
|
||
uvo=[(ph[0]+now*rt[0])%1,(ph[1]+now*rt[1])%1];} // texture drift, UV/second
|
||
gl.uniform2fv(loc.uvoff,uvo);
|
||
gl.uniform3fv(loc.color,g.color);
|
||
if(g.tex>=0&&!wire){gl.activeTexture(gl.TEXTURE0);gl.bindTexture(gl.TEXTURE_2D,m.textures[g.tex]);
|
||
gl.uniform1i(loc.tex,0);gl.uniform1f(loc.useTex,1);}else{gl.uniform1f(loc.useTex,0);}
|
||
if(wire){gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER,g.line);gl.drawElements(gl.LINES,g.nline,gl.UNSIGNED_SHORT,0);}
|
||
else{gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER,g.idx);gl.drawElements(gl.TRIANGLES,g.n,gl.UNSIGNED_SHORT,0);}
|
||
}
|
||
if(document.getElementById("spin").checked)az+=0.005;
|
||
requestAnimationFrame(draw);
|
||
}
|
||
|
||
// ---- input -----------------------------------------------------------------
|
||
var drag=false,px=0,py=0;
|
||
canvas.addEventListener("pointerdown",function(e){drag=true;px=e.clientX;py=e.clientY;
|
||
document.getElementById("spin").checked=false;
|
||
var fb=document.getElementById("fly");if(fb)fb.checked=false;
|
||
canvas.setPointerCapture(e.pointerId);});
|
||
canvas.addEventListener("pointermove",function(e){if(!drag)return;
|
||
az-=(e.clientX-px)*0.01;el+=(e.clientY-py)*0.01;
|
||
el=Math.max(-1.5,Math.min(1.5,el));px=e.clientX;py=e.clientY;});
|
||
canvas.addEventListener("pointerup",function(){drag=false;});
|
||
canvas.addEventListener("wheel",function(e){e.preventDefault();
|
||
dist*=Math.pow(1.0015,e.deltaY);},{passive:false});
|
||
|
||
function fail(msg){document.querySelector(".panel").style.display="none";
|
||
var el=document.getElementById("err");el.style.display="grid";
|
||
el.innerHTML="<div><h1 style='color:#f2a73b;margin-bottom:8px'>Can’t render</h1>"+msg+"</div>";}
|
||
|
||
try{
|
||
initGL();
|
||
models=SCENE.models.map(buildModel);
|
||
var pick=document.getElementById("pick");
|
||
SCENE.models.forEach(function(md,i){var o=document.createElement("option");
|
||
o.value=i;o.textContent=md.name;pick.appendChild(o);});
|
||
pick.addEventListener("change",function(){select(+this.value);});
|
||
select(0);draw();
|
||
}catch(err){fail(String(err));}
|
||
</script>"""
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|