Files
TeslaRel410/dpl3-revive/patha/vrview_gl.py
T
CydandClaude Fable 5 cbf7015620 Weapon visuals + aim: beams render from the guns; pick self-target fixes
Renderer (dpl3-revive/patha):
- Instance visibility honored at DRAW time (stored word 4 = live
  dpl_SetInstanceVisibility field): laser beams (4-frame pulses) and
  missile models now render; cache tags w3 0/1 instances 'gated'
- PSFX world bursts (dcs=0 muzzle/impact/explosion) rendered; psfx
  storage list-based so one-shot bursts stop collapsing onto one key
- fix_degenerate applied to INSTANCE chains (was camera-only): the
  beam chain rides four rank-2 rig DCSs; composed rank-2 drew beams
  away from the guns. Offline f1635 now shows both arm beams
  converging on the aim point
- First-person canopy cull: the always-armed cockpit model at the
  head drew as a vertical line down screen center (user-confirmed
  fixed live)

Device (vpx-device/vpxlog.cpp):
- CAM backchannel on the fifosock: bridge streams its validated
  camera (+ vehicle root DCS); raycast_pick aims along the player's
  real look instead of the static view-node pose, which had locked
  every missile/beam onto one fixed wrong world point
- raycast_pick skips hidden-until-armed instances and the player's
  own articulation subtree: picking own beams created a target
  feedback loop, picking own missiles retargeted salvos mid-flight,
  and a picked missile's deleted DCS froze the game (arena2 crash)

Sound (vpx-device/vweawe.cpp): stereo-linked peak limiter replaces
the hard output clamp (the "generator out" crackle); VWE_AWE_LOG
reports pre-limit peaks + duck counts, enabled in launch_pod.ps1.
Missile-fire crackle persists: per-voice EMU8000 clamp suspected.

Eggs/confs: TESTAR2.EGG + gauge_arena2_sound.conf (map=arena2 city
variant); cavern.egg (console-era reference); plasma display on COM2
in the gauge confs; gauge_arena_net_sound.conf (slirp net stack).

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

777 lines
32 KiB
Python

