> Cinder 0.1.0 documentation: ADR-0010: Renderer architecture (forward+, frame graph, shader assembly)
> Canonical page: https://cinder.ash10.dev/docs/adr/0010-renderer/ · Source: `docs/adr/0010-renderer.md` in the engine repository (engine commit 47c9352, imported 2026-11-09)
> Index of every page for agents: https://cinder.ash10.dev/llms.txt

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