-
Notifications
You must be signed in to change notification settings - Fork 10
Renderer Technical overview
Scope:
engine/renderer_core.py,engine/renderer_F.py,editor/qt_game_view.py
OpenGL target: 3.3 Core Profile · Platform baseline: Snapdragon 8CX / Adreno
The renderer is split across three layers that mirror a classic separation of concerns: the Qt host, the abstract base, and the concrete render path.
┌─────────────────────────────────────────────────────┐
│ QtGameView (qt_game_view.py) │
│ QOpenGLWidget subclass — owns the GL context, │
│ the game loop timer, HUD, input, and play mode. │
│ │
│ Holds a reference to one renderer instance: │
│ self.renderer = Renderer_F(...) │
└────────────────────┬────────────────────────────────┘
│ calls render_scene() each frame
▼
┌─────────────────────────────────────────────────────┐
│ Renderer_F (renderer_F.py) │
│ Concrete forward-rendering implementation. │
│ Owns: draw_lit_brushes, draw_textured_brushes, │
│ draw_glow_brushes, render_scene (entry point). │
│ │
│ Inherits all shared infrastructure from: │
└────────────────────┬────────────────────────────────┘
│ super().__init__ / inherited methods
▼
┌─────────────────────────────────────────────────────┐
│ BaseRenderer (renderer_core.py) │
│ Abstract base — texture manager, VAO factory, │
│ shader compiler, grid, sprites, terrain, portals, │
│ water/glass/fog, shadows, editor helpers, LOD. │
│ render_scene() raises NotImplementedError. │
└─────────────────────────────────────────────────────┘
The registry in qt_game_view.py makes the forward path hot-swappable at runtime:
_RENDERER_CLASSES = { 'Forward': Renderer_F, }
switch_renderer(mode) tears down the old renderer (calling cleanup() to release all GL resources), constructs a new instance of the target class, and restores state. This is the hook point for adding a deferred path later — register a new key and the rest of the machinery just works.
The base class owns every piece of the renderer that is not specific to a rendering path. Nothing in this file calls render_scene(); it only provides building blocks.
| Subsystem | Key methods |
|---|---|
| Shader management |
_compile_common_shaders(), _compile_arm_shaders(), _compile_standard_shaders(), ShaderLoader
|
| Texture management |
load_texture(), preload_level_textures(), _load_3d_texture()
|
| VAO factory |
_create_cube_vao(), _create_sprite_vao(), _create_gizmo_buffers(), update_grid_buffers()
|
| Terrain |
render_terrain(), setup_terrain_shader(), _ensure_terrain_textures()
|
| Sprites / billboards |
draw_sprites(), set_sprite_textures(), set_instance_textures()
|
| 3-D models |
load_model(), draw_models() (OBJ + GLB) |
| Special materials |
draw_water_brushes(), draw_glass_brushes(), draw_fog_volumes()
|
| Portal rendering |
_init_portal_gl(), draw_portals(), _draw_one_portal()
|
| Projected shadows | render_projected_shadows_optimized() |
| Editor helpers |
draw_selected_brush_outline(), draw_face_highlight(), render_gizmo(), draw_connection_lines(), draw_path_node_cubes(), draw_portal_wireframes()
|
| Grid |
draw_grid(), update_grid_buffers()
|
| Sorting helpers |
_sort_objects(), _split_opaque()
|
| Lighting upload | _upload_lights_once() |
| Math utilities |
_brush_model_matrix(), _compute_normal_matrix(), _distance_sq()
|
| Performance |
LODManager, RenderStats, ShadowBatch
|
| Cleanup |
cleanup() — explicit GL resource release |
render_scene() is declared but raises NotImplementedError. Subclasses must override it.
UniformCache — wraps a shader program and caches glGetUniformLocation results by name. Avoids per-frame driver queries and is preloaded at shader compile time with preload().
LODManager — distance-squared bucketing into LOD_FULL, LOD_REDUCED, and LOD_CULLED thresholds. Distances are configurable and driven by set_cull_distance() in QtGameView.
RenderStats — lightweight counters (total_brushes, visible_brushes, draw_calls, batched_draws, etc.) reset at the start of each render_scene() call and read by the sysmon overlay.
ShadowBatch — pre-allocated numpy arrays (position, scale, rotation, alpha) for projected floor shadows, avoiding per-frame heap allocation.
ShaderLoader — locates, reads, and compiles GLSL source from assets/shaders/. Falls back to DEFAULT_SHADERS (embedded Python strings from engine/shaders.py) if disk files are absent, ensuring the engine can always start.
Renderer_F is the only concrete render path currently in production. It overrides render_scene() and provides three brush-drawing methods that BaseRenderer does not implement.
| Method | Purpose |
|---|---|
draw_lit_brushes_optimized() |
Untextured geometry using the lit/lit_arm shader. Handles selection highlight (yellow), subtract-brush tint (red), trigger tint (cyan, semi-transparent), and gizmo state. |
draw_textured_brushes_optimized() |
Per-face texture assignment, texture-tiling scale, and caulk/nodraw filtering. Sorts draw calls by texture ID to minimise glBindTexture churn. |
draw_glow_brushes() |
Re-uses the lit shader but drives the colour uniform above 1.0 (overbright) to fake HDR emission. Intensity is controlled by the glow_intensity brush property. |
render_scene() |
The master frame composition entry point. See §3. |
_upload_lights_once() is guarded by a per-frame flag (_frame_lights_uploaded) so the uniform array is written at most once per shader per frame regardless of how many brush batches are rendered.
QtGameView(QOpenGLWidget) is the editor's 3-D viewport. It is simultaneously:
- A Qt widget — owns the OpenGL context, resize handling, mouse/keyboard input, and a 16 ms repaint timer (~60 fps target).
-
The game loop host — manages
ThreadedGameStateandLogicThread, polls gamepad input (via pygame), processes the sound queue, and drives frame timing. -
An overlay compositor — after the GL pass, a
QPainterpass renders HUD, FPS counter, debug window, death screen, level-complete overlay, and all text.
Key responsibilities not in the renderer layer:
| Responsibility | Notes |
|---|---|
| Play / edit mode toggle |
toggle_play_mode() — creates/destroys Player instances, hides cursor, sends angle to logic thread |
| Split-screen |
_toggle_splitscreen() — halves the viewport with glScissor/glViewport, renders the scene twice with P1 and P2 view matrices |
| Renderer hot-swap |
switch_renderer(mode) — calls cleanup(), instantiates new renderer class, restores sprite textures and LOD settings |
| IO connection lines |
_gather_io_connections() — queries the IO system and PathNode graph to build coloured debug lines drawn by renderer.draw_connection_lines()
|
| Face mode |
get_brush_face_at_coords() + draw_face_highlight() — per-face texture assignment workflow |
| Post-effect GL passes |
_render_bullet_marks(), _render_projectiles(), _render_monster_debug_rays(), _render_spatial_grid() — rendered after the main scene using the renderer's existing VAOs and shaders |
| Sound | Pool of QSoundEffect instances pre-warmed at startup; consumed from game_state.consume_sounds() each frame |
| Gamepad | Pygame joystick polled at 60 Hz by a QTimer, axis values sent to game_state.set_p2_input()
|
This describes one call to Renderer_F.render_scene().
QtGameView.paintGL()
│
├─ Assemble _render_config dict
│ (render_mode, play_mode, brush_display_mode,
│ selected_object, time, terrain, all_brushes, ...)
│
├─ Handle split-screen scissor / viewport setup
│
└─ renderer.render_scene(projection, view, camera_pos,
brushes, things, selected, config)
│
├─ 1. CLEAR
│ glClear(COLOR | DEPTH | STENCIL)
│
├─ 2. GRID
│ draw_grid() [editor only, skipped in play mode]
│
├─ 3. SORT OBJECTS
│ _sort_objects() → opaque, transparent, sprites,
│ fog, water, glass, glow
│ _split_opaque() → textured_opaque, solid_opaque
│ Extract models (Things with model_path)
│ Extract active lights
│
├─ 4. TERRAIN [if enabled]
│ render_terrain()
│
├─ 5. PORTALS [play mode + _portal_gl_ready]
│ draw_portals()
│ └─ Per portal pair: _draw_one_portal()
│ ├─ Stencil mask pass (portal quad → stencil)
│ ├─ Depth prime pass (push depth to far)
│ ├─ Virtual scene pass (re-enter render_scene
│ │ with transformed view)
│ └─ Rim glow pass (additive line-loop)
│
├─ 6. OPAQUE GEOMETRY [depth write ON, blend OFF]
│ ├─ Unlit mode → draw_textured + draw_lit
│ ├─ Lit mode
│ │ ├─ Textured display → draw_textured + draw_lit
│ │ └─ Solid/Wireframe → draw_lit only
│ └─ Wireframe/Vertex mode → draw_lit (polygon mode)
│
├─ 7. GLOW BRUSHES
│ draw_glow_brushes() [overbright colour trick]
│
├─ 8. 3-D MODELS
│ draw_models() [OBJ / GLB via obj_loader / glb_loader]
│
├─ 9. PROJECTED SHADOWS [Lit mode + shadows_enabled]
│ render_projected_shadows_optimized()
│ [Skipped on ARM/Adreno by default]
│
├─ 10. DEPTH-SORTED TRANSPARENT / SPECIAL MATERIALS
│ [Sort all back-to-front by distance2]
│ ├─ Trigger brushes (transparent pass, cyan tint)
│ ├─ Sprites / billboards
│ ├─ Water brushes [Lit mode only]
│ ├─ Glass brushes [Lit mode only]
│ └─ Fog volumes [Lit mode only, 3-D noise texture]
│
├─ 11. EDITOR-ONLY OVERLAYS [!play_mode]
│ draw_path_node_cubes()
│ draw_portal_wireframes()
│
├─ 12. SELECTION & GIZMO
│ draw_selected_brush_outline()
│ render_gizmo() [RGB axis arrows + cones]
│
└─ 13. CLEANUP
glUseProgram(0)
depth test / blend state reset
After render_scene returns, QtGameView adds:
├─ _render_bullet_marks()
├─ _render_projectiles()
├─ _render_monster_debug_rays() [debug mode]
├─ _render_spatial_grid() [debug mode]
├─ draw_connection_lines() [editor, show_logic_links]
├─ draw_face_highlight() [face mode]
│
└─ QPainter pass (2-D overlay)
├─ FPS counter
├─ HUD (health bar, crosshair, weapon sprite,
│ muzzle flash, key icons, messages)
├─ Split-screen divider line + P2 HUD
├─ Death screen / level-complete overlay
├─ Face mode indicator
└─ Debug window manager (sysmon, etc.)
| Name | Used by | Purpose |
|---|---|---|
simple |
Grid, outlines, gizmo, path nodes, debug lines | Untextured coloured geometry |
lit / lit_arm
|
Solid brushes, glow, models without texture | Phong-style per-point lighting, up to 16 lights |
textured / textured_arm
|
Textured brushes, textured models | Lit + diffuse texture + per-face UV scale |
sprite |
All billboard Things | Camera-facing quad, world-space position uniform |
water |
Water brushes | Time-animated normal map, wave displacement, opacity |
glass |
Glass brushes | Distortion, refraction index, Fresnel, roughness |
fog / fog_arm
|
Fog volume brushes | Ray-marched volumetric fog using a 3-D noise texture |
shadow_volume |
Projected floor shadows | Stencil-projected planar shadow matrix |
terrain |
Terrain mesh | Biome-weighted 4-texture blend (grass/rock/sand/snow) |
portal_mask |
Portal stencil pass | Writes to stencil only |
portal_rim |
Portal rim glow | Additive coloured line-loop |
ARM variants (_arm) use simplified GLSL tuned for Adreno's compiler quirks. The platform is detected at startup and committed for the session — both variants expose identical uniform names.
ShaderLoader._ensure_defaults() # write embedded sources to disk
│
▼
BaseRenderer._compile_common_shaders() # compile shared shaders
│
├─ arm_mode → _compile_arm_shaders()
└─ else → _compile_standard_shaders()
Each program → UniformCache(program).preload([names...])
UniformCache wraps a program and lazily queries glGetUniformLocation, storing results by name. preload() is called once at compile time for all known uniform names. This eliminates all per-frame location queries from hot paths — a significant concern on mobile drivers where glGetUniformLocation can be expensive.
_upload_lights_once(shader_name, lights) writes the full lights[N].position/color/intensity/radius uniform array and active_lights count. A per-frame flag prevents writing the same data more than once per shader per frame, regardless of how many draw calls share that shader.
All loaded textures are keyed in self.texture_manager (a dict[str, int]) by their subfolder-relative path, e.g. "textures/bricks.jpg". The two special procedural entries are:
-
"textures/default.png"— ×ばつ1 white RGBA pixel, fallback for missing assets. -
"textures/caulk"— ×ばつ2 magenta/black checkerboard, visible in the editor only.
Real textures are loaded via Pillow, flipped vertically, uploaded with GL_LINEAR_MIPMAP_LINEAR / GL_LINEAR filters, and mipmapped. Filter parameters are always set explicitly to avoid driver-default issues on Adreno.
preload_level_textures(brushes) scans every face of every brush and batch-loads all referenced textures at level load time, avoiding mid-frame stalls.
Model textures are resolved by _resolve_model_texture_path(), which checks (in order): MTL-relative path, assets/textures/, assets/models/, then falls back to the default texture.
A 323 3-D noise texture (assets/noise_3d.bin, raw GL_R8) is loaded once at startup and bound to GL_TEXTURE1 during fog volume rendering.
Portals use a stencil-buffer technique inspired by Prey (2006). Up to MAX_PORTALS = 8 pairs are rendered per frame.
For each portal pair (A → B):
- Stencil mask pass — render portal A's quad with colour/depth writes disabled, writing a unique stencil ID.
- Depth prime pass — push depth to the far plane inside the stencil region, allowing the virtual scene to write freely.
-
Virtual scene pass —
_portal_build_virtual_view()computes a transformed camera position and view matrix (yaw-rotated around portal B's position). The scene is re-rendered through this virtual view, masked to the stencil region, via thedraw_scene_fncallback. -
Rim glow pass — an additive coloured
GL_LINE_LOOPdrawn over the portal quad as a visual indicator. Unlinked portals render red wireframes instead.
The portal pair's virtual view transform is a 2-D yaw rotation by (yaw_A − yaw_B + π), preserving pitch and height — appropriate for the kind of liminal-space portal geometry Fio targets.
Portal GL resources (_portal_quad_vao, _portal_mask_shader, _portal_rim_shader) are initialised by _init_portal_gl() at the end of BaseRenderer.__init__(). The _portal_gl_ready flag gates all portal rendering; if shader compilation fails at startup, portals silently degrade to wireframe-only display.
All three special material types are rendered in the transparent/back-to-front pass with blending enabled and depth writes disabled.
Uses a time-animated normal map sampled from water_normal.png. Per-brush properties (water_opacity, water_reflectivity, water_tint, water_wave_enabled, water_wave_height) are uploaded as uniforms. water_plane: true renders only the top face (face index 5, vertices 30–35 in the cube VAO); otherwise all five non-bottom faces are rendered.
Physical approximation with configurable glass_opacity, glass_distortion, glass_refraction (index), glass_roughness, and glass_fresnel. Back-face culling is enabled with GL_BACK to avoid double-blending on thin panes.
Ray-marched volumetric fog using the 3-D noise texture bound to unit 1. The fog shader receives both the model matrix and its inverse (inverseModel uniform) to reconstruct object-space ray direction inside the GLSL. Front-face culling (GL_FRONT) is applied on the first draw call to render the interior for camera-inside cases, then back-face culling on the second for the exterior.
Floor shadows use the classic projective shadow matrix technique: for each brush, a matrix S is computed that projects the brush's geometry onto the floor plane (Y = 0) from a given light position. The brush model matrix is pre-multiplied with S, and the cube VAO is redrawn with the shadow shader. Shadow draw calls are gated by per-brush distance to the light (within light.radius).
Main thread (Qt) Logic thread (LogicThread)
───────────────── ─────────────────────────
update_loop() (16 ms) Runs at its own tick rate
set_keys(pressed_keys) → reads input
try_swap() ← swap render state buffer
repaint()
paintGL()
game_state
.get_render_state() reads from the back buffer
ThreadedGameState owns a double-buffered RenderState. The logic thread writes to the back buffer (physics, AI, visibility culling, camera matrix) and signals a swap. The main thread reads from the front buffer without locks during paintGL(). try_swap() atomically promotes the back buffer to front if new data is available.
RenderState carries: visible_brushes, visible_things, all_brushes, camera_view_matrix, player_pos/angle/pitch, player_health, bullet_marks, projectiles, monster_debug_rays, splitscreen_active, player2_view_matrix, hud_message, collected_keys, and more.
When threading is off (use_threading = False), paintGL() reads directly from editor.state — useful for debugging.
Split-screen is activated by _toggle_splitscreen() (F9 in play mode). The viewport is divided vertically at width // 2 using glScissor + glViewport. render_scene() is called twice per frame:
- Left half: P1 view matrix + camera position from
render_state - Right half:
render_state.player2_view_matrix+render_state.player2_pos
Both calls use the same brush/thing lists. Post-effect passes (_render_bullet_marks, _render_projectiles) are also duplicated with their respective matrices. The QPainter HUD pass draws a 2-pixel divider line and renders separate health bars, crosshairs, and labels for each player.
_detect_arm_platform() checks platform.machine() for arm/aarch, and on Windows also inspects PROCESSOR_ARCHITECTURE, PROCESSOR_IDENTIFIER, and related environment variables (covering Qualcomm/Snapdragon Windows-on-ARM builds).
When ARM is detected:
| Feature | ARM behaviour |
|---|---|
| Lit / textured shaders |
lit_arm / textured_arm variants (simpler GLSL) |
| Fog shader |
fog_arm variant (16 ray-march steps vs 32) |
| Projected shadows | Disabled by default |
| Texture filters | Always set explicitly (avoids Adreno default-filter crashes) |
| Uniform queries | Cached via UniformCache to avoid expensive per-frame driver calls |
The ARM flag is set once in BaseRenderer.__init__() and shared with subclasses. It can be overridden in settings.ini → [Renderer] arm_mode.
To add a deferred rendering path (for example):
- Create
engine/renderer_D.py, subclassingBaseRenderer. - Implement
render_scene(projection, view, camera_pos, brushes, things, selected_object, config). - Optionally override
draw_lit_brushes_optimized()anddraw_textured_brushes_optimized()for G-buffer passes. - Register in
qt_game_view.py:from .renderer_D import Renderer_D _RENDERER_CLASSES = { 'Forward': Renderer_F, 'Deferred': Renderer_D, }
- Call
game_view.switch_renderer('Deferred')from a menu action.
All shared subsystems (portals, terrain, water, glass, fog, shadows, sprites, editor overlays) are inherited from BaseRenderer for free.
| Constant / setting | Location | Default | Effect |
|---|---|---|---|
MAX_LIGHTS |
BaseRenderer |
16 | Maximum lights uploaded per frame |
MAX_PORTALS |
BaseRenderer |
8 | Maximum portal pairs rendered per frame |
LOD_FULL threshold |
LODManager |
500 units | Full draw distance |
LOD_CULLED threshold |
LODManager / set_cull_distance()
|
4096 units | Culling distance |
fog_quality |
BaseRenderer |
'low' (16 steps) |
Ray-march step count; 'high' = 32 |
vsync |
settings.ini [Display] |
true |
swapInterval(1 or 0) |
arm_mode |
settings.ini [Renderer] |
auto-detect | Shader path selection |
shadows_enabled |
settings.ini [Renderer] |
not arm_mode |
Projected shadow toggle |
show_hud |
settings.ini [Display] |
true |
HUD visibility in play mode |
show_fps |
settings.ini [Display] |
false |
FPS counter overlay |
| File | Class | Lines (approx.) |
|---|---|---|
engine/renderer_core.py |
BaseRenderer, UniformCache, ShadowBatch, LODManager, RenderStats, ShaderLoader
|
~1850 |
engine/renderer_F.py |
Renderer_F(BaseRenderer) |
~340 |
editor/qt_game_view.py |
QtGameView(QOpenGLWidget) |
~2220 |