#!/usr/bin/env python3
"""
vrview_gl.py -- moderngl GPU backend for the virtual VelociRender board.
Same wire-state-in, pixels-out contract as vrview.Renderer (the software
rasterizer, which stays intact as the debugging reference -- select it with
vrrun --soft / VRVIEW_SOFT=1). SceneCache, DCS chain math, and picking are
inherited; only the draw path is replaced:
- SceneCache._mesh() results become static VBOs, cached per geometry handle
on the mesh dict's identity (the _mesh version key already covers vertex
updates + morph alpha -- a new mesh dict means re-upload, else zero work).
- Per frame only camera/instance matrices and a few uniforms change.
- Row-vector convention rides for free: numpy row-major mat4 bytes are read
column-major by GLSL (= transposed), so `u_m * v` in the shader IS p @ M.
- The scene renders linear into an offscreen 832x512 FBO; a present pass
applies the Division DAC gamma (out = in^(1/1.25)) exactly where the
software path does (once, over the final image, back color included).
- Shaders replicate the software formulas: per-vertex lighting (ambient +
|n.L| suns double-sided, specular, 0.7-per-sun no-normal fallback),
vertex-color modulate, texture * shade(=base*1.275), SCROLL uv offset,
alpha cutout (texel sum <= 24/255), DITHER 2x2 screen-door discard,
per-vertex linear fog mixed after texturing, sphere-list and SPECIALFX
point sprites.
Headless (SDL_VIDEODRIVER=dummy, e.g. regress.py) uses a standalone WGL
context -- no window, frames read back from the FBO via .last_frame.
"""
import os
import struct
import numpy as np
import vrview
from vrview import SceneCache, _mat_from_dcs
MESH_VS = """
#version 330
uniform mat4 u_mv; // model -> eye (row-vector: u_mv * v == p @ M)
uniform mat4 u_p; // eye -> clip
uniform mat4 u_model; // model -> world (normals; matches software: no FIX)
uniform vec3 u_ambient;
uniform int u_nsuns;
uniform vec3 u_sun_dir[4];
uniform vec3 u_sun_col[4];
uniform vec3 u_diffuse;
uniform vec3 u_emissive;
uniform vec4 u_specular; // rgb + exponent
uniform int u_has_nrm;
uniform int u_has_col;
uniform int u_fog_on;
uniform vec2 u_fogrange; // near, far
in vec3 in_pos;
in vec3 in_nrm;
in vec3 in_col;
in vec2 in_uv;
in float in_alpha; // vtype 0x15 vertex alpha (cloud/sky layers)
out vec3 v_base;
out vec2 v_uv;
out float v_fog;
out float v_alpha;
void main() {
vec4 pe = u_mv * vec4(in_pos, 1.0);
gl_Position = u_p * pe;
v_alpha = in_alpha;
float z = -pe.z;
vec3 lit = u_ambient;
if (u_has_nrm == 1 && u_nsuns > 0) {
vec3 nw = mat3(u_model) * in_nrm;
nw /= max(length(nw), 1e-9);
float smax = max(u_specular.r, max(u_specular.g, u_specular.b));
for (int i = 0; i < u_nsuns; i++) {
float nl = abs(dot(nw, u_sun_dir[i])); // double-sided
lit += nl * u_sun_col[i];
if (smax > 1e-3) {
float p = (u_specular.w > 1.0 && u_specular.w < 200.0)
? u_specular.w : 16.0;
lit += pow(nl, p) * u_specular.rgb * u_sun_col[i];
}
}
} else {
for (int i = 0; i < u_nsuns; i++) lit += 0.7 * u_sun_col[i];
}
vec3 base = u_emissive + u_diffuse * lit;
if (u_has_col == 1) base *= in_col; // vertex colors modulate
v_base = clamp(base, 0.0, 1.0);
v_uv = in_uv;
v_fog = (u_fog_on == 1)
? clamp((z - u_fogrange.x) / (u_fogrange.y - u_fogrange.x), 0.0, 1.0)
: 0.0;
}
"""
MESH_FS = """
#version 330
uniform sampler2D u_tex;
uniform int u_has_tex;
uniform int u_alpha_cut;
uniform vec2 u_uvoff; // board-side SCROLL
uniform float u_opacity; // DITHER n -> screen-door
uniform vec3 u_fogcol;
in vec3 v_base;
in vec2 v_uv;
in float v_fog;
in float v_alpha;
out vec4 f_color;
void main() {
if (u_opacity < 0.99) { // 2x2 ordered screen-door vs opacity
float bay = (float((int(gl_FragCoord.x) & 1) * 2
+ (int(gl_FragCoord.y) & 1)) + 0.5) / 4.0;
if (bay > u_opacity) discard;
}
vec3 rgb;
if (u_has_tex == 1) {
vec3 t = texture(u_tex, v_uv + u_uvoff).rgb;
if (u_alpha_cut == 1 && (t.r + t.g + t.b) <= 24.0 / 255.0)
discard; // near-black texel = transparent
rgb = t * (v_base * 1.275); // software: texel * (base/200), base 0..255
} else {
rgb = v_base;
}
// alpha only takes effect in the deferred blended pass (opaque pass
// renders with BLEND disabled)
f_color = vec4(mix(rgb, u_fogcol, v_fog), clamp(v_alpha, 0.0, 1.0));
}
"""
POINT_VS = """
#version 330
uniform mat4 u_mv;
uniform mat4 u_p;
uniform float u_kx; // zeye / (x1-x0) * W (pixels per unit at z=1)
uniform float u_minpr;
uniform float u_maxpr; // <=0 = unclamped (spheres)
in vec3 in_pos;
in float in_rad; // world radius (spheres) / pr_world (particles)
in vec3 in_col;
in float in_fade;
out vec3 g_col;
out float g_fade;
void main() {
vec4 pe = u_mv * vec4(in_pos, 1.0);
gl_Position = u_p * pe;
float z = max(-pe.z, 1e-6);
float pr = max(in_rad * u_kx / z, u_minpr);
if (u_maxpr > 0.0) pr = min(pr, u_maxpr);
gl_PointSize = min(2.0 * pr, 4096.0);
g_col = in_col;
g_fade = in_fade;
}
"""
POINT_FS = """
#version 330
uniform int u_particle; // 0 = opaque disc (spheres), 1 = SPECIALFX blend
in vec3 g_col;
in float g_fade;
out vec4 f_color;
void main() {
vec2 d = gl_PointCoord - vec2(0.5);
if (dot(d, d) > 0.25) discard;
if (u_particle == 1) {
// dst*(1 - 0.6*fade) + cook*0.75*fade via (ONE, ONE_MINUS_SRC_ALPHA)
f_color = vec4(g_col * 0.75 * g_fade, 0.6 * g_fade);
} else {
f_color = vec4(g_col, 1.0);
}
}
"""
PRESENT_VS = """
#version 330
out vec2 v_uv;
void main() { // fullscreen triangle from gl_VertexID
vec2 p = vec2(float((gl_VertexID << 1) & 2), float(gl_VertexID & 2));
v_uv = p;
gl_Position = vec4(p * 2.0 - 1.0, 0.0, 1.0);
}
"""
PRESENT_FS = """
#version 330
uniform sampler2D u_tex;
in vec2 v_uv;
out vec4 f_color;
void main() { // Division DAC gamma, once over the final image
vec3 c = texture(u_tex, v_uv).rgb;
f_color = vec4(pow(c, vec3(1.0 / 1.25)), 1.0);
}
"""
HUD_VS = """
#version 330
uniform vec4 u_rect; // view rect x0 y0 x1 y1 (dpl2d coordinate space)
in vec2 in_pos;
void main() {
vec2 p = vec2((in_pos.x - u_rect.x) / (u_rect.z - u_rect.x),
(in_pos.y - u_rect.y) / (u_rect.w - u_rect.y)) * 2.0 - 1.0;
gl_Position = vec4(p, 0.0, 1.0);
}
"""
HUD_FS = """
#version 330
uniform vec3 u_col;
out vec4 f_color;
void main() { f_color = vec4(u_col, 1.0); }
"""
class GLRenderer(vrview.Renderer):
"""Drop-in replacement for vrview.Renderer with a moderngl draw path."""
def __init__(self, w=832, h=512,
title="VelociRender virtual board -- FLYK live [GL]"):
import pygame
import moderngl
self.pygame = pygame
self.moderngl = moderngl
pygame.init()
sz = os.environ.get('VRVIEW_SIZE')
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.headless = os.environ.get('SDL_VIDEODRIVER') == 'dummy'
if self.headless:
self.screen = None
self.ctx = moderngl.create_standalone_context(require=330)
else:
pygame.display.gl_set_attribute(pygame.GL_CONTEXT_MAJOR_VERSION, 3)
pygame.display.gl_set_attribute(pygame.GL_CONTEXT_MINOR_VERSION, 3)
pygame.display.gl_set_attribute(
pygame.GL_CONTEXT_PROFILE_MASK, pygame.GL_CONTEXT_PROFILE_CORE)
self.screen = pygame.display.set_mode(
(w, h), pygame.OPENGL | pygame.DOUBLEBUF | pygame.RESIZABLE)
pygame.display.set_caption(title)
self.ctx = moderngl.create_context(require=330)
ctx = self.ctx
self._fbo_tex = ctx.texture((w, h), 3)
self._fbo = ctx.framebuffer(
color_attachments=[self._fbo_tex],
depth_attachment=ctx.depth_renderbuffer((w, h)))
self._mesh_prog = ctx.program(vertex_shader=MESH_VS, fragment_shader=MESH_FS)
self._point_prog = ctx.program(vertex_shader=POINT_VS, fragment_shader=POINT_FS)
self._present_prog = ctx.program(vertex_shader=PRESENT_VS,
fragment_shader=PRESENT_FS)
self._present_vao = ctx.vertex_array(self._present_prog, [])
ctx.enable(moderngl.PROGRAM_POINT_SIZE)
self._vbocache = {} # geom handle -> {mesh(ref), vao, vbo, ibo, n}
self._texcache = {} # id(rgb array) -> {arr(ref), tex}
self._pbuf = None # dynamic particle/sphere staging buffer
self._pvao = None
self._pcap = 0
self.cache = SceneCache()
self.frame = 0
self.clock = pygame.time.Clock()
self.fps = int(os.environ.get('VRVIEW_FPS', '60'))
self.skip = 1
self._last_ms = 0.0
self._seq = 0 # monotonic rendered-frame counter (readback key;
# self.frame gets reset by regress per scene)
self._lf_seq = -1
self._lf = None
self._psys = {}
self.light = np.array([0.3, 0.8, 0.5]); self.light /= np.linalg.norm(self.light)
# ---- uniform helpers -------------------------------------------------
def _set(self, prog, name, value):
u = prog.get(name, None)
if u is not None:
u.value = value
def _mat(self, prog, name, m):
u = prog.get(name, None)
if u is not None:
u.write(np.ascontiguousarray(m, dtype='<f4').tobytes())
# ---- GPU resource caches ----------------------------------------------
def _vao(self, gh, mesh):
"""Static VBO per geometry, invalidated by mesh dict identity (the
SceneCache._mesh version key covers uploads/vertex updates/morph)."""
e = self._vbocache.get(gh)
if e is not None and e['mesh'] is mesh:
return e
n = len(mesh['pos'])
buf = np.zeros((n, 12), dtype='<f4')
buf[:, 0:3] = mesh['pos']
if mesh['nrm'] is not None:
buf[:, 3:6] = mesh['nrm']
if mesh['col'] is not None:
buf[:, 6:9] = mesh['col']
if mesh['uv'] is not None:
buf[:, 9:11] = mesh['uv']
buf[:, 11] = 1.0 # 0x15 field 6 is NOT alpha (see vrview._mesh)
idx = np.ascontiguousarray(mesh['tri'], dtype='<u4')
if e is not None: # same geometry re-uploaded (morph /
e['vbo'].release(); e['ibo'].release(); e['vao'].release()
vbo = self.ctx.buffer(buf.tobytes())
ibo = self.ctx.buffer(idx.tobytes())
vao = self.ctx.vertex_array(
self._mesh_prog,
[(vbo, '3f 3f 3f 2f 1f', 'in_pos', 'in_nrm', 'in_col', 'in_uv',
'in_alpha')],
ibo, index_element_size=4)
e = {'mesh': mesh, 'vbo': vbo, 'ibo': ibo, 'vao': vao,
'n': idx.size}
self._vbocache[gh] = e
return e
def _gl_tex(self, rgb):
"""rgb = float32 (H, W, 3) 0..255 from SceneCache (BSL slices included)."""
e = self._texcache.get(id(rgb))
if e is not None and e['arr'] is rgb:
return e['tex']
h, w = rgb.shape[:2]
data = np.ascontiguousarray(np.clip(rgb, 0, 255).astype(np.uint8))
tex = self.ctx.texture((w, h), 3, data.tobytes())
# AUTHENTIC = point sampling: the i860 texture inner loop (VRENDER/
# AS860/SCANLINE.SS) does ONE fld.l per pixel from the interpolated
# u,v -- no bilinear fetch existed on the board. VRVIEW_FILTER=linear
# opts into smoothing for modern taste.
if os.environ.get('VRVIEW_FILTER') == 'linear':
tex.filter = (self.moderngl.LINEAR, self.moderngl.LINEAR)
else:
tex.filter = (self.moderngl.NEAREST, self.moderngl.NEAREST)
tex.repeat_x = tex.repeat_y = True
self._texcache[id(rgb)] = {'arr': rgb, 'tex': tex}
return tex
def _points_vao(self, data):
"""Dynamic interleaved [x y z rad r g b fade] point buffer (grow+orphan)."""
nbytes = data.nbytes
if self._pbuf is None or nbytes > self._pcap:
if self._pbuf is not None:
self._pvao.release(); self._pbuf.release()
self._pcap = max(nbytes, 4096)
self._pbuf = self.ctx.buffer(reserve=self._pcap, dynamic=True)
self._pvao = self.ctx.vertex_array(
self._point_prog,
[(self._pbuf, '3f 1f 3f 1f', 'in_pos', 'in_rad', 'in_col', 'in_fade')])
self._pbuf.orphan(self._pcap)
self._pbuf.write(data.tobytes())
return self._pvao
# ---- projection --------------------------------------------------------
@staticmethod
def _proj(vp):
"""Row-vector frustum matching the software mapping: x_ndc =
(2*(x_e*zeye/z) - (x0+x1))/(x1-x0), z = -z_e, far = yon*1.05."""
x0, x1, y0, y1 = vp['x0'], vp['x1'], vp['y0'], vp['y1']
ze, n = vp['zeye'], vp['hither']
f = max(vp['yon'] * 1.05, n * 2)
P = np.zeros((4, 4))
P[0, 0] = 2 * ze / (x1 - x0)
P[1, 1] = 2 * ze / (y1 - y0)
P[2, 0] = (x0 + x1) / (x1 - x0)
P[2, 1] = (y0 + y1) / (y1 - y0)
P[2, 2] = -(f + n) / (f - n)
P[2, 3] = -1.0
P[3, 2] = -2 * f * n / (f - n)
return P
# ---- frame readback (readpixels replies, regress PNGs) -----------------
@property
def last_frame(self):
if self._seq == 0:
return None
if self._lf_seq != self._seq:
raw = self._fbo.read(components=3)
arr = np.frombuffer(raw, np.uint8).reshape(self.h, self.w, 3)[::-1]
# post-gamma, like the software path's stored last_frame
arr = (np.power(arr / 255.0, 1 / 1.25) * 255).astype(np.uint8)
self._lf, self._lf_seq = arr, self._seq
return self._lf
def pump(self):
if self.screen is None:
return
pg = self.pygame
for ev in pg.event.get():
if ev.type == pg.QUIT:
pg.quit(); raise KeyboardInterrupt
# ---- the draw path ------------------------------------------------------
def draw(self, board):
pg = self.pygame
if self.screen is not None:
for ev in pg.event.get():
if ev.type == pg.QUIT:
pg.quit(); raise KeyboardInterrupt
self.frame += 1
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
ctx, mgl = self.ctx, self.moderngl
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)
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))
P = self._proj(vp)
fog_on = 1 if (vp['fog_on'] and vp['fog'][1] > vp['fog'][0]) else 0
fbo = self._fbo
fbo.use()
fbo.depth_mask = True
ctx.viewport = (0, 0, W, H)
ctx.enable(mgl.DEPTH_TEST)
ctx.disable(mgl.BLEND)
back = vp['back']
fbo.clear(back[0], back[1], back[2], depth=1.0)
# frame-constant uniforms
prog = self._mesh_prog
if c.dirlights or c.ambient.any():
amb, suns = c.ambient, c.dirlights
else:
amb, suns = np.full(3, 0.35), [(self.light, np.full(3, 0.65))]
suns = suns[:4]
sd = np.zeros((4, 3), '<f4'); sc = np.zeros((4, 3), '<f4')
for i, (L, lcol) in enumerate(suns):
sd[i], sc[i] = L, lcol
self._set(prog, 'u_ambient', tuple(np.asarray(amb, float)))
self._set(prog, 'u_nsuns', len(suns))
u = prog.get('u_sun_dir', None)
if u is not None:
u.write(sd.tobytes())
u = prog.get('u_sun_col', None)
if u is not None:
u.write(sc.tobytes())
self._mat(prog, 'u_p', P)
self._set(prog, 'u_fog_on', fog_on)
self._set(prog, 'u_fogrange', (float(vp['fog'][0]), float(vp['fog'][1])))
self._set(prog, 'u_fogcol', tuple(float(v) for v in vp['fog'][2:5]))
self._set(prog, 'u_tex', 0)
self._mat(self._point_prog, 'u_p', P)
kx = vp['zeye'] / (vp['x1'] - vp['x0']) * W
self._set(self._point_prog, 'u_kx', float(kx))
FIX = np.eye(4)
FIX[:3, :3] = [[0, 0, -1], [0, 1, 0], [1, 0, 0]]
for inst in c.instances:
if not vrview.inst_visible(board.nodes, inst):
continue
# fix_degenerate: see vrview draw loop (rank-2 rig DCSs skewed
# the laser beam off the gun)
Mw = self.chain_matrix(board, inst['chain'], fix_degenerate=True)
M = Mw if inst.get('hud') else Mw @ V
# first-person canopy cull (see SceneCache radius note in vrview)
if (inst.get('gated') and not inst.get('hud')
and np.linalg.norm(M[3, :3]) + inst.get('radius', 0) < 10.0):
continue
if inst['chain'] and inst['chain'][0] in board.anim:
M = FIX @ M
if inst['billboard']:
s = np.linalg.norm(M[0, :3])
M = M.copy()
M[:3, :3] = np.eye(3) * s
geoms = inst['geoms']
if len(inst['lods']) > 1:
dist = float(np.linalg.norm(M[3, :3]))
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._draw_spheres(mesh, M, c.geom_mtl.get(gh))
continue
self._draw_geom(board, c, gh, mesh, M, Mw)
self._draw_particles(board, V, vp, self.skip / 60.0)
self._draw_psfx(board, V)
self._draw_hud2d_gl(board, vp)
# present: FBO -> window with the DAC gamma
if self.screen is not None:
ctx.screen.use()
ww, wh = self.screen.get_size()
ctx.viewport = (0, 0, ww, wh)
ctx.disable(mgl.DEPTH_TEST)
self._fbo_tex.use(0)
self._set(self._present_prog, 'u_tex', 0)
self._present_vao.render(mgl.TRIANGLES, vertices=3)
pg.display.flip()
cam = self.cam_matrix(board)
ct = cam[3, :3]
pg.display.set_caption(
f"VelociRender virtual board [GL] -- cam ({ct[0]:.1f}, {ct[1]:.1f}, "
f"{ct[2]:.1f}) {1000.0 / max(self._last_ms, 0.1):.0f}fps/{self.skip}")
self._seq += 1
self._last_ms = (_t.perf_counter() - _t0) * 1000
self.skip = max(1, min(10, int(self._last_ms / 50)))
if not self.headless:
self.clock.tick(self.fps) # pace draw_scene acks (RETRACE-aware)
def _draw_hud2d_gl(self, board, vp):
"""Composite the game's dpl2d HUD into the FBO (pre-present, so the
DAC gamma pass covers it -- matches the software path's colors)."""
root = vrview.hud2d_root(board, self.cache.view)
if not root:
return
try:
prims = vrview.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
if not prims:
return
ctx, mgl = self.ctx, self.moderngl
if getattr(self, '_hud_prog', None) is None:
self._hud_prog = ctx.program(vertex_shader=HUD_VS,
fragment_shader=HUD_FS)
self._hud_cap = 8192
self._hud_buf = ctx.buffer(reserve=self._hud_cap, dynamic=True)
self._hud_vao = ctx.vertex_array(
self._hud_prog, [(self._hud_buf, '2f', 'in_pos')])
ctx.disable(mgl.DEPTH_TEST)
self._set(self._hud_prog, 'u_rect',
(float(vp['x0']), float(vp['y0']),
float(vp['x1']), float(vp['y1'])))
MODES = {'strip': mgl.LINE_STRIP, 'segs': mgl.LINES,
'poly': mgl.TRIANGLE_FAN, 'points': mgl.POINTS}
for kind, col, wd, pts in prims:
if len(pts) < (3 if kind == 'poly' else 1):
continue
data = np.asarray(pts, '<f4')
if data.nbytes > self._hud_cap:
self._hud_vao.release(); self._hud_buf.release()
self._hud_cap = data.nbytes * 2
self._hud_buf = ctx.buffer(reserve=self._hud_cap, dynamic=True)
self._hud_vao = ctx.vertex_array(
self._hud_prog, [(self._hud_buf, '2f', 'in_pos')])
self._hud_buf.orphan(self._hud_cap)
self._hud_buf.write(data.tobytes())
self._set(self._hud_prog, 'u_col', tuple(float(v) for v in col))
ctx.line_width = max(1.0, float(wd))
ctx.point_size = max(1.0, float(wd))
self._hud_vao.render(MODES[kind], vertices=len(pts))
ctx.line_width = 1.0
def _draw_geom(self, board, c, gh, mesh, M, Mw):
"""Set per-geometry uniforms and render one mesh (both passes)."""
prog = self._mesh_prog
self._mat(prog, 'u_mv', M)
self._mat(prog, 'u_model', Mw)
props = c.geom_mtl.get(gh)
self._set(prog, 'u_diffuse',
tuple(props['diffuse']) if props else (0.75, 0.78, 0.82))
self._set(prog, 'u_emissive',
tuple(props['emissive']) if props else (0.0, 0.0, 0.0))
self._set(prog, 'u_specular',
tuple(props['specular']) if props else (0.0, 0.0, 0.0, 0.0))
self._set(prog, 'u_opacity',
float(props['opacity']) if props else 1.0)
self._set(prog, 'u_has_nrm', 1 if mesh['nrm'] is not None else 0)
self._set(prog, 'u_has_col', 1 if mesh['col'] is not None else 0)
tex = c.geom_tex.get(gh)
has_tex = tex is not None and mesh['uv'] is not None
self._set(prog, 'u_has_tex', 1 if has_tex else 0)
acut = 0
uvoff = (0.0, 0.0)
if has_tex:
texn = c.geom_texn.get(gh)
if texn is not None:
tb = board.nodes.get(texn, {}).get('body') or b''
if len(tb) >= 48: # FLYK SCROLL u0/v0 + du/dv per
u0, v0, du, dv = struct.unpack_from('<4f', tb, 32)
if all(np.isfinite((u0, v0, du, dv))):
if getattr(board, 'munga', False):
# MUNGA: floats 10/11 are a STATIC uv offset
# (atlas positioning) -- animating them scrolled
# the BT terrain "like clouds" (user-observed)
uvoff = (u0 + du, v0 + dv)
else:
t = self.frame / 60.0
uvoff = (u0 + du * t, v0 + dv * t)
if len(tb) >= 20 and struct.unpack_from('<I', tb, 16)[0]:
acut = 1 # texture alpha flag -> cutout
self._gl_tex(tex).use(0)
self._set(prog, 'u_alpha_cut', acut)
self._set(prog, 'u_uvoff', (float(uvoff[0]), float(uvoff[1])))
self._vao(gh, mesh)['vao'].render(self.moderngl.TRIANGLES)
def _draw_spheres(self, mesh, M, props):
"""Sphere-list geometry (vtype 0x41) as depth-tested point-sprite discs."""
n = len(mesh['sphere'])
if n == 0:
return
col = np.array([0.8, 0.8, 0.8])
if props is not None:
col = np.clip(props['emissive'] + props['diffuse'], 0, 1)
s = float(np.linalg.norm(M[0, :3]))
data = np.zeros((n, 8), '<f4')
data[:, 0:3] = mesh['sphere']
data[:, 3] = mesh['rad'] * s
data[:, 4:7] = col
data[:, 7] = 1.0
prog = self._point_prog
self._mat(prog, 'u_mv', M)
self._set(prog, 'u_minpr', 0.7)
self._set(prog, 'u_maxpr', 0.0)
self._set(prog, 'u_particle', 0)
self._points_vao(data).render(self.moderngl.POINTS, vertices=n)
def _draw_particles(self, board, V, vp, dt):
"""Ambient SPECIALFX pools: identical CPU simulation to the software
path (vrview._particles), drawn as blended point sprites -- blend
(ONE, 1-SRC_ALPHA) with rgb=cook*0.75*fade, a=0.6*fade reproduces
tile*(1-0.6*fade) + cook*0.75*fade. Depth-tested, no depth write."""
if not board.sfx or os.environ.get('VRVIEW_SFX') != '1':
return
eye = np.linalg.inv(V)[3, :3]
chunks = []
for code, d in board.sfx.items():
n = int(min(max(d['frags'], 1) * max(d['rpt'], 1) * 4, 192))
ps = self._psys.get(code)
if ps is None or len(ps['age']) != n:
ps = self._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
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']
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
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))
data = np.zeros((n, 8), '<f4')
data[:, 0:3] = ps['pos']
data[:, 3] = pr_world
data[:, 4:7] = cook
data[:, 7] = fade
chunks.append(data)
if not chunks:
return
data = np.concatenate(chunks, 0)
# back-to-front for the order-dependent blend
pe_z = data[:, 0:3] @ V[:3, :3] + V[3, :3]
data = data[np.argsort(pe_z[:, 2])]
ctx, mgl = self.ctx, self.moderngl
prog = self._point_prog
self._mat(prog, 'u_mv', V)
self._set(prog, 'u_minpr', 0.6)
self._set(prog, 'u_maxpr', float(self.h * 0.08))
self._set(prog, 'u_particle', 1)
ctx.enable(mgl.BLEND)
ctx.blend_func = (mgl.ONE, mgl.ONE_MINUS_SRC_ALPHA)
self._fbo.depth_mask = False
self._points_vao(data).render(mgl.POINTS, vertices=len(data))
self._fbo.depth_mask = True
ctx.disable(mgl.BLEND)
def _draw_psfx(self, board, V):
"""Triggered PSFX (0x1d) as glowing additive discs, two kinds:
- DCS-attached bolts (weapon fire): the emitter rides the flying
projectile DCS, so the glow tracks the shot. We keep a short trail of
recent world positions and draw a fading tail, so the continuous
particle stream (rate ~300/s) reads as a streak, not a lone dot. The
bolt lives until the projectile DCS is deleted (impact).
- DCS=0 world bursts (muzzle flash, impact spark, explosion): a fixed
world-space burst at (px,py,pz); big-radius explosions expand and the
colour lerps from the start interior toward the end interior over the
burst's ttl, then it fades out."""
if not board.psfx:
return
cache = {}
rows, live = [], []
for e in board.psfx:
dcs = e['dcs']
if dcs:
nd = board.nodes.get(dcs)
if not nd or nd.get('type') != 5:
continue # projectile consumed (impact)
m = cache.get(dcs)
if m is None:
try:
m = self.chain_matrix(board, self._chain(board, dcs, links=True))
except Exception:
m = self.dcs_matrix(board, dcs)
cache[dcs] = m
pos = np.asarray(m[3, :3], float)
else:
e['ttl'] -= 1
if e['ttl'] <= 0:
continue
pos = np.asarray(e['pos'], float)
if not np.all(np.isfinite(pos)):
continue
e['age'] += 1
live.append(e)
frac = e['age'] / max(e['maxttl'], 1) # 0..1 over life
col = np.clip((1.0 - frac) * np.array(e['col'])
+ frac * np.array(e['ecol']), 0.0, 2.0)
a0 = min(e['alpha'], 1.0)
if dcs: # bolt + trail
tr = e.setdefault('trail', [])
tr.append(pos)
if len(tr) > 9:
del tr[0]
nt = len(tr)
for k, tp in enumerate(tr):
w = (k + 1) / nt # head brightest
rows.append([tp[0], tp[1], tp[2],
max(e['rad'] * (0.35 + 0.65 * w), 1.2),
col[0], col[1], col[2], a0 * w * w])
else: # world burst
grow = 1.0 + min(e['exp'], 12.0) * 0.09 * frac # explosions swell
fade = min(1.0, e['ttl'] / 10.0)
rows.append([pos[0], pos[1], pos[2], max(e['rad'] * grow, 1.5),
col[0], col[1], col[2], a0 * fade])
board.psfx = live
if not rows:
return
data = np.array(rows, '<f4')
# back-to-front for the order-dependent additive-over blend
pe_z = data[:, 0:3] @ V[:3, :3] + V[3, :3]
data = data[np.argsort(pe_z[:, 2])]
ctx, mgl = self.ctx, self.moderngl
prog = self._point_prog
self._mat(prog, 'u_mv', V)
self._set(prog, 'u_minpr', 0.6)
self._set(prog, 'u_maxpr', float(self.h * 0.22))
self._set(prog, 'u_particle', 1)
ctx.enable(mgl.BLEND)
ctx.blend_func = (mgl.ONE, mgl.ONE_MINUS_SRC_ALPHA)
self._fbo.depth_mask = False
self._points_vao(data).render(mgl.POINTS, vertices=len(data))
self._fbo.depth_mask = True
ctx.disable(mgl.BLEND)