> Cinder 0.1.0 documentation: cinder/render: the renderer
> Canonical page: https://cinder.ash10.dev/docs/guides/render/ · Source: `docs/guides/render.md` in the engine repository (engine commit 47c9352, imported 2026-11-09)
> Index of every page for agents: https://cinder.ash10.dev/llms.txt

# `cinder/render`: the renderer

`cinder/render` turns a `RenderScene` (camera, lights, instances, joint palettes, environment)
into one submitted command buffer per frame. It is a clustered forward+ renderer with cascaded
and atlas shadow maps, GPU-baked image-based lighting, GTAO, physically based bloom, AgX or ACES
tone mapping, optional MSAA and FXAA, meshoptimizer LODs, GPU skinning and morph targets, and a
frame graph that orders passes and aliases transient textures. The design decisions are recorded in
ADR-0010; the allocation-free steady state in ADR-0014.

In the layering of `docs/ARCHITECTURE.md` the module sits above `cinder/gfx` (typed wgpu handles,
`ShaderManager`), `cinder/core` (math, `ByteWriter`, logging) and `cinder/platform` (`NativePtr`
for meshoptimizer and MikkTSpace), and below `cinder/scene` (the ECS bridge `RenderBridge` that
fills a `RenderScene`), `cinder/app` (which owns the `Renderer` in a `Boxed` cell and calls
`render` once per frame) and `cinder/tools` (the debug overlay shows `graph.to_graphviz()`). It
also imports `CookedMesh` from `cinder/assets/cooked` for pre-cooked meshes. Every GPU call goes
through `cinder.gfx`; nothing in this module waits on the GPU.

`docs/api/render.md` lists every public symbol with its signature.

## Key types and ownership

| Type | Role | Owned by |
|---|---|---|
| `Renderer` | Pipelines, layouts, persistent buffers and textures, mesh and material registries, the `FrameGraph`, `ShaderLibrary`, `ShadowAtlas`, frame scratch. | The application (`App` boxes it). Holds raw pointers to `Gfx` and `Resources`, which must outlive it. |
| `RenderSettings` | Quality knobs; `Renderer.settings` is read at the start of every `render`. | Copied into the renderer; mutate `renderer.settings` between frames. |
| `RenderScene` | `camera: Camera`, `lights: List[Light]`, `instances: List[RenderInstance]`, `joints: List[Mat4]`, `environment: Environment`. | The caller. The renderer only reads it. |
| `RenderInstance` | `mesh` and `material` indices into the renderer's registries, `transform`, `joint_offset` (-1 when not skinned), `morph_weights`, `cast_shadows`. | Inside `RenderScene.instances`. |
| `Camera`, `Frustum`, `Plane` | Perspective camera (position, rotation, `fov_y`, `aspect`, `near`), five-plane frustum, SIMD sphere culling. | Values. |
| `MeshData`, `GpuMesh`, `MeshLod`, `MorphTarget`, `Bounds` | CPU mesh, uploaded buffers, LOD index ranges. | `GpuMesh` lives in `Renderer.meshes`; `destroy_mesh` frees it. |
| `Material`, `GpuMaterial`, `AlphaMode` | glTF metallic-roughness description and its uniform buffer plus bind group. | `Renderer.materials` and `Renderer.material_params`. |
| `GpuTexture`, `DefaultTextures` | Uploaded images with mips; 1x1 white, flat-normal and black fallbacks with two samplers. | `Renderer.defaults`; textures referenced by a `Material` are the caller's. |
| `Light`, `LightKind`, `ClusterParams` | Directional, point and spot lights; logarithmic z-slice parameters. | Values. |
| `Cascade`, `ShadowView`, `ShadowAtlas` | Cascade fit results; atlas tiles for spot (1) and point (6) lights. | `Renderer._cascades`, `Renderer.atlas`. |
| `FrameGraph`, `TransientDesc` | Pass and resource declarations, compiled order, pooled physical textures. | `Renderer.graph`. |
| `ShaderLibrary`, `AssembledShader` | `#include` assembly, error mapping, hot reload over `ShaderManager`. | `Renderer.library` wraps `Renderer.shaders`. |
| `FrameStats`, `DebugMode`, `Tonemapper` | Per-frame counters; enums mirrored in `common.wgsl` and `post.wgsl`. | `Renderer.stats`, settings. |

