Files
TeslaRel410/dpl3-revive/patha/vrview.py
T
CydandClaude Fable 5 bb8978c127 Retire the near-eye cull by default: the eye-shroud is authentic content
The death sequence re-flushes the view with hither=1/yon=2 -- a thin
render shell that reveals geometry parked around the camera: the
escape-pod interior and the "blue swirly" ring dome (period VHS stills
of a 4.10 death match: violet concentric ripples, drifting center,
static canopy silhouette at the frame edge). Normal play clips the
shroud via the standard hither plane, so nothing leaks; the cull was
only masking authentic content. VRVIEW_NEARCULL=1 re-enables it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 19:53:52 -05:00

1212 lines
59 KiB
Python

#!/usr/bin/env python3
"""
vrview.py -- board-side real-time display for the virtual VelociRender board.
Renders, in a pygame window, exactly what FLYK.EXE streams over the link:
geometry uploaded via action 0x17 (vertices) + 0x19 (connections), placed by the
instance/DCS graph, viewed through the VIEW node's projection with the camera
pose taken from DCS 0x2 under the animated root DCS 0x1, and animated by the
per-frame 0x1d transform updates. This is the picture the dead i860/PixelPlanes
board would have put on its video output.
Wire facts this depends on (see spec/VELOCIRENDER_PROTOCOL.md, session 2026-07-04):
- DCS flush body: 4x4 row-major matrix at words 4..19 (translation in row 3),
parent DCS handle at word 20.
- instance flush body: object handle at word 6.
- object -> lod -> geogroup -> geometry via list_add; geogroup's f_material at
word 16 of its flush body.
- vertex strides: vtype 0x13 = [x y z nx ny nz u v], vtype 0x15 = [x y z r g b a u v].
- gtype 2 = single implicit polygon (fan); others use conn19 index lists
(n_conns x per indices, fan-triangulated per connection).
- view body floats [6..17] = x0 y0 x1 y1 zeye xs ys hither yon back_r g b;
[18..23] = fog_enable, fog near/far/r/g/b. Camera looks down -Z, row-vector
convention (p' = p @ M), child-to-parent composition = M_child @ M_parent.
- 0x1d = [1][dcs handle][3x3][t] replacing that DCS's rotation+translation.
"""
import os
import struct
import numpy as np
TYPE = {2:'zone',3:'view',4:'instance',5:'dcs',6:'lmodel',7:'object',8:'lod',
9:'geogroup',0xa:'geometry',0xb:'material',0xc:'texture',0xd:'texmap',
0xe:'light'}
def inst_visible(nodes, inst):
"""Draw-time gate for 'gated' (hidden-until-armed, w3 0/1) instances:
stored word 4 of the CURRENT instance body is the live visibility field
(dpl_SetInstanceVisibility). Checked per draw because a flush bumps no
node/edge counts, so the cache never rebuilds on a visibility pulse
(BT laser beams arm for ~4 frames per shot)."""
if not inst.get('gated'):
return True
b = nodes.get(inst['handle'], {}).get('body') or b''
return len(b) >= 20 and struct.unpack_from('<I', b, 16)[0] != 0
# First-person near-eye cull -- DEFAULT OFF (VRVIEW_NEARCULL=1 re-enables).
# It was the cockpit-canopy "vertical line" workaround, but the eye-shroud
# it culls IS authentic content: the escape-pod interior and the death
# "blue swirly" ring dome live parked around the camera, normally clipped
# by the standard hither plane (0.25) and revealed when the death sequence
# re-flushes the view with hither=1/yon=2 (period VHS stills match). The
# true-eye camera renders the canopy correctly, so the workaround retires.
NEARCULL = os.environ.get('VRVIEW_NEARCULL', '0') != '0'
# NOTE: vrboard.do_flush stores the flush payload MINUS the leading remote word,
# so stored-body word k = wire-payload word k+1. Offsets below are stored-body.
def _mat_from_dcs(body):
return np.frombuffer(body[12:76], dtype='<f4').reshape(4, 4).astype(np.float64)
# ---- 2D display-list HUD overlay (actions 0x29/0x2b; tags per DPL2DTAG.H) ----
# The game draws its HUD (reticle, twist/pitch ladders, warning glyphs) through
# dpl2d_* display lists composited over the view -- L4VIDRND.CPP builds them,
# the view node binds the root list at stored-body word 25. Coordinates are
# view-rect space (x0..x1, y0..y1, y up), colors 0..1 floats.
_HUD2D_TAGS = 23
_HUD2D_F = struct.Struct('<f')
def _hud2d_f(w):
return _HUD2D_F.unpack(struct.pack('<I', w))[0]
def hud2d_prims(dl2d, root):
"""Execute display list `root` -> [(kind, (r,g,b), width, [(x,y)..])].
kind: 'strip' (connected polyline), 'segs' (independent segments, point
pairs), 'poly' (filled convex polygon), 'points'."""
prims = []
st = {'m': (1.0, 0.0, 0.0, 1.0, 0.0, 0.0), 'col': (0.0, 0.75, 0.0), 'w': 1.0}
stack = []
mode = [None] # current open path kind (or 'clip')
pts = []
def xf(x, y):
m = st['m']
return (m[0] * x + m[2] * y + m[4], m[1] * x + m[3] * y + m[5])
def emit(kind):
if pts and kind not in (None, 'clip'):
prims.append((kind, st['col'], st['w'], list(pts)))
del pts[:]
mode[0] = None
def run(h, depth):
w = dl2d.get(h)
if w is None or depth > 8:
return
i, n = 0, len(w)
while i < n:
t = w[i]
i += 1
if t == 0: # open_polyline
emit(mode[0]); mode[0] = 'strip'
elif t == 1:
emit('strip')
elif t == 2: # open_polypoint
emit(mode[0]); mode[0] = 'points'
elif t == 3:
emit('points')
elif t == 4: # open_polygon
emit(mode[0]); mode[0] = 'poly'
elif t == 5:
emit('poly')
elif t == 6: # open_lines (point PAIRS)
emit(mode[0]); mode[0] = 'segs'
elif t == 7:
emit('segs')
elif t == 8 and i + 2 <= n: # point x y
if mode[0] is not None:
pts.append(xf(_hud2d_f(w[i]), _hud2d_f(w[i + 1])))
i += 2
elif t == 9 and i + 4 <= n: # circle x y r filled
cx, cy, r = (_hud2d_f(v) for v in w[i:i + 3])
filled = w[i + 3] != 0
i += 4
ring = [xf(cx + r * np.cos(a), cy + r * np.sin(a))
for a in np.linspace(0, 2 * np.pi, 25)]
prims.append(('poly' if filled else 'strip',
st['col'], st['w'], ring))
elif t == 10: # open_clip_polygon (unsupported:
emit(mode[0]); mode[0] = 'clip' # collect + discard)
elif t == 11: # close_clip_polygon <mode>
emit('clip'); i += 1
elif t == 12: # clip_circle x y r <mode>
i += 4
elif t in (13, 14): # clip_full / clip_none
pass
elif t == 15 and i + 3 <= n: # set_drawcolor r g b
st['col'] = tuple(_hud2d_f(v) for v in w[i:i + 3])
i += 3
elif t == 16 and i + 6 <= n: # set_matrix (2x3 affine)
st['m'] = tuple(_hud2d_f(v) for v in w[i:i + 6])
i += 6
elif t == 17 and i + 7 <= n: # concat_matrix + post flag
a = tuple(_hud2d_f(v) for v in w[i:i + 6])
post = w[i + 6] != 0
i += 7
m = st['m']
x, y = (m, a) if post else (a, m)
st['m'] = (x[0] * y[0] + x[1] * y[2],
x[0] * y[1] + x[1] * y[3],
x[2] * y[0] + x[3] * y[2],
x[2] * y[1] + x[3] * y[3],
x[4] * y[0] + x[5] * y[2] + y[4],
x[4] * y[1] + x[5] * y[3] + y[5])
elif t == 18: # push_state
stack.append(dict(st))
elif t == 19: # pop_state
if stack:
st.update(stack.pop())
elif t == 20 and i + 1 <= n: # call_displaylist
run(w[i], depth + 1)
i += 1
elif t == 21 and i + 1 <= n: # set_linewidth
st['w'] = _hud2d_f(w[i])
i += 1
elif t == 22 and i + 1 <= n: # set_alpha (HUD is opaque; skip)
i += 1
# unknown word: skip (keeps a malformed chunk from cascading)
run(root, 0)
emit(mode[0])
return prims
def hud2d_root(board, view):
"""The view node's bound 2D display list (dpl2d_SetViewDisplayList):
the word appended after the 96B view struct (stored-body offset 96).
Short view re-flushes (fog animation) truncate the stored body, so the
binding from the last full-length flush is cached per view. 0 = none."""
if view is None:
return 0
cache = getattr(board, '_hud2d_bind', None)
if cache is None:
cache = board._hud2d_bind = {}
vb = board.nodes.get(view, {}).get('body') or b''
if len(vb) >= 100:
cache[view] = struct.unpack_from('<I', vb, 96)[0]
root = cache.get(view, 0)
return root if root in getattr(board, 'dl2d', {}) else 0
class SceneCache:
"""Static structure distilled from board state (rebuilt when node count changes)."""
def __init__(self):
self.n_nodes = -1
self.instances = [] # [{dcs, chain(list of dcs handles leaf->root), tris,...}]
self.view = None
self.cam_chain = []
self._meshcache = {} # geom handle -> {ver, mesh}
def maybe_rebuild(self, board):
key = (len(board.nodes), len(board.edges), len(board.uploads),
len(board.conns), len(board.tex))
if key == getattr(self, '_key', None):
return
self._key = key
self.rebuild(board)
def rebuild(self, board):
nodes = board.nodes
tname = lambda h: TYPE.get(nodes.get(h, {}).get('type'))
kids = {}
inst_dcs = {}
# vr_dcs_NEST = parent/child; vr_dcs_LINK = SIBLING ring (1994 protocol:
# [bro, sis]) -- FLYK's top-level DCSs are a flat sibling list, NOT a
# tree. Treating link as parentage chained every instance through the
# tracker-animated head DCS 0x1, cancelling all camera motion.
self.dcs_parent = {}
self.link_parent = {}
for op, a, b in board.edges:
if op == 'nest': # true hierarchy only
self.dcs_parent[b] = a
if op == 'link': # camera-rig hierarchy (MUNGA:
self.link_parent[b] = a # vehicle->cockpit->view head)
if op != 'list_add':
continue
if tname(a) == 'dcs' and tname(b) == 'instance':
inst_dcs[b] = a
else:
kids.setdefault(a, []).append(b)
# camera: the view hangs off its DCS via list_add; the tracker-animated
# HEAD is DCS 0x1 (a sibling root, not a parent) -- pose = M_viewdcs @ M_head
self.view = None
view_dcs = None
for op, a, b in board.edges:
if op == 'list_add' and tname(b) == 'view' and tname(a) == 'dcs':
self.view = b; view_dcs = a
# MUNGA ONLY: the camera chain follows dcs_link parentage -- the game
# rigs the view as vehicle --link--> cockpit --link--> head DCS (world
# pose lives in the vehicle DCS, 0x1f-animated). FLYK's links are flat
# SIBLING rings; following them there relocated every scene's camera
# (regress diffs blew up), so FLYK keeps M(view dcs) . M(head 0x1).
# INSTANCE chains stay nest-only in both dialects (link-as-parentage
# on instances is the session-3 camera-cancellation bug).
self.cam_chain = self._chain(board, view_dcs,
links=getattr(board, 'munga', False))
if tname(1) == 'dcs' and 1 not in self.cam_chain:
self.cam_chain = self.cam_chain + [1]
# lights: lmodel (0x6) and light (0xe) bodies carry [dcs][type][r g b]
# at stored words 2,3 + floats 4..6; type 2 = ambient, 3 = directional
# (aim = the light DCS's -Z; SHARKS: ambient .1/.3/.4, sun (0,1,0) ✓)
self.ambient = np.zeros(3)
self.dirlights = [] # [(L_toward_light_world, color)]
for h, nd in nodes.items():
if nd.get('type') not in (0x6, 0xe):
continue
body = nd.get('body') or b''
if len(body) < 28:
continue
ldcs, ltype = struct.unpack_from('<II', body, 8)
col = np.array(struct.unpack_from('<3f', body, 16))
if not np.all(np.isfinite(col)) or col.max() > 100:
continue
if ltype == 2:
self.ambient += col
elif ltype == 3:
chain = self._chain(board, ldcs)
M = np.eye(4)
for d in chain:
b2 = nodes.get(d, {}).get('body') or b''
if len(b2) >= 76:
M = M @ _mat_from_dcs(b2)
L = M[2, :3] # light DCS +Z row = toward light
n = np.linalg.norm(L)
if n > 1e-6:
self.dirlights.append((L / n, col))
# meshes are resolved lazily per-draw via _mesh() (version-cached per
# handle) so per-frame vertex-update uploads don't rebuild the graph
# geometry -> texture RGB array, resolved geogroup -> f_material -> texture
# node -> texmap node -> board.tex. Refs are found type-directed (scan the
# body words for a handle of the right node type) -- robust to layout drift.
def find_ref(h, want_type):
body = nodes.get(h, {}).get('body') or b''
for i in range(len(body) // 4):
w = struct.unpack_from('<I', body, i * 4)[0]
if w != h and nodes.get(w, {}).get('type') == want_type:
return w
return None
self.geom_tex = {}
self.geom_mtl = {}
self.geom_texn = {}
texcache = {}
# texmaps that carry at least one sliced (sel > 0) texture node
self._sliced_packs = set()
for h, nd in nodes.items():
if nd.get('type') == 0xc:
body = nd.get('body') or b''
if len(body) >= 56 and struct.unpack_from('<I', body, 52)[0]:
self._sliced_packs.add(struct.unpack_from('<I', body, 4)[0])
def texmap_rgb(texm, texn):
"""RGB array for a texture node: full-color page, or its BSL slice.
Slice encoding (DECODED, see spec): texture-body selector word
sel = 0 -> file bitslice 0 (unsliced / whole page)
sel = 0x13 + b -> file bitslice b (1..8)
and bitslice b addresses nibble plane b + 2 (pad byte occupies
planes 0-1; proven vs GENH: bitslices 0..5 = the exact set of
content planes 2..7). b >= 6 clamps to plane 7 (rare; only the
9-texture GENS-class packs).
"""
entry = board.tex.get(texm)
if entry is None:
return None
body = nodes.get(texn, {}).get('body') or b''
sel = struct.unpack_from('<I', body, 52)[0] if len(body) >= 56 else 0
# sel==0 on a pack that also has sliced siblings = bitslice 0;
# sel==0 on a plain page = full-colour texture
if entry['mode'] == 0 and (sel or texm in self._sliced_packs):
b = (sel - 0x13) if sel else 0
plane = min(b + 2, 7)
key = (texm, plane)
if key not in texcache:
w = np.frombuffer(bytes(entry['data']), '<u4')
w = w[:entry['u'] * entry['v']].reshape(entry['v'], entry['u'])
g = (((w >> (4 * plane)) & 0xF).astype(np.float32)) * 17.0
texcache[key] = np.repeat(g[:, :, None], 3, axis=2)
return texcache[key]
if texm not in texcache:
a = np.frombuffer(bytes(entry['data']), np.uint8)
a = a[:entry['u'] * entry['v'] * 4].reshape(entry['v'], entry['u'], 4)
texcache[texm] = a[:, :, [3, 2, 1]].astype(np.float32) # [pad,B,G,R]
return texcache[texm]
def mtl_props(mtl):
body = nodes.get(mtl, {}).get('body') or b''
if len(body) < 84:
return None
# MUNGA material bodies carry one extra leading word (88B vs 84B):
# every FLYK offset shifts +1 (misparse showed as a green-washed,
# all-dithered world -- ambient read one float early)
s = 1 if getattr(board, 'munga', False) and len(body) >= 88 else 0
f = struct.unpack_from('<22f' if s else '<21f', body, 0)
amb, dif = np.array(f[7+s:10+s]), np.array(f[10+s:13+s])
# exact (1,0,0)/(1,0,0) ambient+diffuse = the shipped build's UNSET
# material marker (seen on all SHARKS/.B2Z-default and star mtls),
# not a colour -- render as white
if np.array_equal(amb, [1, 0, 0]) and np.array_equal(dif, [1, 0, 0]):
amb = dif = np.ones(3)
op = float(np.clip(np.mean(f[13+s:16+s]), 0, 1))
return {'emissive': np.array(f[4+s:7+s]), 'ambient': amb,
'diffuse': dif, 'opacity': op,
'specular': np.array(f[16+s:20+s])}
for gg, members in kids.items():
if tname(gg) != 'geogroup':
continue
mtl = find_ref(gg, 0xb)
texn = find_ref(mtl, 0xc) if mtl else None
gg_texm = find_ref(texn, 0xd) if texn else None
props = mtl_props(mtl) if mtl else None
for g in members:
if tname(g) != 'geometry':
continue
# PER-GEOMETRY refs take precedence: FLYK geometry bodies name
# their texmap directly (word 3); MUNGA/BTL4 bodies name their
# TEXTURE node (word 2) and often their material -- resolving
# only through the geogroup chain assigned one shared texture
# (BT: every terrain tile got the cloud page).
g_texn = find_ref(g, 0xc)
tn = g_texn or texn
g_texm = (find_ref(g, 0xd)
or (find_ref(g_texn, 0xd) if g_texn else None))
texm = g_texm or gg_texm
rgb = texmap_rgb(texm, tn) if texm else None
if rgb is not None:
self.geom_tex[g] = rgb
if tn is not None:
self.geom_texn[g] = tn # for live u0/v0/du/dv scroll
g_mtl = find_ref(g, 0xb)
g_props = mtl_props(g_mtl) if g_mtl else props
if g_props is not None:
self.geom_mtl[g] = g_props
# instances -> flattened triangle lists in model space
self.instances = []
for h, nd in nodes.items():
if tname(h) != 'instance' or h not in inst_dcs:
continue
body = nd.get('body') or b''
if len(body) < 24:
continue
obj = struct.unpack_from('<I', body, 20)[0] # wire word 6 = stored word 5
# stored word 3 = display mode: 3 = normal, 2 = BILLBOARD (FXTEST
# flamebig), 1/0 = HIDDEN until armed -- census across captures:
# RP's 4 w3=1 instances are the vehicle's effect attachments (they
# blocked the lens), BT's 11 are the player mech awaiting
# translocation, SHARKS' single w3=0 is the null-object 'fishes'.
w3 = struct.unpack_from('<I', body, 12)[0]
bboard = w3 == 2
# w3 0/1 = HIDDEN-until-armed: stored word 4 is the live
# visibility field (dpl_SetInstanceVisibility) -- BT pulses it
# ~4 frames per laser shot (beam quad) and missile models fly
# with it set. A flush bumps no node/edge counts so the cache
# never rebuilds on the pulse: keep these instances in the list
# tagged 'gated' and test the CURRENT body word at draw time.
# w3 2/3 ignore the field (BT arena buildings sit at 0 yet draw).
gated = w3 not in (2, 3)
if tname(obj) != 'object':
continue
# SCHILD/DCHILD effect attachments (Trek shield bubbles) carry a
# nonzero hierarchy pointer at stored word 20 of their DCS body;
# they were invisible-until-hit on the real board -- skip them.
# FLYK ONLY: MUNGA vehicle-rig DCSs (mech torso/limb parts) carry
# host pointers in the same slot -- skipping them hid the player
# mech entirely.
dbody = nodes.get(inst_dcs[h], {}).get('body') or b''
if (not getattr(board, 'munga', False)
and len(dbody) >= 84
and struct.unpack_from('<I', dbody, 80)[0]):
continue
# NOVIEWMATRIX: the instance DCS's parent word references a ZONE
# (SHARKS banner: parent 0x7=zone vs normal 0x3=dcs) -> render in
# camera space (skip the view matrix)
hud = False
if len(dbody) >= 80:
par = struct.unpack_from('<I', dbody, 76)[0]
hud = nodes.get(par, {}).get('type') == 2
lods = []
for lod in kids.get(obj, []):
if tname(lod) != 'lod':
continue
lg = []
for gg in kids.get(lod, []):
if tname(gg) != 'geogroup':
continue
for g in kids.get(gg, []):
if tname(g) == 'geometry':
lg.append(g)
if not lg:
continue
# switch_in/out at stored words 15/16 of the lod body
lb = nodes.get(lod, {}).get('body') or b''
sw_in = sw_out = 0.0
if len(lb) >= 68:
sw_in, sw_out = struct.unpack_from('<2f', lb, 60)
if not (np.isfinite(sw_in) and np.isfinite(sw_out)
and 0 <= sw_in < sw_out < 1e7):
sw_in, sw_out = 0.0, 1e9
lods.append((sw_in, sw_out, lg))
if not lods:
continue
# BT's wire LOD bodies carry no usable switch distances (zeros ->
# the (0,1e9) fallback on every lod), and much of its content is
# authored ADDITIVE_LODS (BGF SV_SPECIAL: the arena walls are a
# posts-lod + panel-lod that draw TOGETHER). Range-select over
# identical windows always took lods[0] -- the wall posts without
# the wall. When every window is the degenerate fallback, draw
# the union of all lods; genuine distance windows keep switching.
if len(lods) > 1 and all(a == 0.0 and b >= 1e8 for a, b, _ in lods):
allg = []
for _, _, lg in lods:
for g in lg:
if g not in allg:
allg.append(g)
lods = [(0.0, 1e9, allg)]
# MUNGA: vehicle/mech part instances get their world pose through
# the same dcs_link rig as the camera (torso 0x1f-animated) --
# nest-only chains left the player mech rendering at world origin.
# FLYK stays nest-only (links there are sibling rings; session 3).
# gated instances carry their model-space radius so the draw can
# cull the player's own cockpit canopy (a small always-armed
# fixture AT the camera -- our eye is not at the true head node,
# so it smears a sliver across the view). Beams/missiles are
# gated too but extend far, so radius keeps them drawing.
rad = 0.0
if gated:
for g in lods[0][2]:
m = self._mesh(board, g)
if m is not None and len(m.get('pos', [])):
rad = max(rad, float(np.abs(m['pos']).max()))
self.instances.append({'dcs': inst_dcs[h], 'handle': h,
'chain': self._chain(
board, inst_dcs[h],
links=getattr(board, 'munga', False)),
'geoms': lods[0][2], 'lods': lods,
'billboard': bboard, 'hud': hud,
'gated': gated, 'radius': rad})
# vertex strides seen on the wire (SHARKS + SDEMO): stride -> field slices
# 3 = [x y z] (vtype 0x01)
# 4 = [x y z r?] (vtype 0x41, gtype 0xa -- spheres/points; positions only)
# 5 = [x y z u v] (vtype 0x11)
# 8 = [x y z nx ny nz u v] (vtype 0x13)
# 9 = [x y z r g b a u v] (vtype 0x15)
def _mesh(self, board, gh, _depth=0):
ups = board.uploads.get(gh) or []
cns = board.conns.get(gh) or []
morph = board.morphs.get(gh) if _depth == 0 else None
ver = (len(ups), len(cns), len(ups[-1]['data']) if ups else 0,
ups[-1]['hdr'][1] if ups else 0, morph)
cached = self._meshcache.get(gh)
if cached is not None and cached['ver'] == ver:
return cached['mesh']
stride = ups[-1]['hdr'][3]
scale = ups[-1]['scale'] or 1.0
# merge uploads at their vertex offsets (hdr word1 = first vertex,
# word7 = total) -- later offset uploads are vertex animation updates
total = 0
for up in ups:
w = up['hdr']
total = max(total, w[1] + w[2], w[7])
v = np.zeros((total, stride))
for up in ups:
w = up['hdr']
if w[3] != stride:
continue
fl = np.frombuffer(bytes(up['data']), dtype='<f4')
n = min(w[2], len(fl) // stride)
v[w[1]:w[1] + n] = fl[:n * stride].reshape(n, stride)
n = total
# wire positions arrive divided by GEOMSCALE; the header's scale
# field restores model units (verified vs raw .B2Z bounds)
pos = v[:, 0:3] * scale
if stride == 4: # vtype 0x41: sphere list [x y z r]
mesh = {'sphere': pos, 'rad': np.abs(v[:, 3]) * scale}
self._meshcache[gh] = {'ver': ver, 'mesh': mesh}
return mesh
if morph:
a, b, alpha = morph
ma = self._mesh(board, a, 1) if a in board.uploads else None
mb = self._mesh(board, b, 1) if b in board.uploads else None
if (ma is not None and mb is not None
and len(ma['pos']) == len(mb['pos']) == n):
pos = ma['pos'] * (1 - alpha) + mb['pos'] * alpha
col = nrm = uv = alpha = None
if stride == 5:
uv = v[:, 3:5]
elif stride == 8:
nrm = v[:, 3:6]; uv = v[:, 6:8]
elif stride >= 9:
# vtype 0x15 = [x y z r g b ? u v]: field 6 is NOT alpha -- values
# run -3.75..1.0 across products (blending on it made SHARKS' kelp
# ghostly). Kept as mesh['alpha'] DATA only; semantics undecoded.
col = v[:, 3:6]; alpha = v[:, 6]; uv = v[:, 7:9]
tris = []
if cns:
for c in cns:
idx = np.frombuffer(bytes(c['data']), dtype='<u4').astype(np.int64)
per = c['per']
idx = idx[:(len(idx) // per) * per].reshape(-1, per)
idx = idx[(idx < n).all(axis=1)]
for k in range(1, per - 1): # fan-triangulate each connection
tris.append(np.stack([idx[:, 0], idx[:, k], idx[:, k + 1]], 1))
elif n >= 3 and ups[-1]['hdr'][6] == 2: # gtype 2: implicit polygon fan
fan = np.arange(1, n - 1)
tris.append(np.stack([np.zeros_like(fan), fan, fan + 1], 1))
mesh = None
if tris:
mesh = {'pos': pos, 'col': col, 'nrm': nrm, 'uv': uv,
'alpha': alpha, 'tri': np.concatenate(tris, 0)}
self._meshcache[gh] = {'ver': ver, 'mesh': mesh}
return mesh
def _chain(self, board, dcs, links=False):
"""leaf->root list of DCS handles following vr_dcs_nest hierarchy ONLY
(the flush-body word at stored offset 76 is the SIBLING pointer, not a
parent -- following it merged the camera head into every instance chain
and cancelled all tracker motion). links=True additionally follows
vr_dcs_link parentage where no nest parent exists -- used for the
CAMERA chain only (MUNGA vehicle->cockpit->head rig; FLYK 0x1->0x2)."""
nodes = board.nodes
chain = []
seen = set()
while dcs and dcs not in seen and nodes.get(dcs, {}).get('type') == 5:
chain.append(dcs); seen.add(dcs)
nxt = getattr(self, 'dcs_parent', {}).get(dcs)
if nxt is None and links:
nxt = getattr(self, 'link_parent', {}).get(dcs)
dcs = nxt
return chain
class Renderer:
def __init__(self, w=512, h=320, title="VelociRender virtual board -- FLYK live"):
import pygame
self.pygame = pygame
pygame.init()
sz = os.environ.get('VRVIEW_SIZE') # e.g. 384x240 for more fps
if sz and 'x' in sz:
try:
w, h = (int(v) for v in sz.lower().split('x'))
except ValueError:
pass
self.w, self.h = w, h
self.screen = pygame.display.set_mode((w, h), pygame.SCALED | pygame.RESIZABLE)
pygame.display.set_caption(title)
self.cache = SceneCache()
self.frame = 0
self.clock = pygame.time.Clock()
# RETRACE pacing: run_demo sets VRVIEW_FPS from the scene's divider
self.fps = int(os.environ.get('VRVIEW_FPS', '60'))
self.skip = 1 # adaptive: render 1 of N draw_scenes
self._last_ms = 0.0
self._psys = {} # SPECIALFX particle pools by code
self.light = np.array([0.3, 0.8, 0.5]); self.light /= np.linalg.norm(self.light)
# Division 10-bit-DAC output gamma. GAMMA.C (the original source) builds
# out = (i/255)^(1/1.7); this live renderer historically used 1.25.
# VRVIEW_GAMMA selects it so the two can be compared against the real pod.
self.gamma = float(os.environ.get('VRVIEW_GAMMA', '1.25'))
def dcs_matrix(self, board, h):
m = None
body = board.nodes.get(h, {}).get('body') or b''
if len(body) >= 80:
m = _mat_from_dcs(body)
else:
m = np.eye(4)
if h in getattr(board, 'anim_abs', {}): # live 0x1f pose (MUNGA): REPLACES
f = board.anim_abs[h]
a = np.eye(4)
a[:3, :3] = np.array(f[:9]).reshape(3, 3)
a[3, :3] = f[9:12]
if np.all(np.isfinite(a)):
m = a
elif h in board.anim: # live 0x1d articulation
f = board.anim[h]
a = np.eye(4)
# 0x1d 3x3 is row-vector convention, same as flush matrices
# (heading_test.py: model -Z tracks velocity, dot~-1, 95% of samples)
a[:3, :3] = np.array(f[:9]).reshape(3, 3)
a[3, :3] = f[9:12]
if np.all(np.isfinite(a)):
# flush_artics COMPOSES with the flushed base matrix (KLNGVID:
# the DCS holds the 0.1 model scale, the spline pose rides on
# top; SHARKS' identity base hid the distinction)
m = m @ a
if h in board.anim4: # live 0x1b full-matrix overrides
f4 = np.array(board.anim4[h]).reshape(4, 4)
# identity 3x3 + junk row3 = host sent an identity-flagged DCS
# verbatim (uninitialized translation) -- keep the flushed matrix
if not np.allclose(f4[:3, :3], np.eye(3)):
m = f4.copy()
m[:, 3] = (0, 0, 0, 1)
return m
def chain_matrix(self, board, chain, fix_degenerate=False):
m = np.eye(4)
for h in chain: # leaf first: M_leaf @ ... @ M_root
dm = self.dcs_matrix(board, h)
if fix_degenerate:
# Some pass-through DCS (e.g. 0xa2c in the BTL4 cockpit rig)
# flush a rank-2 rotation whose X and Y rows both collapse to
# +-Y (shifted body -> wrong read window). Composed into the
# camera chain this drops the whole rig to rank 2 (only the
# look-row survives) and, once the head DCS articulates, mixes
# the head glance into the wrong axis (left/right hat -> pitch).
# A degenerate rotation carries no real orientation -- treat it
# as identity. Preserves the neutral look exactly; lets the
# glance compose as a proper yaw.
R = dm[:3, :3]
if np.linalg.matrix_rank(R, tol=1e-3) < 3:
dm = dm.copy(); dm[:3, :3] = np.eye(3)
m = m @ dm
return m
# MUNGA vehicles fly nose-along-+Z: straight-fast-flight samples of the
# RAW vehicle 0x1f records put local velocity 3.8 deg off +Z (the earlier
# +X reading came from the composed chain, biased by the attract head-yaw
# swinging +/-54 deg). The view projection looks down -Z, so the raw chain
# renders 180 deg backward -- yaw(180) maps render-forward onto vehicle +Z.
# Camera only; instances carry their pose in their own DCS frames.
_CAMFIX = np.eye(4)
_CAMFIX[:3, :3] = [[-1, 0, 0], [0, 1, 0], [0, 0, -1]]
def cam_matrix(self, board):
if os.environ.get('VRVIEW_CHASE') in ('1', '2'):
m = self._chase_cam(board)
if m is not None:
return m
m = self.chain_matrix(board, self.cache.cam_chain, fix_degenerate=True)
if getattr(board, 'munga', False):
m = self._CAMFIX @ m
return m
def _chase_cam(self, board):
"""Debug camera: follow the most-traveled 0x1f-animated DCS (the
vehicle/mech torso) from behind-above. VRVIEW_CHASE=1 chases behind
the target's nose (turns with it); =2 keeps a world-locked offset
(no rotation when the target spins in place)."""
anim = getattr(board, 'anim_abs', None)
if not anim:
return None
# prefer the PLAYER vehicle: the root of the camera rig (last DCS in
# the cam chain) when animated; else the most-traveled animated DCS
chain = getattr(self.cache, 'cam_chain', [])
if chain and chain[-1] in anim:
h = chain[-1]
else:
h = max(anim,
key=lambda k: float(np.abs(np.array(anim[k][9:12])).sum()))
f = anim[h]
R = np.array(f[:9]).reshape(3, 3)
t = np.array(f[9:12])
if os.environ.get('VRVIEW_CHASE') == '2':
offset = np.array([-45.0, 25.0, -45.0]) # world-locked NE-above
else:
nose = R[2, :3] # MUNGA vehicles nose along +Z
offset = -nose * 60.0 + np.array([0.0, 25.0, 0.0])
eye = t + offset
fwd = t - eye
fwd /= max(np.linalg.norm(fwd), 1e-6)
back = -fwd
right = np.cross([0.0, 1.0, 0.0], back)
right /= max(np.linalg.norm(right), 1e-6)
up = np.cross(back, right)
M = np.eye(4)
M[0, :3], M[1, :3], M[2, :3], M[3, :3] = right, up, back, eye
return M
def pick(self, board, origin, direction):
"""World-space ray -> nearest instance handle (sect_pixel/sect_vector).
Moller-Trumbore over every instance's world triangles."""
self.cache.maybe_rebuild(board)
o = np.asarray(origin, np.float64)
d = np.asarray(direction, np.float64)
dn = np.linalg.norm(d)
if dn < 1e-9:
return 0
d = d / dn
best_t, best_h = np.inf, 0
for inst in self.cache.instances:
if not inst_visible(board.nodes, inst):
continue
M = self.chain_matrix(board, inst['chain'], fix_degenerate=True)
R, T = M[:3, :3], M[3, :3]
for gh in inst['geoms']:
mesh = self.cache._mesh(board, gh)
if mesh is None or 'sphere' in mesh:
continue
pw = mesh['pos'] @ R + T
tri = mesh['tri']
v0, v1, v2 = pw[tri[:, 0]], pw[tri[:, 1]], pw[tri[:, 2]]
e1, e2 = v1 - v0, v2 - v0
pv = np.cross(d[None, :], e2)
det = (e1 * pv).sum(1)
okd = np.abs(det) > 1e-12
inv = np.where(okd, 1.0 / np.where(okd, det, 1.0), 0.0)
tv = o[None, :] - v0
u = (tv * pv).sum(1) * inv
qv = np.cross(tv, e1)
vv = (d[None, :] * qv).sum(1) * inv
t = (e2 * qv).sum(1) * inv
hit = okd & (u >= 0) & (vv >= 0) & (u + vv <= 1) & (t > 1e-6)
if hit.any():
tm = float(t[hit].min())
if tm < best_t:
best_t, best_h = tm, inst['handle']
return best_h
def pick_screen(self, board, u, v):
"""Pick through screen point (u,v in 0..1) using the live camera +
view projection (0.5,0.5 = crosshair center). Returns instance handle."""
c = self.cache
c.maybe_rebuild(board)
cam = self.cam_matrix(board)
origin = cam[3, :3]
# image-plane point in eye space: x0..x1 / y0..y1 at z = -zeye
x0, x1, y0, y1, zeye = -1.0, 1.0, -0.615, 0.615, 1.3
if c.view is not None:
vb = board.nodes[c.view].get('body') or b''
if len(vb) >= 96:
f = struct.unpack_from('<24f', vb, 0)
x0, y0, x1, y1, zeye = f[5], f[6], f[7], f[8], max(f[9], 1e-3)
de = np.array([x0 + u * (x1 - x0), y0 + (1 - v) * (y1 - y0), -zeye])
dw = de @ cam[:3, :3]
return self.pick(board, origin, dw)
def pump(self):
"""Keep the window responsive while the link is idle (no draw_scene)."""
pg = self.pygame
for ev in pg.event.get():
if ev.type == pg.QUIT:
pg.quit(); raise KeyboardInterrupt
def draw(self, board):
pg = self.pygame
for ev in pg.event.get():
if ev.type == pg.QUIT:
pg.quit(); raise KeyboardInterrupt
self.frame += 1
# adaptive frame skip: keep FLYK paced at 60/skip Hz while the software
# rasterizer draws what it can (heavy scenes render 1 of N frames)
if self.frame % self.skip:
self.clock.tick(self.fps)
return
import time as _t
_t0 = _t.perf_counter()
self.cache.maybe_rebuild(board)
c = self.cache
W, H = self.w, self.h
# view parameters
vp = None
if c.view is not None:
vb = board.nodes[c.view].get('body') or b''
if len(vb) >= 96:
f = struct.unpack_from('<24f', vb, 0)
# stored-body float idx = wire idx - 1: x0@5 y0@6 x1@7 y1@8 zeye@9
# xs@10 ys@11 hither@12 yon@13 back@14..16 fog_enable@17 fog@18..22
vp = {'x0': f[5], 'y0': f[6], 'x1': f[7], 'y1': f[8], 'zeye': f[9],
'hither': max(f[12], 1e-3), 'yon': f[13], 'back': f[14:17],
'fog_on': struct.unpack_from('<I', vb, 68)[0],
'fog': f[18:23]}
if vp is None:
vp = {'x0': -1, 'y0': -0.615, 'x1': 1, 'y1': 0.615, 'zeye': 1.3,
'hither': 8, 'yon': 4500, 'back': (0.4, 0.6, 0.9), 'fog_on': 0,
'fog': (500, 4000, 0.05, 0.1, 0.12)}
V = np.linalg.inv(self.cam_matrix(board))
# fogged background: at infinite distance the clear must be the fog
# colour (pre-drop hold is FULLY black; flash covers the sky band)
if vp['fog_on'] and vp['fog'][1] > vp['fog'][0]:
back = np.array(vp['fog'][2:5]) * 255
else:
back = np.array(vp['back']) * 255
img = np.empty((H, W, 3), np.float32); img[:] = back
zbuf = np.full((H, W), np.inf, np.float32)
fog_near, fog_far = vp['fog'][0], vp['fog'][1]
fog_col = np.array(vp['fog'][2:5]) * 255
# FLYK's spline animation (0x1d) builds headings for nose-along--Z models.
# The original ..\sharks shark is lost; our stand-in SHARK.B2Z is authored
# nose-along-+X, so dynamically animated instances get a +90-deg yaw
# model correction (X -> -Z). Statics carry their pose in the .SCN.
FIX = np.eye(4)
FIX[:3, :3] = [[0, 0, -1], [0, 1, 0], [1, 0, 0]]
for inst in c.instances:
if not inst_visible(board.nodes, inst):
continue
# fix_degenerate: BTL4 vehicle rigs contain pass-through DCSs
# whose flushed rotation is rank-2 (shifted body variant); the
# laser-beam chain rides four of them and composed rank-2 = beam
# drawn away from the gun. Same treatment the camera chain gets.
Mw = self.chain_matrix(board, inst['chain'], fix_degenerate=True)
# NOVIEWMATRIX instances live in camera space: no view transform
M = Mw if inst.get('hud') else Mw @ V
# first-person canopy cull (see SceneCache radius note)
if (NEARCULL and inst.get('gated') and not inst.get('hud')
and np.linalg.norm(M[3, :3]) + inst.get('radius', 0) < 10.0):
continue
# FIX is the FLYK stand-in shark's +90-deg yaw correction (the
# replacement model was authored nose-along-+X). BT/munga models
# are authored correctly -- applying it yawed the player's own
# mech 90 degrees ("looking through the right shoulder").
if (not getattr(board, 'munga', False)
and inst['chain'] and inst['chain'][0] in board.anim):
M = FIX @ M
if inst['billboard']:
# spherical billboard: keep scale + eye position, face the camera
s = np.linalg.norm(M[0, :3])
M = M.copy()
M[:3, :3] = np.eye(3) * s
R, T = M[:3, :3], M[3, :3]
geoms = inst['geoms']
if len(inst['lods']) > 1: # range-select LOD by eye distance
dist = float(np.linalg.norm(T))
for sw_in, sw_out, lg in inst['lods']:
if sw_in <= dist < sw_out:
geoms = lg
break
for gh in geoms:
mesh = c._mesh(board, gh)
if mesh is None:
continue
if 'sphere' in mesh:
self._spheres(mesh, M, c.geom_mtl.get(gh), vp, img, zbuf, W, H)
continue
pe = mesh['pos'] @ R + T # eye space
z = -pe[:, 2]
# near AND far clip (yon): beyond the fog wall nothing is
# visible anyway -- kills most of a 6 km canal per frame
ok = (z > vp['hither']) & (z < vp['yon'] * 1.05)
zs = np.where(ok, z, 1.0)
px = (pe[:, 0] * vp['zeye'] / zs - vp['x0']) / (vp['x1'] - vp['x0']) * W
py = (1 - (pe[:, 1] * vp['zeye'] / zs - vp['y0']) / (vp['y1'] - vp['y0'])) * H
# whole-mesh early-out: nothing in front of the camera, or the
# visible verts' screen bbox misses the viewport entirely
if not ok.any():
continue
vx, vy = px[ok], py[ok]
if (vx.max() < 0 or vx.min() >= W
or vy.max() < 0 or vy.min() >= H):
continue
# per-vertex shade: emissive + diffuse * (wire ambient + suns)
props = c.geom_mtl.get(gh)
diffuse = props['diffuse'] if props is not None else np.array([0.75, 0.78, 0.82])
emissive = props['emissive'] if props is not None else np.zeros(3)
if c.dirlights or c.ambient.any():
amb, suns = c.ambient, c.dirlights
else: # no lights on the wire
amb, suns = np.full(3, 0.35), [(self.light, np.full(3, 0.65))]
lit_rgb = np.repeat(amb[None, :], len(pe), 0).copy()
if mesh['nrm'] is not None and suns:
# world-space normals; DPL lights double-sided (abs)
Rm = Mw[:3, :3]
nw = mesh['nrm'] @ Rm
nw /= np.maximum(np.linalg.norm(nw, axis=1, keepdims=True), 1e-9)
spec = props['specular'] if props is not None else None
for L, lcol in suns:
nl = np.abs(nw @ L)
lit_rgb += nl[:, None] * lcol[None, :]
if spec is not None and spec[:3].max() > 1e-3:
p_exp = spec[3] if 1.0 < spec[3] < 200 else 16.0
lit_rgb += (nl ** p_exp)[:, None] * spec[None, :3] * lcol[None, :]
else:
for L, lcol in suns:
lit_rgb += 0.7 * lcol[None, :]
base = (emissive[None, :] + diffuse[None, :] * lit_rgb) * 255
if mesh['col'] is not None:
base = mesh['col'] * base # vertex colors modulate
base = np.clip(base, 0, 255)
# board-side SCROLL: texture body u0/v0 + du/dv * seconds
# (FLYK); MUNGA treats floats 10/11 as a STATIC uv offset
uvoff = (0.0, 0.0)
texn = c.geom_texn.get(gh)
if texn is not None:
tb = board.nodes.get(texn, {}).get('body') or b''
if len(tb) >= 48:
u0, v0, du, dv = struct.unpack_from('<4f', tb, 32)
if all(np.isfinite((u0, v0, du, dv))):
if getattr(board, 'munga', False):
uvoff = (u0 + du, v0 + dv)
else:
t = self.frame / 60.0
uvoff = (u0 + du * t, v0 + dv * t)
tex = c.geom_tex.get(gh)
if tex is not None:
# texture modulated by shade; fog applied to the modulator only
# when untextured, so blend fog after texturing instead
shade = base / 200.0
else:
shade = None
fz = None
if vp['fog_on'] and fog_far > fog_near:
fz = np.clip((z - fog_near) / (fog_far - fog_near), 0, 1)
if tex is None:
base = base * (1 - fz)[:, None] + fog_col[None, :] * fz[:, None]
# transparency: texture alpha flag (texture body stored word 4)
# -> cutout near-black texels; material opacity < 1 (DITHER n)
# -> ordered screen-door
acut = False
if texn is not None and tex is not None:
tb = board.nodes.get(texn, {}).get('body') or b''
if len(tb) >= 20 and struct.unpack_from('<I', tb, 16)[0]:
acut = True
opac = props['opacity'] if props is not None else 1.0
self._raster(mesh['tri'], px, py, z, ok, base, mesh['uv'], tex,
shade, fz, fog_col, img, zbuf, W, H, uvoff,
acut, opac)
cam = self.cam_matrix(board)
self._particles(board, cam[3, :3], V, vp, img, zbuf, W, H,
self.skip / 60.0)
arr = np.clip(img, 0, 255).astype(np.uint8)
# Division DAC output gamma (VRVIEW_GAMMA; GAMMA.C = 1.7, live-renderer
# legacy = 1.25)
arr = (np.power(arr / 255.0, 1.0 / self.gamma) * 255).astype(np.uint8)
surf = pg.surfarray.make_surface(np.transpose(arr, (1, 0, 2)))
self.screen.blit(surf, (0, 0))
if self._draw_hud2d(board, vp):
# refresh from the screen so last_frame includes the HUD overlay
arr = np.transpose(pg.surfarray.array3d(self.screen), (1, 0, 2))
pg.display.flip()
self.last_frame = arr # for vr_readpixels replies
# live camera telemetry in the title bar (tracker diagnostics)
ct = cam[3, :3]
pg.display.set_caption(
f"VelociRender virtual board -- cam ({ct[0]:.1f}, {ct[1]:.1f}, {ct[2]:.1f})"
f" {1000.0 / max(self._last_ms, 1):.0f}fps/{self.skip}")
self._last_ms = (_t.perf_counter() - _t0) * 1000
self.skip = max(1, min(10, int(self._last_ms / 50)))
self.clock.tick(self.fps) # pace draw_scene acks (RETRACE-aware)
def _draw_hud2d(self, board, vp):
"""Composite the game's dpl2d HUD (reticle/ladders/carets) over the
frame -- the layer the real board mixed onto its video output.
Returns True if anything was drawn."""
root = hud2d_root(board, self.cache.view)
if not root:
return False
pg = self.pygame
W, H = self.w, self.h
x0, y0, x1, y1 = vp['x0'], vp['y0'], vp['x1'], vp['y1']
sx, sy = W / (x1 - x0), H / (y1 - y0)
g = 1.0 / self.gamma
def P(pt):
# dpl2d Y grows DOWN (screen convention; period VHS footage:
# twist dial UNDER the reticle, range bar filling top-to-bottom)
return (int((pt[0] - x0) * sx + 0.5),
int((pt[1] - y0) * sy + 0.5))
try:
prims = hud2d_prims(board.dl2d, root)
except Exception as e:
if not getattr(self, '_hud2d_err', None):
self._hud2d_err = True
print(f'hud2d: decode failed: {e}')
return False
for kind, col, wd, pts in prims:
c8 = tuple(int(255 * max(0.0, min(1.0, v)) ** g + 0.5) for v in col)
wd = max(1, int(wd + 0.5))
sp = [P(p) for p in pts]
if kind == 'strip' and len(sp) >= 2:
pg.draw.lines(self.screen, c8, False, sp, wd)
elif kind == 'segs':
for i in range(0, len(sp) - 1, 2):
pg.draw.line(self.screen, c8, sp[i], sp[i + 1], wd)
elif kind == 'poly' and len(sp) >= 3:
pg.draw.polygon(self.screen, c8, sp)
elif kind == 'points':
for p in sp:
self.screen.fill(c8, (p, (wd, wd))) # clips at edges
return bool(prims)
def _particles(self, board, eye, V, vp, img, zbuf, W, H, dt):
"""Ambient SPECIALFX emitters (installed 0x1c defs): the shipped board
stepped these autonomously. Approximation: per-def particle pools spawned
in a volume around the camera (SHARKS bubbles/marine snow), rising with
the def's velocity, cooled/faded by o_cool, respawned per frags x rpt.
Event-TRIGGERED effects (PSFX .EVT lines) need the input path first.
OPT-IN (VRVIEW_SFX=1 / run_demo --sfx): most scenes install their defs
for event use only -- ambient-simulating them puts bubbles everywhere."""
if not board.sfx or os.environ.get('VRVIEW_SFX') != '1':
return
psys = self._psys
for code, d in board.sfx.items():
n = int(min(max(d['frags'], 1) * max(d['rpt'], 1) * 4, 192))
ps = psys.get(code)
if ps is None or len(ps['age']) != n:
ps = psys[code] = {
'pos': np.zeros((n, 3)), 'vel': np.zeros((n, 3)),
'age': np.full(n, 1e9),
'rng': np.random.default_rng(0xC0DE + code)}
rng = ps['rng']
life = 1.0 / max(d['cool'], 1e-3) / 60.0 # seconds
dead = ps['age'] > life
nd = int(dead.sum())
if nd:
rad = float(np.clip(d['size'] * 0.15, 4.0, 90.0))
p = rng.uniform(-1, 1, (nd, 3)) * rad
p[:, 1] = rng.uniform(0, 1, nd) * rad * 0.5 + d['bias'] + d['off_y']
# keep spawns off the camera lens
nrm = np.linalg.norm(p, axis=1)
close = nrm < 8.0
p[close] *= (8.0 / np.maximum(nrm[close], 1e-6))[:, None]
ps['pos'][dead] = eye + p
v = rng.uniform(-1, 1, (nd, 3)) * d['variance'] * max(d['velocity'], 0.5)
v[:, 1] = d['velocity'] * rng.uniform(0.6, 1.2, nd)
ps['vel'][dead] = v
ps['age'][dead] = rng.uniform(0, life * 0.5, nd)
ps['vel'][:, 1] -= d['gravity'] * 0.002 * dt
ps['pos'] += ps['vel'] * dt
ps['age'] += dt
# draw as fading discs
pe = ps['pos'] @ V[:3, :3] + V[3, :3]
z = -pe[:, 2]
fade = np.clip(d['o_cool'] ** (ps['age'] * 60.0), 0.05, 1.0)
cook = np.clip(np.array(d['cook']), 0, 2)
pr_world = float(np.clip(d['size'] * 0.008, 0.15, 4.0))
kx = vp['zeye'] / (vp['x1'] - vp['x0']) * W
for i in np.argsort(-z):
zi = z[i]
if zi <= vp['hither'] or zi > vp['yon']:
continue
cx = (pe[i, 0] * vp['zeye'] / zi - vp['x0']) / (vp['x1'] - vp['x0']) * W
cy = (1 - (pe[i, 1] * vp['zeye'] / zi - vp['y0']) / (vp['y1'] - vp['y0'])) * H
pr = min(max(pr_world * kx / zi, 0.6), H * 0.08)
x0 = int(max(0, cx - pr)); x1 = int(min(W - 1, cx + pr)) + 1
y0 = int(max(0, cy - pr)); y1 = int(min(H - 1, cy + pr)) + 1
if x0 >= x1 or y0 >= y1:
continue
gx, gy = np.meshgrid(np.arange(x0, x1), np.arange(y0, y1))
m = ((gx - cx) ** 2 + (gy - cy) ** 2) <= pr * pr
tz = zbuf[y0:y1, x0:x1]
upd = m & (zi < tz)
if upd.any():
# additive-ish blend, no zbuf write (soft particles)
tile = img[y0:y1, x0:x1]
tile[upd] = np.clip(
tile[upd] * (1 - 0.6 * fade[i])
+ cook[None, :] * 255 * 0.75 * fade[i], 0, 255)
def _spheres(self, mesh, M, props, vp, img, zbuf, W, H):
"""Sphere-list geometry (vtype 0x41): shaded screen-space discs."""
R, T = M[:3, :3], M[3, :3]
s = np.linalg.norm(M[0, :3])
pe = mesh['sphere'] @ R + T
z = -pe[:, 2]
col = np.array([0.8, 0.8, 0.8])
if props is not None:
col = np.clip(props['emissive'] + props['diffuse'], 0, 1)
rgb = col * 255
kx = vp['zeye'] / (vp['x1'] - vp['x0']) * W
for i in np.argsort(-z):
zi = z[i]
if zi <= vp['hither']:
continue
cx = (pe[i, 0] * vp['zeye'] / zi - vp['x0']) / (vp['x1'] - vp['x0']) * W
cy = (1 - (pe[i, 1] * vp['zeye'] / zi - vp['y0']) / (vp['y1'] - vp['y0'])) * H
pr = max(mesh['rad'][i] * s * kx / zi, 0.7)
x0 = int(max(0, cx - pr)); x1 = int(min(W - 1, cx + pr)) + 1
y0 = int(max(0, cy - pr)); y1 = int(min(H - 1, cy + pr)) + 1
if x0 >= x1 or y0 >= y1:
continue
gx, gy = np.meshgrid(np.arange(x0, x1), np.arange(y0, y1))
m = ((gx - cx) ** 2 + (gy - cy) ** 2) <= pr * pr
tz = zbuf[y0:y1, x0:x1]
upd = m & (zi < tz)
if upd.any():
tz[upd] = zi
img[y0:y1, x0:x1][upd] = rgb
def _raster(self, tri, px, py, z, ok, col, uv, tex, shade, fz, fog_col,
img, zbuf, W, H, uvoff=(0.0, 0.0), alpha_cut=False, opacity=1.0):
if len(tri) == 0:
return
# vectorized pre-cull: clip-rejected verts, off-screen bboxes and
# degenerate triangles never reach the per-triangle Python loop
A, B, C = tri[:, 0], tri[:, 1], tri[:, 2]
keep = ok[A] & ok[B] & ok[C]
if not keep.any():
return
xs = np.stack([px[A], px[B], px[C]])
ys = np.stack([py[A], py[B], py[C]])
minxs, maxxs = xs.min(0), xs.max(0)
minys, maxys = ys.min(0), ys.max(0)
keep &= (maxxs >= 0) & (minxs < W) & (maxys >= 0) & (minys < H)
areas = ((xs[1] - xs[0]) * (ys[2] - ys[0])
- (xs[2] - xs[0]) * (ys[1] - ys[0]))
keep &= np.abs(areas) > 1e-9
for t in tri[keep]:
a, b, cc = int(t[0]), int(t[1]), int(t[2])
x0, y0, x1, y1, x2, y2 = px[a], py[a], px[b], py[b], px[cc], py[cc]
minx = int(max(0, min(x0, x1, x2))); maxx = int(min(W - 1, max(x0, x1, x2))) + 1
miny = int(max(0, min(y0, y1, y2))); maxy = int(min(H - 1, max(y0, y1, y2))) + 1
if minx >= maxx or miny >= maxy:
continue
area = (x1 - x0) * (y2 - y0) - (x2 - x0) * (y1 - y0)
xs = np.arange(minx, maxx, dtype=np.float32) + 0.5
ys = np.arange(miny, maxy, dtype=np.float32) + 0.5
gx, gy = np.meshgrid(xs, ys)
w0 = ((x1 - gx) * (y2 - gy) - (x2 - gx) * (y1 - gy)) / area
w1 = ((x2 - gx) * (y0 - gy) - (x0 - gx) * (y2 - gy)) / area
w2 = 1 - w0 - w1
inside = (w0 >= 0) & (w1 >= 0) & (w2 >= 0)
if not inside.any():
continue
# perspective-correct weights via 1/z
wa = w0 / z[a]; wb = w1 / z[b]; wc = w2 / z[cc]
iz = wa + wb + wc
zi = 1.0 / np.maximum(iz, 1e-12)
tile_z = zbuf[miny:maxy, minx:maxx]
upd = inside & (zi < tile_z)
if opacity < 0.99:
# DITHER screen-door: 2x2 ordered pattern vs opacity
bay = (((gx.astype(np.int32) & 1) * 2 + (gy.astype(np.int32) & 1))
+ 0.5) / 4.0
upd &= bay <= opacity
if not upd.any():
continue
na = wa / iz; nb = wb / iz; nc = wc / iz
if tex is not None and uv is not None:
u = na * uv[a, 0] + nb * uv[b, 0] + nc * uv[cc, 0] + uvoff[0]
v = na * uv[a, 1] + nb * uv[b, 1] + nc * uv[cc, 1] + uvoff[1]
th, tw = tex.shape[:2]
tx = (u % 1.0 * tw).astype(np.int32) % tw
ty = (v % 1.0 * th).astype(np.int32) % th
rgb = tex[ty, tx]
if alpha_cut:
upd &= rgb.sum(axis=-1) > 24.0 # near-black = transparent
if not upd.any():
continue
mod = (na[..., None] * shade[a] + nb[..., None] * shade[b]
+ nc[..., None] * shade[cc])
rgb = rgb * mod
if fz is not None:
f = na * fz[a] + nb * fz[b] + nc * fz[cc]
rgb = rgb * (1 - f)[..., None] + fog_col[None, None, :] * f[..., None]
else:
rgb = (na[..., None] * col[a] + nb[..., None] * col[b]
+ nc[..., None] * col[cc])
tile_z[upd] = zi[upd]
tile_c = img[miny:maxy, minx:maxx]
tile_c[upd] = rgb[upd]