The main loop rendered EVERY draw_scene in arrival order, so any render slowdown played the mission slower than real time and the view drifted seconds behind the game (user: "renderer fell way out of sync"); the backpressure hides in the socket, so backlog= stayed 0. Now each pass slices all complete records, applies every state record in order, and presents only the newest frame; superseded presents are counted in the new skipped= report field. Live verdict over a full mission: frames=10169 skipped=3 backlog=0B -- locked to the wire. Also from the tuner session: seat trim granularity 0.1 (0.5 was too jumpy) and a HUD size trim (vrview_gl u_scale + VRVIEW_HUDSCALE env, live +/- keys) -- tuners stay enabled for pilot feedback; seat trim turns out to be per-mech (Mad Cat vs Thor want different values). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
802 lines
34 KiB
Python
802 lines
34 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;
|
|
// fog factor moves to the FRAGMENT shader: computing it per vertex
|
|
// zeroed it on vertices BEHIND the eye, and the terrain/cloud triangles
|
|
// are huge enough to span the camera -- interpolation dragged the whole
|
|
// ground to "unfogged" (the pre-drop blackout leak). The eye depth
|
|
// itself interpolates perspective-correct, so pass that instead.
|
|
v_fog = z;
|
|
}
|
|
"""
|
|
|
|
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;
|
|
uniform int u_fog_on;
|
|
uniform vec2 u_fogrange; // near, far
|
|
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)
|
|
float fogf = (u_fog_on == 1)
|
|
? clamp((v_fog - u_fogrange.x) / (u_fogrange.y - u_fogrange.x),
|
|
0.0, 1.0)
|
|
: 0.0;
|
|
f_color = vec4(mix(rgb, u_fogcol, fogf), 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)
|
|
uniform float u_scale; // HUD size trim (VRVIEW_HUDSCALE / live +/- keys)
|
|
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;
|
|
// dpl2d Y grows DOWN (screen convention): period VHS footage shows the
|
|
// twist dial UNDER the reticle and the range bar filling top-to-bottom;
|
|
// mapping straight onto GL's Y-up NDC rendered the whole HUD flipped.
|
|
p.y = -p.y;
|
|
gl_Position = vec4(p * u_scale, 0.0, 1.0);
|
|
}
|
|
"""
|
|
|
|
# HUD size trim, live-adjustable from the bridge (+/- keys); pin with
|
|
# VRVIEW_HUDSCALE once the right size is found vs period footage.
|
|
HUDSCALE = [float(os.environ.get('VRVIEW_HUDSCALE', '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)
|
|
# With fog on, the background sits at infinite distance and must
|
|
# saturate to the FOG colour (the pod's pre-drop hold is FULLY black
|
|
# and the start flash covers the sky band too -- period-pilot
|
|
# confirmed); raw back_color left a clear blue stripe.
|
|
back = vp['fog'][2:5] if fog_on else 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 (vrview.NEARCULL and inst.get('gated') and not inst.get('hud')
|
|
and np.linalg.norm(M[3, :3]) + inst.get('radius', 0) < 10.0):
|
|
continue
|
|
# FLYK-only +90 yaw stand-in correction (see vrview draw loop)
|
|
if (not getattr(board, 'munga', False)
|
|
and 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'])))
|
|
self._set(self._hud_prog, 'u_scale', float(HUDSCALE[0]))
|
|
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)
|