Handles (`BufferHandle`, `TextureViewHandle`, ...) are generational indices into `Resources`;
`Renderer.release` frees the frame graph's pooled textures and cached bind groups, everything else
is released with `Resources`.

## Setting up a renderer

Adapted from `tests/render/test_renderer.mojo`. The native library, `Gfx` and `Resources` must be
declared so that they are destroyed after the renderer (struct fields die in declaration order).

```mojo
var lib = NativeLibrary()
var gfx = Gfx(lib.ptr(), WGPUSurface())            # headless: no surface
var res = Resources(_ptr(gfx), 4096)
var settings = RenderSettings()
var renderer = Renderer(_ptr(gfx), _ptr(res), lib.ptr(), settings, WGPUTextureFormat_RGBA8Unorm)

var ground = renderer.add_mesh(String("ground"), plane(8.0, 4), False)   # no LODs
var ball = renderer.add_mesh(String("sphere"), uv_sphere(0.5, 24, 48), True)
var red = Material(String("red"))
red.base_color = Vec4(0.8, 0.1, 0.1, 1.0)
var m_red = renderer.add_material(red)

var camera = Camera.look_at(Vec3(4.0, 3.0, 6.0), Vec3(0.0, 0.5, 0.0), Vec3.unit_y(), 0.9, 4.0 / 3.0, 0.1)
var scene = RenderScene(camera)
scene.instances.append(RenderInstance(ground, m_red, Mat4.identity(), -1, False))
scene.instances.append(RenderInstance(ball, m_red, Mat4.translation(Vec3(-1.6, 0.5, 0.4))))
scene.lights.append(Light.sun(Vec3(0.5, -1.0, 0.4), Vec3(1.0, 0.96, 0.9), 4.0, True))
scene.lights.append(Light.point_light(Vec3(2.0, 1.5, 2.0), Vec3(1.0, 0.8, 0.5), 60.0, 8.0, True))
```

The last constructor argument is the format of the view passed to `render`; `App` passes the
swapchain format. Rendering a frame and reading it back (tests only; the engine never waits):

```mojo
renderer.render(scene, target, W, H)
var encoder = CommandEncoder(_ptr(gfx), _ptr(res), String("readback"))
encoder.copy_texture_to_buffer(target_tex, readback, STRIDE, W, H)   # STRIDE is 256-aligned
encoder^.finish_and_submit()
wait_for_queue(gfx)
var rows = read_buffer(gfx, res, readback, STRIDE * H)
gfx.pump(False)
assert_equal(gfx.error_count, 0, "no validation errors")
```

`render` raises `GfxError` when `gfx.lost` is set (device loss); the renderer and its resources
must then be recreated. A zero-sized viewport returns without recording.

## The frame, in order

`Renderer.render(scene, target, width, height)` performs the CPU work below, then records every
pass of the compiled graph into one `CommandEncoder` and submits it.

1. **Environment bake (only when changed).** If `scene.environment` (zenith, ground,
   `sun_intensity`, `equirect`) or the sun direction differs from the last bake, or shaders were
   reloaded, `_bake_environment` runs the `ibl.wgsl` compute passes: the 256 px environment cube
   with a full mip chain (`cs_environment`, procedural sky or equirect), 6 GGX-prefiltered mips at
   128 px with 64 filtered importance samples each (`cs_prefilter`), a 32 px irradiance cube
   (`cs_irradiance`) and the 256 px split-sum BRDF LUT (`cs_brdf_lut`). These are submitted
   separately before the frame.
2. **Lights and shadow views.** The first directional light is the sun; other directional lights
   are ignored. Point and spot lights are packed with `pack_lights_into` (64 bytes each, at most
   `MAX_LIGHTS` = 1024; later lights in scene order are dropped before any shadow tile is handed
   out). Shadow-casting local lights among the kept ones get atlas tiles through
   `ShadowAtlas.allocate` (one for a spot, six for a point light); `pack_views_into` writes the `ShadowView` array. With
   `settings.shadows` and a shadow-casting sun, `fit_cascades_into` fits the four cascades.
3. **Pass uniforms.** One 256-byte slot per view-projection in `pass_uniforms`: slots 0..3 the
   cascades, then one per atlas view, then the camera (`MAX_PASS_VIEWS` = 128 in total). Depth
   passes select their slot with a dynamic offset on group 3.
