Skip to main content
How a frame is drawn, and the optimisations in place. Everything lives in engine/WizmRender (renderers, passes, GL objects) and engine/WizmECS/src/system/*_system.cpp (the ECS side that feeds the render queue). Both WizmPlayer/src/main.cpp and WizmEditor/src/main.cpp assemble the same pass list; the editor adds the wireframe overlay of its view modes, billboards, collider gizmos, the selection outline and picking.

Frame order

Simulation (in this order, every frame):
  1. update_gui — UI input for the frame: hit-testing, hover/press/release, button callbacks. Runs before scripts so they see what the UI consumed.
  2. update_scripts — AngelScript on_update with the filtered input view.
  3. update_physics
  4. refresh_transforms — rebuilds every entity’s cached local matrix that changed. After this point nothing moves entities, so the parallel systems below only read.
  5. update_cameras, update_animations (parallel), update_particle_systems (parallel), update_audio
  6. apply_cursor_state — pushes the effective cursor lock to the window, only on change.
Submission: queue.clear() (also advances the shared instance ring buffer), submit_lights, submit_scene, submit_particles, queue.sort(), submit_gui. Render passes, in pipeline order: Both shadow maps are bound as comparison samplers (sampler2DArrayShadow / samplerCubeArrayShadow, linear filtering, GL_COMPARE_REF_TO_TEXTURE): a lookup returns a hardware-filtered 2x2 depth comparison, not a depth. The cascade lookup adds a 3x3 tap kernel on top with a slope-scaled bias measured in texels; the fragment shader’s debugMode 7/8/9 show the cascade-0 comparison, the cascade factor tinted by cascade, and the point-light shadow factor (8 and 9 are the editor’s Shadow Cascades and Point Light Shadows view modes - see Editor view modes). All four screen-space / temporal effects are configured per level on the post_process_component (SSAO, SSR, volumetric fog, TAA sections next to bloom and depth of field) and default to off; a scene without the component renders exactly as before. SSAO and SSR need the depth pre-pass (depth_prepass_enabled), which is what fills the gbuffer. The runtime UI is drawn last, straight into the scene target, as one draw call per run of quads that share a texture (a whole HUD is typically one or two draws). Quads arrive with their corners already transformed (widgets can be rotated and scaled); layout, text and quad generation live in gui_layout.cpp, shared with the editor’s GUI Designer - see Runtime UI. Material slots. A mesh asset is a list of sections (one GL mesh each) and a list of material slots taken from its source file; submit_scene emits one render item per section with the material of its slot - the mesh component’s override, else the mesh asset’s own material, else the default material. Sections that share a material and a mesh still sort together and instance as before. See the Editor guide. Materials. The forward fragment shader is assembled from shaders/material/pbr_common.glsl (structs, uniforms, BRDF, shadows) + a surface() function + shaders/material/pbr_main.glsl (the lit main). The built-in material is one such assembly; a project’s .wshader files are others, compiled at run time by material_shader_library into a per-draw and an instanced program each, so batched static meshes keep instancing under a custom surface. The clustered forward pass uploads its per-frame uniforms (camera, sun, cascades, fog, SSAO, sky reflection, time, debugMode) to every program the library holds. core_gl_shader resolves #include "file" lines relative to the including file and can recompile a program in place, which is what keeps material pointers valid across shader edits. See GLSL materials.

Editor view modes

The editor Viewport’s toolbar picks a view mode (renderer::e_view_mode, see the Editor guide); it reaches the renderer as render_pass_context::view_mode. WizmPlayer never sets it, so a game always draws lit.
  • apply_view_mode() (renderer/render_pass.h) runs once the editor has filled the context and before taa_renderer::apply_jitter, which reads taa_enabled. It fills shader_debug_mode from the mode, turns TAA and depth of field off for every mode but Lit, and for the diagnostic modes also the sky (draw_skybox), particles (draw_particles), bloom, volumetric fog, SSAO and SSR - for that frame only; the level’s post-process component is not touched. The depth pre-pass stays on in every mode and nothing in these modes writes depth, so the scene depth, and with it picking, mesh drops, SSR and the editor overlays, behaves as in Lit. A TAA switched off drops its history, so going back to Lit does not ghost.
  • cluster_renderer::configure_forward_shader uploads debugMode = shader_debug_mode to every forward program each frame: both default programs and every GLSL-material program. A program built or rebuilt mid-session starts at 0 (initialise_forward_program) and is caught up the same frame.
  • The debugMode values; the k_debug_* constants in render_pass.h and the DEBUG_* constants in pbr_common.glsl must match:
The replacing branches (8-11, 13, 15, 16) return before surface(), write alpha 1 and leave the material / environment targets at “reflects nothing”. 12 and 14 shade fully and only change the final colour - after the fog, and at the unlit return too, so unlit GLSL materials show them.
  • Wireframe mechanics: the forward pass skips its draw, so the clear colour stays; wireframe_overlay_pass then draws the hidden edges with GL_GREATER (dim) and the visible edges with GL_LEQUAL plus glPolygonOffset(-1, -1) / GL_POLYGON_OFFSET_LINE, under glPolygonMode(GL_FRONT_AND_BACK, GL_LINE) with depth writes off. Culling is off for Wireframe (back faces show) and on for Lit Wireframe. The pass switches the programs’ debugMode per draw with cluster_renderer::set_forward_debug_mode, and restores GL_FILL, the depth state and shader_debug_mode before it returns. It runs after skybox_pass, because the sky draws wherever the depth is still 1 and would paint over the line pixels of silhouettes, and it reuses the forward pass’s per-frame uniforms and texture units 2/7-10: nothing that rebinds those may go between the two.
  • Lines are 1 px (no glLineWidth). Each line draw rebuilds the instance batches and appends them to the instance ring again, so Wireframe costs two extra scene draws and Lit Wireframe one.
  • To force a raw view from a temporary diagnostics hook, set frame_context.shader_debug_mode = n after the context is filled (in the editor, after apply_view_mode). A one-off setInt("debugMode", n) on the programs is overwritten on the next frame.

Optimisation techniques

  • Frustum culling (CPU, parallel) with cached per-mesh bounding spheres, per pass: the main camera for pre-pass/forward/picking, each cascade’s light frustum, each point light’s influence sphere.
  • Light culling before cluster assignment: a light whose sphere can’t touch the frustum is dropped from the cluster pass, the shadow pass and the shading pass alike (they all read the same list, so point-shadow slots stay aligned).
  • Sorting by a packed 64-bit key (material index, then mesh index), so state changes are minimised and instance batches fall out of a linear scan without hashing.
  • GPU instancing of all static meshes sharing mesh + material, in the pre-pass, the forward pass and every shadow pass. Skinned meshes stay single draws (each has its own bone palette).
  • Shared instance ring buffer: one persistently-mapped GL buffer (GL_MAP_PERSISTENT_BIT | GL_MAP_COHERENT_BIT), three regions used round-robin with fence syncs, that every mesh VAO points its per-instance attributes at. Each pass appends its instance matrices with a memcpy and draws with glDrawElementsInstancedBaseInstance — zero glBufferData calls per draw. Grows (and re-points every VAO) if a frame ever needs more than the region holds.
  • Depth pre-pass so the expensive forward fragment shader (PBR, cluster light loops, PCF shadows) only runs for visible surfaces. The pre-pass and forward vertex shaders compute gl_Position with the same expression and declare it invariant, which is what makes the GL_LEQUAL test exact instead of z-fighting.
  • Back-face culling in the opaque passes (render_pass_context::cull_backfaces).
  • No per-draw uniform lookups by name: core_gl_shader caches uniform locations with heterogeneous string_view lookup, so passing a string literal never allocates; array uniforms cache their [0] location too. Sampler unit assignments are uploaded once per program at creation, not per material bind.
  • No glGet* state readbacks in the hot passes: each pass restores the frame’s target framebuffer/viewport from the pass context instead of querying GL.
  • DSA texture binds (glBindTextureUnit) — one call instead of two per texture.
  • Cached local transforms in node_3d: the TRS matrix is rebuilt only after a setter ran, instead of three axis rotations per component per system per frame.
  • Reused scratch storage in render_queue — visibility masks, instance data and batch lists are members that keep their capacity across frames.
The editor’s Stats panel shows the effect: mesh items submitted vs. in view, total draw calls, how many of those were instanced batches and how many instances they covered, and lights submitted vs. in view.

Switches worth knowing

  • render_pass_context::depth_prepass_enabled and cull_backfaces (set in both main.cpps) — turn off if a scene relies on double-sided or inverted-winding meshes.
  • render_pass_context::view_mode, apply_view_mode() and shader_debug_mode — the editor’s view modes (see Editor view modes), with the draw_skybox / draw_particles gates they switch. WizmPlayer leaves them all at their lit defaults; a diagnostics hook can set shader_debug_mode to any raw debugMode.
  • instance_buffer::k_initial_region_capacity (4096 instances per region) — the ring grows automatically; raise it if the grow message ever shows up in a profile.
  • shadow_renderer resolution (4096) and point_shadow_renderer resolution (1024) are constructor defaults; m_shadow_max_distance (300) and m_split_lambda (0.8) in shadow_renderer.h set how far shadows reach and how tightly the first cascade hugs the camera.
  • camera_3d::get_projection_matrix() includes the TAA jitter; anything that must be stable frame to frame (the cluster grid, TAA’s own reprojection) uses get_unjittered_projection_matrix(). Screen-space passes that reconstruct positions from the depth buffer must use the jittered one, since that is what the depth was rendered with.
  • SSAO kernel size (16), SSR step count (48) and the TAA jitter sequence length (8) are constants in their renderers’ .cpp files.
  • Sky reflections: face size limits, prefilter sample count and BRDF table size are constants at the top of environment_renderer.cpp; the mip count is environment_renderer::k_prefilter_levels. Sky reflections are not occluded by anything but AO: there are no local reflection probes, so interiors under an open sky reflect the sky too - lower the skybox’s Reflection Intensity there, or turn on SSR, which replaces the sky wherever its rays hit geometry. Faces are capped at 512 px, so a mirror shows a 4K panorama at about half its on-screen sharpness; and because the sky is clipped at display white before it is reflected, a mirrored sun is at most white and blooms less than the sun in the sky itself.