vpx-device/vpxlog.cpp -- WEAPONS FIRE. The game's fire gate is targetReticle.targetEntity (mech+0x388), filled every frame by a Division-board reticle pick (dpl_RapidSectPixel) our HLE never answered, so every shot misfired. The device now casts the camera centre ray against the live scene (Moller-Trumbore) and returns the full hit -- instance + DCS + geogroup + geometry -- piggybacked on the draw_scene reply; terrain is a valid target so you can fire and miss. Sending geogroup=0 was hanging the game (GetAppSpecific on a null); returning the real geogroup fixed it. Pick is default-on with a single VPX_NO_PICK escape hatch. Also swaps the upper-left/right MFD explode windows (screen location only -- decode untouched, real cause TBD on a pod). dpl3-revive/patha/vrview.py -- HAT-GLANCE fix at the source. One cockpit DCS (0xa2c) flushes a rank-2 rotation (shifted body -> wrong read window) that collapsed the camera chain to rank 2 and smeared the head glance onto the wrong axis (left/right hat read as pitch). chain_matrix(fix_degenerate) treats a degenerate chain DCS as identity: preserves the look exactly and keeps stick-Y torso pitch working (an earlier bridge-level yaw/pitch swap broke stick-Y and was reverted). User-verified live: hat all 4 dirs + stick-Y both correct. render-bridge/launch_pod.ps1 -- launch the bridge with pyw (windowless) so no console window parks over the cockpit displays (Start-Process ignores -WindowStyle once stdout/stderr are redirected). render-bridge/live_bridge.py -- surface bridge render errors, flush the status line; reverted glance-swap note. Also vendored HUD (dpl2d) renderer work in vrboard/vrview_gl and RENDERER-COLLAB / RIO notes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1134 lines
54 KiB
Python
1134 lines
54 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'}
|
|
|
|
|
|
# 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
|
|
if w3 not in (2, 3):
|
|
continue
|
|
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
|
|
# 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).
|
|
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})
|
|
|
|
# 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:
|
|
M = self.chain_matrix(board, inst['chain'])
|
|
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))
|
|
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:
|
|
Mw = self.chain_matrix(board, inst['chain'])
|
|
# NOVIEWMATRIX instances live in camera space: no view transform
|
|
M = Mw if inst.get('hud') else Mw @ V
|
|
if 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):
|
|
return (int((pt[0] - x0) * sx + 0.5),
|
|
int((y1 - pt[1]) * sy + 0.5)) # y up
|
|
|
|
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]
|