4. **Frame uniform** (720 bytes, `Frame` in `common.wgsl`): view, projection, their inverses,
   camera position and near, screen size, `ClusterParams` (near to 200 m), light count, sun
   direction, colour and shadow flag, cascade splits, matrices and texel sizes, IBL intensity and
   mip count, time, exposure, debug mode and the AO flag. The writer asserts the byte count.
5. **Instances: cull, LOD, batch.** Each instance's bounding sphere is its mesh `Bounds` sphere
   transformed and scaled by the largest column length. `Frustum.cull_spheres_into` tests four
   spheres per SIMD step (skipped when `settings.cull` is false). `GpuMesh.select_lod` picks a
   level from the projected diameter. Visible instances are sorted by
   (mesh, material, LOD, skinned, morphed) and consecutive equal keys form one `_Batch` drawn with
   `first_instance`. The key (`_batch_key`) scales by `len(materials)` and the largest LOD count,
   so it never collides however many materials or LODs exist. Per-instance data (model, normal
   matrix, params) is 144 bytes in the `instances` storage buffer; `params.y` carries the flags
   (bit 0 skinned, bit 1 morphed) and the LOD index in bits 8..15. Blended materials go to
   `blend_batches`, sorted back to front. Joint matrices are copied verbatim into the `joints`
   buffer; morph weights (8 per instance) take one 256-byte slot each, so a morphed instance is
   always its own batch. The `morph weights` buffer starts with 32 slots and doubles (rebuilding
   the object group) when a frame has more morphed instances, so every instance keeps its own
   weights.
6. **Frame graph declaration and compile** (see below), then `stats.passes` and
   `stats.transient_textures`.
7. **Bind groups that depend on this frame's views**: the frame group is rebuilt only when the AO
   view changes; the object group only when the instance, joint or morph buffer changes.
8. **Recording**, in the compiled order:
   - `cascade shadows`: four depth-only render passes into the layers of the 2048 px
     `Depth32Float` cascade array (`depth.wgsl`, pipeline kind 2, no culling, negative depth bias
     because of reverse-Z).
   - `atlas shadows`: one render pass over the 4096 px atlas; each view sets viewport and scissor
     to its 512 px tile.
   - `depth prepass`: camera depth into the `depth` transient at the frame's MSAA count;
     `fs_masked` discards below the alpha cutoff.
   - `gtao` and `gtao blur`: compute passes (8x8 workgroups) from depth only into `ao raw` then
     `ao` (RGBA8). The MSAA variant reads sample 0.
   - `cluster light cull`: one dispatch of `clusters.wgsl` (16 x 9 x 24 froxels, up to 32 lights
     per cluster) writing `cluster_grid` and `cluster_indices` through the external resource
     `cluster lists`.
   - `forward+ opaque`: `forward.wgsl` into the RGBA16F `hdr` target; depth is loaded from the
     prepass and not written. Sun with cascaded shadows, clustered local lights with atlas
     shadows, IBL, emissive, debug outputs.
   - `sky`: a full-screen triangle at depth 0 (infinitely far) sampling prefiltered mip 0.
   - `forward+ blend`: alpha-blended batches; with MSAA this pass resolves into `hdr resolved`.
   - `bloom down 0..5`, `bloom up 5..1`: half-resolution chain; the first downsample applies the
     soft threshold, Karis averaging and a clamp against non-finite texels; upsamples are additive
     tent filters.
   - `tonemap`: mixes bloom by `bloom_strength`, multiplies by exposure, applies AgX, ACES or
     none, into `target` (or the `ldr` transient when FXAA is on), then `fxaa` into `target`.

   Post and GTAO bind groups are cached by the (index, generation) of the views they bind.

## Render settings and defaults

