#!/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=' 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(' 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), ' 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, ' 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(' 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), ' 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), ' 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, '