Skip to content

Navigation Menu

Sign in
Sign up

Renderer Technical overview

Vicious Squid edited this page Jun 5, 2026 · 3 revisions

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


1. Architecture Overview

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.


2. Module Responsibilities

2.1 renderer_core.py — BaseRenderer

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.

Utility classes

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.


2.2 renderer_F.py — Renderer_F (Forward Renderer)

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.


2.3 qt_game_view.py — QtGameView

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 ThreadedGameState and LogicThread, polls gamepad input (via pygame), processes the sound queue, and drives frame timing.
  • An overlay compositor — after the GL pass, a QPainter pass 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()

3. Render Pipeline — Single Frame

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.)

4. Shader System

Shader inventory

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.

Compilation flow

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...])

Uniform caching

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.

Light upload

_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.


5. Texture Management

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.


6. Portal 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):

  1. Stencil mask pass — render portal A's quad with colour/depth writes disabled, writing a unique stencil ID.
  2. Depth prime pass — push depth to the far plane inside the stencil region, allowing the virtual scene to write freely.
  3. 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 the draw_scene_fn callback.
  4. Rim glow pass — an additive coloured GL_LINE_LOOP drawn 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.


7. Special Materials

All three special material types are rendered in the transparent/back-to-front pass with blending enabled and depth writes disabled.

Water

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.

Glass

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.

Fog Volumes

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.


8. Projected Shadows

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).


9. Threading Model

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.


10. Split-Screen

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.


11. Platform Considerations (ARM / Adreno)

_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.


12. Adding a New Renderer Backend

To add a deferred rendering path (for example):

  1. Create engine/renderer_D.py, subclassing BaseRenderer.
  2. Implement render_scene(projection, view, camera_pos, brushes, things, selected_object, config).
  3. Optionally override draw_lit_brushes_optimized() and draw_textured_brushes_optimized() for G-buffer passes.
  4. Register in qt_game_view.py:
    from .renderer_D import Renderer_D
    _RENDERER_CLASSES = {
     'Forward': Renderer_F,
     'Deferred': Renderer_D,
    }
  5. 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.


13. Key Constants and Config

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

14. File Index

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

Clone this wiki locally

AltStyle によって変換されたページ (->オリジナル) /