| Field | Default | Effect |
|---|---|---|
| `msaa` | 1 | 1 or 4 samples for depth, HDR and the sky; GTAO switches to `gtao_msaa.wgsl`. Changing it rebuilds pipelines lazily (cached per sample count). |
| `bloom`, `bloom_strength`, `bloom_threshold` | True, 0.04, 1.0 | Enables the 6-mip chain; strength is the mix factor in `fs_tonemap`; threshold is the soft knee on the first downsample. |
| `tonemapper` | `Tonemapper.agx()` | `agx`, `aces` or `none` (clamp). |
| `exposure` | 1.0 | Linear multiplier before the tone curve. |
| `fxaa` | False | Adds the `ldr` transient and the `fxaa` pass. |
| `ao` | True | GTAO; when false the AO passes are culled from the graph and the forward pass binds the white default. |
| `shadows` | True | Cascades and atlas. When false no shadow views are allocated and nothing is drawn into the shadow maps. |
| `shadow_distance` | 60.0 | End of the last cascade (practical split, lambda 0.75). |
| `cascade_resolution` | 2048 | Cascade array size; read at construction only. |
| `atlas_size`, `atlas_tile` | 4096, 512 | Atlas texture and tile size (64 tiles); construction only. |
| `ibl_intensity` | 1.0 | Scales the IBL term and the sky. |
| `debug` | `DebugMode.none()` | `normals`, `cascades`, `clusters`, `overdraw`, `lod`, `albedo`, `ao`. Debug output bypasses exposure, bloom and the tone curve. `overdraw` switches the forward pipelines to additive blending with the depth test off (each fragment adds 0.1, the sky is black); `lod` colours by each instance's LOD index, morphed ones included. |
| `wireframe` | False | Forward pipelines use line polygon mode when `Gfx.wireframe_supported`; otherwise ignored. |
| `cull` | True | Frustum culling of instances. |

`Environment` defaults: zenith (0.25, 0.45, 0.9), ground (0.28, 0.25, 0.22), `sun_intensity` 1.0,
no `equirect`. A set `equirect` view (a float 2D texture) replaces the procedural sky.

Toggling at runtime, from `test_variants_msaa_fxaa_debug_and_lods`:

```mojo
h.renderer.settings.debug = DebugMode.normals()
var normals = h.render()
h.renderer.settings.debug = DebugMode.none()
h.renderer.settings.ao = False
h.renderer.settings.bloom = False
var plain_dot = h.renderer.graph_dot()
assert_true("gtao" not in plain_dot or "culled: gtao" in plain_dot)
```

## Conventions

- Right-handed, Y up, the camera looks down -Z (`Camera.forward()` is `Vec3.forward()` rotated);
  `plane` faces +Y, `cube` winds counter-clockwise seen from outside, back faces are culled unless
  the material is `double_sided`.
- Metres; light intensity in lux for the sun and watts for point and spot lights
  (`color * intensity`, radiometric `/ 4 pi` in the shader); angles in radians.
- Reverse-Z with an infinite far plane everywhere: `Mat4.perspective_reverse_z_infinite`,
  depth cleared to 0 (`DepthAttachment.cleared_reverse_z`), `DepthState.reverse_z` compares
  greater-or-equal, the frustum has five planes and the shadow pipelines use a negative bias.
- Linear lighting; textures are uploaded as `RGBA8UnormSrgb` when `srgb` is true and sampled
  linear; intermediates are `RGBA16Float`; the output is the format given at construction, with
  AgX linearised so an sRGB target encodes once.
- Column-major `Mat4`; `ByteWriter.mat4` writes columns, matching WGSL `mat4x4<f32>`.
- Vertex stream of 56 bytes (position, normal, tangent with handedness in w, uv0, uv1) on slot 0;
  skinning (u16x4 joints, f32x4 weights, 24 bytes) on slot 1; u32 indices.
- Bind groups: 0 frame (`common.wgsl`), 1 objects (instances, joints, morph deltas, morph weights
  with a dynamic offset), 2 material (`material.wgsl`), 3 pass-specific (view-projection slot,
  cluster outputs or GTAO textures).

## Camera and culling

`Camera.look_at` builds the rotation quaternion from `Mat4.look_at`; `view`, `projection`,
`view_projection` and `frustum` derive from the fields, and `set_viewport` fixes the aspect from
the target size (`render` does this every frame). From `tests/render/test_render_core.mojo`:

```mojo
var cam = Camera.look_at(Vec3(0.0, 2.0, 5.0), Vec3.zero(), Vec3.unit_y(), 1.0, 1.0, 0.1)
var f = cam.frustum()
assert_true(f.contains_sphere(Vec3(0.0, 0.0, -50.0), 1.0), "infinite far plane keeps distant objects")
assert_false(f.contains_point(Vec3(0.0, 2.0, 5.05)), "closer than near")
var visible = f.cull_spheres(spheres)        # List[Vec4]: xyz centre, w radius
```

## Meshes, LODs, skinning, morph targets

