DocsDecisions

ADR-0010: Renderer architecture (forward+, frame graph, shader assembly)

Status: accepted, 2026-11-08.

Context #

Section 5 of the brief asks for a clustered forward+ renderer with cascaded and atlas shadows, image-based lighting, HDR post-processing, MSAA, GTAO, FXAA, instancing, LODs, skinning, morph targets, debug views and golden-image proof. This ADR records the shape chosen and the places where the implementation deliberately differs from the most common textbook choices.

Decisions #

  1. Forward+ with a fixed 16 x 9 x 24 froxel grid and 32 lights per cluster. One compute dispatch per frame tests every point and spot light as a sphere against each cluster's view-space AABB (logarithmic depth slices between the near plane and 200 m). The fragment shader walks the cluster's list. The grid is small enough that the whole list fits in 442 KB and the dispatch is a single workgroup column (24 groups), which suits lavapipe and small scenes while scaling to the 256 lights the stress benchmark needs. Directional lights are not clustered.
  2. Shadows: 4 cascades in a 2D array plus one depth atlas. Cascades use the practical split scheme (lambda 0.75), a bounding-sphere fit per slice so the footprint is rotation invariant, and texel snapping in a fixed light-space basis. Spot lights take one 512 px atlas tile, point lights six (one per cube face, 90 degrees plus a small overlap). Filtering is 3x3 PCF through a comparison sampler; receiver-side normal offsets scale with the texel footprint. Reverse-Z is used everywhere, so the shadow pipelines use a negative depth bias.
  3. Frame graph owns ordering and transients; the renderer records. Passes declare reads and writes on texture resources or on external (buffer-backed) resources, the graph orders and culls them and aliases transients, and the renderer executes the compiled order with its own bind groups. wgpu forbids a resource being sampled and written in one pass, so the renderer keeps variants of the frame bind group (shadow passes, cluster pass, compute passes) with dummies in the slots that would conflict. The graph's DOT dump feeds the debug UI.
  4. WGSL assembled from files with #include. WGSL has no preprocessor; ShaderLibrary splices includes once, maps naga's wgsl:line:col back to the including file and line, and hot reloads whole programs when any included file changes. Pipelines are rebuilt in place under their handles.
  5. IBL baked on the GPU at scene start. A procedural sky (or an equirect map) is rendered into a 256 px cube with a full mip chain, GGX-prefiltered into 6 mips with filtered importance sampling, convolved into a 32 px irradiance cube, and the split-sum BRDF LUT is computed at 256 px. The bake reruns only when the environment or sun direction changes.
  6. Post chain: physically based bloom (13-tap downsample with Karis average and soft threshold, tent upsample, 6 mips), exposure, AgX (default) or ACES, optional FXAA on the LDR image. Debug visualisations bypass exposure, bloom and the tone curve. A non-finite texel would otherwise spread through the bloom chain, so the first downsample clamps its input and the BRDF clamps the inputs of every pow.
  7. GTAO from depth only, 4 slices x 6 steps with interleaved-gradient rotation and a depth-aware 4x4 blur, in two compute passes. With MSAA on it reads sample 0 of the multisampled depth (gtao_msaa.wgsl), so no depth resolve pass is needed.
  8. Instancing and batching on the CPU. Visible instances (SIMD frustum culling of bounding spheres) are sorted by (mesh, material, LOD, skinned, morphed) and drawn with one indexed draw per batch using first_instance; per-instance matrices live in a storage buffer indexed by instance_index. Skinning matrices and morph weights are per-frame storage/uniform data; morph weight slots use dynamic offsets. GPU culling and indirect draws are left for the performance phase (Phase 8) when the stress benchmark can measure them.
  9. LODs from meshoptimizer, halving triangles per level with a 2% relative error bound and border locking, chosen by projected bounding-sphere diameter relative to the frame height (1/4, 1/8, 1/16). All levels share one index buffer.
  10. Wireframe through wgpu-native's polygon-mode feature, requested only when the adapter has it (Gfx.wireframe_supported); without it the setting is ignored rather than failing.

Consequences #

  • Every pass is validation clean on lavapipe (the tests fail on any uncaptured error).
  • The renderer never blocks on the GPU in a frame; wait_for_queue/read_buffer are test tools.
  • Golden images (tests/golden) are the correctness proof; thresholds live in docs/RENDER_VALIDATION.md.
  • Known gaps tracked for later phases: GPU-driven culling, text in debug draw (needs ImGui, Phase 7), KTX2 textures (Phase 5 cooker), animation playback (Phase 6).
View as MarkdownSource: docs/adr/0010-renderer.md