`MeshData` holds parallel per-vertex lists; `tangents`, `uv1`, `joints` and `weights` may be
empty (defaults are packed at upload), but `joints` and `weights` are a pair: both empty or both
one per vertex (`has_skin` requires both). `validate` checks counts (including that pairing) and
index ranges,
`compute_normals` accumulates area-weighted normals, `compute_tangents(lib)` runs MikkTSpace
through the shim. Primitives: `cube(half)`, `uv_sphere(radius, rings, segments)`,
`plane(half, subdivisions)`.

`Renderer.add_mesh(label, data, lods=True)` builds LODs with `MeshData.build_lods` when the mesh
has more than 64 triangles: level 0 is vertex-cache optimised, each further level targets half the
triangles with a 2% error bound and locked borders, and all levels share one index buffer
(`MeshLod.first_index`, `index_count`). `GpuMesh.index_count` is the full-detail count
(`lods[0].index_count`) for both `upload_mesh_lods` and cooked meshes. `select_lod` switches to level 1 below a quarter of the
frame height, level 2 below an eighth, level 3 below a sixteenth. `add_cooked_mesh`,
`replace_cooked_mesh` (hot reload under the same index) and `remove_mesh` (slot reused) serve the
asset server; `mesh_lod_count` reports the levels.

Skinning: a mesh with `joints` and `weights` gets a second vertex buffer and the `vs_skinned`
entry point; the instance's `joint_offset` indexes `RenderScene.joints` (joint world matrix times
inverse bind, written by the animation system through `JointPalette`). Morph targets:
`MorphTarget` position and normal deltas are packed as two `vec4` per vertex per target into a
storage buffer; `RenderInstance.morph_weights` supplies up to 8 weights
(`MAX_MORPH_TARGETS`). Each morphed mesh's delta buffer gets its own cached object group
(`morph_groups`, keyed by the buffer handle and released when no batch used it in a frame), so
any number of different morphed meshes can draw in one frame; meshes without targets share
`object_group`. Weight slots start at 32 per frame and the weights buffer doubles when a frame
needs more.

## Materials and textures

`Material` mirrors glTF 2.0 metallic-roughness plus `KHR_texture_transform` (`uv_scale`,
`uv_offset`, `uv_rotation`, applied to every map) and `emissive_strength`. `textures` holds five
optional views (`TEX_BASE_COLOR`, `TEX_METALLIC_ROUGHNESS`, `TEX_NORMAL`, `TEX_OCCLUSION`,
`TEX_EMISSIVE`), `texcoords` picks uv0 or uv1 per map, and `sampler` defaults to
`DefaultTextures.sampler_linear_repeat`. `pack_uniform` produces the 96-byte layout documented at
the top of `material.mojo` and in `material.wgsl`.

```mojo
var tex = upload_image(res, String("checker"), img, True, True)   # sRGB, with mips
var mat = Material(String("test"))
mat.base_color = Vec4(0.5, 0.25, 1.0, 1.0)
mat.textures[0] = tex.view
mat.texcoords[2] = 1
mat.alpha_mode = AlphaMode.mask()
mat.uv_scale = Vec2(2.0, 2.0)
var gpu = upload_material(res, material_bind_group_layout(res), defaults, mat)
```

`upload_image` builds the mip chain on the CPU with `downsample` (2x2 box filter); `mip_count`
gives the level count. `update_material` re-uploads parameters only; textures are fixed per bind
group, so a texture change needs a new material.

The renderer's material registry: `Renderer.add_material(material) -> Int` uploads and always
appends. `Renderer.replace_material(index: Int, material: Material) raises` uploads the new
version into the same slot first (a failed upload leaves the slot untouched), then frees the old
uniform buffer and bind group and stores a copy in `material_params[index]`; instances keep their
index, which is how hot reload swaps a material. `Renderer.remove_material(index: Int) raises`
frees the slot's uniform buffer and bind group and leaves null handles (removing an empty slot
does nothing). Indices are stable: a removed slot is never handed out by `add_material`, only
revived by `replace_material` on the same index, and `render` raises
`Renderer: instance <i> uses removed material <m>` when a visible instance still uses it.
`Renderer.material_is_live(index) -> Bool` reports whether a slot exists and is not removed. Both
calls raise on an out-of-range index. Texture views and samplers referenced by a `Material` stay
the caller's on replace and remove (destroy them yourself afterwards, as the asset server does).

## Lights and clustered culling

`Light.sun(direction, color, lux, cast_shadows)`, `Light.point_light(position, color, watts, range,
cast_shadows)` and `Light.spot_light(position, direction, color, watts, range, inner, outer,
cast_shadows)` construct lights; `direction` is the direction light travels. `ClusterParams.compute
(near, far)` gives the logarithmic slice scale and bias shared with `cluster_z_slice` in
`common.wgsl`; the renderer uses `camera.near` to 200 m. `cs_cull` tests each local light as a
sphere against each cluster's view-space AABB and writes up to `CLUSTER_MAX_LIGHTS` indices per
cluster; the fragment shader walks that list. `DebugMode.clusters()` shows the per-cluster count
as a heat map.

## Shadows

`cascade_splits_into(out, near, shadow_distance)` fills a stack array of four end distances;
`fit_cascade` fits an orthographic projection to the bounding sphere of a frustum slice (radius
quantised to 1/16) and snaps its centre to whole shadow texels in a fixed light-space basis, so the
footprint is rotation invariant and edges do not shimmer; `fit_cascades_into` does all four. The
shader (`sample_cascade_shadow`) selects the cascade by view depth, applies a normal offset scaled by
`Cascade.texel_world` and takes 3x3 PCF taps through a comparison sampler.

`ShadowAtlas(size, tile_size)` hands out tiles in order: `allocate(light)` returns the first view
index or -1 when full; a spot light gets a perspective view of `2 * outer_angle + 0.05`, a point
light six 90-degree faces with a small overlap. `sample_atlas_shadow` picks the face from the
dominant axis, clamps inside the tile so PCF never reads a neighbour, and filters 3x3. From
`tests/render/test_shader_programs.mojo`:

```mojo
var atlas = ShadowAtlas(2048, 512)
assert_equal(atlas.capacity(), 16)
assert_equal(atlas.allocate(spot), 0)
assert_equal(atlas.allocate(point), 1)       # six views: 1..6
assert_equal(len(atlas.views), 7)
```

## The frame graph

The graph is declared from scratch every frame and compiled by `FrameGraph.compile(res)`:

- `create(name, desc)` declares a transient texture from a `TransientDesc` (size, format, usage,
  sample count, mips). `import_view(name, view, desc)` declares a view the caller owns (swapchain,
  the shadow maps): never aliased, never freed. `create_external(name)` declares a buffer-backed
  resource (the cluster lists) that only carries ordering and liveness. `export(resource)` marks
  what must survive the frame; the renderer exports `target`.
- `add_pass(name)`, `read(pass, resource)`, `write(pass, resource)` record the data flow. A
  reader depends on the most recent earlier writer (or the first writer if the producer is declared
  later); writers of one resource are chained and each waits for the previous version's readers
  (write-after-read). Readers declared before the first writer read the first writer's version,
  so the second writer waits for them too.
- `compile` keeps only passes that transitively feed an exported resource (`is_culled`,
  `culled_count`), orders them with a stable Kahn sort (`order`, lowest declaration index first),
  raises `Error("FrameGraph: cycle between passes")` on a cycle, computes each resource's
  `lifetime` as an interval of order indices and assigns pooled physical textures: transients with
  equal descriptions and disjoint lifetimes share one texture. Pooled textures not requested this
  frame (after a resize or a settings change) are destroyed.
- `view_of` and `texture_of` resolve a resource after compile; `find(name)` looks a resource up
  by name; `physical_count` is reported as `FrameStats.transient_textures`. `to_graphviz` returns
  DOT text (executed passes as boxes in order, culled passes dashed, resources as ellipses with
  their physical index and lifetime); `Renderer.graph_dot()` produces it on demand.

From `tests/render/test_frame_graph.mojo` (passes declared deliberately out of order):

```mojo
var graph = FrameGraph()
var backbuffer = graph.import_view(String("backbuffer"), view, desc)
graph.export(backbuffer)
var depth = graph.create(String("depth"), _depth(64, 64))
var hdr = graph.create(String("hdr"), _hdr(64, 64))
var tonemap = graph.add_pass(String("tonemap"))
graph.read(tonemap, hdr)
graph.write(tonemap, backbuffer)
var prepass = graph.add_pass(String("depth prepass"))
graph.write(prepass, depth)
var forward = graph.add_pass(String("forward"))
graph.read(forward, depth)
graph.write(forward, hdr)
graph.compile(res)
assert_equal(graph.order, [prepass, forward, tonemap])
```

The graph never calls back into user code: the renderer walks `graph.order` and compares each
pass index with the ones it declared. wgpu forbids sampling a texture that the same pass writes,
so the renderer keeps `frame_group` variants (`shadow_frame_group`, `cluster_frame_group`) with
dummies in the conflicting slots.

### Adding a pass

1. Declare its resources in `render` next to the existing ones: `self.graph.create` for a new
   transient (give its name a persistent `String` field, not a per-frame literal), or reuse an
   existing one.
2. `var p_mine = self.graph.add_pass(String("my pass"))` followed by `read` and `write` calls.
   The pass is culled unless something it writes reaches `target`.
3. In the recording loop add an `elif p == p_mine:` branch: fetch views with
   `self.graph.view_of`, begin a render or compute pass on `encoder`, bind groups, draw, end.
   Bind groups that reference transient views must be cached by view (index, generation) like
   `_post_group` and `_gtao_group`, because views change on resize.
4. Create the pipeline once in `__init__` (or lazily, cached), then run
   `tests/render/test_renderer.mojo` (validation clean, deterministic second frame) and the
   zero-allocation test.

## Shaders: assembly, errors, hot reload

WGSL has no preprocessor. `assemble(directory, entry)` reads `entry` and splices every
`#include "file.wgsl"` line (each file once per program, depth limit 16) into one
`AssembledShader` whose `chunks` remember which file and line each run of text came from.
`ShaderLibrary.load(entry)` assembles, compiles through `ShaderManager.compile_source`, adopts the
module as a `ShaderHandle` and records the modification times of every file involved. The
renderer loads eight programs: `forward.wgsl`, `depth.wgsl`, `clusters.wgsl`, `sky.wgsl`,
`ibl.wgsl`, `post.wgsl`, `gtao.wgsl`, `gtao_msaa.wgsl` (the last two include `gtao_body.wgsl`
after declaring `depth_tex`, `load_depth` and `ao_out`).

Compile errors raise `ShaderError` (from `cinder.gfx.shaders`) whose `ShaderMessage`s print as
`path:line:column: error: text`. naga reports positions in the assembled text; `_compile_assembled`
maps every `wgsl:LINE:COL` mark back through `AssembledShader.locate` so the message names the
including file and its own line:

```mojo
var library = ShaderLibrary(_ptr(gfx), _ptr(res), _ptr(shaders), dir)
try:
    _ = library.load(String("broken.wgsl"))
except e:
    message = String(e)
assert_true("broken.wgsl" in message and ":4:" in message)
```

Hot reload: `ShaderLibrary.check_for_changes` re-assembles programs whose files (including
includes) changed, swaps the module under the same handle, lists the handles in `reloaded` and
returns the count; a failing recompile keeps the last good module and appends an error
`LogRecord` to `diagnostics` (`take_diagnostics`). `Renderer.reload_shaders` calls it and, when
anything changed, rebuilds the cached scene pipelines in place (`replace_render_pipeline`, handles
unchanged), recreates the sky pipeline and marks the environment for re-bake. Note that the
compute pipelines (clusters, GTAO, IBL) and the post pipelines are not rebuilt by
`reload_shaders` today; a change to those programs needs a restart.

### Adding a shader include

Put the file in `cinder/render/shaders/`, reference it with `#include "name.wgsl"` from the
program that needs it (includes resolve relative to `SHADER_DIR`), and keep the declaration order
in mind: an include sees everything spliced before it. If the include declares bindings, mirror
the layout in `Renderer.__init__` (`create_bind_group_layout`) and any CPU packing in Mojo with a
byte-count assertion like the frame uniform's. `tests/render/test_shader_programs.mojo::
test_all_programs_compile` must still compile every program and `assemble` must report the
expected file count.

## How validation errors surface

- wgpu validation errors are uncaptured errors delivered through the shim's event queue;
  `Gfx.pump` increments `Gfx.error_count` and appends a `wgpu.validation` `LogRecord` to
  `Gfx.diagnostics`. Every render test asserts `gfx.error_count == 0` after pumping. A validation
  error inside `wgpuQueueSubmit` (for example a readback stride that is not 256-aligned) aborts the
  process instead (see `docs/MOJO_NOTES.md`).
- Shader compile errors raise `ShaderError` with mapped `file:line:column`; reload failures become
  diagnostics instead of exceptions.
- Device loss sets `Gfx.lost`; `render` raises `GfxError("device lost: ...")` on the next call.
- Engine-side invariants raise `Error`: frame graph cycles, `view_of` on an external or unused
  resource, `MeshData.validate` failures, a frame uniform of the wrong size.

## Golden tests

`tests/golden/test_golden_gltf.mojo` renders 16 Khronos glTF-Sample-Assets models headless at
512 x 512 with `RenderSettings()` plus `msaa = 4`, a sun from (-0.5, -1.0, -0.4) at 3 lux, the
procedural sky, and `shadow_distance` scaled to the model; the camera is framed from the model's
bounds (Sponza is viewed from inside). The result is compared with
`tests/golden/<platform>/<Model>.png` by `cinder.core.image.compare`: luma SSIM over 8x8 windows
must be at least 0.985 and at most 0.5% of pixels may differ by more than 8/255 in any channel.
Both thresholds, and the review of every reference image, live in `docs/RENDER_VALIDATION.md`.

Running: `pixi run test-golden` (also part of `tools/run_tests.sh`; models come from
`tools/fetch_sample_assets.py`). Updating: `pixi run update-golden` sets `CINDER_UPDATE_GOLDEN=1`,
which rewrites the references instead of comparing. After an intentional rendering change, inspect
each new image against the Khronos screenshot, update the review table in
`docs/RENDER_VALIDATION.md`, and commit the PNGs. Rendered frames are also written to
`.cache/golden/<Model>.png` on every run. Only Linux (lavapipe) references exist; `PLATFORM` in the
test selects the directory.

## Render stats

`Renderer.stats` (`FrameStats`) after each frame: `instances_submitted`, `instances_drawn`,
`draw_calls` (forward opaque and blend), `prepass_draw_calls`, `shadow_draw_calls`, `lights`,
`shadow_views`, `passes` (executed) and `transient_textures` (pooled physical textures).

## Zero-allocation rules

`tests/perf/test_zero_alloc.mojo` (`pixi run test-zero-alloc`, `libcinder_alloccount.so`
preloaded) renders a representative scene for 90 warm-up frames and then requires exactly zero
Mojo heap allocations over 60 frames. Any change to the frame path must keep that true:

- Per-frame data lives in persistent scratch fields of `Renderer` (`_lights`, `_cascades`,
  `_spheres`, `_visible`, `_order`, `_keys`, `batches`, `instance_bytes`, the `ByteWriter`s, ...)
  that are cleared, never recreated. `List.clear` and `ByteWriter.reset` keep capacity;
  `resize` only allocates when growing.
- Use the `_into` variants: `cull_spheres_into`, `fit_cascades_into`, `cascade_splits_into`,
  `pack_lights_into`, `pack_views_into`. The allocating forms (`cull_spheres`, `fit_cascades`,
  `cascade_splits`, `pack_lights`, `pack_views`) exist for tests only.
- Small fixed-size data goes in stack `Array`s (cascade splits, frustum corners, attachments).
- No per-frame strings: pass and resource names that vary by index are built once in `__init__`
  (`_bloom_names`, `_bloom_down_names`, `_bloom_up_names`); change detection compares numbers
  (`_env_*`, `_object_key`, `post_group_keys`) rather than formatted keys; `graphviz` is only
  produced when `graph_dot()` is called.
- Bind groups that depend on transient views are cached by (index, generation) keys
  (`_post_group`, `_gtao_group`) rather than created per frame.
- The frame graph recycles pass records (`_spare_passes`) and its compile scratch; declaring and
  compiling a steady-state graph allocates nothing.
- GPU buffers grow by doubling (`instances_buffer`, `joints_buffer`) only when the scene outgrows
  them: expected during warm-up, not in steady state. Avoid `List` literals (`[x]` allocates every
  time), t-strings and `String` concatenation in the frame path.
- To find a regression set `Renderer.alloc_marks = True` (with the counter library loaded): `_mark`
  prints the allocation delta after each phase (`start`, `lights+shadows`, `pass uniforms`,
  `frame uniform`, `instances`, `graph declare`, `graph compile`, `bind groups`).
