# Rendering

Every pipeline builds asynchronously in the native layer, in parallel, and binding waits for an unfinished build; see [Pipeline compilation](/docs/systems/pipeline-compilation.md). Scene material pipelines are adopted only once their build is ready. Shared pipeline keys deduplicate work, and until adoption the draw uses the compatible pink placeholder, never waiting on a compile. The native side copies what a build needs, so scene retirement drops scene borrows immediately, and a build still running when its pipeline is released frees it itself. Terminal failures retain the placeholder without retrying each frame. A host-owned activity counter and completion generation keep the editor's idle viewport from hiding completion.

This is the technical reference for engine developers and graphics programmers. Artists and level designers can start with [Working in the editor](/docs/guides/editor-workflow.md), [Visual Zones](/docs/systems/visual-zones.md), or [Time of day](/docs/guides/time-of-day.md). Game developers configuring a project can start at [Pipeline configuration](/docs/systems/rendering.md#pipeline-configuration).

Structural map (layers, RHI, graph, code layout): [Renderer architecture](/docs/systems/renderer-architecture.md).

World / view / clip conventions (RH Y-up −Z-forward, reverse-Z depth): [Coordinate space](/docs/systems/coordinate-space.md).

## Renderer model

Shared `graphics/` owns policy, frame construction, and graph scheduling. Platform renderers own native resources, encoding, sync, and present. Keep platform trees thin.

**macOS / Metal product floor:** shaders compile as **MSL metal4.0** (Akari metalc `-std=metal4.0`, min OS 26); packaging `LSMinimumSystemVersion` defaults to **26.0**. Device init requires `MTLGPUFamilyMetal4` and argument-buffer tier 2 (the requirement table in `MetalDevice.m`; a failure logs the missing rows, shows them in an alert when a window-server session exists, and exits with code 1); submit/encoding uses `MTL4CommandQueue` / reusable `MTL4CommandBuffer` with reused argument tables, an `MTL4Compiler` for engine PSOs, a texture-view pool for mip views, and three queue-attached residency sets (persistent / scene / frame). MetalFX temporal plugin encodes via `MTL4FXTemporalScaler` on the shared `MTL4CommandBuffer` when the GPU supports Metal 4 FX; otherwise internal TAAU remains the fallback.

Metal shutdown first completes submitted graphics, compute, and acceleration
structure work before releasing RT scratch or detaching residency sets. It
then drops pooled command objects, timing heaps, and command queues, drains
teardown autoreleases, and finally resets/releases the frame and immediate
command allocators. GPU completion alone does not release CPU command owners.
Argument-table checkout transfers its strong reference out of the pool. The
headless native gate is `src/hikari/tools/macos/lifecycle/test_metal_shutdown.py`
after rebuilding the native platform library through Kaji.

```text
World render data
  → RendererCore / render graph policy
  → RHI states and pass dependencies
  → Metal or D3D12 driver
  → swapchain presentation
```

Draw-list publish skips renderables that are not **effectively visible**: entity active **and** `render.is_visible` **and** not soft-pending (`hi.render().setVisible` / scene `is_visible`). Lights and probes also skip inactive owners. See [Active / enable trio](/docs/systems/scenes-and-gameplay.md#active--enable-trio).

RHI (`graphics/rhi/`) is backend-neutral vocabulary for resources, sync, queues, compute, and acceleration structures. The graph tracks texture/buffer/AS hazards and queue dependencies; backends lower contracts without recreating policy.

## Rendering contracts (composability)

Three feature layers must stay **orthogonal**: classical deferred lighting, optional visibility/effect backends, and optional content async compute. They may only meet at the contracts below. No layer may hardcode knowledge of another.

```text
  [content async compute] ──optional result texture──► [G-buffer geometry]
                                                              │
                                                    G-buffer attachments
                                                              │
                    [direct lighting + shadow mode: off | raster | RT]
                                         │
                    optional AO (SS | RT) → optional GI (SS | RT) → optional reflections (SS | RT)
                                         │
         reconstruction / camera post / bloom (orthogonal) → tonemap | debug grid → UI
```

Volumetrics, TAA/TAAU, exposure, DOF, and motion blur are feature-gated around this bus — they do not change G-buffer or lighting contracts. Assemble order: [Implemented pipeline](/docs/systems/rendering.md#implemented-pipeline).

### Shared shading foundations

Deferred, forward, and RT hit lighting share GGX evaluation with a minimum
perceptual roughness of 0.03. Its positive-term denominator preserves smooth
highlight peaks without cancellation or a denominator floor that changes the
lobe. Reflection sampling and PDF evaluation use the same roughness floor.
Scene writes bound RGB to RGBA16F's finite range (65504) at storage; BRDF
calculations remain FP32. Raster pixel derivatives and RT triangle edges use
`hikari_uv_gradient_axes`, including the signed frame orientation, so normal
maps and mirrored UVs agree across both paths. View vectors use the camera
position for perspective projection and the inverse view direction for
orthographic projection; forward vertices interpolate the unnormalized vector.

Environment sampling, blending, and reflection filtering retain linear radiance.
`hikari_ibl_specular_contribution` applies the shared environment response once,
including the split-sum BRDF, energy compensation, horizon, and occlusion terms.
The reflection composite attenuates the base specular layer using the clearcoat
normal and Fresnel, matching lighting and RT mirror-chain replacement. Its graph
read retains the coat plane only when a resident material declares that lobe.
Inactive material lobes skip texture sampling, tangent-frame work, and coat
specular antialiasing in the G-buffer and forward material paths.

The headless core lighting regression covers smooth GGX peaks, the anisotropic
zero-strength limit, reflection ownership across the roughness transition,
clearcoat attenuation, linear environment sampling, degenerate anisotropic
reflection directions, perspective/orthographic cameras, and raster normal-map
orientation. See the engine shader tests and Metal harness README files.

### Contract A — G-buffer (shared bus)

A successful graph producer must initialize its declared outputs and complete its final barriers. The first G-buffer pass clears every color/depth attachment even while material tables or fallback textures are unavailable; the late pass preserves them. Draw readiness gates geometry emission, not attachment initialization.

**Owner:** `src/hikari/src/graphics/effects/gbuffer.zig` (layout + `target_formats`); geometry packages write it; lighting/AO/reflections only read it. PSO color formats for other kinds live in `material/pipeline_kind.zig` — backends map `rhi.Format` only.

| Attachment | Format | Contents |
|---|---|---|
| Albedo | RGBA8 | RGB base color; A = emissive pack lo (with ARM.A) |
| Normal | RG16F | Octahedral world normal |
| ARM | RGBA8 | AO, **specular-AA filtered** roughness, metallic; A = emissive pack hi |
| Linear depth | RG32F | Current view depth (R), corresponding previous-pose view depth (G; nonpositive = invalid) |
| Material | R32_UINT | Bits 0–7 `MaterialFlags`; bits 8–31 meshlet visualizer payload |
| Motion | RG16F | Motion vectors |
| Anisotropy | RGBA8 | Octahedral world-space anisotropy tangent, strength, reserved |
| Clearcoat | RGBA8 | Octahedral world-space coat normal, factor, roughness |

**Rules:**

- Opaque geometry always fills this layout (engine `_engine/gbuffer` or a game package with the same outputs).
- **Unread planes cost nothing.** The opaque pass always binds all eight attachments (one PSO layout), but the render graph discards any transient colour attachment no pass reads this frame: Metal creates it memoryless (tile memory only, `DontCare` load and store), D3D12 binds a null RTV of the slot's format (writes dropped), and neither gets a barrier. Motion vectors are therefore free without TAA/TAAU, temporal effects, RT GI probes, motion blur or a plugin hook that asks for velocity; the material plane is free without decals or a debug view; and the anisotropy and clearcoat planes are free unless a resident material row has a non-zero anisotropy strength or clearcoat factor (`material_table.CpuTable.surfaceLobes`) — otherwise lighting samples a black stand-in (zero strength, zero coat). A game G-buffer shader that writes a lobe its material's surface factors do not declare is shaded without it.
- The material target is an integer attachment, not a hardware stencil — the engine enables no stencil plane on either backend. Shaders declare it `Tex2D<uint>` and read it with `load`; sampling an integer texture is a compile error in Akari, which is the point: filtering would blend unrelated flag bits across edges. It clears to zero, so `MaterialFlags.geometry` distinguishes covered pixels from sky.
- Lighting reconstructs world position from depth + camera matrices; do not invent a second depth convention per effect.
- Direct-light BRDF evaluation is shared. Shadow mode chooses unshadowed, virtual-shadow-map, or inline-ray visibility without changing material or geometry contracts.
- **Anisotropy and clearcoat are first-class surface lobes.** Imported anisotropy (glTF `KHR_materials_anisotropy`, and the equivalent FBX PBR maps) rotates the tangent-space direction, transforms it into the shaded world frame, and feeds anisotropic GGX direct and image-based lighting. Imported clearcoat (glTF `KHR_materials_clearcoat` and FBX coat) evaluates a separate dielectric GGX lobe with its own normal and roughness, then attenuates the base layer by the coat Fresnel. Neutral payloads preserve the ordinary metallic/roughness path: a zero-strength surface evaluates isotropic GGX and never builds a tangent frame. When a frame is needed, `hikari_surface_tangent` (brdf.akari) is the only place a tangent hint becomes a frame axis — it projects onto the shading normal and falls back to a fixed axis when the projection is degenerate (zero UV derivative, hit tangent collapsed along one axis, unwritten G-buffer texel). A NaN tangent is not black downstream (`max(NaN, ε)` returns `ε`), so that choke point is what keeps one bad tangent from becoming a white firefly. Both lobes keep the Schlick-remapped Smith masking term (`hikari_geometry_smith`, k = (r+1)²/8) rather than the exact height-correlated form: the exact term is ~1/(2·α·NoL) at grazing incidence on smooth surfaces, two orders of magnitude hotter, and RT hit shading (which has neither specular AA nor a firefly clamp) meets exactly that configuration on the inner wall of every refracting bottle. Deferred, forward, and RT hit lighting share the same lobe representation.
- **Specular is multiple-scattering compensated** (`hikari_specular_energy_compensation`, `helpers.akari` — Turquin 2019). Smith masking-shadowing accounts for one bounce, so energy that would have scattered again between microfacets is dropped — approaching half of it as roughness nears 1, and visible mainly on metals, whose response is entirely specular: rough gold and brushed steel read dark and desaturated without it. The single-scattering directional albedo comes from the split-sum DFG the specular term already evaluates (`hikari_env_brdf_approx` at `f0 = 1` returns `A + B`), so there is no LUT and no new sampling; the deficit is re-added tinted by `f0`, which is what keeps saturated metals saturated instead of merely brighter. The factor is cached on `HikariBrdfSurface` — it depends only on `f0`, roughness and `NoV` — so the cluster loop pays it once per surface, not once per light. Applied in `hikari_evaluate_direct_brdf_surface` and in both IBL paths (`hikari_eval_ibl_impl`, `hikari_eval_ibl_hit`), so raster, forward, and RT hit shading agree.
- **Stored roughness is already specular-antialiased** (`hikari_filter_specular_roughness`, `brdf.akari` — Tokuyoshi & Kaplanyan 2019). The geometry pass folds the sub-pixel normal distribution, measured from screen-space derivatives of the final shading normal, into the GGX roughness it writes. Filtering at the *write* is what makes this one fix for both pipelines: deferred lighting and every RT effect's primary surface read roughness from here, so neither re-derives it and neither pays per light. Un-filtered, sub-pixel normal variance resolves as single-pixel glints that crawl under camera motion — which temporal filters handle worst, since a glint is a real radiance change rather than a reprojection failure — and as ray-traced fireflies the denoiser then smears. Derivatives are per *render* pixel, so the filter strengthens automatically under temporal upscaling. `forward_lit_common` applies the identical filter so a material does not shade differently across the opaque/transparent split. **Not covered:** RT *secondary* hits, which load roughness from the material table rather than this buffer; that needs cook-time normal-map variance plus ray-cone LOD at hit points.
- Content (e.g. water) may change what is *written* into the G-buffer (displaced positions, FD normals). It must not branch on lighting mode.

### Area lights

The Light component supports `rectangle`, `disk`, and `tube` alongside directional,
point, spot, and ambient lights. The inspector, scene/prefab schemas, Zig
`SpawnLight` / `LightUpdate` / `LightView`, and Kawa `Light.state` / `Light.update`
share the same fields:

| Kind | `area_width` | `area_height` | Orientation |
|---|---|---|---|
| `rectangle` | Full width | Full height | Local XY plane, emits along local −Z |
| `disk` | Full diameter | Ignored | Local XY plane, emits along local −Z |
| `tube` | Full length | Full diameter | Closed cylinder along local X, emits outward |

Dimensions default to one **world metre** and are independent of actor scale.
Zero dimensions disable the emitter; negative dimensions are clamped to zero by
authoring and rejected by gameplay spawn validation. `two_sided` enables both
faces of planar emitters; tubes always emit outward. `radius` remains the
centre-based spherical influence cutoff, with the existing linear range window;
choose it large enough to contain the emitter. `color × intensity` is emitted
radiance for area lights, so increasing the emitting area increases total power.
These are analytic emitters: add a matching emissive mesh if the fixture itself
should be visible to the camera. `source_radius` and `source_angle` belong to the
punctual/directional types; area geometry comes only from its dimensions.

Surface lighting integrates the emitter with **Linearly Transformed Cosines**
(LTC). A shared shader module serves deferred, forward, RT reflection hits, and
GI direct irradiance. Rectangle integration clips exactly against the receiver
horizon. Disks use a 16-sided boundary and tubes use a closed 12-sided cylinder;
these bound the work independently of apparent source size. Isotropic GGX uses
the authors' 64×64 matrix/Fresnel fits with bilinear lookup; anisotropy stretches
the fitted lobe along the authored tangent, and clearcoat integrates a second
lobe with its own normal and roughness. These are fitted BRDF approximations,
not a path-traced reference. Immutable shader constants hold the fits, with no
per-frame LUT upload, new resource binding, or asset residency dependency.
Source, generator, and license details live in `src/hikari/tools/lighting/` and
the shader provenance/notices files. The GPU light record remains 96 bytes and
the persistent entry remains 128 bytes; kind-dependent cone lanes carry the
emitter's orientation and dimensions.

Area lights participate in bounded clustering, light/shadow budgets, indirect
lighting, fog, lit particles, and opted-in lens flares. Fog and particles use a
finite projected-area approximation. Primary temporal RT shadows and secondary
hit shadows sample the actual emitter shape; visibility modulates the integrated
LTC result. Raster shadows use six centre-projected VSM faces, with a bounding
emitter radius for SMRT softness. Forward receivers and fog retain stable
centre-ray visibility. This visibility approximation does not resolve separate
occlusion for every point on a large source; use RT for shape-aware temporal
soft shadows. Emitter size, roll, and sidedness changes invalidate retained
lighting and radiance-cache inputs.

Headless validation: `tools/shader-tests/run --test area_lights` emits the shared
integrator for Metal, D3D12 and Vulkan-dialect HLSL. With
`HIKARI_AREA_TEST_OUTPUT=<repo>/build/reports/area-lights`, run
`tools/metal/run area_lights_test <output>/area_lights_test.metal` for numerical
GPU comparisons against independent area quadrature. Neither command starts a
product application.

### Light clustering

Direct, forward, and RT lighting consume one logarithmic per-project (`capacity.light_grid`: 16×9×16 / 24×14×24 / 24×14×48) light-cluster grid. Point, spot, rectangle, disk, and tube lights are in it. The renderer's `PersistentLightTable` retains a logical ordering with directional/ambient lights first and indirect-enabled bounded lights next. `ShadowFrameUniforms.rt_hit_params2.w` carries the global count; every pixel reads logical indices `0 .. global_count`, then its cluster's run. Shadow faces and cluster runs use these logical indices. `hikari_load_light` resolves each through a view row to the stable physical property slot keyed by `LightId`. Reordering or removing a light does not move surviving property slots.

- **One producer, all GPU.** `light_binning.akari` records seven kernels in one pre-graph submission: init, per-light coarse count, coarse prefix sum, per-light scatter into a coarse arena (each axis rounded up over 4×4×4 fine clusters, 6×4×6 on the standard grid), per-fine-cluster exact sphere/AABB count, fine prefix sum, per-cluster re-test and compacted write. Both arenas are handed out by prefix sums over measured counts, so storage tracks what cells actually hold; coarse is 256 Ki references per frame slot, fine reserves up to `2 × cluster_count × cap` words, where `cap` is `quality.local_lights` (32/64/128/256, ceiling `max_lights_per_cluster`); each prepare slot's grid is sized to it (headers only until a frame has bounded lights), grows immediately and shrinks after 60 consecutive smaller-tier dispatches for surface runs and their fog subsets (asserted at comptime, so the prefix sum can never overflow it). The CPU retains `LightBounds` (32 B: world sphere plus `luminance × intensity`) and uploads uniforms; assignment is never host-generated. Changed light inputs patch the affected bounds, while logical-order/global-boundary changes rebuild their mapping. Explicit dirty rows accumulate separately for every ring slot; ordinary uploads visit those rows only.
- **Depth slices are placed by the lights, not the camera.** The 24 slices are logarithmic in view depth, so their metric size is set by the ratio they partition; from a 3 cm near plane under a 120 m far plane a slice at 10 m was four metres deep, one far cluster held every light in a corridor, and its shadowed-light budget was exhausted at once. `clustered_lighting.partition` takes two things from the bounded lights: the *range* — nearest to farthest point/spot sphere reach, clamped to the camera planes, which stand when there are no bounded lights — and a *depth offset* at the nearest light centre: `slice = log2(depth + offset) × scale + bias`. The offset flattens the spacing in front of the first centre (no sphere ends there, so resolution there separates nothing) and hands the slices to the depths behind it; zero offset is the plain split. Pixels outside the range clamp into the end slices, where the lights they meet cannot reach them and attenuate to nothing. One partition is derived per frame in `consume.zig` and handed to both the binning kernels (`depth` + `axesFlags.w`) and the lookup (`ShadowFrameUniforms.clusterDepth`).
- **The layout is compacted.** Cluster `i` owns `[offset, offset + count)`; `count` is the exact run length. There is no overflow escape and no fall-back to the full light list: a pixel's light loop is bounded by the `quality.local_lights` cap (at most 256) in every scene; over the cap a cluster keeps its most important lights.
- **Fog walks only contributing bounded lights.** During the same seven kernels, binning appends an ordered subset of each admitted surface run whose light intensity and volumetric scattering are strictly positive. Tiny positive scattering is retained because it can participate in shadow ranking even below the shading threshold. Header bits 20..29 store subset length plus one; zero reuses the surface run and one denotes an empty subset. The subset begins at `offset + surface_count`. Count reserves at most the smaller of fog-eligible candidates and admitted surface count; all-active candidates reuse the surface list, and all-disabled candidates need no extra words. Under budget this reservation is exact; over budget it can contain unused tail words. No buffer binding, dispatch or surface admission changes. Froxel sampling gates still use the original candidate count, retaining weighted selection and contributing-light order.
- **Shadow admission is per pixel, not per cluster.** The binning pass writes each cluster's surface caster count into the header's count word (bits 8–15; bits 0–7 stay the run length) and makes no admission decision. The shading walk (`hikari_evaluate_cluster_lights`) reads the per-pixel budget from `clusterDimensions.w` (packed with the light cap by `packClusterDimensionsW`); a cluster holding no more casters than the budget admits them all for free, and only an over-budget cluster pays a ranking pass — each caster's unshadowed contribution at this pixel, the K strongest kept in registers (K ≤ 8; a larger budget shadows everything), the K-th score and light identity as the threshold. Equal scores favor lower light identities, so ties never exceed K or depend on candidate order. The same pre-pass sums every directional and punctual light's potential at the pixel for the **importance floor** (`HIKARI_SHADOW_IMPORTANCE_FLOOR`, 1 %): a bounded caster whose unshadowed potential is under that share of the pixel's direct light is shaded unshadowed, like a caster the ranking rejects — its shadow could darken the pixel by less than the threshold of luminance discrimination, and it would cost the same visibility query (a ray, or a VSM page translation and filter) as the shadow that defines the image. Unbounded lights are never floored. A lone caster pays no pre-pass; the floor runs where a second light competes. VSM page demand stays conservative (it requests pages for every caster at a receiver cell), so a floored or ranked-out light can hold pages the shading never samples. The previous per-cluster ranking flipped between neighbouring clusters whose caster sets differed by one light, switching a shadow off along hard tile edges that blinked under camera motion. Fog-volume shadows need no admission here: every mapped fog caster costs one medium shadow map fetch ([Volumetric media](/docs/systems/volumetric-fog.md#fog-volumes-shadow-surfaces)).
- **Over budget keeps the most important lights.** Importance is the upper bound `luminance × intensity × (1 - d/radius)` at the cluster box's nearest point, so it subsumes the overlap test. Selection histograms scores into 16 log2 buckets in group-shared memory and takes whole buckets brightest-first. The boundary bucket admits its lowest light identities up to the remaining quota. Count stores a second cutoff word with the last admitted identity; a nibble radix selection runs only when a partial boundary bucket needs it, rejecting other prefixes before scoring. Whole buckets and a boundary containing every bounded light need no selection. Atomic scatter order cannot change membership. Lights whose best possible contribution is below `min_contribution` (1e-3) are not binned at all.
- **Fail-open survives only in the coarse level:** a coarse cell whose run overflows its arena makes its fine clusters test every bounded light. That is binning cost per cluster, never per pixel.
- Both projections share one formula (`ProjectionAxes.depth_weight`, 1 for perspective, 0 for orthographic). `graphics/effects/light_binning.zig` carries a host transcription the tests hold to the requirement (no point inside a light may lose it); the same module owns the `Phase` enum and bind-slot table both backends drive from. A backend creates all seven pipelines at startup or none. A frame with zero bounded lights or no frustum dispatches nothing: it clears the headers once per prepare slot (latched until that slot's next dispatch, and reset whenever the grid buffer is replaced).
- **Diagnostics:** fixed GPU counters (bounded lights, arena words used, coarse overflows, candidates tested, accepted, `fine_dropped`, fine words used) publish through `light_binning.logStats` while GPU timing recording is active. `fine_dropped` non-zero means a cluster shows its best `cap` lights (raise `quality.local_lights` or thin the scene) and not all of them.
- **Persistent light properties:** `graphics/gpu/light_table.zig` owns stable slots, the retained logical snapshot, and packed records. An unchanged authoritative light-publication fingerprint skips reconciliation; changed snapshots are compared by identity and content, independently of one-shot component dirty hints. Only changed properties are packed. Removals free slots for reuse; changing Worlds resets the owner identity. Each GPU ring slot retains its own pending dirty rows and acknowledges them only after a successful upload. First use, buffer growth, or a changed owner seeds the buffer; later commits coalesce dirty entry ranges without a whole-array hash/memcmp.
- **View data is separate from properties:** a `GpuLightEntry` is 128 B: a 96 B physical property record and a 32 B logical view row (physical index, cone/emitter parameters, VSM view slot/count, and resolved shadow far). These share the existing buffer binding, so loading a logical light adds one index lookup. Camera and atlas changes update view rows; static properties remain intact. Track every admitted shadow light, including cached faces, and clear assignments for lights that lose pages. The dense matrix table remains proportional to atlas admission. Test/preview callers without a table use self-mapped transient entries.
- **Scope and cost:** after ring warm-up, an unchanged light/view set does no light-record packing or upload writes. A single intensity change normally writes one 128 B entry; a property and its logical view can occupy different entries. Memory grows with the peak live light count, and entries cost 32 B more than the old flat records. World publication still fingerprints light inputs, changed publications still reconcile the snapshot, and camera-dependent depth partitioning and shadow admission still scan lights. This is persistent GPU storage and sparse transfer, not a claim that all CPU lighting work is proportional to changed lights. Headless coverage: `tools/metal/Harnesses/persistent_lights_test/main.swift` plus the `graphics` Zig tests.

### Deferred decals

After opaque G-buffer geometry (including two-phase late draws) and before AO / GI / reflections / lighting, published projectors rewrite albedo, world normal, and ARM. Depth, motion, and material flags are not written. Visible projector bounds are CPU-binned into compact 16×16 screen tiles. A compact occupied-tile list dispatches one 16×16 workgroup per tile. The shader reads and updates the existing G-buffer textures through UAV bindings, applies the ordered projector list, and stores only modified pixels. The graph declares read/write access, retaining all geometry producers and ordering later readers; no extra G-buffer triple or full-screen copy is allocated. Tile overlap is bounded to the highest 64 stable sort layers. At most `render.capacity.decals` (default 256, 16–8192) on-screen projectors are encoded per frame; past it projectors marked **Important** are kept first (they count against the budget but are never dropped), then the nearest to the eye, still in sort order, and a warning is logged once per budget value. Selection never walks the whole loaded list: when the retained decal domain rebuilds, publish groups the projectors into 32 m spatial buckets (`decal.buildBuckets`, `content_budget.bucketKey`), and each surface rejects whole buckets whose bounding sphere misses its frustum, then each projector's own sphere, before projecting the survivors. The per-projector sphere test also drops boxes that cross the camera plane entirely beside the view, which `dispatchRect` alone would encode full screen. The per-tile layer cap of 64 does not scale with the budget. Cost: 96 B of GPU records per kept projector per frame in flight. The volume is the actor transform: local box `[-0.5, 0.5]³`, scale is the box, local **−Z** is the projection axis. Pixels whose material lacks `MaterialFlags.receives_decals` (default **on**) are skipped. The test is against the *G-buffer surface*, so the box must contain the visible face — a projector hovering in front of a wall paints nothing (the editor flags that on a selected decal). Zero published decals omit the pass.

Metal allocates the decal G-buffer triple with `RenderTarget | ShaderRead | ShaderWrite` usage; compute-only textures retain `ShaderRead | ShaderWrite`. Texture usage is part of the pool key, so changing between scenes with and without decals cannot reuse an allocation with incompatible capabilities. Geometry-to-decal and decal-to-reader hazards use the graph's resource fences and queue events. Metal has no resource layouts to transition; debug barrier validation checks that each state fits its resource kind, allowing all such producer/consumer pairs.

### Contract B — Classical deferred

**Graph role:** default opaque path for shadows off or raster shadows.

**Passes:** G-buffer + deferred lighting is the baseline. Optional AO / GI / reflections / volumetrics / reconstruction / camera post / bloom sit around it. Exact assemble order and gates: [Implemented pipeline](/docs/systems/rendering.md#implemented-pipeline).

**Inputs:** G-buffer, VSM physical page pool and page table, lights, global environment.
**Outputs:** HDR `scene_color` (or backbuffer when no post).

**Independence:** runs with no RT master, no acceleration structure, and no content compute. May run *with* content compute (vertex texture only) and/or screen-space AO/reflections.

**Must not:** require `async_compute` materials, TLAS, or ray-query shaders.

#### Hybrid shadows

For game-facing mode selection and quality tuning, see [Choosing and scaling shadows](/docs/guides/shadows.md).

`features.shadows = hybrid` is a single fullscreen lighting path, not the full sparse RT-direct producer and denoising chain. It traces exact alpha-tested visibility for directional lights within the first four global slots (normally the sun), while punctual lights and any additional globals sample the cached virtual shadow maps through the same clustered deferred evaluator. With temporal reconstruction active the directional ray rotates across the authored emitter and converges to a soft shadow in the final reconstruction; without a temporal consumer it uses the emitter centre for a stable hard-shadow fallback instead of exposing unconverged one-ray noise. This keeps the global-light ray count bounded and avoids paying for both a full RT direct-light image and a deferred lighting image.

The four traced lanes are global *slots*, not the first four directionals: the lane a light reads is its record index. `sortLightsGlobalsFirst` therefore ranks directionals to the front of the unbounded run, so ambients — which are unbounded, and have no direction to trace along — cannot spend a lane they can never read. Reordering there is what keeps four lanes sufficient; widening the packet would not have been.

**What it costs.** Hybrid adds directional rays while retaining virtual-shadow coverage for its other readers. VSM demand, allocation and page raster run on every hybrid frame (`usesRasterShadows` is unconditionally true for the mode), because the maps are what forward transparency samples, what the froxel pass marches for shafts, and what a mid-frame readiness loss degrades onto. The saving is entirely on the punctual side, where a cached VSM page already answers what a per-light ray would have re-derived. The rays it does spend are declared to the shared ray budget (`rtEffectSet` admits `.shadows` for hybrid as well as `ray_traced`), so GI, AO and reflections share down against them rather than being granted a frame that has already been spent.

Because TAA is hybrid's only accumulator — there is no dedicated denoiser or spatial resolve behind it — expect the traced penumbra to be the first thing that shows noise on a history reset or under fast motion. That is the trade the mode makes against `ray_traced`, and the place to look before suspecting the trace itself.

Hybrid always prepares virtual shadow maps, builds the shared RT scene, and requires the dedicated hybrid SDR/HDR lighting PSOs plus hit-shading tables. If the TLAS, hit tables, or hybrid PSOs are unavailable for a frame, effective mode degrades to raster; it never degrades to unshadowed lighting because the VSM pages — directional clipmaps included — are already valid. That degrade is visually complete: screen-space contact shadows apply to any global reading VSM under hybrid — the ones past the four lanes, and every global on a degraded frame — so a fallback frame looks like raster rather than like raster with its contacts missing. A traced lane never takes them, since the ray already resolved the detail the march approximates. Full `ray_traced` shadows retain their independent sparse producer, temporal reconstruction, and spatial resolve. Forward transparency uses VSM in raster/hybrid and ray queries in pure RT.

#### Virtual shadow maps

Raster and hybrid use one virtual-shadow implementation. Each punctual view has a 16,384² virtual address space, 128² physical pages and eight mip levels. Point lights own six views; spots own one. Directional lights own four, six or eight world-aligned clipmap levels. The surface's shared physical pool, receiver density and view budget follow `quality.shadows` (`VsmConfig.fromQuality(ShadowQuality)`); `capacity.shadow_views` overrides the view budget up to 4,096, and table headers and the view cache follow the live budget. Pure RT does not prepare, allocate, request or render VSM pages; its unused typed binding has a one-pixel placeholder.

Set **Project Settings → Quality → Shadows → Shadow quality** to Low, Medium, High or Ultra. Save persists `pipeline.quality.shadows` in `configs/render.json` and applies it to the session. The tier follows `quality.preset` (Medium by default) unless set explicitly. It is the one shadow cost tier: besides VSM storage it sets shadowed punctual lights per pixel (1 / 2 / 4 / 8, overridable by `quality.shadow_lights`) and the RT shadow trace grid. Selecting a lower tier never changes the independently selected shadow filter or contact shadows. Pure RT builds no VSM, so the storage columns below apply only to raster, hybrid and the raster fallback.

| Shadow tier | D32 atlas / memory | Physical pages | Directional levels | Receiver mip bias | Maximum view slots | Transparent claims |
|---|---|---:|---:|---:|---:|---:|
| `low` | 2,048² / 16 MiB | 256 | 4 | +2 | 32 | 4,096 |
| `medium` | 4,096² / 64 MiB | 1,024 | 6 | +1 | 128 | 65,536 |
| `high` | 6,144² / 144 MiB | 2,304 | 8 | 0 | 256 | 262,144 |
| `ultra` | 8,192² / 256 MiB | 4,096 | 8 | 0 | 512 | 1,048,576 |

Lower tiers trade close-range detail and simultaneous light capacity for less memory, page demand, redraw work and submission overhead. They keep the authored outer directional coverage and caster geometry; fewer clipmaps remove the finest levels. A point light requires an aligned eight-slot block for its six faces; spots use one slot. Tier budgets are ceilings, not a promise that all lights fit. Residency pressure keeps the existing ancestor fallback and denial diagnostics. Dirty resident pages still update in the current frame; there is no time-sliced stale-shadow quota.

Page-table and request buffers follow the scene's view high-water mark, rounded to powers of two without an eight-view floor. One spot needs about 152 KiB of table storage; four directional levels need about 414 KiB, and eight views need about 763 KiB. The view-slot ceiling does not preallocate that many tables, even at Ultra. Physical metadata, reclaim queues, dirty lists and the batch loop follow the tier's actual pool capacity. Transparent claim storage ranges from 16 KiB to 4 MiB. These reductions lower the VSM baseline cost, but do not remove its demand/allocation passes or make a full-frame performance guarantee.

```zig
hi.render().setShadowQuality(.low);
// Combine mode and independent quality choices in one transaction:
hi.render().setGraphics(.{ .render = .{
    .features = .{ .ray_tracing = .on, .shadows = .hybrid },
    .quality = .{ .shadows = .medium, .shadow_filter = .smrt_high,
        .directional = .{ .rt_far = 800 } },
} });
const shadows = hi.world().runtimeInfo().graphics.quality.shadows;
```

Hybrid composes the same two backends. Its primary directional visibility retains the bounded RT lanes; VSM serves punctual lights, transparent receivers, froxels and readiness fallback. `quality.shadows` and `quality.shadow_filter` tune the map side; `quality.shadows` / `shadow_lights` also set punctual light admission. `quality.rt_budget` controls shared RT scheduling and `quality.directional.rt_far` sets the independent directional trace reach; `rt_hit` concerns secondary GI/reflection shading, not primary directional precision. A shadow tier change cannot reduce RT ray reach or change the RT budget. Pure RT remains map-free; the tier's VSM storage applies again after a mode switch.

Tier changes rebuild view identities and residency at a frame boundary; resized buffers retire only after their owning prepare-slot fence completes. Atlas replacement uses the existing GPU-safe resize path, so changing capacity can stall that transition frame. Runtime changes do not save developer JSON. Filter-only changes retain the map cache.

Zero-intensity lights do not reserve VSM views. Volume-only lights reserve views only when their scattering contribution is enabled; froxel demand uses the injection pass's intensity/scattering gates.

The producer runs after final opaque depth. GPU kernels mark receiver demand from clustered lights and projected pixel footprints, then mark froxel receivers at their actual sample positions and footprints. Transparent refractive, additive and OIT mesh receivers replay the current visible-triangle stream before allocation. The demand-only fragment rejects opaque occlusion but never writes depth, so overlapping transparent layers all contribute. It uses the same skinned vertex pull as forward lighting. Coarse coverage remains the residency fallback, not the source of transparent receiver resolution.

Transparent demand is deduplicated in 8×8 screen/depth cells (64 logarithmic depth intervals per octave). The first claimant requests a conservative world-space bound at the nearest receiver's pixel footprint, including a projection-jitter guard; this does not reduce shadow resolution or discard thin geometry. Cells crossing light-cluster boundaries use exact fragment requests. A surface-owned, tier-sized hash table allows four probes, with exact fragment fallback on collisions or capacity pressure. Claims reset before each frame's transparent demand and the pass is absent without transparent meshes. Claims and page requests finish before residency protects or allocates pages.

Demand bits grow monotonically between reset and the allocation barrier. The first requester of each page owns propagation to its ancestors; duplicate receivers stop at an already claimed page. Coarse demand uses the same ancestor rule, so contention is reduced without changing the requested set. The GPU protects the complete demand set before reclaiming unrequested pages, allocates coarse ancestors before fine pages, and rasterizes only dirty pages. Unrequested pages and warm hits skip world-key construction, while dirty pages retain one GPU invocation per page for parallel allocation. Local-only scenes omit the directional allocation phases. Large allocation phases compact nonempty request words on the GPU and indirectly expand them into parallel page lanes. Small phases retain direct dispatch; mip and clipmap priority barriers stay intact. Moving-caster invalidation uses a balanced stackless sphere tree for larger dirty lists, retaining exact leaf tests and conservative bounds at large world coordinates; small lists stay linear. Ranked view admission continues past a light whose aligned block cannot fit, allowing smaller lights to use the remaining budget. Missing fine mappings walk to a resident ancestor. A page is allocated but invalid until its clear and caster draws have been encoded, then the publish kernel makes it sampleable. Pressure never exposes another light's leftover depth.

Directional sun caching uses fixed projection snapshots. Static suns keep one exact set. Prepare predicts the next 0.25° interval (`sun_cache.zig` `Config.angle_deg`, an engine constant) from current motion and warms that fixed snapshot ahead of the boundary; visibility blends for up to 8 advancing frames (`Config.transition_frames`, shortened with motion). The current set wins allocation priority. When it fills the pool, missing peer roots may borrow a bounded number of current inner mip-zero pages with requested, valid outer ancestors that remain protected. Only secondary coarse allocation can consume these reservations after free and unrequested pages run out; outer coverage, punctual pages and invalid fallback are excluded. Both sets receive opaque/transparent/froxel demand. Replacement and settling views budget only newly allocated fine pages, globally capped at `min(pool_pages / 8, 256)` per frame. Required coarse pages and resident dirty updates remain immediate. Publish marks replacement views ready only after all their mip-seven ancestors have been allocated and rasterized; readers retain the current visibility until the selected replacement and its blend neighbor are ready. A completed coverage bitmap in counter words 24/25 reaches the CPU through the existing asynchronous feedback ring. Its receipt matches light, slots and projection generations; camera scrolling keeps generations, while readers check live coverage. Waiting does not advance the fade clock, and weight one is displayed before promotion. Reset, abort and peer release revoke receipts. Promotion retains the replacement's view slots/generations and retires the old set. Fine refinement continues for at least a pool-sized allocation interval after promotion. No separate atlas or new shader binding is needed. Transition peers live in the first 64 blocks the feedback bitmap addresses; prepare reserves two sun blocks below local lights.

The two projection sets share the selected pool and temporarily consume two aligned view blocks. Primary admission can reclaim optional secondary blocks. Static startup allocates no second set; a stopped sun settles exactly, although table capacity retains its view high-water mark. Large time jumps and excessively fast sun motion use immediate projection updates. Sun-state advancement is surface-owned and idempotent per frame. Aborted work retains the existing full-residency invalidation contract. This is bounded temporal approximation, not exact continuous-sun visibility or a hard GPU-time cap. Crossfading can double/soften edges; low-angle, fast-day and camera-cut scenes need visual validation. Moving casters still invalidate overlapping pages in both sets; static/dynamic geometry is not stored in separate depth layers. Authoring and tradeoffs: [Moving and static suns](/docs/guides/shadows.md#moving-and-static-suns).

Directional page keys use signed absolute light-space coordinates. Each mip's toroidal table includes a one-page scroll skirt, so the root can cover both sides of an unaligned window. Camera XY translation retains overlapping world pages. Orientation, depth-range and projection changes advance the view generation. Stable light IDs own view slots; per-frame light ordinals never identify cached pages.

Directional clipmaps share equivalent world pages: level L at mip M addresses level L+M at mip zero, clamped to the outermost level with any remaining mip there. All levels share the light basis and depth projection, so these representations have the same page projection and world texel size. Only canonical owners allocate and rasterize depth. Lookup retains the reader's mip for filtering and bias; each reader's fallback range remains unchanged. Demand propagates through the complete canonical ancestor chain, including beyond that reader's fallback range, so an earlier claim cannot suppress a coarser view's ancestors. Allocation visits the outermost mip chain first, then clipmap mip-zero owners from outer to inner. `origin.z` stores the remaining coarser levels and participates in the view fingerprint; only XY scrolling is excluded.

`quality.directional` holds sparse overrides of fixed engine defaults, independent of the preset and shadow tier: outer clipmap half-width (`far`, 120 m), independent RT ray length (`rt_far`, 0 derives it), depth `padding` (4 m), `caster_pullback` (80 m), `clipmap_blend` (0.1 of half-width), `depth_bias` (0.001) and `normal_bias` (0.02). Clipmaps have fixed world alignment; receiver footprint selects punctual mip demand.

Page rasterization uses batches of up to 512 pages, capped by the physical pool and reduced when complete pair, meshlet and triangle-packet scratch exceeds its memory target. Low therefore reserves at most 256 pages of caster scratch. Lowering the pool budget releases oversized caster buffers as each fence-waited prepare slot is reused; ordinary geometry fluctuations retain capacity to avoid allocation churn. GPU LOD selection and meshlet culling emit a compact queue of visible meshlet instances and pages. A separate triangle pass rejects triangles wholly outside a common homogeneous page plane and packs survivors into 40-byte records of up to 32 triangle IDs; all eight rigid/skinned, solid/masked, sided/two-sided caster classes remain distinct. Homogeneous clip distances clip geometry to its virtual page **before** atlas placement. One instanced rectangle clears each dirty page with reverse-Z depth zero and compare-always. Scratch is reused only after that batch's raster work. Instanced vertex pulling expands each triangle packet, with degenerate tail primitives that never fetch out-of-range topology. Boundary triangles remain for hardware clipping. Skinned and deformable meshlets retain all triangles because their undeformed positions cannot prove rejection. The existing triangle-based LOD budget remains unchanged; compact submission does not coarsen geometry. Raster uniforms contain only four projection columns and the atlas rectangle (80 bytes per page), separate from the culling records. This keeps the raster constant buffer below 64 KiB and permits up to 512 pages per batch while retaining the existing 128 MiB geometry-scratch target. A full 4,096-page pool needs eight submissions at maximum batch capacity. Every submission still uses current-frame GPU counts; empty tail draws have zero instances. Larger batches reduce empty render-pass overhead without CPU readback or delayed invalidation. Request and residency kernels share two compute encoders, with barriers between dependent stages. Once a fence-completed producer reports complete coverage with no residency denial or deferred refinement, an exactly unchanged eligible frame skips demand, allocation and raster submission. One `vsm.reuse` maintenance dispatch refreshes requested-page ages and warm-hit diagnostics. Input changes, layout replacement and aborts invalidate this receipt. Masked, transparent, skinned/deformable, particle/content-compute and changing-LOD inputs retain the full path. Camera jitter is part of the exact input key, so jittering frames normally retain the full producer. Feedback uses existing prepare-slot completion; it introduces no GPU wait or delayed caster invalidation. Readback consumption has a separate per-slot frame marker from resource preparation, because the native frame hook visits resources before the shadow pass. Consume each completed slot once, including frames whose first visit has no feedback buffer; a later pass must not read feedback encoded earlier in that same frame.

GPU traces expose exact-input maintenance as `vsm.reuse`. GPU traces expose transparent demand as `vsm.transparent_demand`, separately from opaque/froxel `vsm.requests`. They separate `vsm.cull` (batch preparation, LOD, meshlet and triangle culling) from `vsm.raster` (page clear and depth draws). Sum repeated names across the first 16 batches. If scratch constraints require more batches, their combined work appears as `vsm.tail`, bounding timestamp use without dropping work from the total.

Shadow storage and filtering are independent. Set **Project Settings → Quality → Shadow filter**; Save persists `pipeline.quality.shadow_filter` in `configs/render.json` and applies it to the running session. The default follows `quality.preset` (SMRT Low at Medium, SMRT High at High and Ultra, PCF at Low), independent of `quality.shadows` (light budget) and `quality.contact_shadows`. Runtime changes use the same sparse quality transaction:

```zig
hi.render().setShadowFilter(.smrt_low);
// Equivalent when batching with other settings:
hi.render().setGraphics(.{ .render = .{ .quality = .{ .shadow_filter = .pcf } } });
// Read the current selection:
const filter = hi.world().runtimeInfo().graphics.quality.shadow_filter;
```

| Filter / JSON value | Surface visibility |
|---|---|
| SMRT High / `smrt_high` | 4 rays × 8 steps, source-size contact hardening |
| SMRT Low / `smrt_low` | 2 rays × 4 steps, same maximum penumbra with fewer samples |
| PCF / `pcf` | Fixed 3×3 bilinear comparison kernel; no source-size tracing |
| Hard / `hard` | One point depth comparison; no spatial filtering |

All four use the same VSM page storage, resolution policy and caster geometry. PCF and Hard omit SMRT footprint expansion from opaque and transparent demand; shared filtering/bias guards remain. Filter changes reset temporal reconstruction through the normal quality transaction, without flushing the VSM cache. Runtime changes do not save developer JSON. Pure RT ignores the surface filter and keeps its selection available for switching back to raster/hybrid. Froxels retain their existing four-tap PCF independently of this setting.

Bilinear footprints wholly inside the resolved physical page use hardware comparison filtering. Boundary footprints translate each virtual texel independently, preventing filtering into an unrelated physical neighbor. Inside a single resident page, the PCF nine-tap kernel combines its separable weights into four hardware comparison samples with the same mathematical footprint and weights; boundary footprints keep the original nine taps. Hardware interpolation precision can cause small numerical differences. Receiver biases follow the actual resident mip; froxels apply no surface bias. Directional readers blend adjacent clipmaps; receiver demand requests the adjacent view only inside that same blend region. Filter cost depends on page boundaries and cache behavior: PCF is not guaranteed cheaper than SMRT Low for every workload.

With an SMRT filter selected, surface lights with nonzero emitter size use shadow-map ray tracing (SMRT): finite ray segments intersect VSM depth planes, creating contact hardening without a TLAS or hardware ray queries. Local lights use `source_radius` in metres; directionals use `source_angle`, their full angular diameter in degrees (default 0.68755°, preserving the previous RT angular spread). Both are available in the inspector, scene data, Zig and Kawa light APIs. A zero directional angle or zero local raster radius retains PCF; the existing zero-radius automatic sizing for RT and lens flares is unchanged.

The independent SMRT Low/High presets select 2×4 or 4×8 ray/step samples. Ray footprints are clamped to eight texels at the receiver's requested mip; enlarging a source changes ray geometry, not sample count or an unbounded blocker search. Demand includes the full footprint plus filtering/bias guards, including adjacent point-light faces. Interior samples reuse the page lookup, while boundary samples resolve the proper virtual page or cube face. Directional SMRT uses a separate orthographic traversal with constant clip W. Rays whose projected endpoints stay inside one physical page select a direct atlas sampler before stepping, avoiding repeated bounds and page-key checks; crossing rays retain virtual lookups. Both paths share the same intersection and gap-extrapolation code. This changes address work, not sample count or visibility rules. Directional demand needs one projected ray radius plus its guard; local faces retain the two-radius projection guard. Far occluders retain hard visibility beyond the bounded penumbra, and one-step depth-slope extrapolation limits single-layer gaps. As with other single-depth SMRT implementations, overlapping occluders and very wide penumbrae remain approximations. For filter selection and the quality tradeoffs, see [Choosing and scaling shadows](/docs/guides/shadows.md#choose-filtering-independently).

Local SMRT rays cache their last page lookup and cube-face projection within each ray. The lookup cache uses the requested mip's page key and the view's table identity, preserving independent fine mappings when neighboring samples fall back to coarser pages. Missing mappings may be reused only for the same key; caches never survive a ray or frame.

A stratified emitter sequence rotates over 16 phases only when a temporal reconstruction provider consumes the frame. Existing surface motion, disocclusion rejection, transparent guides and per-surface history then stabilize the result; there is no extra full-screen shadow denoising pass. Without temporal reconstruction the world-anchored pattern stays fixed. Resets and fast motion can expose the finite sample count. The VSM sampler remains entirely in raster/hybrid; pure RT remains map-free. Colored transmission by shadow casters is a separate, unimplemented feature.

**Alpha-cut casters get a second pipeline, not a wider first one.** Coverage is a property of the material — `gbuffer.akari` discards below `alpha_cutoff` — so a pass that ignores it disagrees with the G-buffer about what the surface *is*: a leaf card, a chain-link fence, or a grate laid down its whole quad as an occluder while the camera saw the cut-out. `_engine/shadow_depth_masked` is the same page fill *with* a fragment stage that re-runs the G-buffer's alpha term (base × vertex × albedo map, sampled only when the albedo bit is set) and discards. It reads no normals: a depth page needs position and coverage and nothing else.

A pixel shader roughly halves depth-path rasteriser throughput and almost nothing in a scene cuts, so `alpha_masked` rides on the `DrawKey`: the GPU shadow meshlet cull writes solid and masked caster classes to separate indirect streams and raster traverses them pipeline-major (a D3D12 PSO bind drops every root argument, so each class run re-issues its binds).

**The cutoff is per primitive, not per material.** `ResolvedPrimitive` stores `alpha_maskable` (the binding layout can reach an albedo map) and `alpha_cutoff` (the effective value from the `ObjectData` row the G-buffer discards against), because `MaterialParams` overrides resolve per instance and the shadow page must follow the instance the camera draws. A scene with no cut-out material never binds the masked pipeline.

The masked run binds **only** the albedo slot of the geometry table (D3D12 fills the rest of the declared range with the first bound descriptor, so a partial fill leaves no uninitialized entry) and falls back to the white default when the map is not yet resident — a streaming-in leaf casts solid for a frame instead of sampling whatever the slot last held. Required caster pipeline creation failures fail initialization; missing draw resources fail the frame rather than publishing incomplete depth.

**A masked caster's page is not pure depth, so map rebinds invalidate it.** `update_primitive_textures` joins the commands that dirty the shadow cache — gated on the primitive being a cutting shadow caster and either side of the swap being able to reach an albedo map, since losing that reach also leaves the cached page wrong. A cutoff change arrives as an `ObjectDataUpdate`, which already invalidates for casters. Async texture uploads reach no render command at all, so residency latches `masked_caster_maps_dirty` whenever a masked caster's maps change and `createRenderQueue` drains it into the caster revision. Like a caster being created or released, this has no bounded extent to test pages against — a completed upload says nothing about where in the world its users are — so it invalidates the atlas wholesale. Only masked casters trip it; an ordinary streaming burst does not.

**Ray-traced visibility reproduces the raster alpha cut inside the traversal.** Alpha-cut instances enter the TLAS as non-opaque (`FORCE_NON_OPAQUE` / `MTLAccelerationStructureInstanceOptionNonOpaque`; BLAS geometry stays opaque), so their triangles surface as candidates in the inline query loop. Each candidate runs `hikari_rt_candidate_accepts` (UV and vertex alpha from the bindless geometry row, the instance's effective `alpha_cutoff`, material/albedo alpha) and commits only when the texel is really there. One traversal per ray regardless of cut-out depth; rays that never touch masked geometry pay nothing. Invalid or stale rows fail closed as opaque. Shadows and AO use this exact path; there is no project-wide coverage approximation or authoring dial.

**Software opacity micromaps remove most of those exact tests without a hardware-specific extension.** The RT scene reserves one persistent run per exact `(BLAS range, material handle, alpha cutoff)`, fingerprinted with the **resident albedo texture handle**, and both backends lazily dispatch `_engine/opacity_micromap_bake` into one renderer-owned GPU arena. A material whose authored base-colour map is not resident yet, or failed to seat in the bindless heap, has its row on the defaults' white; a bake taken then would classify every micro-triangle Opaque and cache whole-triangle cutout shadows under the material, so such a range stays unreserved (Unknown, exact test) until the row carries the real map. Texture streaming publishes a new handle per resident-mip change, which re-bakes the run in place; the pyramids those superseded handles leave behind are reclaimed once idle pyramids are a quarter of the coverage section (survivors repack and rebuild, finished bakes are untouched). The bake conservatively classifies each N=8 micro-triangle as opaque, transparent, or unknown from vertex alpha and the albedo footprint. Traversal accepts/rejects the first two from one compact-state load. Baked unknown cells still perform the exact filtered material sample and cutoff, but reuse full-precision UV and vertex-alpha data stored in a capacity-stable fallback section; this removes three index reads and six dependent vertex-attribute reads per unresolved candidate. State zero is reserved for absent or invalid storage and takes the original source-geometry path. New, resized, missing, or failed ranges are never published: their instance row carries `no_resource`, so allocation, shader, residency, or dispatch failure changes performance only, never coverage. Buffer replacement keeps the old native allocation alive for the frame ring, invalidates every classification, and republishes runs only after the replacement bake has been submitted. D3D12 uses the direct-indexed frame descriptor heap; Metal binds the same per-frame argument tables and residency set to compute. The arena maintains an intrusive pending queue and a constant-time pending count; idle reclamation checks run every 60 prepare frames, while excessive stranded storage can trigger immediate compaction. The bake kernel runs one workgroup per triangle with one lane per micro-triangle (the texel scan is serial per cell, so a triangle's 64 cells are the parallelism), packing the states through groupshared memory. Bake admission visits at most 64 pending runs and dispatches at most 4096 triangles per primary frame across both backends; a foliage scene with a million masked triangles is through in seconds rather than minutes, during which traversal pays the exact alpha test. The bake's exact texel scan is capped at 1024 texels per micro-triangle; past that it reads the texture's **alpha coverage pyramid** — per level L ≥ 2 the alpha min/max over 2^L×2^L texel blocks (unorm8, rounded outward), built once per alpha-masked albedo texture by `opacity_coverage_build_cs` (one dispatch per level, barriers between) into a reserve at the end of the same arena (`opacity_micromap.Layout`), and read at the coarsest level where the footprint spans ≤ 16 cells. A whole-texture leaf card therefore resolves instead of falling to Unknown; a bake waits for its texture's pyramid rather than baking without it, and a buffer replacement rebuilds both. Large runs retain triangle progress across frames, with range offsets adjusted for each chunk. Encoding failures commit no progress, and buffer replacement, compaction or changed classification inputs reset partial work. Traversal uses exact alpha until the entire run is encoded, so budgeting cannot expose partial or uninitialized classifications.

Alpha-cut casters retain the `instance_mask.shadow_masked` / `RtMask.ShadowMasked` classification, while visibility queries include both the ordinary and masked shadow sets. The shared RT instance/material tables therefore make a canopy, chain-link fence, and per-instance cutoff override agree with the G-buffer and virtual shadow maps. This intentionally makes RT shadows/AO consumers of hit-table readiness; the renderer falls back until those tables are valid.

**Primary RT soft shadows reconstruct normalized shadow attenuation.** The raw pass stores `sum(L × S) / sum(L)`; final lighting evaluates deterministic unoccluded lighting at full resolution and multiplies it by the reconstructed ratio. Unshadowed fill, material detail, and BRDF variation therefore stay outside the noisy visibility signal.

The compute producer traces alternating diagonal pairs in smooth regions and supplements the opposite diagonal at surface discontinuities. Raw alpha carries the measured penumbra bound, or a negative sentinel for an untraced phase. Temporal reconstruction handles missing phases; the spatial resolve bounds its reach by that penumbra. `quality.shadows` selects 1 / 2 / 4 / 8 shadowed punctual lights, with `quality.shadow_lights` as an optional override. Forward transparency uses its own deterministic visibility queries in pure RT and VSM in raster/hybrid.

**RTAO is one fresh cosine-weighted visibility ray per raw pixel.** Independent blue-noise radial and angular phases cover the whole hemisphere over time; varying both dimensions avoids the bias of merely rotating a fixed-radius sample. Noise is indexed by the AO target's own contiguous pixel coordinates—never by every second full-resolution depth texel—so half-resolution RTAO retains the intended blue-noise spectrum. The temporal history is RG16F `(E[V], E[V²])`, and that second moment is **temporal on purpose**: this producer is noisy across frames while looking spatially smooth after the temporal filter, so a cascade that re-measures variance from its own taps under-reads exactly the noise it exists to remove and the receiver shimmers. Its current estimate remains the five-ray receiver-compatible cross instead of restoring a single binary centre after convergence. A three- to five-stage a-trous cascade (by `quality.ambient_occlusion`) propagates the temporal variance at half resolution before a coverage-aware full-resolution resolve. The later [temporal reconstruction discussion](/docs/systems/rendering.md#dual-resolution-and-temporal-reconstruction) describes its guide and rejection rules. `quality.ambient_occlusion` remains the SSAO slice/step budget and cannot multiply exact alpha-tested RTAO traversal. A zero shared RT grant writes neutral visibility without traversing a ray; it is not replaced by a hidden one-ray fallback. Exact material alpha acceptance and software opacity micromaps remain unchanged for both AO and shadows.

**Ray scheduling is local to each RT producer.** Direct shadows trace the packed alternating diagonal. RT GI owns a sparse screen-probe pass: one centre-biased diffuse receiver represents each ceil-rounded tile, at least 8×8 render pixels and coarsened to the GI tier's probe ceiling and traces the quality ladder (2 / 4 / 6 / 8 rays). The normal path tests one of the four central pixels. A sky/metal centre searches an immutable nearest-first pixel order and stops at the first diffuse surface; row-major distance ties preserve the same representative receiver. Empty depth samples skip material reads. The following gather shades the raw GI grid without traversal when a 3×3 probe neighbourhood passes normal, depth, and bilateral world-plane tests. Silhouettes, thin geometry, and mixed-surface tiles that reject interpolation enter a bounded fallback queue; admitted receivers take two rays through the same screen/world-cache/exact hierarchy. This keeps probe amortization while avoiding the separate one-ray noise regime previously exposed at every rejected edge.

**Bounded direct signal.** The normalized shadow ratio stays in 0…1. Channels with effectively zero unoccluded energy publish neutral attenuation and remain zero after the deterministic baseline multiply. This removes both albedo-frequency contamination and inverse-dark-channel amplification without a material-specific demodulation heuristic.

**Closest-depth snap on reduced-resolution G-buffer reads.** A reduced trace pixel centre can sit on a full-resolution texel boundary. A nearest fetch there arbitrarily picks one covered owner, and TAA jitter then swaps which texel is a grass blade vs the ground every Halton phase — history rejects, the upsample follows, and small foliage shimmers. `hikari_gbuffer_snap_uv` picks the closest linear-depth texel in the even-aligned 2×2 for full/half paths; the screen-space compute producers extend the same rule to their actual 2×2 or 4×4 trace footprint. Both skip sky. AO and GI use geometry-aware reconstruction. RT direct shares the snap on its reduced trace grid and resolves through its own penumbra-bounded filter, re-tracing at render resolution where no trace texel owns a pixel's receiver; the producer's local depth/normal test still protects surface discontinuities before temporal reconstruction. Every spatial path falls back to the geometrically closest tap when bilateral weights collapse — typical of fanned foliage normals.

**Idle scene-encode freeze** is an editor **Efficient** gate (Editor Settings → Appearance → Idle encode), not a game path; standalone games and the editor's default **Realtime** always encode. Under Efficient, when `canSkipStaticPublish` succeeds the world keeps its last published frame, and after `scene_encode_freeze_after_static` (8) consecutive static skips with chrome settled, the present list goes idle and the render thread is skipped. A non-empty content-compute set keeps encoding on (water advances only on GPU dispatch). Camera motion, world UI, chrome changes, live graphics setters (`wakeSceneEncode`), a `texture.residency_epoch` bump, or any still-`soft_pending` mesh bind resume encoding, so a cook or HDRI that lands after the gate opens is not frozen out.

Point/spot receivers use **perspective** reverse-Z depth (`ndc.z = A/d - A/far`, `A = near*far/(far-near)`). Authored `depth_bias` keeps its meaning as a fraction of the far plane in world units and the shader scales it by `A/d²` with `d = clip.w`. The normal offset scales by the face's texel footprint `2d/(focal·tile_px)` so large-radius point lights do not under-bias at range.

The atlas is a depth attachment sampled through depth loads. Metal uses a depth-only pipeline; D3D12 uses typeless storage with D32 DSV and R32 SRV. Graph acquire barriers precede demand, and final publication follows page rasterization and table publication. The view prefix and page table share a buffer so deferred, hybrid, forward and froxel readers consume the same snapshot without expanding root signatures.

Caster changes retain the existing bounded dirty-sphere feed: old and new bounds cover motion, while geometry/material topology changes and unknown bounds invalidate all pages. Masked texture residency and skinning participate in invalidation. Work-queue changes invalidate the cache. Abort recovery invalidates primary and secondary surface view caches; replacement starts a fresh GPU residency table. All page tables, pool metadata and depth resources belong to their scene surface.

The shadow diagnostic grid reconstructs every view of the selected light in numbered subpanels (directional clipmaps 0–7 near to far, or punctual faces), dirty-page count, cache hits, denied requests, remaining free pages, resident mip/page boundaries, receiver depth, HDR and the mode badge. Captions, exact counter values and the mode name are rendered by the diagnostic pass itself, so they remain visible without the optional profiler UI. Green-to-orange page colors indicate fine-to-coarse residency; magenta marks missing pages. Counter bars are normalized to physical capacity and read directly from GPU-published statistics; no synchronous readback is required. CPU admission reports lights denied by the 512-view-slot capacity. Neither page pressure nor view admission changes the RT light set.

### Contract C — Ray tracing (shadows + AO + reflections + GI)

**Config owner:** `RenderPipelineConfig` in `src/hikari/sdk/src/render_config.zig`. Paths below are relative to that value (`pipeline` in `configs/render.json`).

| Field | Values | Meaning |
|---|---|---|
| `look.tonemap` | `none` / `reinhard` / `aces` / `agx` / `filmic` | Global tonemapping mode; `none` is off |
| `quality.preset` | `low` / `medium` (default) / `high` / `ultra` | Resets every `quality` cost tier at once; other `quality` fields in the same patch override on top. Never changes `features`, the governor, directional geometry or `lens_flare`. Table: [Quality preset](/docs/guides/project-file.md#quality-preset-renderjson) |
| `features.ray_tracing` | `off` / `on` | Master switch (device must support inline ray queries) |
| `features.shadows` | `off` / `raster` / `hybrid` / `ray_traced` | Independent shadow visibility backend |
| `quality.shadows` | `low` / `medium` / `high` / `ultra` | The one shadow cost tier: VSM storage/receiver density/view budget (`VsmConfig.fromQuality`), shadowed punctual lights per pixel (1 / 2 / 4 / 8) and the RT shadow trace grid (half resolution below Ultra). Primary RT visibility is always one temporally rotated ray per admitted light; optional `quality.shadow_lights` overrides the light count. Filter and contact shadows are separate tiers |
| `features.reflections` | `off` / `screen_space` / `ray_traced` | Independent of shadows |
| `features.ambient_occlusion` | `off` / `screen_space` / `ray_traced` | Independent of shadows and reflections |
| `quality.reflections` | `low` / `medium` (default) / `high` / `ultra` | Reflection march / resolve budget per traced pixel: Hi-Z march steps 24/40/64/64, neighbour resolve taps 4/6/8/12, spiral radius 2/3/4/5 texels, raw trace resolution quarter at low and half above (`trace_scale`), and the roughness ceiling 0.35 / 0.5 / 0.5 / 0.6 (`roughness_ceiling`, which materials reflect) (`ReflectionBudget.fromQuality`). Shared by both producers — the ray-traced one traverses a BVH and spends no march steps. Independent of RT bounce depth (`rt_hit`) |
| `quality.ambient_occlusion` | `low` / `medium` / `high` / `ultra` | SSAO slices × steps per side (2×4 / 4×6 / 6×8 / 8×10, bounded by `ssao_slices_max` / `ssao_steps_max`) and SSAO raw trace resolution (quarter at low/medium/high, half at ultra, `AmbientOcclusionQuality.traceScale()`); RTAO authors one fresh ray per raw pixel on its own half-resolution lattice before the shared RT budget; default `medium` |
| `features.global_illumination` | `off` / `screen_space` / `ray_traced` | Indirect diffuse (SSGI + RT GI). Default `off` = pure IBL ambient. SS/RT availability flip when their PSOs + shared temporal/spatial chain exist |
| `quality.gi` | `low` / `medium` / `high` / `ultra` | SSGI slices × steps (1×6 / 2×8 / 3×10 / 4×12), SSGI raw trace resolution (quarter at low, half above, `GiQualityBudget.ssgi_trace_scale`), fresh RT screen-probe rays (2 / 4 / 6 / 8), SS/RT range (15–60 m / 25–100 m), and RT hit-lighting quality (default `medium`; ignored unless GI is on) |
| `quality.rt_hit` | `performance` / `balanced` / `high` | Full hit re-light budgets for RT reflections and RT GI. The tier resolves `RtHitConfig` (shadow rays and light samples at hit, shadow contribution floor, hybrid depth tolerances and reuse, mirror bounces — 2 at `high`); those fields are engine tuning, not authorable. Material alpha continuation for shadows/AO has no quality dial |
| `reconstruction` | provider / preset / custom_scale | Sole policy for temporal AA and upscaling (see Dual resolution). Provider is a stable plugin-owned id; empty disables reconstruction |
| `look.environment_lighting` | bool | Enable global-environment indirect diffuse/specular lighting |
| `features.gpu_frustum_culling` | bool | Launch-time camera-cull gate. Off keeps meshlet expansion but makes it fail open (no frustum/cone/Hi-Z rejection). |
| `scheduling.async_compute` | `auto` / `off` | Device-wide queue policy. `auto` uses async compute when supported; `off` demotes eligible work to graphics without removing passes |
| `quality.occlusion.mode` | `off` / `single_phase` / `two_phase` | How occlusion works when the feature is on (default `two_phase`). Project Settings → Quality (editor viewport uses the same pipeline) |
| `quality.occlusion.profile` | `stable` / `balanced` / `performance` | Camera-motion policy for single-phase Hi-Z (default `stable`); two-phase ignores it. Project Settings → Quality |

**RT scene state is device-wide, not per surface.** One layer owns the TLAS, the RT instance / hit tables, and the bindless heap, so `renderer_prepare` runs RT preparation for the **device-primary surface only**. `renderer_common.rayTracingSceneReady` reports false on secondary surfaces (asset previews), whose ray-traced features resolve down to raster / screen-space through the normal availability path. Per-surface ray tracing would need per-surface RT scene state.

The direct-shadow spatial filter reuses the depth already read when selecting
its G-buffer receiver. Its depth, material and colour exponentials share one
exponential evaluation, and its normal power uses repeated squaring. Receiver
ownership, penumbra reach and kernel taps stay unchanged; the numerical
comparison tool is documented in [`tools/metal/README.md`](https://github.com/Flying-Rat/hikari/blob/develop/src/hikari/tools/metal/README.md#shadow-optimization-comparisons).

### Dual resolution and temporal reconstruction

Internal render resolution and presentation (output) resolution are separate domains:

| Domain | What sizes it | Used by |
|--------|---------------|---------|
| **Render** | `resolveRenderDimensions(output, reconstruction)` | G-buffer, lighting, motion, depth, pre-recon volumetrics path inputs |
| **Output** | Drawable / inset presentation size | Temporal history ping-pong, bloom, tonemap, UI, editor composite |

`RenderPipelineConfig.reconstruction` is the source of truth:

| Field | Values | Role |
|-------|--------|------|
| `provider` | empty, `com.hikari.internal_temporal`, or any plugin-owned id | Selected temporal path. Vendor ids are data, not engine enum cases |
| `preset` | `native_aa`, `quality`, `balanced`, `performance`, `ultra_performance`, `custom` | Scale of render vs output (`native_aa` = 1.0; `performance` = 0.5; …) |
| `custom_scale` | 0.25–2.0 | Used only when preset is `custom`. Below 1 renders smaller and reconstructs up; above 1 supersamples and the resolve filters down (vendor providers are capped at 1.0 — a scaler reconstructs upward only) |
| `sharpness` | −1, or 0–1 | Post-reconstruction RCAS strength. Negative (the default) is automatic: a mild base for the internal resolve, ramping with the preset's upscale ratio (the render allocation, so governor steps never move it), gated per pixel by the resolve's accumulated frame-equivalent count, and zero under a vendor resolve. An authored value applies everywhere including over a vendor resolve, and 0 means off |

Render scale is a percentage of the **physical output pixel grid**, independent of OS DPI or UI scale. `native_aa` is 100% on both a conventional display and a Retina display. macOS converts view points to backing pixels once at the native window boundary; reconstruction must neither multiply those pixels by backing scale again nor divide them down to logical points. Windows PMv2 client dimensions are already physical. Moving between displays updates presentation metadata and resizes render targets when physical drawable dimensions change.

Supersampling is explicit: select the internal temporal provider, `custom`, and a `custom_scale` above 1. There is no display-density floor or `reference_density` setting. The governor works inside the authored allocation, so `native_aa` with `min_render_scale = 0.6` spans 60–100% on every display; an explicit 200% ceiling spans 120–200%. Output-sized temporal history and UI remain at the drawable resolution. This follows the separation of screen percentage from presentation DPI described in [Unreal's screen-percentage documentation](https://dev.epicgames.com/documentation/en-us/unreal-engine/screen-percentage-with-temporal-upscale-in-unreal-engine). Unreal additionally offers editor-specific automatic resolution and DPI policies; Hikari uses the project's explicit preset and frame governor rather than implicitly selecting one of those editor overrides.

`resolveReconstruction` produces `ResolvedReconstruction` (`requested_provider`, `effective_provider`, render/output dims, `reset_history`). Live changes use `GraphicsUpdate.render.reconstruction`; the session resolves the patch into one complete pipeline transaction.

**Internal temporal (TAA / TAAU):** when `effective_provider` is the built-in `com.hikari.internal_temporal` key, the graph runs `temporal_reconstruction` after transparency. Volumetric fog composites first in the render domain, where its depth and color share the exact jittered raster sample; transparent forward shading applies the same medium independently, and temporal reconstruction then filters both. The fog composite writes opacity into color alpha. Temporal reconstruction keeps that signal separate from transparent coverage: opacity reduces the final history color only while reprojection indicates motion, but never shortens the stationary geometry accumulator. At scale 1.0 this is native-res TAA; at scale &lt; 1 it is mixed-res TAAU (render-res current/depth/motion → output-res history); at scale &gt; 1 it is supersampling — the raster is *larger* than the display and the same 3x3 reconstruction filter widens its footprint to one output pixel (`filter_span`) so every extra sample is averaged in rather than dropped. Supersampling is the internal resolve's alone: `resolveReconstruction` caps a vendor provider at native, since an upscaler reconstructs upward only. Any scale &lt; 1 also biases geometry material reads by `log2(render/output)` mips through derivative-preserving `geometry_sample_bias` / `SampleBias`. The value is published per frame, so governed resolution changes keep texture LOD tied to the fixed display grid without rebuilding D3D12 static-sampler root signatures; the same shader path supplies Metal parity, where the sampler descriptor has no LOD-bias field. The internal resolve is followed by the `sharpen` RCAS pass. RCAS filters resolved RGB independently of depth: subpixel coverage can change the current depth owner while accumulated color stays stable. Depth-gated tap substitution would turn that metadata change back into visible flicker. Signed depth is preserved in alpha, and the final HDR neighborhood clamp bounds sharpening overshoot.

**Internal TAAU history:** geometry supplies the corresponding previous rendered pose's view depth, including object transforms, skinning and authored deformation; transforming the current position by the old camera is insufficient for radial object motion. History rejection compares that depth against individual history texels, never a min/max interval spanning unrelated surfaces. Thin-feature coverage bridging remains a separate bounded lock policy. Every current reconstruction and edge-search tap stays inside the active render rectangle, including during dynamic resolution changes.

Color history remains RGBA16F. Stationary accumulation is bounded to 32–64 samples according to stability, with shorter moving windows; explicit unbiased stochastic rounding to adjacent FP16 values prevents small updates from disappearing at storage. Depth and classification remain deterministic. Radiance changes release history even under a live stability lock. Transparent passes supply a separate previous-depth/valid-coverage guide; particles without a retained previous rendered pose mark it invalid and use current reconstruction instead of inventing a static correspondence. CPU numerical regressions execute the production scalar shader policy: `src/hikari/tools/shader-tests/run --test temporal_reconstruction`.

**History color reconstruction:** the nine-fetch Catmull–Rom kernel reconstructs accumulated RGB coverage continuously. History alpha stores one surface's depth, while RGB can already contain foreground/background coverage from several jitter phases. Rejecting individual color taps by alpha, renormalizing that subset or substituting a nearby owner changes the coverage kernel and caused stationary shimmer. Depth still validates actual footprint samples for history admission (never an interpolated depth interval); bounded coverage locks and current-frame rectification control reuse. A completely unsupported footprint contributes no history color.

The current-sample Gaussian and history sampling retain their established evaluation order. Even a nominally exact-center point-fetch shortcut changed stationary coverage in the 33% scale regression; output UV equality alone is insufficient to guarantee identical finite-precision filtering. Statistics remain inline: a measured 8×8 tiled-compute prototype with a shared 10×10 source halo and two RGBA16F mean/sigma transients did not consistently reduce total preparation-plus-resolve time on the tested Apple M5 Max, including 50% and 33% scales at 1080p/4K. It is not part of the production graph.

**Flicker rejection:** the existing three-sample luminance analysis follows the temporal-periodicity approach described in [Epic's TSR documentation](https://dev.epicgames.com/documentation/unreal-engine/temporal-super-resolution-in-unreal-engine). Its input validity is independent of accumulated color count: shortening accumulation does not invalidate the two stored analysis lumas. Proven periodic luminance attenuates shading reactivity as well as supporting the stability lock; otherwise the same jitter phase that earns protection immediately resets accumulation and removes that protection. Depth, motion and transparency still gate the evidence, monotonic lighting changes remain reactive, and unsupported locks expire. This uses the existing state and pass, with no new surfaces or history-color filter.

The offscreen [sequence benchmark](https://github.com/Flying-Rat/hikari/blob/develop/src/hikari/tools/metal/README.md#temporal-reconstruction-sequences-macos) compares HDR reconstruction and production RCAS/ACES display output against a baseline. Bright and dark wires at 33%, 50% and 67% scales, diagonal detail, removed features and lighting steps use ratio-dependent jitter periods. Separate flicker, supersampled-reference error and time-averaged contrast gates distinguish stable reconstruction from simple detail loss. Product captures remain the authority for scene quality and total frame cost.

**Provider seam (Zig-only):** plugins register a stable, plugin-owned id via `hi.render().registerReconstructionProvider` with Zig `on_config` / `on_frame` callbacks. Authored configuration stores the id as a string; composition hashes it to a POD key, and registration computes the same key. The engine has no vendor provider enum or method table, so XeSS, DLSS, or another adapter can be added without changing engine source. The temporal pass fills a `ReconstructionFramePayload` with **native** handles (Metal: `id<MTLTexture>` / `id<MTL4CommandBuffer>`; D3D12: `ID3D12Resource*` / `ID3D12GraphicsCommandList*`), dimensions, jitter, frame delta, and camera projection parameters, then dispatches `on_frame` before internal TAAU. Both reverse-Z device depth and linear view depth are exposed because vendor APIs use different depth conventions. Reconstruction outputs carry an explicit backend usage bit; on Metal this creates the `ShaderRead | ShaderWrite | RenderTarget` texture MetalFX requires without adding shader-write usage to unrelated render targets, while D3D12 adds UAV capability for FSR. A matching `.handled` callback replaces encode (and must write `output`). `.failed` warns once per surface/provider until a successful encode; adapters log the vendor-specific reason. `.skip`, `.failed`, or a missing selected provider falls back to internal TAAU. Config (provider / preset / scale) is pushed on every live change and at register. Private C/vendor SDKs stay behind plugin Zig glue.

**FSR 4.1 (Windows/D3D12):** `hikari-games/example/plugins/fsr4/` wraps AMD FSR SDK 2.3.0's signed FidelityFX loader and FSR Upscaling 4.1.1 provider. It caches the upscaler context across scale and preset changes, chains `ffxCreateContextDescUpscaleVersion` (`FFX_UPSCALER_VERSION`) at context create, supplies Hikari motion/jitter in FSR pixel conventions, passes the reverse-Z camera convention, and encodes on the open D3D12 graphics command list into the render graph's UAV-capable reconstruction output. **Hardware depth** is host-guaranteed sampleable on D3D12 (`R32_TYPELESS` + `D32` DSV + `R32` SRV — same layout as the shadow atlas; pure `D32_FLOAT` cannot carry an SRV and will TDR if a vendor binds it). Resize recreates the context at the renderer's GPU-idle boundary; unavailable hardware (FSR 4.1.1 requires AMD RDNA 3 discrete / RDNA 4+) or failed dispatches retain internal TAAU fallback. The package advertises `supported_os: ["windows"]`; on macOS its Zig wrapper registers nothing, stages no native artifacts, and Project Settings marks it incompatible.

**MetalFX Temporal (macOS/Metal):** `hikari-games/example/plugins/metalfx_temporal/` registers only when the active Apple GPU reports Metal 4 FX temporal-scaler support (`supportsMetal4FX:` / `supportsDevice:` plus a successful `MTL4Compiler` create). It borrows the current render targets and shared frame `MTL4CommandBuffer`, converts Hikari's de-jittered NDC curr→prev motion and NDC projection offset to MetalFX pixel conventions, supplies reverse-Z device depth, and encodes via `MTL4FXTemporalScaler` (`newTemporalScalerWithDevice:compiler:` + `encodeToCommandBuffer:`) into the output history. MetalFX 4 signposts that encoder with `globalTraceObjectID`; Metal 4 debug wrappers omit the selector, so the native bridge installs a forwarding shim (`hikari_mtl_debug_install_global_trace_id_shim`) before the vendor encode — otherwise a Debug build with the validation layer on aborts on the first MetalFX frame. The same shim scores those vendor encoders as having side effects; the debug layer does not, and would otherwise log `endEncoding called for an encoder with no side effects` every frame. The scaler and compiler are cached by device, formats, and the fixed input/output envelope; steady-state dispatch performs no adapter allocation or texture copy. MetalFX dynamic input-content properties carry the envelope and only the valid content extent changes when the frame governor steps. A scaler is recreated only after an allocation / output / policy-envelope / format change and requests asynchronous internal pipeline compilation. Unsupported ratios/devices and creation failures return `.skip`; invalid frame inputs return `.failed`; a non-`MTL4CommandBuffer` payload returns `.skip`; all fallback paths reset MetalFX history before it resumes.

### Plugin post-process passes

Typed post-process is a **separate** contract from reconstruction. Plugins register fixed insertion points via `hi.render().registerPostProcessPass` (game thread, typically `onLaunch`). `pass_registry.Kind` stays closed; the host owns a `PostProcessRegistry` and inserts graph passes around existing closed work.

| Slot | When | Color domain |
|------|------|--------------|
| `before_reconstruction` | Before internal/plugin temporal | Render-res HDR |
| `after_reconstruction` | After temporal (or no-op section) | Output HDR if temporal on, else render |
| `before_tonemap` | After bloom (if any), only if tonemap runs | Output HDR |
| `after_tonemap` | Immediately after tonemap (only if tonemap runs) | Display-referred LDR |
| `before_ui` | Near-final display color after debug visualizers and before UI | output |

Execute callbacks run on the **render thread** with:

- Frame-scoped opaque `RenderTextureRef` (not raw MTL/D3D pointers)
- Dims + `color_domain` on the payload
- Host encoder utilities — v1: `drawFullscreenTint(rgb, strength)` via the composite PSO (display slots: `after_tonemap` / `before_ui`; no plugin shaders). HDR slots identity-copy only when plugins skip (bloom_composite PSO).

Host **ping-pongs** `color` writes through intermediates. For `after_tonemap`, tonemap is redirected into a sampleable BGRA8 intermediate (matches tonemap/composite PSO; present swapchain is never sampled). Sample package: `hikari-games/example/plugins/host_tint/` (`com.hikari.sample_host_tint`).

A skipped plugin gets an identity copy. Missing fallback textures or pipelines and host encoding failures propagate to the frame transaction, including errors a plugin catches from a host callback. An unwritten ping-pong target is never reported as a successful pass.


The registry permits at most eight passes globally and four in one slot. Frame assembly performs an allocation-free, conservative transient-cost preflight (RGBA16F for HDR slots, BGRA8 for display slots); Metal and D3D12 currently expose a 256 MiB plugin-PP budget. Rejection disables the whole plugin chain for that frame, avoids forcing HDR solely for plugins, and publishes a coherent `postProcessDiagnostics()` snapshot. Each graph node uses its stable plugin id as the profiler label.

Project Settings → Rendering exposes the reconstruction provider id, render scale preset, and custom scale as live knobs.

Motion is intentionally responsive across the temporal stack. Correctly
reprojected camera motion keeps a bounded 4–16 sample window in the final TAA
resolve; reducing it to one or two samples makes sub-pixel geometry, stochastic
LOD fades, and low-resolution transparency expose the jitter sequence instead
of converging. Geometry/radiance rejection still collapses true disocclusions
to one sample. Transparent composition uses a short window capped at four
samples when fully covered rather than disabling history. Each stochastic
effect owns its response: AO falls from a 0.92 stationary ceiling to 0.85 in
motion, SSGI/direct fall to 0.70, and RT GI pairs a responsive short history
with its dedicated stable long-tail policy. Stationary opaque pixels retain the longer integration
needed for noise and Halton convergence.

TAA jitters current/previous projections (Halton, perspective cameras only),
then reprojects in UV space with nine-tap Catmull-Rom history reconstruction,
scale-aware **YCoCg rectification**, depth rejection, and output-resolution color
+ state history ping-pongs. Current color deliberately filters across coverage
edges: a low-resolution raster contains point coverage, so depth-gating color
taps turns a sub-pixel cable into a binary hit/miss sequence instead of
reconstructing fractional coverage. Depth remains the authority for motion,
surface identity, and disocclusion—not for the color kernel.

When upscaling, the 3×3 render taps are weighted by `exp(-2.29 d²)` of each
jittered sample's distance to the output pixel, with `d` measured in **output
pixels** (`temporal_kernel_span`, floored at 0.25). Measuring it in
render texels convolved the converged picture with the whole render-texel
footprint: at 50% scale that halved spatial detail contrast, which RCAS could
only partly restore. FSR 2 also measures in output pixels but caps the scale
at 1.99×; with nearest-sample admission weights that cap only cost detail and
stability at 33%. At exactly 1:1 the resolve keeps the jittered point sample,
which integrates a one-pixel box over a period and is sharper than any kernel
that keeps the anti-aliasing.

Render textures can be larger than the active picture under the frame governor.
Addressing current color/depth/motion therefore uses allocation texels, while a
render pixel's footprint in output history uses picture texels. The history
probe covers the coarsest of the output, current active-render, and previous
active-render grids: history was produced by the previous grid, so using only
the current extent still made coarse-to-fine governor steps reject legitimate
coverage. When a jitter phase misses a sub-pixel primitive, the resolve searches
compatible current and provisional-history depth footprints and borrows motion
only from the current tap at that depth. Native resolves skip the search:
it cut moving-silhouette error by a third in the headless harness but cost a
quarter of the native resolve on edge-dense content, because every SIMD group
holding an edge pixel pays for it.
Both hit→background and background→hit results become one bounded
`coverage_transition` fact, which is evidence for the instability lock below.

Geometry admission compares the current surface's previous-pose depth with the
history depths at the reprojected point **and** its footprint corners. History
stores one depth per output pixel, the depth of whichever jittered sample was
nearest, so it is phase-dependent wherever sub-pixel geometry meets the pixel.
The corners alone land in the neighbouring pixels whenever a render texel is
wider than an output pixel; at non-integer scales those neighbours disagree with
a thin feature and rejected it on most phases (a 1 px wire never accumulated at
67-90%).

**Input statistics and the instability lock.** Jitter makes sub-pixel detail
come and go between phases; at fractional coverage whole phases miss a thin line
entirely, so the current 3×3 holds no trace of it. Absence of evidence must not
read as change. Each output pixel therefore keeps a running mean and mean
absolute deviation of its own admitted input luma (state y/z), and a lock (state
w) that is a one-jitter-period timer of evidence that the input wanders through
sampling alone: a 3×3 luminance ridge (FSR 2's thin-feature test), a coverage
transition, or the input **crossing its own mean** (the side is the sign of w).
Oscillation keeps crossing; drift — a lighting ramp, a removed feature — moves
to one side and stays, so the lock expires within one period and ordinary
rectification resumes. While the lock is live:

- radiance reactivity fires only beyond three observed deviations (and the
  spatial σ threshold), so a dropout phase is not a shading change;
- rectification may keep history up to 2.5 observed deviations outside the
  current neighbourhood box — bounded by what the pixel's input has done, never
  an unclipped history;
- a depth disagreement with history is bridged in proportion to the observed
  deviation, because a pixel whose input flips between surfaces disagrees with
  its single-depth history by construction.

The statistics keep running through a geometry rejection: a pixel whose depth
flips every phase (foliage at native) would otherwise be rejected forever and
never prove that the flip is sampling. A rejected frame writes the current
surface into history, so nothing stale rides on them; a pixel that was stable
before a real disocclusion has no deviation to bridge with. Edge taps mirror at
the picture border instead of clamping, so a border pixel never counts its own
sample twice (a fabricated solid quadrant for the ridge test).

Two precision rules keep grey content stable: the clip box extent is floored at
FP16 precision of the compressed signal (a flat chroma axis otherwise lets
rounding noise scale the whole clip toward the box centre, luma included), and
stochastic FP16 rounding uses one draw for all three channels so grey history
stays grey. Diagnostics decode this exact state rather than re-running depth or
luma policy. When a vendor reconstruction provider handles the frame, TAA
diagnostics explicitly report no internal state; the provider writes color but
does not own this codec, and a persistent target left from an earlier internal
frame is not evidence.

The camera is the single source of the jitter period, passed unchanged through
`reconstructionParams.z`; the shader does not derive or cap it again. The exact
FidelityFX sequence is 8 at native/mild ratios, 18 at 1.5×, 23 at 1.7×, 32 at
2×, 72 at 3×, and 128 at the supported custom 4× ratio, taken at the governor's
narrowest extent (`render_min_dims`): a resolution step changes the jitter
amplitude but never the period, because `index % phases` restarts mid-cycle and
clusters samples whenever the length moves. The sequence advances with surface
history writes rather than the host frame counter, so a dirty-only editor
viewport cannot skip phases while idle.

Valid stationary surfaces use a true running integration up to a 32–64-sample
stability-dependent ceiling. At native and supersampled scales every phase
enters as one sample; upscaling, each frame enters at the kernel weight of its
jittered sample nearest the output pixel (`temporal_current_sample_weight`,
floored at 0.05), so a phase whose nearest sample sits far from the pixel
cannot swing thin geometry; reactive pixels take a full sample. The persistent
state stores **frame-equivalents**: accumulated weight divided by the expected
weight of an average frame at the current kernel span
(`temporal_expected_sample_weight`, FSR 2's average Lanczos weight per frame),
so motion caps, lock lifetime and the RCAS gate count frames at every scale and
across governor steps, and a reset pixel restarts at exactly one. The still
ceiling counts information (32–64 full samples, i.e. divided by the expected
weight); moving windows count frames. The cap is continuous in velocity: the
still window fades into the motion window over the first quarter render texel
per frame, so slow pans and float noise in object motion cannot flip a pixel
between two history lengths. The instability lock owns history admission and
rectification, not a second accumulation-window policy: visible motion remains
on the single absolute 4–16-frame reprojection-error bound, and transparent
composition can only shorten it. Disocclusion, radiance/rectification
reactivity, cuts, and provider fallback release it. Ordinary settled history may
relax variance clipping only to the current 3×3 AABB; the lock's band widens that
box by the observed deviation, and the final HDR guard expands only to the
history it admitted. When the lock expires, ordinary current-neighbourhood
rectification resumes immediately.

**Motion vectors are computed in the fixed temporal-output grid**: exact
current/previous jitter offsets are removed before perspective-correct
interpolation. History is the previous resolve output, not an untouched
jittered raster; retaining `J_prev - J_curr` in velocity would make static
content walk once per Halton phase. Render-domain denoiser histories (AO,
reflections, GI, RT direct) are the opposite case: each texel holds its own
frame's jittered sample, so `hikari_render_history_uv` moves the sample point
(`uv + J_curr − motion`) and subtracts `J_prev` to find the texel that saw it;
plain `uv − motion` compares two points a jitter step apart and rejects
sub-pixel features every other frame. Transparent forward draws emit corrected
motion, linear depth, and composition weight. Engine raster targets and
pipelines are single-sample. Resize, viewport, camera discontinuity, and live
mode changes invalidate history; reconstruction plugins receive the same reset
contract.

AO and reflection modes use independent temporal policies. Their guide representation is shared with direct shadows, SSGI, and RTGI: RG stores the octahedral world normal, B stores positive linear view depth, and `abs(A)` stores the **accumulated frame count** on a fixed scale (1.0 = `HIKARI_TEMPORAL_CONVERGED_FRAMES`, 32). Negative A marks reactive history without discarding the count. Every chain blends as a running mean (history of n frames takes weight n/(n+1)), every retention ceiling is applied as a cap on n (`hikari_temporal_accumulate`), and the stored count is the count the applied blend implies plus the new observation — so a lighting change or a motion ceiling that shortens history is visible to the spatial filters, which size their reach from the same number (`hikari_temporal_blur_scale`, reach ∝ 1/√n; the cascade's wide stages gate on it; the direct and reflection resolves scale their kernel by it). The RT GI probe ray taper reads the same count (full rays under four frames, the floor past eight). Signal alpha is deliberately outside this contract and remains estimator-owned metadata.

Screen-space AO integrates a visibility bitmask (below); RTAO and SSAO feed a common RG16F moment history `(mean visibility, second moment)`. AO's dedicated temporal entry gathers a five-tap scalar neighborhood, weights every tap by depth and normal before computing moments, clamps visibility to [0,1], and accumulates with a sample-independent retention ceiling (0.92 stationary, 0.85 in motion). A stationary receiver replaces at least 95% of stale visibility in 37 updates even if only its occluder moves. Geometry rejection still resets immediately. The binary neighborhood never clips history or changes retention: an all-clear/all-blocked cross is valid Monte Carlo evidence, not proof of a lighting change. Explicit stochastic FP16 rounding of both moments prevents storage truncation from introducing a persistent dark bias. RTAO traces one cosine-weighted ray per half-res texel, stratified over a 16-frame ring (`hikari_stratified_ring_sample`) with a per-pixel blue-noise rotation re-seeded each ring, and stores the ray's **nearest** occluder distance beside its visibility (`ambient_occlusion_raw` RG16F: R visibility, G reach in metres, unbounded for a clear ray; SSAO writes unbounded). The reach comes from a closest-hit traversal (`hikari_rt_material_nearest_occluder_distance`): an any-hit query may stop on a farther occluder and let the cascade widen across the crease it missed. The ray leaves the triangle, not the normal map, using the shadow rays' origin model: the origin is displaced along the depth-derived geometric normal by the reconstruction's own precision (`ray_origin.akari`), with no explicit near bound, and lobe directions that a normal map tilts under the real surface are mirrored into its open side. A centimetre origin bias hid every occluder within about 5 cm, and rays under the surface counted the receiver's own triangle as occlusion.

Both AO producers then share half-resolution scalar a-trous stages at strides 1, 2, 4, 8 and 16 — the first three, four or five by `quality.ambient_occlusion` (low 3, medium 4, high/ultra 5; `AmbientOcclusionQuality.atrousStages`), and a stage whose stride exceeds the nearest occluder's reach on the grid returns the centre, so a crease is never averaged into the open surface around it. The stages propagate that **temporally accumulated** estimator variance and gate every tap by receiver depth, normal, and variance-scaled visibility; stable receivers return after the center fetch, and the two wide stages return the centre for any receiver whose temporal guide already counts eight accepted frames (`hikari_temporal_wide_stage`), so they cost a guide read per texel on a still frame and reach ±31 texels (±15 at four stages, ±7 at three) for a receiver that was just reset.

AO retains a scalar visibility/moment contract. A directional output would require its own storage; the temporal second-moment channel is required for variance reconstruction.

Both GI producers have a cascade too, so `usesAtrous` is true for GI in either mode and the fused AO+GI kernel serves screen-space GI as well as ray-traced; the estimator plane is ray-traced GI's alone and its absence no longer refuses the fusion. The fused kernel shares exact receiver-footprint results in a 4 KiB threadgroup tile at strides 1, 2 and 4; strides 8 and 16 retain direct guide loads. Padded lanes participate in the halo/barrier before returning, and all tap weights, sample counts, confidence gates and FP16 stage boundaries stay unchanged.

AO and GI tiers set their cascade depths independently. The fused kernel covers the shared prefix (the smaller depth) and the deeper chain runs its remaining wide stages alone on the raster path, so only its intermediates also carry colour-attachment usage. Every chain's ping-pong is counted back from its last stage (`temporal_effects.atrousRoute`), so the last stage always writes `atrous_a` and the resolves read it at any depth; when the depths differ by an odd count the halves of one fused stage write different planes.


The final full-resolution resolve is conservative at coverage boundaries: if none of the four half-resolution owners represents the current surface, AO fades to neutral visibility instead of borrowing another face and blinking as jitter changes coverage.

SSR/RT reflections use RGBA16F and their radiance-reactive filter, which reprojects history through the virtual reflected point (`hikari_reflection_virtual_distance` in the resolve output's alpha) instead of the mirror surface. Raw screen-space signals use a deterministic half/quarter trace resolution owned by each effect's tier (`quality.ambient_occlusion`, `quality.gi`, `quality.reflections`); their full-resolution outputs and geometry/confidence guides stay unchanged. AO multiplies the filtered visibility by material AO, lifting only the pass's estimate by the multi-bounce series (`hikari_ambient_diffuse_occlusion`: `multi_bounce(pass) × baked`, never `multi_bounce(pass × baked)`, which brightened an authored 0.3 cavity on a white material to 0.76 whenever the pass ran). Ambient-kind lights take that same term on the raster, RT-shadow and forward paths; reflections filter prefiltered radiance, then apply Karis `hikari_env_brdf_approx` from the full-resolution G-buffer at composite so metallic sky specular cannot bleed onto neighboring dielectrics.

SSAO and SSGI are 8x8 async-compute producers; both integrate visibility bitmasks over the same Hi-Z through one shared module (`screen_space_bitmask.akari`: slice frame, sector mapping, per-sample arc, the closed-form cosine weight, and the fresh-bit accounting). What differs is the range — a metre against thirty — the step density inside it, and what a newly occluded arc means: AO subtracts the arc from the open hemisphere, GI fills it with the arc's radiance.

Sector centres decide coverage, and every occupied sector contributes its full cosine-weighted measure. AO integrates the completed union once per slice; GI integrates fresh contiguous runs as it assigns radiance. Both integrate analytically, so overlapping or nested samples have the same total visibility weight in either order; each sector is charged at most once per slice. This is a 32-sector angular approximation, not exact fractional-interval coverage. Both producers use parallel camera rays and depth-independent projected radii for orthographic cameras; sample thickness extends along those same rays. AO denoising converts world-space occluder reach with the matching projection rule.

The AO march (`hikari_ao_bitmask_sample`) has three rules of its own:

- **Noise is indexed by the producer's output pixel**, never by the Hi-Z texel under the receiver. At the default quarter resolution the half-resolution Hi-Z lattice would be read every other texel, and the lattice's x step doubled is 0.51: neighbouring pixels alternate by half a slice period instead of spreading.
- **Visibility is normalized by the same slices' open measure** (`Σ proj_len · open weight`), not by the slice count. The projected-normal weighting integrates the hemisphere only on average: over two slices an open receiver tilted 80° from the view reads 0.86–1.10, and the clamp to 1 darkened its mean by 3.5%. The ratio reads exactly 1 when nothing occludes.
- **A Hi-Z footprint no nearer than the receiver's own plane is not an occluder.** A coarse step reads the footprint's min depth but places it at the step's UV, so on any surface whose depth varies across the footprint the receiver's own plane rises above its tangent. That darkened an open floor by 2–4% at 2–5 m. The tangent plane's nearest depth over the footprint's pixel centres is closed-form (perspective depth is `n / d(uv)` with `d` affine; orthographic depth is affine), and a min no nearer than it, less the self-hit tolerance, is skipped. Real occluders stand in front of that bound: in a floor-against-wall fixture, a receiver 10 cm from the wall reads 0.683 against an exact 0.695 (0.669 before).

Their marches stay separate because a shared one would have to serve both ranges and the near field is where AO spends all of its samples; at the default `medium` tiers they are not even on the same grid (AO quarter, SSGI half resolution). One lane per workgroup classifies the covered tile from the shared min/max linear-depth pyramid; sky-only groups write their neutral value without entering the march. Both raw producers are registered before either raster reconstruction chain, keeping them contiguous on the compute queue and avoiding a compute→graphics→compute queue ping-pong. SSR consumes the same pyramid but remains after deferred lighting because its trace reads lit scene colour; moving it earlier or pretending it is independent would sample an incomplete frame. SSR, SSGI, and contact shadows (raster or hybrid, `quality.contact_shadows` ≠ off) use one typed hierarchical traversal (`screen_space_trace.akari`): clip projection, coarse free-flight, min/max slab tests, mip refinement, full-resolution binary search, and termination classification live there. A proved range miss is distinct from off-screen, near-plane, invalid-origin, and step-budget exits, so consumers cannot turn an unresolved ray into sky visibility. The result also carries hit UV/depth, travel, confidence, depth disagreement, last mip, and a roughness-cone footprint. Effect profiles tune the physical range/slab and quality budget without duplicating traversal.

**Reflections are stochastic.** Both producers are compute kernels writing one raw contract per half-res pixel: the radiance along ONE GGX visible-normal sample (visible normals sampled as a spherical cap in the stretched configuration, alpha = roughness²) and its hit distance in alpha.

The screen march spends `quality.reflections`' step budget (24/40/64/64, clamped by the trace's own 64-step loop bound, which `render_config/reflections.zig` mirrors); fewer steps shorten how far a reflection reaches before the far field fills in, so the tier trades reach and convergence rather than brightness. Both budgets travel in the producer and resolve pass params via `LightingTargets.reflection_budget`, resolved once at assembly. Roughness up to `reflectionParams.x` (the `quality.reflections` ceiling `ReflectionBudget.roughness_ceiling`: 0.35 / 0.5 / 0.5 / 0.6, shared by SSR and RT reflections) is traced; `hikari_reflection_trace_gate(uniforms, r)` fades over the last 0.1 and lighting weights the prefiltered lobe by `1 - gate` and the final composite weights traced radiance by `gate`. Producers store unweighted radiance, so the transition retains constant brightness when both sources agree. A screen hit is read from `scene_color_pyramid` at the mip its cone footprint covers; a miss takes the far field (the active sky environment) at half the roughness, since the neighbour resolve widens the lobe back. `reflections_resolve` gathers `quality.reflections` neighbours on a blue-noise-rotated spiral (4/6/8/12 taps; radius 1 to the tier's 2/3/4/5 texels by roughness/ceiling, the ring spread over however many taps the tier buys so a low tier still reaches full radius), regenerates each neighbour's direction and pdf from its G-buffer and the same blue noise (nothing about the ray is stored), forms the direction to its hit point from the centre, and weights by `f·NoL / pdf` (no Fresnel — the composite's EnvBRDF is Fresnel), clamped at 4 and gated by depth/normal/roughness agreement. Its alpha is the virtual reflected distance (hit distance faded to 0 over roughness 0.08–0.4); the temporal filter reprojects `P - V × a` (`V` points toward the viewer) through the previous camera and blends that with the motion-vector reprojection by `a / (a + 0.1)`, relaxing the depth gate under virtual reprojection (the history texel belongs to another point of the same mirror). Glossy metal receives real hits; there is no radiance floor in the composite.

Ray-traced reflections: on-screen hits may reuse already-lit scene color at the cone mip (hybrid); off-screen hits bindless-fetch materials and re-light at the hit (capped shadow rays + cheap IBL). Nested specular bounces (`RtHitConfig.specular_bounces`, primary inclusive, resolved from `quality.rt_hit`: 2 at `high`, otherwise 1) continue the mirror chain by swapping each hit’s IBL specular lobe for a traced child; values above 1 bypass hybrid reuse on smooth hits. Each surface is shaded when the chain reaches it; one that sends a further ray keeps only a compact parent record (its lit colour, env-specular contribution and the swap's BRDF inputs) until its child resolves, innermost first. The kernel never holds whole hit records for the chain, which also removed sporadic NaN-scale texels the old array-backed chain produced on Apple GPUs. Misses stay on prefiltered env. EnvBRDF is applied at the same full-res composite.

**Hit shading follows the reflection's footprint** (`reflection_hit.akari`). Each sample tracks the cone it stands for: one trace texel wide at the camera, widened by every surface's lobe (2α) along the chain. A hit whose cone is narrower than the finest surface-cache cell (25 cm) is shaded exactly, so mirror reflections keep sharp hit shadows. A wider cone cannot show more of the hit's diffuse direct light than a cell average, so with RT GI active it reads the GI surface cache at the cone's level (albedo multiplied back in) and posts a request on a miss, then shades exactly. The hit's specular response to lights still pays shadow rays, but only a quarter of the exact path's picks, drawn by a Phong-equivalent lobe around the hit's mirror direction (a floor term keeps every contributing light drawable); blurred metal hits take the same specular path without the cache. Ambient lights, sky and emission are evaluated per hit on every path. All paths are one light-walk call with runtime switches: two walk instantiations in one kernel doubled RT reflection cost through register pressure and made Apple GPUs nondeterministic, and a full BRDF evaluation inside the candidate scans cost more than the picks it saved. `surface_cache_update` therefore runs after the reflection producer. Frame counters `reflection_cached_shades` and `reflection_cache_misses` report the split; synthetic measurement (`rt_reflections_bench`, 8 hit-light samples) puts a cached hit 15–20% cheaper than an exact one, while the two candidate scans over the bounded lights remain the larger share of hit cost.

RT GI and RT reflections shade hits through `GpuMaterialRecord[instance.material_index]`. That index is the object-data handle id (same row the G-buffer uses). The game-published `ObjectData.instance_id` is not that id — prepare stamps the handle onto the RT source list. If it is left 0, every hit samples the white default row: warm sun bounce on the whole scene and blocky chrome, which clears when Hardware RT is off (raster G-buffer) or after Stop rebuilds the RT tables.

Authored cubemaps use split-sum image-based lighting (IBL): GGX-prefiltered specular mips and L2 diffuse irradiance coefficients cooked by Shinra, with `hikari_env_brdf_approx` at runtime. The procedural atmosphere instead supplies a generated environment cube whose cosine-convolved tail carries diffuse irradiance. Specular env samples use a roughness floor (0.08) for mip selection and EnvBRDF, a luminance + soft channel knee, dielectric albedo occlusion, and horizon occlusion so HDR probe/sun discs and dark mirror cables cannot firefly. Legacy cubemaps without the `ibl_split_sum` trailer fall back to last-mip diffuse.

There are no placed reflection probes: past the reflection trace gate, where no SSR/RT producer answers, lighting leans the prefiltered sky lobe toward the GI gather's irradiance (E/π, by GI confidence and by how far past the gate the roughness sits), so a rough interior surface stops mirroring a sky it cannot see. Below the gate SSR/RT reflections own specular. AO is an `R8` pre-light visibility buffer and attenuates ambient/IBL without rewriting HDR scene color.

Directional **raster deferred** lighting multiplies VSM by a **short-range screen-space contact** pass (`contact_shadows.akari`) after the atlas sample: ~6-15 cm world ray only, capped screen-space thickness, temporal interleaved-gradient-noise ray dither, shared Hi-Z confidence, and a distance fade to zero by ~22 m so far city facades stay pure stable VSM.

The contact profile caps hierarchy descent at mip 2 because its entire ray covers only a few pixels; this replaces 8–14 fixed full-resolution depth fetches with the same coarse/refine traversal used by SSR and SSGI. The short ray and the distance fade are load-bearing: an unbounded march with depth-scaled thickness degenerates at city distance, where the whole ray falls inside one depth texel and every surface not facing the sun reads as occluded - that printed sun-aligned black streaks that swam with the camera and was long mistaken for a VSM defect. VSM supplies the underlying geometry visibility. Only `evaluate_lighting` (deferred) applies it; `evaluate_lighting_forward` and the RT paths do not (rays already resolve contact).

GPU culling uses resident local bounds transformed into the camera's frustum and Hi-Z space. Cuts and resizes disable Hi-Z for a frame while retaining frustum culling; in `single_phase` the motion profile also disables it on camera movement. Camera and shadow submission both use the mandatory meshlet path described in [Compute-driven meshlet submission](/docs/systems/rendering.md#compute-driven-meshlet-submission); shadow faces select their own work because off-camera geometry may cast visible shadows. There is no direct-draw fallback when meshlet preparation fails.

**Four controls (do not conflate):**

| Layer | Control | Role |
|---|---|---|
| Feature | `features.gpu_frustum_culling` | Launch-time camera-cull policy; meshlet submission itself is unconditional |
| Quality | `quality.occlusion.mode` | `off` (frustum only), `single_phase` (frustum + Hi-Z), `two_phase` (predict + correct + late G-buffer) |
| Motion quality | `quality.occlusion.profile` | Single-phase only: `stable` guards nearly all deliberate motion; `balanced` guards fast traversal; `performance` keeps Hi-Z active |
| Camera | `camera.occlusion` (`inherit` / `disabled` / `enabled`) | Whether *this* view uses occlusion; `disabled` forces frustum-only. Does not invent the feature or force Hi-Z when quality mode is `off`. Editor: camera inspector |

### Dual view (lens vs game camera)

Publish writes **two** camera snapshots into the render frame (never one overloaded “primary”):

- **`lens`** — image formation: G-buffer VP, TAA/motion, transparent sort, light clusters. Edit normally uses the neutral editor fly-cam; the camera toolbar button explicitly previews the primary camera and its authored optics. Possessed Play uses the game camera, while unpossessed Play returns to the neutral fly-cam.
- **`game_camera`** — player authority: visual-zone look sample, and **while unpossessed** both the directional clipmap focus and the GPU frustum cull. In Edit and possessed Play the clipmaps are centered to the `lens`, because the froxel grid, clusters and any raster shadow lookup all walk the image frustum; centering them to a primary camera that is not forming the image leaves everything outside its pages reading as unshadowed. HostApi `primaryCamera` / audio stay on the live primary actor.

**Unpossess cull-debug:** freecam draws the picture; frustum planes stay on the game camera so orbiting outside shows what the player view drops (missing geometry = culled). Hi-Z occlusion is forced off in that mode — the depth pyramid is from the freecam G-buffer and must not be mixed with game frustum planes. Possessed and Edit freecam keep cull = draw.

Visual-zone sampling follows the image-forming fly-cam in Edit, so spatial
looks preview where the author is looking. Play keeps player authority:
possessed and detached views both sample at the game camera, preventing the
inspection freecam from changing live fog or exposure.

Hi-Z footprint / bias / sample-pattern tuning lives in the cull constant buffer (`CullTuning` packed into `FrameUniforms`) as internal defaults — not project or inspector settings in v1.

**Depth pyramid addressing.** Level *k* texel *i* holds the min/max view depth of render pixels `[i·2^(k+1), (i+1)·2^(k+1))` of the active render extent, and the last valid texel of each axis also folds whatever a floor-halved level leaves past that span. Every reader addresses it by pixel shift through `hiz_pyramid.akari` (the cull with the extent the pyramid was built over, which is the previous frame's for predict; screen-space readers through `hikari_render_hiz`), never by `uv × dimensions(level)`. Level sizes floor-halve, so a UV lookup drifts toward the picture's centre near the right and bottom edges — at 1510 rows level 7 spans only the top 85% of the picture — and the cull used to reject meshlets there against depth that was not in front of them.

When quality mode is `two_phase`, occlusion runs in **two phases** on every continuous frame — camera motion included, since the correct phase repairs whatever a stale prediction rejects — because a single test against last frame's depth makes objects blink out whenever an occluder moves off them. The *predict* phase runs before the graph and samples the previous frame's pyramid reprojected with the previous camera; the *correct* phase (`occlusion_cull`) runs after the G-buffer and its pyramid rebuild, and re-tests only the instances the predict phase rejected on occlusion — using this frame's depth and the current camera. Survivors are tagged with the late bit in their visible meshlet records and land in the late bucket's own indirect arguments, so `gbuffer_geometry_late` draws them into the loaded G-buffer without re-issuing the first draw. A wrong prediction therefore costs one extra small draw instead of a missing object. Only uncertain Hi-Z rejects enter a compact correction queue; visible, frustum-rejected and cone-rejected pairs never enter it. Both phases use the same current-frame planes. Cuts, single-phase guarded motion, per-camera `disabled`, and dual-view inspection do not reassemble the graph; prepare skips Hi-Z and late CPU/GPU staging and leaves `isLateReady` false (empty correct/late graph passes may still run). `single_phase` / `off` size cull rings for one phase only; `mode == off` also omits the depth pyramid unless screen-space AO/GI/reflections, contact shadows (raster or hybrid, `quality.contact_shadows` ≠ off), or volumetrics need it.

**Effective resolution** (`resolvedForAvailability` / `resolved*Mode`):

- **Requested** = project/session policy.
- **Effective** = requested ∩ device support ∩ **RT scene ready** (non-empty TLAS after prepare) ∩ optional pipeline existence.
- Ray-traced shadows with the RT **master off** fall back to **raster** (a deliberate, persistent config state; `usesRasterShadows` makes the same config-level decision, so VSM is prepared on exactly those frames). With the master **on** but the TLAS **not ready** (transient), they fall back to **off** — no VSM pages were prepared that frame, so raster would sample nothing. Hybrid always prepares VSM and therefore falls back to raster whenever its TLAS, hit tables, or dedicated lighting PSOs are not ready. Ray-traced AO/reflections/GI fall back to screen-space; screen-space falls back to off if its pipeline is missing.
- Runtime HUD must expose **effective** modes (`RuntimeInfo.graphics`, including `ray_tracing.scene_ready`). Do not claim “ray traced active” from device support alone.

**Two layers (do not conflate):**

```text
ray_tracing master
  └── RT scene (TLAS/BLAS)     ← shadows, AO, reflections, GI  (usesRayTracingScene)
        └── RT material hits   ← shadows, AO, reflections, GI  (usesRtHitShading)
              bindless geometry/materials + HikariRtInstance
              reflections/GI additionally re-light full hits
```

| Layer | Cost when active | Consumers |
|---|---|---|
| **RT scene** | AS build/refit + visibility rays | RT shadows, RTAO, RT reflections, RT GI |
| **RT material hits** | Bindless geometry/material tables + alpha fetch; full re-light where required | RT shadows, RTAO, RT reflections, RT GI |

- Master **off** ⇒ no TLAS or RT effect cost. The renderer-global texture namespace remains active for raster materials.
- Master **on** + only shadows/AO ⇒ AS + RT material-hit tables for exact in-traversal alpha testing, but no full surface re-light.
- RT reflections need master **and** `reflections == ray_traced` **and** hit-shading ready (else SSR fallback).
- RT GI participates in the same hit-shading gate (`usesRtHitShading`); without master RT or scene readiness it falls back to screen-space (then off until SSGI availability is true).

**Shared scene (TLAS):**

- Built in `graphics/raytracing/raytracing_scene.zig` from the **opaque** draw list (not only shadow casters). Transparent draws stay out (FORCE_OPAQUE queries would hard-shadow glass).
- All RT effects share one TLAS / `frame_ready`. Empty opaque set ⇒ no RT scene ⇒ all RT modes fall back together.
- **BLAS geometry:** triangle soup and indexed draws. Imported rigid meshes preserve Shinra's welded vertices; only their triangle indices are spatially sorted during model decode. RT independently selects a stabilized LOD and divides its index slice into at most 32K-triangle BLAS ranges; cache identity includes the first vertex/index element. This prevents a material-wide city mesh from becoming one city-sized BLAS without multiplying raster draws or geometry storage. Indexed skinned geometry remains one refittable BLAS.
- BLASes are cached with delayed eviction; static BLASes are compacted after build. An unchanged instance set reuses its TLAS, transform-only changes refit it, and membership, mask, or BLAS identity changes rebuild it. Replaced acceleration structures retire after the frames-in-flight window.
- **Retained CPU roster and hit rows:** producer structure/batching/material receipts preserve the TLAS-ordered roster. Tracked object deltas patch their cached BLAS ranges; deforming draws alone revisit BLAS update policy. Untracked or skipped deltas, changed topology, and material changes rebuild. The hit mirror resolves each distinct geometry buffer once and tracks changed row receipts, including opacity buffer identity and triangle offset. GPU slots acknowledge their own sparse uploads only after success; missing receipts or changed heap ownership force a complete reseed.
- **Asynchronous structure submission:** `prepare()` requires begin, flush, and compaction-poll backend hooks. Builds publish valid uncompacted BLASes and complete through GPU receipts; later prepares read sizes only after those receipts signal. Profitable destinations enter bounded 384 MiB copy waves, and adopting them forces a TLAS refit before traversal. The CPU never waits for compaction. D3D12 uses compute batches with direct→compute input and compute→direct consumer waits. Metal publishes pending skinning inputs before a separate graphics-queue AS batch, gives each in-flight batch its own completion-owned allocator and scratch arena, and fences its TLAS consumer. Scratch ranges are disjoint within a batch, with an acceleration-stage barrier on arena wrap. Superseded scratch survives until its submission completes; old compacted-copy sources retire behind the frame ring. Residency telemetry includes every live scratch arena and retired source.

- Analytic-light shadow visibility (primary, hybrid, and secondary GI/reflection hit lighting) uses `ray_origin.akari`: a representable floating-point displacement along the geometric surface normal, following [Wächter and Binder, Ray Tracing Gems chapter 6](https://www.realtimerendering.com/raytracinggems/rtg/index.html). The offset scales with position precision (256 ULP steps, with a 1/65536 m near-zero fallback); it does not grow with grazing incidence. Fixed 3–6 cm offsets can jump past thin wall/roof geometry and produce rotating false visibility even with a stationary camera.
- Primary shadow receivers share the discontinuity-aware depth geometry reconstruction in `surface_geometry.akari` with GI probes. Secondary hits carry the actual triangle normal separately from the authored/interpolated and normal-mapped BRDF normals. This adds no G-buffer attachment or RT instance-table field; material lighting and authored foliage-normal policy stay separate from ray-origin placement.
- Depth-reconstructed primary/hybrid receivers also include a floating-point reconstruction margin before the representable offset: `32 * FP32 epsilon / (1 - 32 * FP32 epsilon)` times the sum of the maximum absolute world-position and view-translation components. A floor near world Y=0 can be reconstructed from camera-space values tens of metres large; an offset based only on the final Y coordinate can remain below its own triangle. The margin is computed once per receiver and shared by its light queries. It adds no ray or texture reads and leaves the secondary-hit policy independent. `akari`'s `ray_origin` regression executes the shader scalar IR on cooked street-scene floor geometry and checks that the corrected origin clears its own triangle while retaining a caster 0.5 mm above it.

- Shadow queries use an explicit zero near bound after displacing the origin. Opaque and alpha-tested visibility honor the same caller-provided interval on Metal and HLSL. Punctual rays aim from the displaced receiver to the sampled emitter point and exclude only the endpoint's final representable distance; there is no 4 cm light-end hole. The emitter ring and ray count are unchanged. AO and the reflection/refraction/GI transport rays retain their separate `raytracing_common` policy; their secondary analytic-light shadows use the shared visibility policy above.
- **Metal usage split:** `PreferFastIntersection` (static BLAS) must not be combined with `Refit`. Refittable BLAS/TLAS use `PreferFastBuild|Refit`. Compacted sizes are read back as 64-bit values through `writeCompactedAccelerationStructureSize:toBuffer:` (`MetalRayTracing.m`); a 32-bit readback corrupts the compact copy.
- **RT shadow lights:** `GpuLightRecord.casts_shadow` gates ray visibility (VSM `shadow_face_count` stays 0 when VSM is skipped). Directional ray length uses `radius_and_shadow.y`, packed from `DirectionalShadowConfig.rt_far` (auto = max of VSM `far` and camera projection far when available; independent of clipmap construction so large worlds are not clipped at the default 120 m VSM far). Punctual RT soft penumbra uses authored `source_radius` (metres) packed in `inner_cone_and_source_radius.y` when non-zero; otherwise falloff × `HIKARI_SOFT_POINT_FRAC`. Every RT ray toward a bounded light (surface, hit shading, particles, froxels) stops `shadow_near_plane` (0.1 m, `hikari_light_visibility_segment`) short of its emitter sample — the same shell the raster point/spot faces are built outside of — so a light authored inside its bulb or fixture is lit identically by both producers instead of being self-occluded under RT. A receiver inside that shell is unshadowed by that light under both.
- **Shadow mode split:** raster and hybrid prepare cached virtual shadow maps. Pure RT prepares no VSM pages: opaque lighting, froxel injection and engine forward transparency trace geometry visibility through the current surface's TLAS and alpha-cut hit tables. Off prepares no pages and traces no shadow rays. Cloud transmittance and bounded-medium extinction remain separate from geometry visibility. Typed fallback VSM resources may remain bound but RT consumers declare no VSM reads.
- **Transparent shadow policy:** `_engine/forward_lit_rt` traces direct-shadow visibility in pure RT, including refractive, additive and OIT meshlet lanes; raster/hybrid retain VSM visibility. Forward shadows use deterministic emitter-centre rays because transparent receivers have no independent visibility history. The companion also retains reflection/refraction behavior. Only the engine `forward_lit` package has an automatic companion; custom game packages must provide an RT-capable lighting path using `hikari_forward_light_visibility` and the callback-based forward evaluators. A raster-only custom package receives no geometry shadows in pure RT; it never promotes VSM preparation.

**RT material-hit data and full hit shading:**

- Owners: `residency/bindless_heap.zig` (pipeline-agnostic and shared with raster), `raytracing/raytracing_bindings.zig` (`HikariRtInstance` in TLAS order), shaders `raytraced_scene` + `hikari_shade_hit_rt`.
- Gate: `usesRtHitShading()` — true when any ray-traced visibility feature needs material-aware hits: shadows, AO, reflections, or GI. Shadows/AO use the rows only to reproduce raster alpha acceptance. `RtHitQuality` affects the full reflection/GI re-light path: shadow-ray budget, hybrid depth tolerances (`ShadowFrameUniforms.rt_hit_params`), specular bounce count (`rt_hit_params2.x`, primary inclusive, clamped 1–4), and the bounded punctual-light estimator (4/6/8 samples; globals remain exact).
- Secondary-hit material sampling uses an explicit ray footprint: triangle UV/world-area density and travelled cone width select the mip for albedo, normal, metallic/roughness, AO, and emissive maps. Ray shaders therefore neither force LOD0 nor depend on nonexistent screen derivatives.
- The shared RT budget is a deterministic authored ceiling (`RenderQuality.rt_budget.tier`; `render.json` path `pipeline.quality.rt_budget.tier`). Adaptive feedback belongs to the separate total-frame governor (`RenderQuality.governor`; `pipeline.quality.governor`), enabled by default with `mode: adaptive`. Its quality ladder first changes render resolution when allowed, then reduces above-floor ray grants, and finally lowers the stochastic input grids for RT reflections and the RT GI gather/denoise grid, down to half their authored linear size. RTAO stays on its fixed half-resolution producer/history lattice so a pressure transition neither reallocates the target nor changes which surface owns a ray. RT GI's separate 8×8 probe lattice remains tied to render geometry; its quality-scaled probe rays are reduced by the ray lever and its fixed one-ray floor is already only one trace per 64 render pixels. Histories remain at authored extent and the temporal/bilateral chains reconstruct their normal outputs. The authored configuration remains the ceiling and all feature gates remain authoritative. Set `pipeline.quality.governor.mode: off` for deterministic captures or fixed-budget benchmarks; an `unlimited` RT tier disables the governor altogether.
- Hybrid path: on-screen hits can reuse scene color; off-screen hits re-shade. Nested specular bounces (`specular_bounces` > 1) skip hybrid reuse on smooth hits. Hit shading not ready ⇒ SSR for that frame.
- D3D12: one logical namespace replicated into fixed prefixes of the frame-slot shader-visible heaps; descriptors are written only after that slot's fence wait, and shaders index that heap directly (no descriptor table, no per-pass `CopyDescriptors`) — see *Dynamic resources* below. Metal: direct-write argument-buffer bindless + residency lists. RT instance tables remain root/storage buffers.
- Static scenes: skip the instance-table pack when the **bound ring slot** already records this frame's TLAS + geometry-row fingerprints; skip the GPU upload when that slot's content hash also matches. Material changes update the shared table without repacking RT geometry. Reuse is decided per ring slot, never from the previous frame: the table binds without size information (root SRV / device pointer), so a slot still holding a shorter table would let the hit shader read past the buffer as soon as the TLAS grows (D3D12 page fault / device hung in `ray_traced_reflections`).
- Backstop: the rows actually uploaded for the bound slot travel in `rt_hit_params2.z`, and `hikari_rt_load_hit` / `hikari_rt_load_material` refuse instance ids at or beyond it. A regression in the reuse rule then shades a neutral surface instead of removing the device.

### Shared material indirection

Opaque G-buffer, forward, alpha-masked shadow, mesh-vertex content, and RT hit shading use one 112-byte `GpuMaterialRecord`, addressed by the existing stable `HikariObjectData.instanceId`. Each surface owns a frame-ringed table because instance ids are surface-local. A row carries base colour, metallic/roughness/occlusion, emissive state, anisotropy/clearcoat factors, nine stable texture indices, a dense **geometry sampler catalog index**, and optional content-compute heightfield indices. Pipeline binds install the material buffer, global texture range, and geometry sampler catalog once; draw loops no longer construct or flush per-material texture tables or rebind wrap/filter samplers. Shaders sample maps via `geometry_sample(tex, uv, material.sampler)`. The table binds as an unbounded root SRV / device pointer, so every consumer checks the per-surface commit flag (`gpuMaterialsReady`) first: a frame with no committed table (bindless heap cold, default textures pending, upload failure) skips scene raster draws, disables masked shadow casters, and reports RT hit shading not ready — never a draw against an unbound table. `.mesh_vertex_texture` shares the same standard bind model: heightfields are resolved into the bindless texture heap at encode and written onto the material row. A row that cannot name a heightfield (no content key, producer with no output yet, heap full) gets the default texture's index rather than `invalid_index` — the vertex shader indexes the table untested, so an unwritten row is an out-of-range descriptor read, not a black surface.

The catalog is carried by the geometry layouts only — `standard`, `mesh_vertex_texture`, `forward_lit`, `forward_lit_rt`, `fullscreen_raytraced`. That list exists in three places that must agree: the D3D12 root signatures built from `geometryCatalogStaticSamplers` (`material.zig`), `layout_uses_geometry_catalog` in `akari_emit_common` (which decides which shaders declare it), and `anisotropic.bindingLayoutUsesGeometryAniso` (which rebuilds exactly those signatures when the AF tier moves). Particles and SSR are deliberately outside it: neither samples a material map, and `particle.akari` keeps its own `@sampler` at `s0, space0` — the catalog lives in `space1` precisely so per-layout samplers keep their own registers. On Metal the catalog is an argument-buffer sampler table bound on render passes only, so only vertex/fragment entry points that actually reach a `geometry_sample` take it as a parameter; a compute kernel that declared it would read an argument-table entry the renderer never sets.

`HikariRtInstance` is geometry-only apart from its material-row index: normal transform, bindless vertex/index buffers, range/counts, and flags. RT no longer packs a second copy of scalar material state or texture identity. The packed row is 112 bytes; the added range origin lets multiple spatial BLAS instances share one raster vertex buffer.

### Batching

`DrawKey` carries pipeline variants, geometry/LOD identity, submission lane, and cull/shadow policy — **not material**. Every byte a shader reads about a material lives on `GpuMaterialRecord`, addressed per instance, so two objects that share a compatible pipeline bucket batch together no matter how differently they are textured. Anything that genuinely differs by material (binding layout, blend mode, cull) already differs by `pipeline`, because the pipeline is built from exactly those properties.

The one consumer that needs the material *object* rather than its shader-visible state is content-compute: heightfield indices are completed at encode by `material_table.patchContentTextures`, which walks **draw items** and keys by `object_data_handle.id`, the same as `build`. Batching therefore never enters into it, and transparents are covered by the same pass. Emission does not touch the material table at all.

What still splits authored work: pipeline, geometry/LOD identity, shadow and alpha-mask policy, and skinning. Meshlet submission then merges adjacent work with the same raster pipeline into one GPU bucket; material identity remains per object and does not split it.

### Bindless geometry (raster + RT)

Every live mesh vertex buffer and derived index expansion is resolved into the renderer-global bindless **buffer** heap used by raster pull shaders and RT hit shading (`residency/bindless_heap.zig` + `gpu/geometry_table.zig`). A 48-byte `GpuGeometryRecord` per `instanceId` carries stable buffer indices, stride, LOD range, fade state, and the selected meshlet range. Prepare builds that table alongside materials; the ready flag gates both. Both CPU tables retain producer-marked dirty rows independently for every prepare slot. Successful uploads acknowledge only the waited slot; failed prepares retain dirt. Encode-time content patches join the same row tracking. Unchanged tables avoid full comparisons, and sparse uploads merge gaps up to 128 bytes without a fixed range-count fallback. Native dynamic storage buffers grow geometrically and upload only the logical payload; native capacity changes never seed from a shorter source slice.

G-buffer, shadow depth (masked included), and every forward scene-mesh vertex shader **attribute-pull** positions and attributes through meshlet descriptors, remaps, and micro-triangles. Skinned variants pull the packed bone row and perform current/previous LBS from the joint palette. No scene-mesh PSO declares a vertex input layout and no scene-mesh draw binds a vertex buffer. The derived u32 index expansion exists for BLAS and physics, not raster submission.

Pull reads are **vector loads, not one load per component**. `bindless_load_f32x2/3/4` lower to `ByteAddressBuffer::LoadN` on D3D12 and a `packed_floatN` dereference on Metal; both need only 4-byte alignment, so rigid (8 words / 32 bytes) and skinned (10 words / 40 bytes) rows share the same address path. Casting the heap to a `HikariMeshVertex*` would be wrong: MSL's `float3` layout differs from HLSL and neither implicit struct pitch respects `vertexFloatStride`.

Akari has no vertex-input attribute, so no shader can introduce an input-assembler dependency. UI, particles, and debug primitives fetch their own records by vertex and instance id the same way. The native pipeline descriptors carry no input layout and the backend contract has no vertex-buffer bind; index buffers remain.

Raster work graphs retain the resident LOD ranges admitted by the structural memory policy. GPU selection appends the chosen meshlet pairs and carries fade coverage in visible meshlet records. Geometry-owned remap/micro-triangle buffers produce absolute source-vertex indices; moving the camera does not rebuild raster batches or topology.

A row that cannot be seated (evicted mesh or bindless heap high-water) keeps `no_resource` and drops only its own work. Primitive creation rejects incomplete meshlet topology; defensive invalid-range checks isolate and report only the affected batch. Capacity pressure lowers whole LODs; allocation or record-addressing failures clear frame readiness instead of publishing partial geometry.

`raytracing_bindings` reuses `geometry_table.resolveVertexBuffer` / `resolveIndexBuffer` so a mesh drawn in the G-buffer already owns its heap slots before the TLAS packs.

### Compute-driven meshlet submission

`meshlet_lod_cs` selects per-object raster LOD and generates an active pair list plus indirect cull dispatch arguments on Metal and D3D12. Camera object-frustum rejection happens before pair generation, after advancing LOD history. Unknown bounds and disabled camera culling remain fail-open. Predict consumes that active list; correct consumes only the compact Hi-Z reject queue. Opaque and transparent delivery use persistent rows and revision-covered deltas; swarm expansion and preview documents retain unchanged data. Shadow LOD and active work are selected independently per dirty page on the GPU; CPU RT range policy remains independent.

Camera geometry has one shared path on Metal and D3D12:

1. Structural prepare packs one work row per retained LOD meshlet and reserves pair identities per `(instance, meshlet)`. Each frame GPU LOD selection rejects off-screen objects and publishes selected pairs. Adjacent work with the same raster pipeline shares a submission bucket.
2. `meshlet_cull_cs` runs once per active pair. It applies object/meshlet frustum, cone, and Hi-Z policy. A surviving meshlet takes a *visible meshlet slot* — a four-word record `{late|target|fade|work}` `{object}` `{first record}` `{triangles}` in the phase's visible meshlet table — and a run of records in its bucket's region of the visible-triangle stream, growing that bucket's indexed draw `indexCount`. It also publishes the expansion dispatch, one group per slot.
3. `meshlet_expand_cs` runs one 64-lane group per visible meshlet. It reads the meshlet's micro-triangles through the geometry row's bindless topology and writes each triangle's three indices into the run. An index is `(slot << 6) | local vertex`, so the visible-triangle stream *is* the bucket's 32-bit index buffer, and triangles of one meshlet that share a vertex share its index value.
4. The raster pull VS receives that index: the slot names the work and the object (loaded in parallel), the local vertex goes through the cooked remap, and vertex data and optional skin weights come from the bindless table. Because shared vertices share an index, the post-transform cache shades each meshlet vertex once instead of three times per triangle.
5. Emission binds each pipeline once with the whole visible meshlet table and issues one indexed indirect draw per bucket — two for an opaque bucket, whose LOD fade stream draws separately. Buckets address their region through `indexStart`. Metal lowers this to `drawIndexedPrimitives(indexBuffer:indirectBuffer:)`; D3D12 uses the `D3D12_DRAW_INDEXED_ARGUMENTS` command signature over a 32-bit index view.

The expansion is its own kernel because it reads topology through the bindless heap, which on D3D12 cannot be bound alongside the cull's Hi-Z descriptor table. Every reserved record is written: geometry without topology emits degenerate triangles of the same slot. Phases whose geometry table is uncommitted or whose bindless heap is dormant (`gpu_culling.expansionFor`) leave the cull disabled; those are exactly the frames whose scene draws are gated off.

The G-buffer vertex output carries only what the fragment stage cannot rebuild. View depth comes back from the interpolated world position (the view transform is affine), and the LOD fade code and meshlet visualizer payload both come from the one flat visible meshlet word. Normal and vertex colour travel at half precision. Together with vertex reuse this cuts the per-triangle parameter-buffer footprint on TBDR GPUs.

#### Opaque and masked G-buffer pipelines

A fragment shader that *can* `discard` takes its whole pipeline out of hidden-surface removal on Apple TBDR GPUs: every fragment that passes the depth test is fully shaded, in submission order, with no deferred visibility. An opaque G-buffer fragment samples up to nine material maps into eight targets, so a single `discard` in it turns depth complexity directly into shading cost.

Every opaque primitive therefore holds two PSOs in its `PipelineVariant` set (`material/pipeline_variant.zig`): `.base` (`.gbuffer_geometry`, no `discard`) and `.masked` (`.gbuffer_geometry_masked`, alpha cutoff and the LOD screen door). The masked fragment is an explicit twin — `pipeline_kind.maskedFragmentFor` pairs `gbuffer_fragment` / `gbuffer_fragment_masked` and `material_graph_fragment` / `material_graph_fragment_masked`. A fragment with no listed twin (a custom game shader) draws both roles with one PSO and keeps whatever discard behaviour its author wrote.

- **Cut-outs** (`alpha_masked`: an alpha-maskable material with a cutoff) bucket under the masked PSO.
- **LOD cross-fades** leave the main stream: opaque works carry `work_split_fade`, and `meshlet_cull` reserves a fading meshlet's triangles in the bucket's *fade record* (`SubmissionBucket.fade_argument_index`, after all main records). The fade stream shares its bucket's region and fills it from the top (`hikari_reserve_vis_tris_mirrored` lowers indexStart by what it adds to indexCount); the region already holds both LOD levels of every batch, so the two ends never meet and the split costs no memory.
- **Order.** `emit/geometry.zig` draws every discard-free bucket first, then cut-out buckets and every fade stream, so masked fragments early-Z test against the opaque scene's depth.

Transparent works never split: they fade by alpha in their own shader.

D3D12 requires the masked fragment variant whenever the shader pairing table declares one. A missing required variant fails material loading with `ShaderLoadFailed` and an error log, rather than drawing cutouts or fades with the opaque fragment. Shader-load failure releases partial shader/root-signature ownership before returning; actual allocation failures retain the separate `AllocationFailed` error.

The two-phase camera path owns separate predict/correct visible-triangle streams and bucket arguments. Predict tests the previous pyramid and wave-compacts uncertain Hi-Z rejects into a separate queue with indirect dispatch arguments. Correct dispatches over that queue against the completed current pyramid; no settled bitfield or full active-list rescan remains. The render graph explicitly tracks the late bucket-argument buffer between `occlusion_cull` and `gbuffer_geometry_late`.

Shadows use the same geometry tables and plain draw arguments. `shadow_meshlet_lod_cs` builds the active pair list and indirect dispatch for each dirty page; `shadow_meshlet_cull_cs` compacts visible meshlets into an intermediate queue. `shadow_triangle_cull_cs` conservatively rejects triangles outside the page and appends packets to their caster pipeline class. Each indirect instance pulls up to 32 surviving triangles; unused tail primitives are degenerate and never fetch topology. Packet capacities cover every triangle in the largest retained LOD, including skinned and deformable meshlets that bypass triangle rejection. The CPU schedules bounded page batches and pipeline classes but performs no caster sphere loop, list compaction, or per-batch mesh draw. Batch preparation binds only its declared buffers, while retaining the shared VSM root layout. Dirty counts remain GPU-owned; caster scenes still encode pool-bounded batch slots, including empty tails. After the first empty batch zeros its arguments, later empty batches skip preparation writes and retain those zero arguments. Every transparent scene-mesh lane also uses the shared meshlet graph, including skinned LBS. Cone and Hi-Z policy are disabled where transparency requires it; the bucket lane chooses refractive depth/shading, additive, or OIT output. Refractive depth and shading replay the same visible-triangle arguments, so none of those lanes has a per-item or direct-vertex fallback.

There is no scene-mesh fallback pipeline. Missing kernels, allocation failure or unsupported record address ranges make the meshlet frame unavailable; malformed topology is isolated and diagnosed per batch.

For at least 16,384 staged objects, a GPU prepass refits fixed eight-object clusters from current transforms and shadow-admission metadata. It writes conservative cluster AABBs and the same world spheres used by the direct selector. Dirty pages reject clusters before running exact sphere and LOD tests; unknown bounds prevent cluster rejection. Wave publication still includes every surviving or padded lane. The prepass runs once per surface preparation and reads the first batch's auxiliary indirect arguments, so a frame without dirty pages dispatches no refit work. Smaller scenes retain direct selection because the prepass costs more than it saves there. The cluster buffer follows prepare-slot ownership and GPU memory accounting. This changes discovery cost without changing page coverage, caster LODs, or triangle budgets.

**Capacity policy.** Camera submission uses per-instance minimum LODs, chosen by `gpu_culling/geometry_budget.zig`. The planner ranks successive reductions by projected LOD0 bounds size and level; existing reductions get a retention preference to prevent near-equal priorities from oscillating. Cooked LODs do not currently carry geometric-error measurements, so this score is a priority proxy, not a pixel-error guarantee. No instance or triangle is discarded to meet the soft target. Raster-only and masked/transparent rows reserve the largest two retained levels for cross-fades; opaque RT rows reserve their single coordinated level. Resident topology and pair tables remain shared and complete. The independent VSM policy retains its 128 MiB / 48-page single-level reference. Irreducible demand allocates complete streams and warns; allocation failure invalidates preparation.

`residency.geometry_submission_stream_mb` defaults to 144 MiB **per camera index stream per prepare slot**. Early and late streams, frame slots, and extra surface partitions multiply the total. Stream allocations grow in 65,536-record blocks (768 KiB), retain their high-water capacity, and are replaced only in a fence-waited slot. The profiler reports actual capacity bytes across every slot, peak allocation, requested and admitted demand, reduced instances, and completed-slot emitted/overflow triangle counts. CPU pressure transitions are logged even without profiler instrumentation; changes within pressure are throttled. GPU overflow counters and warnings require residency diagnostics. Memory-allocation failures remain separate errors.

Mesh residency (`residency.gpu_geometry_mb`) and RT construction/residency (`residency.ray_tracing_mb`, default 512 MiB) are independent budgets. The RT target includes native BLAS/TLAS allocation, scratch and retained compaction sources. Pressure bypasses the unused-BLAS cache grace period, retiring unreferenced entries through the usual frame horizon. Active TLAS references and in-flight resources are never destroyed to force the soft target; pressure clears below 90% to avoid thrashing. These counters describe Hikari allocations, not the operating system's total GPU-memory allowance.

Pair-index, active-pair and correction dispatches expand into Y after 65,535 X groups; flattened pair IDs stay within checked u32 limits. Geometry records still impose 22-bit object/work addressing. Kernel overflow guards remain defensive. With `--profiler-residency=gpu`, each backend copies early/late/shadow arguments to per-slot readbacks and collects emitted/overflow counts only after the normal slot fence wait; diagnostics introduce no synchronous GPU wait. Pack diagnostics expose both minimum LODs, stream demand/capacity and invalid-topology counts.

Instance uploads retain packed-row dirt independently for each prepare slot.
Ordinary updates compare and upload only pending changed rows, coalescing adjacent
rows into writes; quiet frames perform no full instance-array hash/diff scan.
Buffer growth and structural owner changes receive complete seeds.

### Dynamic resources (D3D12 SM 6.6)

**Compute parameter blocks have two transports on D3D12.** Root constants are
cheapest for a small block and most expensive for a large one, because every
DWORD occupies the 64-DWORD root signature the pass shares with its descriptor
table, CBVs and root SRVs. `compute_passes.paramsBinding` picks per command by
size: at or under 32 DWORDs the block stays inline, above it travels in a
per-frame constant-buffer ring and binds as a root CBV. Both present to the
shader as `cbuffer : register(b0)`, so the Akari source never mentions which,
and Metal — which has no such budget — delivers every block as inline bytes at
buffer slot 0 and ignores the distinction entirely.

`FroxelParams` (192 bytes) and `RtFroxelParams` (256 bytes) use this ring.
The former originally motivated the mechanism:
at 48 DWORDs it made `volumetric_inject` the widest signature in the engine
(61 of 64), so the two root SRVs bounded media needed had to be paid for out of
the reserve. Through the ring the same pass costs 15. The ring is one buffer per
frame in flight, rewound in `beginFrame` right after the slot's fence — the same
point the descriptor heap is reset, and for the same reason — and bumped 256
bytes per push, because D3D12 requires every constant-buffer view to start
256-byte aligned. That alignment is also the cap on a params block, asserted at
comptime against the contract so a block the contract accepts always fits the
slot it is copied into.

The bindless heap is not a *binding* on D3D12. Shaders read it with `ResourceDescriptorHeap[i]`, so it has no descriptor table, no root parameter, and no register space; the only requirement is that the command list has the right shader-visible heap set and that the root signature carries `CBV_SRV_UAV_HEAP_DIRECTLY_INDEXED`. That flag is set on every graphics root signature the engine builds rather than per layout — there is one heap, it is renderer-global, and a signature that omitted the flag would fail PSO creation against any shader that samples a material map or pulls a vertex. D3D12 also requires that heap to be `SetDescriptorHeaps`'d after Reset and *before* `SetGraphicsRootSignature` / `SetComputeRootSignature` (validation #1419), and forbids swapping it afterwards (#1420). The command list binds the current-frame heap on Reset.

`space1` carries only the geometry sampler catalog; there is no `space2`.

Both backends expose 65,536 buffer and 65,536 texture slots. Metal requires argument-buffer tier 2 and validates table sizes against the device buffer limit; D3D12 requires resource binding tier 3 and SM 6.6. GPU residency diagnostics report live slots, capacity, high-water, peak occupancy and failed reservations for each namespace. Exhaustion is logged at the first and power-of-two failures. Retirement still waits for every frame replica before an index is reused; a larger namespace does not relax lifetime rules.

Three things follow that are easy to get wrong:

- **Both halves share one heap, so the index spaces do not match Metal's.** Buffer SRVs occupy `[0, 65536)` and texture SRVs follow, and `ResourceDescriptorHeap` indexes absolutely — so the HLSL backend biases every *texture* index by `BINDLESS_TEXTURE_HEAP_BASE` and leaves buffer indices alone. Metal keeps one argument-buffer table per kind, each from zero, and applies no bias. The `.akari` source is identical either way; the bias is an emitter concern. `gpu/bindless_capacity.zig` defines 65,536 slots per namespace. An Akari golden test checks the HLSL texture base against that Zig contract; the shader-cache epoch changes with it.
- **`NonUniformResourceIndex` is mandatory.** Since materials left `DrawKey`, one draw routinely covers several materials, so lanes in a wave carry different indices by design. Every emitted heap read is wrapped; a golden test counts the wrappers against the reads so a new lowering cannot skip it.
- **A wrong heap is silent.** Indexing the wrong heap just returns someone else's descriptors, so `bindBindlessHeap` binds nothing; it verifies that the pass's shader-visible heap is this frame's bindless heap, and fails the draw if not.

The shader model is engine-wide, not per package: `D3D12_SM_FLOOR` in `akari_hir` names the profile the cooker compiles and `shader_artifact.d3d12_shader_model` names the `.cso` the loader asks for. They must match — drift is not a link error, it is every D3D12 shader silently missing from the AssetStore, which is why both carry a test. Adapter selection requires Feature Level 12_0, SM 6.6, resource binding tier 3 and wave intrinsics, and rejects anything less by name; there is no fallback binding model to fall back to. The floor is one requirement table in `D3D12Device.cpp`; when no adapter passes, the process logs a fatal error and, in an interactive session, shows a message box listing the requirements and what each adapter lacks, then exits with code 1. `DriverRecipe.gpu_preference` changes enumeration order only: Auto uses DXGI's unspecified order and High Performance uses `DXGI_GPU_PREFERENCE_HIGH_PERFORMANCE`. The adapter that wins is logged with its dedicated VRAM against the preference that was asked for — ungated, because "am I on the right GPU" is the one question this setting exists to answer.

Metal does not implement the preference: `hikari_mtl_create_device` always takes `MTLCreateSystemDefaultDevice` (the Metal 4 floor means Apple silicon, one GPU). A non-`auto` value is logged at device init. Changing the preference always requires a process restart; no backend migrates a live resource graph between adapters.

Root descriptors (uniforms, object data, shadow casters, joint palettes, material rows, geometry rows, the RT instance table) stay as root arguments with hand-numbered registers; they are not heap indices. Static samplers are the engine's fixed set in space 0 (`binding_layout.static_samplers`, mirrored by `static_samplers` in `bindings.kaji.json`, `s0`–`s3`) plus, on layouts that shade material maps, the geometry sampler catalog in space 1. Every root signature with space-0 samplers carries the whole set, and Akari gives each `@sampler` the register of its state, so a register never depends on shader declaration order.

### Bindless lighting / IBL

Every scene-lighting texture — G-buffer targets, shadow atlas, skybox pair, AO/GI/direct, scene captures, forward fog volumes, Hi-Z, bounce — reaches shaders as an index into the renderer-global bindless texture heap.

- **The carrier is an 84-byte per-pass block** (`gpu/lighting_resources.zig::Indices` ↔ akari `HikariLightingResources`), set as root constants (D3D12 `b3`, 21 dwords) / fragment bytes (Metal buffer 8) *at encode* — after the graph has fixed physical texture identity, so transient-pool targets cannot go stale in a prepare-time upload. Engine textures have no `RenderHandle`; heap residency is keyed by synthetic per-pointer handles (`lighting_resources.KeyMap`). The last dword is the engine's 128×128 blue-noise phase texture for the stochastic RT samplers.
- **Every hoisted heap read must land on a valid descriptor.** The shader hoists `ResourceDescriptorHeap[...]` per declared field even when a uniform gates the sample, so absent resources substitute type-compatible fallbacks (drivers already bound same-typed stand-ins for the old table; the same substitutions now resolve to indices).
- **One heap, four typed views.** Cube (probes/sky), 3D (fog volumes), and depth (VSM physical page pool) reads go through `bindless_texcube/tex3d/texdepth` builtins against the *one* bound table — MSL forbids two parameters on the same `[[buffer(N)]]`, so non-2D views reinterpret the table pointer; HLSL reads the heap with the matching element type. Comparison shadow sampling works unchanged: the compare function lives on the static/constexpr `SamplerCmp`, not the texture.
- **Lighting layouts carry no descriptor table:** `fullscreen_shadowed`, `fullscreen_reflections`, `forward_lit` / `forward_lit_rt`, and `fullscreen_raytraced` take the index block as root constants.
- **The effect chains keep a 9-wide window.** Temporal/spatial/post passes use the `fullscreen_effect` layout with a single `t1`–`t9` range, because their inputs vary per pass *instance* within a frame, which a per-frame block cannot express.

**Independence among RT features:** shadows, AO, reflections, and GI select separately and may be mixed. They share the master switch, TLAS readiness, and material-hit tables needed for exact alpha continuation. The bindless texture namespace and shared material table also serve raster draws; only reflections/GI perform full hit re-light.

**Geometry update policy:** `static_compacted` is the default. `dynamic_refit` accepts revisioned in-place vertex updates; `dynamic_rebuild` accepts revisioned geometry that cannot refit. `excluded` geometry does not enter the TLAS. Skinned/deformed geometry must use one of the dynamic policies only when its AS vertex buffer contains the deformed positions; otherwise it must be excluded.

**Skinned meshes fail closed through a per-primitive RT stream.** Every RT-eligible skinned render primitive owns a unique GPU-writable 32-byte packed rigid vertex stream (the layout in `sdk/src/vertex_layout.zig`), even when several primitives share the same 40-byte packed skinned rest-pose geometry. After the current joint palette upload, a compute kernel deforms that source into the RT stream. Metal submits the writeback on the graphics queue before AS work under the shared frame fence; D3D12 uses the direct queue with UAV and non-pixel-resource transitions, so queue order makes the result visible to the following BLAS refit.

A successful submission stamps the output with a per-pose receipt: palette heap identity, allocation revision, palette offset/count, and source geometry identity/revision. TLAS preparation admits a skinned primitive only when its output handle is live **and** that stamp matches its current receipt; otherwise it skips only that primitive and retries next frame. Sorted allocation intervals support palettes inside homogeneous population blocks without scanning all poses per primitive. Incomplete palette metadata uses a conservative frame receipt. First allocation attaches the unique stream to residency and starts on the next consumed frame, after the draw revision publishes the handle. A changed pose skins and refits only its affected RT streams; unchanged poses reuse their streams and BLASes without rebuilding the render queue. Allocation, pipeline, shader, input, or dispatch failure stamps nothing. Rebind, release, and scene unload retire the stream through normal deferred vertex-resource lifetime. Cull and raster-shadow bounds continue using the cooked expanded local AABB rather than a per-frame skinned-bound readback.

**Independence from content compute:** RT visibility shaders bind the acceleration structure and shared material-hit tables, but no content packages. RT reflections/GI additionally bind lighting inputs for full hit re-light. Current `mesh_vertex_texture` displacement is excluded from the TLAS because its output is a texture, not AS-compatible vertex positions.

**Live changes:** gameplay and Project Settings submit one sparse `RenderPipelinePatch` through `GraphicsCommand.apply_render_config`. `SessionCore` resolves it against the canonical `RenderPipelineConfig`, synchronizes runtime state once, and sends one coherent value through the render-thread mailbox and backend vtable. Backend-local diffing owns pipeline creation, temporal invalidation, display reconfiguration, sampler refresh, and async-compute queue selection. Adding a render setting therefore changes its canonical field and patch—not another per-setting command/thread/vtable chain. Async-compute policy applies at the next render-frame boundary and does not require a restart; content-compute producers recreate once when their execution queue changes, using the normal in-flight retirement path. GPU preference is captured before device creation and requires a process restart. The editor viewport inherits the composed game pipeline from `render.json` → `pipeline`; optional sparse overrides may live under `editor.json` → `render`.

### Contract D — Global illumination (indirect diffuse)

**Status:** SSGI and **screen-probed, hybrid radiance-cached 1-bounce RT GI** live. Both produce the same reconstructed `indirect_diffuse` contract. SSGI raw resolution follows `quality.gi` (`GiQualityBudget.ssgi_trace_scale`: quarter at low, half above); RT GI traces one sparse receiver per 8×8 render-pixel tile, then geometry-aware gathers that signal onto a raw grid capped by `quality.gi` (`GiQualityBudget.rt_receiver_pixels`: 921,600 pixels — 1280×720 at 16:9 — from medium up, 230,400 — 640×360 — at low; the queue buffers stay sized for the larger cap), with the frame governor able to reduce the gather/denoise grid further. Unsafe interpolation is classified and compacted into a bounded exact-receiver queue; the queue traces at most one receiver per 32 **receivers of the RT GI raw grid**, at two rays each. Every receiver also publishes which tier answered it and how much probe support it had (`rt_gi_classification`), and the spatial filters read that separately from accumulated history confidence. Lighting and the long-history/output contracts are shared; RTGI adds its short history and variance-guided a-trous stages internally. The bounce capture is consumed by SSGI and retained in the RT graph only for mid-frame SSGI fallback. Orthographic receivers use a constant view direction and a depth-independent projected sampling radius; perspective receivers retain the inverse-depth radius. Both use the active Hi-Z extent rather than allocation height.

**Config:** `features.global_illumination` (`off` / `screen_space` / `ray_traced`) and optional `quality.gi` (`low`…`ultra`, default `medium`). Helpers: `withGlobalIllumination`, `withGiQuality`, `GiQualityBudget.fromQuality`, `usesGlobalIllumination` / `usesScreenSpaceGlobalIllumination` / `usesRayTracedGlobalIllumination`, `resolvedGlobalIlluminationMode`. Availability: `screen_space_global_illumination` when the SSGI, bounce-align, bounce-mip, GI-temporal, GI-spatial and bounce-capture PSOs all exist; `ray_traced_global_illumination` additionally requires the radiance-cache update, short- and long-history temporal PSOs, and all five RTGI a-trous PSOs, plus the RT GI *compute* producer, screen-probe and surface-cache compute PSOs. Both GI modes produce through compute. The bounce capture remains part of the RT chain because mid-frame RT readiness loss falls back to the SSGI *compute* producer. RT GI also requires master RT + TLAS readiness (resolved mode falls back to SSGI, then off).

**`quality.gi` budgets** (`render_config/gi.zig` → `GiQualityBudget`):

| Tier | SSGI slices × steps per side | RT probe rays | max distance (m) SS / RT | RT secondary shadow budget |
|------|------|------|--------------------------|----------------------------|
| low | 1 × 6 | 2 | ~15 / ~25 | 0× (one reweighted shadowed-light floor) |
| medium | 2 × 8 | 4 | ~30 / ~50 | 0.35 of `rt_hit` max per probe/fallback hit |
| high | 3 × 10 | 6 | ~45 / ~75 | 0.75 |
| ultra | 4 × 12 | 8 | ~60 / ~100 | 1.0 |

SS range deliberately stays within the same order as RT: a ray that escapes contributes sky, which is what the IBL baseline already was, so a short SSGI range makes the lighting mix a no-op rather than a cheaper effect. Range is not the cost driver; the hierarchical Hi-Z march crosses empty space at coarse mips and the step cap bounds work.

RT probe rays are normally paid once per 64 render pixels. Rejected gather receivers do not trace inline: a ray-free classification pass preserves their best geometrically safe probe estimate, appends them to a compact queue up to the frame-wide quota, and publishes native indirect-dispatch arguments. The exact pass spends two blue-noise rays per admitted receiver; the existing short/long temporal filters accumulate those sparse updates. Two is a correctness floor: `hikari_rt_gi_sample` bounds a ray's positive residual against the other rays of the same pixel, and with one ray that leave-one-out reference is empty. The receiver's reactive bit rides the queue entry's high bit, because the trace has no probe neighbourhood to rediscover it from.

Overflow retains the safe probe estimate (or invalid zero support), and admission is a deterministic per-receiver hash tested against a threshold the reset pass derives from a previous frame’s rejected count, clamped to the receiver population so an uninitialised ring slot cannot produce a wild threshold. The admitted set is scattered across the whole image, rotates with the frame phase, and is reproducible for a frozen camera. Exact fallback work is therefore bounded by resolution policy rather than rejected coverage or the authored per-probe ray tier.

The gather declares a **read and a write** on the fallback counter and argument buffers, because it read-modify-writes both. Graph liveness runs backward and a write kills a value, so a write-only declaration would cull the reset pass out of the schedule.

**Estimator authority.** `rt_gi_classification` carries the answering tier and probe support, normalized against a full 3×3 neighbourhood. The spatial cascade reads it through `effect_spatial.estimator` to widen filtering for an estimate with weak support, even if that estimate is smooth. Temporal history confidence remains separate: low probe support does not shorten geometry-valid history. The temporal pass uses estimator authority only to choose how strongly the current sample should rely on its filtered neighbourhood.

SSGI's slice and step counts travel in the producer's own pass params (`SsgiParams`), resolved once at assembly into `LightingTargets.ssgi_slices/steps`; `gi.zig` mirrors the shader's loop bounds (`ssgi_slices_max` 4, `ssgi_steps_max` 16) and a test holds every tier inside them, because the shader clamps rather than errors. Uniform `globalIlluminationParams.y` carries the runtime path's sample count (RT probe rays, or SSGI slices) for shaders that branch on the mode. Host writes **runtime-path** max distance (SS vs RT, matching the driver fallback when RT hit tables are not ready) into uniforms each frame.

**Orthogonal combinability:** GI mode is independent of shadow mode (raster deferred vs RT shadows), AO, and reflections. Valid mixes include raster shadows + SSGI, RT shadows + SSGI, raster shadows + RT GI, RT shadows + RT GI.

**Graph order:** independent AO/GI raw producers first → their temporal/spatial reconstruction → deferred/RT lighting → **bounce capture** → reflections. Screen-space AO and GI use the async-compute queue when available; RT producers stay on graphics. Any non-off GI builds Hi-Z: SSGI uses it as its visibility estimator, while RT GI uses it as the cheap first tier before triangle traversal and also needs it for mid-frame SSGI fallback. Screen-space AO shares that same hierarchy rather than building or sampling a separate depth structure. Under two-phase occlusion culling, any screen-space reader causes the pyramid to be rebuilt over completed depth rather than left holding the predict-phase subset. RT GI additionally needs TLAS + bindless hit tables. The ring-owned admission reset runs before geometry because it reads no scene textures. Pass names: `rt_gi_fallback_reset` → geometry → `rt_gi_radiance_cache_update` → `surface_cache_prepare` → `rt_gi_screen_probes` → `ray_traced_global_illumination` (gather/classify/compact) → `rt_gi_fallback_trace` (indirect) → `surface_cache_update` (requested cells); then reconstruction and `gi_bounce_capture`. That ordering is asserted by a graph test (the fake backend returns real fallback buffers so `usesRayTraced` is true there). The probe radiance/guide pair is transient and uses `render_tiles(8, 8)`, so odd dimensions and governed render sub-rects retain a covering right/bottom tile. The fallback queue/counter/argument buffers are prepare-partition ring resources, so concurrent product/editor surfaces neither overwrite one another nor require a GPU drain.

**Bounce capture:** GI runs *before* lighting — that ordering is what keeps its cost independent of light count — so the only lit surface SSGI can reuse is the previous frame's. `gi_bounce_capture` writes a half-res RGBA16F secondary-radiance pair (`gi_bounce_0/1`, ping-ponged on frame parity like the temporal histories) after lighting and before reflections and transparents. Each texel selects one nearest exact G-buffer owner rather than averaging materials across a 2×2 edge. RGB analytically removes height fog, replaces the current GI-mixed diffuse IBL with the non-recursive environment baseline, and subtracts sky environment specular. Opaque lighting temporarily publishes admitted diffuse response divided by full direct response in scene alpha; the numerator contains only `affect_indirect` lights. The capture applies that admission only to the recovered direct residual, preserving environment and emissive radiance while preventing artist fills and camera-view direct highlights from being bounced as diffuse light. This alpha hand-off avoids a full-resolution HDR secondary-radiance MRT and is consumed before reflections and transparents. The persistent bounce alpha stores owner linear depth. `globalIlluminationBounceValid` (uniform float at offset 344, in the alignment gap ahead of `globalIlluminationParams`) is 0 until a capture has actually run. SSGI reads the cleaned RGB and falls back to its sky-lit proxy while the capture is invalid. RTGI does not sample the capture: its current-screen and triangle tiers both re-evaluate the same diffuse-only secondary transport, preventing previous camera-view punctual specular from entering GI when the tier boundary moves.

**Technique (SSGI):** visibility-bitmask slice integration (`ssgi_bitmask.akari`, producer `screen_space_global_illumination_cs`), fed by an aligned bounce (`gi_bounce_align.akari`).

1. **Aligned bounce.** `gi_bounce_align` reprojects last frame's cleaned capture (`gi_bounce_{0,1}`) onto this frame's half-res grid once per texel — motion at the texel's exact G-buffer owner, the pre-TAA jitter delta, and a depth test against the capture's stored owner depth so a disocclusion cannot borrow the surface behind it. The output is irradiance-unit `πL`, already firefly-clamped with the ceiling its source deserves (captured bounce ~32× local env, sky-lit albedo proxy ~6×), alpha 1 on a surface and 0 on sky. `gi_bounce_mips` then builds four reduction levels (surface-weighted, one compute pass with barriers, the depth pyramid's shape). The march reads radiance with a plain `sample_level`, and a coarse step reads its footprint's mean instead of one texel.
2. **Slices.** Per raw pixel, `slices` directions through the view vector (per-pixel IGN phase, rotated per frame), each marched both ways with `steps` exponentially spaced samples from 1.5 Hi-Z texels out to the projected `params.z` range (capped at 35% of the picture height). Depth comes from the Hi-Z min at the step's footprint mip, so the pyramid and the bounce chain share one mip index.
3. **Bitmask.** Each sample's front face and a view-space thickness behind it (`0.12 m + 0.12 × distance`) span an arc of the slice's normal hemisphere; the arc maps to a run of bits in a 32-bit mask. Bits set for the first time are hemisphere the receiver sees *that surface* over — the sample's radiance replaces the far field on exactly that cosine-weighted arc (GTAO's `∫ cos(θ−n)|sin θ|` in closed form, scaled by the fresh-bit fraction). Bits already set belong to a nearer surface. Every visited sample contributes; thickness is per sample, so light passes behind thin occluders and a railing occludes only itself.
4. **Far field and control variate.** `E = E_far(N) + mean over slices of |n_proj| × Σ w(arc) × (πL_sample − πL_far(dir))`. An uncovered arc cancels exactly and returns the far field it was already lit by. The far field is the sky. An interior lit by a sky it cannot see is left to the RT tier, whose radiance clipmap carries world-space light; the screen-space tier has no source beyond the picture. A sample whose surface faces away from the receiver occludes but contributes no radiance; a footprint that was partly sky (mip alpha < 1) fills that share from the far field.
5. **Unknown arcs.** A side that leaves the picture charges the unset bits on its side of the normal, scaled by the march it had left, as *unknown*: those arcs take the material-cavity prior (0.75–0.90 × `E_far`) and are subtracted from confidence, so lighting leans toward the deterministic far field exactly where the screen could not answer.
6. Temporal filter (shared geometry-rejected history path; signal format rgba16 like reflections) → **three to five a-trous stages** at strides 1, 2, 4, 8 and 16 (`GiQualityBudget.atrous_stages`: low 3, medium 4, high/ultra 5) → **confidence-aware** spatial bilateral upsample to full-res `indirect_diffuse` (neighbor weight × signal alpha so low-conf texels do not smear into high-conf). SSGI's alpha is estimator confidence, not a second moment — lighting reads it as the mix weight against the far field — so `hikari_ssgi_atrous_filter` measures luminance variance from its own axis cross each stage, and carries confidence on the same weights as the radiance it describes. That cascade is what lets SSGI share the fused kernel with AO: both chains pin signal, history and cascade to half resolution regardless of trace tier, so `ao_gi_atrous_*` runs at every AO/GI trace resolution and the separate `ssgi_atrous_*` stages exist only for the frames AO is off or the fused PSO is missing. `AtrousFusedParams.gi_ray_traced` selects the stage and follows the **runtime** producer, because a frame whose RT readiness lapsed ran the screen-space producer into that signal and its alpha then means something else.
7. **rgb** = diffuse irradiance \(E\) (drop-in for `hikari_skybox_env_diffuse`); **a** = confidence ∈ [0,1] — never folded into rgb. Pure metals (`metallic > 0.95`) early-out with confidence 0.
8. **Cost.** `slices × steps × 2` samples per raw pixel (medium: 32), each one Hi-Z fetch, one bounce fetch, one normal fetch and one sky-environment fetch, and no per-sample reprojection.
9. **Graph.** `gi_bounce_align → gi_bounce_mips → screen_space_global_illumination`, all on the screen-space (async compute) queue. Both align passes are assembled in RT graphs too, because a mid-frame RT readiness lapse runs the SSGI producer; under RT GI their drivers execute only the graph transitions. The SSGI kernel binds the probe records and the bindless texture table (D3D12: the geometry-catalog root family) and needs `countbits`, which is why that builtin exists in akari.

Published GI surface-cache hits request a refresh only after a cell-hashed **2–5 frame** interval from its last lighting update. Bootstrap and expired cells remain urgent; completed fresh samples keep their existing feedback path. Collided requests retry while overdue, and the resident sweep and lit-age limits remain in force. This reduces redundant claim atomics and producer relighting without changing a cache hit's returned irradiance.

**Technique (RT GI):**

1. Primary surfaces never read the world radiance cache directly. A sparse screen-probe grid selects a centre-biased diffuse receiver for each tier-sized tile (at least 8×8 render pixels) and traces 2 / 4 / 6 / 8 blue-noise-rotated cosine samples for low / medium / high / ultra. A valid central receiver avoids a tile scan; a sky/metal centre searches for the nearest valid diffuse fallback. Converged probes may fall toward the two-ray floor, but that decision reconstructs the same geometry-tested bilinear guide footprint as the temporal consumer and scales confidence by accepted coverage. A fast-camera disocclusion therefore keeps the authored ray count on the frame that rejects history instead of exposing a prematurely reduced probe. Every direction is a hardware trace. A bounded Hi-Z screen trace used to go first, but it resolved about 1% of probe rays in Sponza and a city-scale test scene while every ray paid for the march, and it fed the same diffuse-only secondary-hit shader as hardware hits; removing it cut the synthetic probe kernel by 15–50% with identical output wherever the screen tier had not hit. Probe results store radiance plus a finite-difference geometric normal, linear depth, and the representative's 8×8 search-lattice coordinate. The producer and gather map that coordinate through the same runtime tile size. The geometric guide—not normal-map microdetail—owns surface-plane rejection; shading-normal agreement remains a soft interpolation weight.
   Probe ceilings for low / medium / high / ultra are **3,600 / 14,400 / 32,400 / 57,600**. Tile size is the smallest integer footprint that fits the authored ceiling, chosen from render allocation dimensions; the governor changes the active grid without reallocating histories. Receiver selection searches at most 64 stratified positions per tile. Fine geometry missed by a coarser lattice relies on the existing geometry-tested gather and bounded exact fallback.

   Temporal histories, guides, moments and a-trous intermediates have separate ceilings of **640×360 / 1280×720 / 1280×720 / 1920×1080** pixels at 16:9, retaining aspect ratio and at most half render resolution. Final spatial reconstruction stays at render resolution. Raw gather admission retains its independent 640×360 low / 1280×720 upper-tier ceiling. AO/GI cascades fuse only when their actual grids match; otherwise both run separately.

2. `rt_gi_radiance_cache_update` visits the whole small atlas but schedules one lobe in `GiQualityBudget.rc_update_period` per frame (low 8, medium/high 4, ultra 2): **4,608 / 9,216 / 18,432 lobe updates (at most 18,432 / 36,864 / 73,728 primary rays)**, independent of viewport size. The period reaches the shader as `RadianceCacheUpdateParams.update_period`; the per-surface phase cycles mod 8 (`radiance_cache.phase_cycle`), which every period divides. A newly rolled-in cell publishes after period × 4 frames (32/16/8) and reads the sky term until then, so a longer period converges later but never costs more. Each scheduled lobe traces a four-ray equal-area stratified batch, and every punctual light evaluated at one of those farther hits is shadowed — the cache is a converged *indirect diffuse irradiance* estimate, so an unshadowed lamp baked into a lobe is a wrong value rather than noise that averages out. Cache hit shading is diffuse-only; secondary specular remains the reflection path's responsibility and cannot enter a world-space irradiance cell. Before temporal publication, a per-channel positive-residual bound keeps everything within the global-light bounce ceiling (below) and requires energy beyond it to be supported by another stratum (3× leave-one-out). Sunlit and sky-lit transport is therefore retained whole, while one tiny emissive or lamp-side hit cannot contaminate a spatially reused lobe. Negative residual remains signed sky occlusion and is not firefly-clamped; the render look may soften its final contribution independently of positive bounce energy. Bounded punctual-light selection rotates across updates so scenes with more than eight lights remain unbiased; batching and receiver interpolation prevent that rotation from pulsing a whole cell. Published stable lobes retain 95% history, while a batch outside the deliberately broad stability envelope retains 85% so moving-sun and day/night illumination cannot leave neighboring cells at visibly different moments of the transition.
3. The secondary-hit cache is a camera-centred, world-addressed three-level clipmap with 1.5 / 3 / 6 m cells. Every probe owns six axis-aligned cosine lobes in a 96×384 RGBA16F atlas; persistent atlases ping-pong per surface. Its value occupies only the secondary hit's indirect/environment slot: analytic direct light and emissive radiance are evaluated at the actual hit on both cache acceptance and rejection. A volume probe therefore cannot replace local surface lighting or make a nearby lamp blink at a cache boundary. Lookup deterministically trilinear-reconstructs same-side probes, visibility-tests the highest-energy corner that actually contributed (not a skipped young corner), and continuously blends overlapping levels. Insufficient same-side support, an unpublished lobe, occlusion, or out-of-range lookup rejects cache use and selects environment irradiance as the indirect prior; it does not change the direct/emissive estimator. New lobes require four stable batches before publication; published matching world cells remain valid and adapt with bounded 85–95% history.
4. The gather maps every raw receiver to the surrounding 3×3 probes. A candidate contributes only when its normal agrees and both surfaces satisfy linear-depth and bilateral world-plane tolerances; spatial, normal, and plane weights form the interpolation weight. Below the support threshold, the gather writes its normalized safe support when any exists, then compact-appends the receiver under the fixed frame quota. A GPU-authored indirect pass overwrites admitted receivers with two exact blue-noise samples using instance mask **Gi = 8** and `TMax = params.z`; temporal reconstruction supplies the sample count rather than widening a worst-case frame. Overflow never relaxes the surface tests and therefore cannot turn a performance limit into cross-surface light leakage. Diffuse GI deliberately excludes secondary specular; the reflection path owns that transport.
   Secondary-hit lighting honors each light's surface-shadow flag. Lights with shadows disabled retain their contribution without a visibility ray; shadow-enabled lights retain exact visibility.

   Secondary diffuse transport first evaluates the material's diffuse reflectance (`base_color * (1 - metallic)`). An exactly zero reflectance skips direct-light walks, shadow rays and indirect/sky lookup in both screen/triangle hit shading and world radiance-cache updates. This is lobe admission, with no brightness threshold: partially metallic surfaces and arbitrarily dark nonzero colours retain their existing transport. Emission, hit distance, motion validity and the signed environment-occlusion residual remain present. The surface-cache producer stores incident irradiance without albedo, so it retains its material-independent publication contract.

5. **World surface cache.** Secondary diffuse hits read a 512×512 spatial hash of **shadowed direct irradiance only**. Ambient fill, material occlusion, diffuse reflectance, emission and the world radiance clipmap/sky fallback are composed at the current hit on both cache hits and misses. Direct cache publication therefore never waits for indirect-probe confidence and cannot freeze a sky fallback or share another material's AO. The key uses 25 cm finest cells, distance-scaled levels, six normal buckets and four probes. The cache is one RGBA32F texture of four planes over the 512×512 slot grid (16 MiB per surface): entries, and lit stamps whose spare lanes hold the anchor measurement — readers fetch only the entry and the stamp's first lane — then the anchor point with its key, and a record of when a reader last asked for the entry, its packed normals and how many samples its value holds, which only the producer uses. It is allocated as `TextureUsage.scattered`, which turns off Metal's lossless compression: the table is read and written at hashed addresses, and each such write to a compressed tile was a tile read-modify-write. In the sequence bench that alone made lookups 2–3× and the producer 2–5× cheaper.

   **Admission and bounded publication.** Exact-zero diffuse reflectance skips lighting work. A hit's cache level is the finer of two bounds. The view bound keeps 25 cm cells out to 8 m and doubles them per distance doubling, so cells track projected size. The path bound is the coarsest cell no larger than the path that reached the hit: a cached value averages its cell, and a path shorter than its cell would take light from across the corner or contact it cannot reach — the path's length is its footprint. One surface therefore holds entries at several levels, long paths sharing its coarse cells and short paths into a crease its fine ones, each a consistent average at its scale, like texture mips selected by ray footprint. Only paths shorter than the finest 25 cm cell shade their hit exactly (`bypass_distance`). With the view level alone, shorter paths bypassed the cache however large the distant cell; the path bound moves about a third of those hits into the cache in a city-scale test scene (exactly shaded hits 15.5% → 12.4% of hits). Most of the rest are genuine contact paths under 25 cm; `bypass_quarter_cell` and `bypass_half_cell` split them by length. Secondary roughness and metallic thresholds belong only to the coarser indirect clipmap lookup; they do not reject a nonzero diffuse lobe from the direct cache. Specular transport stays in the reflection path.

   `surface_cache_prepare` resets two request ranges before tracing: 16,384 buckets for fresh lighting work and 49,152 for completed miss samples. A miss that already evaluated the producer's complete punctual sample set forwards its shadowed direct irradiance; this avoids tracing that lighting again. A cache hit requests fresh lighting, never feeds its cached value back as a new sample. A lower-budget miss also requests fresh producer lighting unless its budget covers every bounded light. Global lights are always evaluated by both paths. Each eligible hit makes exactly one request.

   **Jittered cells.** A reader looks up — and requests — the cell under its hit displaced by up to half a cell along the surface, from white noise keyed on the hit and frame. Lookups and the samples they request share the displaced point, so a cell's value averages its neighbourhood with a tent falloff and a hit near a border reads either side of it. Cell borders become per-pixel noise the GI denoiser resolves instead of seams it preserves, which matters most for the multi-metre cells at distance. The request carries the cell hash, so the producer files the sample under the cell the reader looked up while lighting the reader's actual point.

   A bucket is two claim words — the owning key, then a batch counter — and six `float4` payload words: the lit point with the cell hash, both normals, then up to three samples. A refresh needs one asker; further askers for the cell coalesce. Completed samples of the same cell join one batch of up to three, each taking its own word from the counter, so a frame's estimate of a cell is the mean of several points rather than whichever reader won the claim, and a new cell publishes from readers' own shading alone. Unrelated hash collisions drop requests, and the bucket hash rotates each frame to redistribute contention. The completed-sample range has its own capacity so miss feedback cannot displace maintenance of resident entries. Claims and payloads occupy 6.5 MiB per prepare slot, sized after a city-scale test scene requested about 135k distinct cells a frame into a quarter of that capacity and left entries expired or in bootstrap. There is no CPU readback or scene-specific policy.

   `surface_cache_update` runs after probe and fallback readers, with graph barriers on request buffers and the cache texture. Completed batches enter the history resolve without lighting rays; geometry-only requests trace one walk at the reader's point under the eight-punctual-light sample budget, and a published entry also re-measures its anchor (below): at most two walks per refresh bucket (262,144 punctual shadow samples per surface update, plus global lights, for at most 16,384 requests) plus bootstrap fills. The expanded feedback capacity does not expand that tracing ceiling. The pass does not bind the indirect cache or sky resources and fires no speculative camera/bounce rays. The update remains a future-frame history side effect even without a current-frame reader. New entries publish after three refreshes, and the producer completes them in the frame they are inserted: a new entry traces the missing light walks at once (at most two per request bucket), because otherwise it waits a refresh per frame, each needing a won request bucket, while every reader in between shades the hit in full.

   **Resident sweep.** The same dispatch carries 8,192 sweep lanes after the request lanes. Each frame they visit the next window of the table, so every slot is visited once per 32 frames. A slot readers refreshed within the last period is left to them; one they stopped refreshing is relit by an anchor measurement (`HIKARI_SC_UPDATE_LIGHT_SAMPLES` lights, like any refresh), which is both its sample and its change check, so an entry stays warm between visits instead of expiring and a light that changed while nobody looked still cuts its history. Relighting is not a use: a slot no reader has asked for in 128 frames is freed instead. The surface plane repeats the key, so a sweep never relights a value at a point a concurrent insert left behind. Inserts evict the probe a reader asked for longest ago, never the first probe blindly.

   **Estimator.** An entry's value is the cell's average, not a point: readers land anywhere on a cell, so wherever a cell spans a shadow edge or a light's falloff successive samples legitimately differ — by 100% at a sun/shadow edge. Each refresh folds its batch into a running mean that grows to 32 samples and then becomes an exponential window of that length. No sample-difference threshold exists. The previous estimator dropped history to 20% whenever a sample differed by more than half, so an edge cell flipped between its lit and shadowed values every refresh and every GI ray reading it flipped together: the "bubbling" at distance and around small objects, where cells are large relative to the shadows they straddle. In `surface_cache_sequence_bench`'s `shadow-edge` fixture (readers anywhere on their cell) 3.7% of published entries changed by over 20% a frame, p99 356%; this estimator measures 0.002%, p99 3%.

   **Measured change.** Each entry keeps an anchor: the point it was created at, and the irradiance a fixed-seed light walk found there (the stamp's spare lanes). Every published refresh and every sweep relight repeats that walk. The walk's randomness depends only on its point and seed, so the anchor moves only when lights, shadows or geometry did. Within a 5% relative change (per channel, against the brighter value, with a dark floor) history is untouched; at 50% or more it is dropped and the refresh's batch becomes the value; between, history shrinks linearly. A dropped history is refilled to publication in the same frame (bootstrap walks), so a switched light or a passing shadow reaches its cells on their next refresh while a cell across a static edge keeps its long mean. Changes the anchor cannot see — a small shadow moving within a cell away from the anchor, or a light not among the anchor walk's sampled lights — converge over the 32-sample window. An entry created from readers' samples has no anchor until its first published refresh measures one. The anchor walk is the price: it roughly doubles producer time in the sequence bench (working-set-sun 0.05 → 0.10 ms on an M5 Max), still a small fraction of the probe pass.

   **Freshness.** Light intensity, colour, position, addition/removal, sky changes and geometry motion do not clear direct-cache residency. Material/opaque-structure revisions, timestamp-era changes and surface lifecycle invalidation still sweep. An entry is served while it was lit within 64 frames (two sweep periods, so only a starved sweep, never the schedule, can reach the limit); past that it restarts with a new anchor. These are Hikari policy values, not vendor defaults.

   Key, refresh count and irradiance share one RGBA32F texel (`.r` packs `key << 2 | refreshes`, `.gba` is irradiance). All writers in a dispatch use the same lit stamp. The producer rechecks the stamp and resident key before blending; readers never overlap stores. Exact 24-bit integer decoding uses `round`. Aborted frames and replaced surface resources invalidate CPU sweep receipts; request reset is independent of profiling and lighting revisions.

   **Industry references.** [Epic's Lumen surface cache](https://dev.epicgames.com/documentation/en-us/unreal-engine/lumen-technical-details-in-unreal-engine) amortizes lighting updates over multiple frames on captured surface representations. [NVIDIA SHaRC](https://github.com/NVIDIA-RTX/SHARC/blob/main/docs/Integration.md) separates sparse updates, temporal resolve and queries, bounds history/lifetime, and rejects reuse when a path segment is smaller than its voxel. Hikari follows those general caching principles with its existing Metal/D3D12 geometry-demand producer plus a budgeted resident sweep; it does not implement Lumen cards or integrate the SHaRC SDK. The estimator, batching, anchor measurement and jittered addressing are Hikari's own, written from the ideas in Binder et al., "Fast Path Space Filtering by Jittered Spatial Hashing" (2018) — jittered cell addressing — and Schied et al., "Gradient Estimation for Real-Time Adaptive Temporal Filtering" (2018) — measuring change by re-evaluating a fixed sample — without reference code; see `src/hikari/shaders/PROVENANCE.md`.

   **Trace diagnostics.** `sc` covers secondary hits from both probes and fallback tracing. `miss` partitions into `zero_diffuse`, `bypass_distance`, `lookup_no_key`, `lookup_bootstrap`, and `lookup_expired`. Actual lookup attempts are `hit + lookup_no_key + lookup_bootstrap + lookup_expired`; the headline hit fraction also includes intentional bypasses in its denominator. `hardware_rays` and `hardware_hits` measure traversal/hit populations, not milliseconds. The obsolete `bypass_material` and `request_clip_rejected` counters are removed.

   Eligible demand partitions into `request_accepted + request_merged + request_duplicate + request_collision`: `request_merged` counts completed samples that joined a cell's batch, `request_duplicate` askers past its capacity. Accepted requests partition into `store_skipped + store_written`. `sample_reused` counts completed batches entering resolve; `refresh_traced` counts geometry requests whose lighting the producer evaluates, not individual rays. These are attempts, so a subsequent competing store can still skip publication. `store_ready` counts writes reaching bootstrap, `store_filled` the inserts the producer completed in their own frame, `sweep_relit` and `sweep_freed` the resident sweep's relights and frees, `anchor_measured` the anchor walks (refreshes and sweep), `history_cut` the stores whose anchor shortened history — a cut rate that stays high in a static scene means anchors are not deterministic — `store_evicted` the stores that replaced another key's live entry (table pressure), and `producer_clear` counts whole-table clears. Profiling splits: `bypass_quarter_cell` / `bypass_half_cell` count bypassed paths under 6.25 cm and 6.25–12.5 cm (the rest of `bypass_distance` is 12.5–25 cm), and `request_collision_sample` is the part of `request_collision` in the completed-sample range — the refresh range's collisions are its tracing budget, by design. Diagnostic atomics remain sampling-gated; ownership atomics always run. Wave reductions use identical active lanes for every field. Readbacks arrive one or two prepare-ring turns late: compare distributions and the separately timed preparation/update passes. Headless tests use emitted production helpers: `tools/metal/Harnesses/surface_cache_request_test/main.swift` verifies ownership and capacity, and `tools/metal/Harnesses/surface_cache_history_test/main.swift` verifies the estimator (running mean, window, anchor deadband/ramp/colour/dark floor), a shadow-edge cell's stability, bootstrap, same-frame bootstrap completion, measured dimming, light-off/on and colour changes, anchor ownership, the sweep's relight/skip/free verdicts (including a torn surface key and a light going out unobserved), flicker, expiry, timestamp wrap, normal separation, cell-size admission and the jitter's reach. `surface_cache_sequence_bench` reports per-fixture producer time and, for the sun fixtures and the in-cell `shadow-edge` fixture, the frame-to-frame change of published entries.

   **Counter integrity.** `sample_inconsistent` counts receiver results that violate either `hit + miss = hardware_hits` or `hardware_hits <= hardware_rays` before aggregation. After the probe/fallback barriers, one producer lane repeats these checks on the GPU totals, increments `trace_checked`, and increments `trace_inconsistent` on failure. The check runs even with empty demand. A complete sampled trace should have `trace_checked = 1` and both error counts zero; the raw counters are never repaired or suppressed. Local failures locate the problem before aggregation; aggregate-only failures point to publication/reset ordering or storage. If both GPU checks pass but recorded totals disagree, investigate readback/serialization. Missing check fields in older captures provide no such localization. `tools/metal/Harnesses/gi_trace_counters_test/main.swift` exercises emitted helpers through Metal 4 command buffers, argument tables and explicit fences. It covers GPU resets between reused frames, two producer dispatches, 8×8 and 64×1 groups, partially/fully inactive waves, empty demand, profiler-off execution, and deliberately corrupt local/aggregate inputs.

6. **Energy / units:** probe results, cache lobes, fallback rays, and `hikari_skybox_env_diffuse` always store diffuse irradiance \(E\). Cosine sampling gives \(E = (\pi/N)\sum L_i\); the last-mip cubemap/solid fallback is promoted from its legacy \(E/\pi\) approximation by `π`, and lighting always applies the Lambert `1/π`, so non-SH output is algebraically unchanged. The environment control variate uses exact \(E(N)\) as its deterministic baseline and \(\pi L_\mathrm{sky}(\omega)\) as each directional reference, whose cosine-sampled expectation is \(E(N)\); an unobstructed ray therefore cancels exactly without substituting a differently convolved diffuse lobe. Passing either cached or environment irradiance through a secondary diffuse hit multiplies by that material's diffuse factor without another `1/π`: the secondary Lambert factor and the receiver's cosine-final-gather `π` cancel. Shadowed direct diffuse and emissive are already expressed as `πLo` and are invariant across the cache decision. Firefly rejection operates per channel on the positive geometry residual, against two references, the larger winning. The **global-light bounce ceiling** (`hikari_gi_global_bounce_ceiling`) is the most a diffuse hit lit only by directional and ambient lights (directional at normal incidence) and the receiver's sky can return: a Lambertian surface reflects at most the irradiance it receives, and none of those sources can concentrate energy into a small solid angle, so a sample within it is ordinary transport and is never clipped, however few rays found it. Energy beyond it can only come from emission or local lights, and is kept as far as the rest of the batch corroborates it (8× leave-one-out in the gather, 3× in the radiance cache). The bound used to be 8× the receiver's own sky irradiance, which judged a sunlit bounce against the shade it lands in: in a sun-dominated synthetic fixture the gather's mean fell from 1.41 to 0.60 as rays per pixel dropped from 8 to 1 (a biased estimator), while the ceiling keeps it at 1.52–1.60 for every ray count; at six rays GI rose 24%, and up to 90% in converged fixtures. Local-light and emissive energy above the ceiling is held to batch agreement, so a scene lit mainly by lamps beside its geometry can lose a few percent at one or two rays (1–4% in the bench's eight-local fixture). The sky share is the receiver's own sky irradiance rather than the zenith's: one more sky evaluation miscompiles the probe kernel on Apple GPUs (`gi_probe_execution_bench` self-compare). Negative residual is sky occlusion; it bypasses that gate but is multiplied by `look.rt_gi_occlusion_strength` (default 0.85, physical 1.0) at the final per-pixel gather. Raw alpha is signed mean final-gather hit distance: its non-zero magnitude marks a valid receiver, a negative sign means a contributing secondary path touched moving geometry, and sky/metal receivers write 0. Probe interpolation preserves the validity/reactive contract but never invents validity from a rejected surface.
7. **Probe-level temporal accumulation.** A probe reads its own previous estimate through the *probe's* reprojected receiver and blends at a 0.75 ceiling (0.4 when the path touched moving geometry), ramping in over four frames.

   The probe lattice is anchored to the screen and surfaces to the world, so while the camera moves a world point is covered by a different set of probes every frame; accumulating only at the receiver, after interpolation, cannot see that re-association and reads as GI noise during movement. Accumulating at the probe makes the estimate follow the world. The ray-count taper is suspended alongside it: a motion term holds the probe at four rays above one render pixel of screen motion, so a fast pan roughly doubles probe cost. Two EMAs in series add about a tenth more latency.
8. The gathered raw signal first updates an RTGI-owned short radiance/luminance-moment history, then enters the stable temporal + tier-depth a-trous + full-resolution bilateral resolve (`rt_gi_fast_temporal` → `gi_temporal` → `rt_gi_atrous_{1,2,4[,8[,16]]}` → `gi_bilateral`). Both short and long history alpha channels store `E[L²]`, with `-1` as the invalid sentinel. During TLAS warm-up or mid-frame readiness loss, the short pair remains initialized from the SSGI fallback: SSGI alpha supplies producer validity only, and the fast pass writes the same radiance/moment contract while the main temporal driver stays on its SSGI filter.

**Secondary-light participation:** `LightComponent.affect_indirect` is independent of shadow casting. It defaults on for physical emitters. Artist-authored fill and fake-bounce lights set it off so SSGI bounce capture, RTGI, reflection-hit shading and traced transmission do not count already-indirect energy again. The frame partitions participating bounded lights into a dense prefix after globals; the RT secondary estimator samples only that prefix, so excluded fills consume neither a light stratum nor a visibility ray. Primary raster and RT direct lighting still evaluate the light normally. SSGI receives the same policy through the opaque-lighting alpha admission described above. Because no cached bounce depends on an excluded light, `lighting_revision.zig` leaves it out of the radiance-cache revision: a flickering candle or fill with `affect_indirect` off never invalidates settled lobes (it still updates direct lighting every frame).

**Temporal accumulation:** GI runs its own `global_illumination_temporal` package rather than sharing `temporal_effect` with reflections, whose ~1.25σ history drop and 0.02-screen-motion invalidation are wrong for a stochastic indirect signal. SSGI uses its current neighbourhood to bound new history. RTGI does not clamp geometry-valid long history to that bound: pixels inside one 8×8 probe footprint share a deliberately correlated current sample, so their local variance can be near zero while the probe still rotates stochastically; clamping to it would replace accumulated history with fresh grain every frame. Camera speed likewise does not shorten geometry-valid RTGI history: moving the view rotates probe directions and changes tile representatives, which is sampler noise rather than evidence of a lighting change. Primary-surface disocclusion and locally reactive secondary paths are the release authorities. Raw RTGI alpha carries signed final-gather hit distance (zero invalid, negative reactive). `rt_gi_fast_temporal` maintains a short radiance EMA (`β = 0.80`, roughly a three-frame half-life) and its luminance second moment; the long history (`β = 0.97` when converged) stores the same pair. Their change detector compares the two means against the conservative standard error `sqrt(Vfast·(1−βfast)/(1+βfast) + Vlong·(1−βlong)/(1+βlong))`, with a two-standard-error dead band. Positive covariance between the histories is deliberately omitted, so shared noise cannot make the detector more eager. After the long temporal resolve, signal alpha remains luminance second moment `E[L²]`; `-1` is the invalid sentinel, and the geometry guide stores explicit signed confidence. Geometry rejection reconstructs world position from current depth, transforms with `prevViewMatrix`, and compares against expected previous-view depth rather than comparing depths from different camera spaces. RTGI history reprojection gathers four taps and validates every signal/guide pair before renormalization, so bilinear filtering cannot turn a mixed valid/invalid footprint into a bright accepted history. Current neighbourhood taps are clamped to the governed active sub-rect rather than sampling stale allocation margins. RTGI then runs its tier's three to five half-resolution a-trous stages (strides 1, 2, 4, 8, 16), followed by a geometry-aware 2×2 full-resolution resolve. The signed temporal confidence accompanies the moment history into every spatial stage: a disoccluded or reactive receiver cannot report zero variance and masquerade as converged, while full-confidence low-variance receivers keep the center-only early-out. AO checks its existing settled-variance exit before gathering occluder reach; that exit and the reach rejection both preserve the same centre value. Depth, material, luminance variance, and broad shading-normal weights preserve indirect structure without crossing primary-surface boundaries.

**Dynamic secondary geometry:** Receiver depth/normal reprojection cannot detect a rotating occluder above a stationary floor. RT instance rows therefore mark changed rigid transforms and deforming geometry; screen-visible bounce reuse independently removes camera motion from the hit's G-buffer velocity. A probe sample that interacts with either publishes a local reactive bit. Temporal reconstruction dilates that evidence only across compatible receiver taps and shortens history there. On the next frame, the probe producer reprojects and geometry-validates the guide before expanding a low-sample reactive probe to a bounded four rays. Classification comes from previous-frame metadata rather than from the samples currently being averaged, so the estimator remains unbiased and motion elsewhere never raises the whole GI grid's cost.

**Lighting mix** (inside `hikari_eval_ibl`):

```
// GI off or confidence ≈ 0: bit-identical to pure IBL diffuse
env_diffuse = mix(ibl_env_diffuse, gi.rgb, gi.a)   // irradiance E; GI active only
// then existing lambert * env_diffuse * inv_pi
// ambient diffuse AO (hikari_ibl_diffuse_occlusion) fades from
//   multi_bounce(AO pass) x baked toward baked-only as GI confidence rises: the
//   AO pass answers the question the GI rays just traced, so applying both
//   darkens twice. Baked material AO always stays, never lifted -- it is detail
//   finer than a half-res GI trace resolves. The GI bounce pass subtracts the
//   same term, so the two must stay one function.
// Specular keeps full AO; specular IBL / reflections otherwise unchanged
```

Lighting also skips sampling the GI texture on near-pure metals (`metallic > 0.95`). Firefly control is preferred in producers (SSGI/RT GI); the IBL mix does not add a second hard clamp that would kill legitimate bright bounces.

**Uniforms:** `float globalIlluminationBounceValid` at offset 344 (occupies the alignment gap, so it costs nothing), then `float4 globalIlluminationParams` at 352:

| Component | Meaning |
|-----------|---------|
| `.x` | active (0/1) — lighting gate |
| `.y` | active backend sample count: quality-tier SSGI rays, or quality-tier rays per RT screen probe |
| `.z` | max distance (metres) for the **active** backend |
| `.w` | RTGI negative-residual/sky-occlusion strength from `look.rt_gi_occlusion_strength` (0–1; default 0.85; SSGI ignores) |

When GI is off the lighting pass binds a black 1×1 (confidence 0) and `.x` is 0 so the texture is never sampled. The same holds for the forward/transparent pass, which samples `indirect_diffuse` at its own screen position — without that, glass and foliage would keep pure IBL ambient while everything opaque behind them shifted. Host packing: `ShaderUniformsData.updateGlobalIlluminationParams`.

**Secondary-hit resolve order.** A final-gather ray first tries bounded Hi-Z traversal against current-screen geometry, then triangle RT if the screen result is unresolved. Both tiers evaluate the same diffuse-only secondary transport:

1. **Current-screen hit** — fetch current G-buffer material data and evaluate the shared secondary-hit lighting function.
2. **Triangle hit** — fetch bindless material data and call the same lighting function.
3. **Direct irradiance** — reuse a valid world-surface-cache entry when eligible; otherwise evaluate shadowed analytic lighting at the hit. Material reflectance and emission are composed at the current surface.
4. **Indirect irradiance selection** — a rough, non-metallic hit at least one
   cache cell (`HIKARI_RC_BASE_CELL_SIZE`) away may replace its environment prior
   with the world-cache estimate. A screen probe or exact fallback always traces
   its own final-gather direction; only that ray's *secondary* hit reads the
   cache. Primary interpolation is governed by the separate depth/normal/plane-
   tested screen-probe grid, never by cache-cell boundaries. Cache rejection
   changes only this indirect term, not local direct or emissive lighting.

For a shadow-enabled punctual light, secondary lighting must evaluate or validly
reuse visibility. Lights authored with shadows disabled remain unshadowed. Secondary-hit lighting scans up to 32 stratified candidates, then importance-resamples within the owning signal’s ray budget. Candidate strata and CDF targets use independently salted world/frame phases; sharing a phase biases light membership even when importance is uniform. The candidate expansion and inverse selection-probability weights preserve the estimate of the complete light set; lighting a hit
through its occluder is a bias no denoiser can remove, and in a closed interior —
where every gather ray lands on a wall rather than escaping to sky — it was the
dominant error.

**Cost:** the scalable final gather is one 8×8 screen probe, not one trace per raw
receiver. At 1920×1080 that is 32,400 probes; medium/high request 129,600/194,400
final-gather samples before cheap Hi-Z resolutions. Geometry-rejected
raw receivers enter the bounded two-ray fallback queue at unsupported silhouettes,
thin geometry, or mixed-surface tiles. Every triangle hit evaluates the small
directional/ambient prefix and the resolved punctual RT-hit width (up to eight
shadowed lights). That local term is deliberately not delegated to the volume
cache: screen-probe amortization bounds its receiver cost, while preserving
surface-local visibility prevents cache cells from turning lamps on through
walls. The fixed-size cache update carries the additional indirect bounce and
can afford to shadow every farther-hit light it evaluates.
Reflections retain the wider per-hit ceiling because their resolved specular path
is not the diffuse GI estimator. Prefer `@branch` early-outs (sky, metal, env off).

Secondary-hit lighting first selects and coalesces its punctual samples into a
bounded list of at most eight light IDs and weights, then traces their visibility
in a separate loop. Neighbouring lanes can select different candidate indices;
keeping ray queries inside the longer candidate walk would leave fewer lanes
active at each query. Compaction preserves the candidate distribution, sample
count, multiplicity, evaluation order and alpha-tested visibility. GI, direct
irradiance cache updates and reflections share this helper. It follows the
execution-coherence principle in NVIDIA's
[ray-tracing optimization guidance](https://developer.nvidia.com/blog/practical-tips-for-optimizing-ray-tracing/);
it does not introduce a new estimator or change shadow-mode routing.

**Reconstruction:** RT GI runs its authored half-resolution SVGF-style a-trous cascade at
steps 1, 2, 4, 8, and 16 (the first three to five, by tier), then a cheap geometry-aware 2×2 full-resolution resolve. The
RGB mean and luminance second moment flow through the cascade; the responsive
temporal guide supplies a signed history-confidence value to each stage. Each stage
propagates estimator variance, skips full-confidence converged receivers, and uses
depth, normal, roughness/metallic, and variance-scaled luminance stops. This
gives newly revealed receivers and indirect-contact regions enough spatial evidence
without crossing silhouettes or globally multiplying rays. RTAO and SSAO share
one cascade over one contract, followed by a conservative coverage-aware
resolve that does not transfer AO between half-resolution surface owners. SSGI uses the same tier-depth schedule with its own confidence-aware filter, followed by `hikari_bilateral_upsample`.

RT GI with master RT on warms the TLAS/hit-shading prepare path from the requested config (same request-driven model as RT reflections); graph-effective mode falls back to SSGI then off until RT PSOs exist.

### Contract E — Content async compute

**Owners:** `graphics/effects/content_compute.zig` (producer set, lag policy, ring helpers); materials declare intent; backends run ringed sims; graph declares one pass per producer.

**Material declaration** (JSON / `MaterialDesc`):

```json
{
  "binding_layout": "mesh_vertex_texture",
  "async_compute": {
    "package": "water_sim",
    "entry": "water_sim",
    "resolution": 512,
    "threads": 8,
    "format": "rg16f",
    "lag_frames": 0
  }
}
```

Optional `"format"`: `"r32f"` (default), `"rg16f"`, or `"rgba16f"`. Optional `"lag_frames"`: only `0` or `1` (default `0`; missing = `0`; other values are rejected). Channel meaning is content-defined. SMA2: format id in the reserved header byte; `FLAG_ASYNC_LAG1` for lag 1.

**Identity vs lag:**

- **Producer key** = package + entry + resolution + threads + format (deduped on the frame).
- **Lag** is per-material consumption policy and is not part of the producer key. Two materials may share one producer with different lag needs. Each lag retains an opaque/transparent consumer mask, so sharing never moves a queue wait earlier than the first pass that actually samples that version.

**Frame path:**

1. Visible materials with `async_compute` publish into a fixed `RequestSet` (cap 8; overflow logs + debug assert; rejected draws bind a fallback texture).
2. Prepare **ensures** GPU resources (ring textures, PSOs, frame CBs, dynamic-input buffers) for each producer; prefers async compute queue, falls back to graphics.
3. Graph assembly adds **one pass per producer** (queue = that entry’s actual queue, `never_cull`), imports the write and valid-history slots once, and attaches each sampled version only to its opaque or transparent consumer pass. A transparent-only producer can overlap opaque G-buffer work.
4. Pass drivers **dispatch** into ring slot N; lag-1 consumers read slot N−1 when that slot’s `produced_frame == frame−1`, otherwise bootstrap to lag-0 (same-frame sample).
5. Raster encode writes the selected current result and the prior produced frame into the bindless texture heap and stores those indices on `GpuMaterialRecord.vertexTextureIndex` / `previousVertexTextureIndex`. Both are graph-declared reads at the material's consumer phase; deforming vertex shaders sample the pair through `resources.texture_table` to emit deformation-correct motion vectors.

**Producer history:** a producer may declare
`@bind(content_compute.history) history: Tex2D`. It receives the previous ring
output (Metal texture 1 / D3D12 SRV t1), while `frame.historyValid` is one only
when that texture was produced on frame N-1. `frame.deltaTime` is elapsed
**simulation** time between those outputs, not wall time; it remains zero while
paused and a clock reset invalidates history. `frame.frameIndex` supports
stochastic sequences. The engine assigns no meaning to history channels or
lifetime: feedback, advection, decay, and rejection remain producer policy.

**Dynamic producer inputs:** games may replace up to 64 fixed-size
`ContentComputeInput` records per producer with
`hi.render().setContentComputeInputs(package, entry, records)`. Publication
copies only records for visible producers into the render-frame triple buffer;
each backend performs one contiguous upload and exposes the records at
`HK_BUFFER(content_compute,inputs)`. `frame.inputCount` is the only valid range.
The three `float4` payloads are intentionally shader-defined, keeping hull
footprints, terrain stamps, and other simulations out of engine policy. The
water sample workgroup-culls those records before evaluating contact foam, so
per-pixel cost follows local overlap rather than the scene-wide body count.
Its contact source is a narrow, texel-feathered world-space waterline driven by
normal impact energy; wake energy is carried separately. Bodies wholly below
the sampled free surface do not publish an interaction. Result alpha feeds back
through generic producer history, is bilinearly backtraced by analytic surface
velocity, and decays in simulation time. This preserves lace and wake trails
without widening crate footprints into filled blobs. The water surface samples
foam per fragment and footprint-fades distant detail.

The sample's water shading keeps the nine geometric waves and CPU buoyancy
mirror separate from its short-wave appearance. Six independently rotated
simplex-gradient noise bands span 6.5 m to 6.5 cm in the displaced surface's
parameter coordinates. Local hashed gradients and compact support avoid the
regular specular crosshatch produced by globally coherent sine waves. The bands
advect on simulation time at approximate deep-water phase speeds; this is a
shading model, not additional buoyancy geometry. Each band filters its stretched
pixel footprint before Nyquist and transfers removed RMS slope into roughness;
fully filtered bands skip noise evaluation. The final roughness takes the larger
of this variance estimate and local shading-normal derivative filtering, avoiding
both sharp undersampled highlights and additive double filtering.

Water-body illumination and the restrained backlit crest term use the macro
normal and the primary sun's atlas/cloud visibility. The crest term shares
transmission, foam coverage, Fresnel and absorption weighting with body scatter;
height remains an artistic crest proxy, not measured optical thickness.
Authored seabed thickness is relative to the undisturbed water level. Local
reflection tracing and depth tracing against submerged objects are separate
future work; this path still uses environment reflections and a projected
seabed capture.


**Sync authority:**

- Graph owns resource hazards, layout barriers, and **cross-queue release/acquire** edges for textures, buffers, and acceleration structures. Exclusive accesses form an ordered chain: reads wait for writes, and writes wait for both earlier readers and writers. Texture ownership is per mip/layer, so disjoint subresources remain independent. Timeline tokens are keyed by native resource identity and producer queue.
- Same-queue producer/consumer pairs emit no sync edges. Backends without a usable async compute queue run compute-domain passes on the graphics queue; the compiler then emits no cross-queue waits/signals for those edges.
- **D3D12:** compute lists cannot transition out of graphics-only states. `compile.zig::compileAccess` publishes `COMMON` on the last owning pass before exclusive texture/buffer handoffs. A final shader-read phase may instead share ownership: when a graphics pass can publish `shader_read` and no later access or terminal transition changes it, graphics and compute readers consume that publication concurrently. Imported textures already in `shader_read` also retain that publication when every access is a shader read and they leave in the same state; cached shadow atlases therefore need no in-frame writer. Explicit external-producer waits remain attached to consumers. The publisher owns synchronization; readers do not overwrite each other’s fences. Final transitions attach to the last owning pass of each subresource.
- **D3D12:** the graph tracks one layout per *subresource*, and a 3D texture has one per mip — `GraphTextureDesc.subresourceLayers()` returns 1 for `.d3` so barrier tracking never walks `z` as array slices (validation error 527). Terminal states pinned with `setTextureTerminalState` are checked in debug against next frame's first writer: pinning `shader_read` on a resource a compute pass writes first is the compute-demote bug, and `compile.zig::debugValidateTerminalStates` panics on it rather than letting it become an intermittent device removal.
- Lag-0 content-compute consumers acquire the current write production; lag-1 acquires the previous frame’s production (via `markCrossQueueProducer` when there is no in-frame writer). Acquires attach to the recorded raster phase, so transparent-only work does not stall G-buffer geometry. Lag-1 skips waiting on *this* frame’s dispatch, not sync entirely. Do not also wait in prepare for those edges.
- Prepare `retainOnly`s the live `RequestSet`; unused producer GPU resources are retired and freed after `frames_in_flight` so in-flight compute writes and G-buffer samples stay valid.

**Independence:**

- Engine has **no** water policy. It transports opaque producer inputs, a neutral `output`/`history` texture pair, simulation timing, and raster-consumer metadata. Sample game packages `water` + `water_sim` and `scenes/water_compute.json` interpret those facilities as displacement, foam, and buoyant-body footprints.
- Compute shaders must not reference G-buffer, lights, or RT. Surface shaders using the result only sample the produced texture.
- Works under deferred or RT lighting unchanged (both read the G-buffer after displacement).

**Profiler:** CPU zones `content_compute` (+ package name, ensure/dispatch). Encode/submit wall time, not GPU sim duration.

### Allowed cross-edges only

| From → To | Allowed interface |
|---|---|
| Content compute → raster consumer | Optional current/history textures at the material vertex-texture bindings; consumer phase is request metadata |
| Geometry → lighting / AO / reflections | G-buffer attachments + camera uniforms |
| Config → graph | `RenderPipelineConfig` after `resolvedForAvailability` |
| RT prepare → runtime UI | `ray_tracing.scene_ready` / effective modes in `RuntimeInfo` |

Backend execution binds the pass-specific handles recorded in `FrameTargets`; only final consumers such as tonemapping read `scene_color`.

Anything else (water checking RT flags, RT passes naming content packages, deferred requiring async compute) is a contract violation.

## Mesh LOD

Shinra generates LOD chains at cook (quadric edge-collapse, `cook/model/simplify.rs`). `model.generate_lods` is on by default; when on, cook builds up to three extra levels for rigid meshes ≥ 8192 triangles, each halving the triangle budget. There are no per-asset level or triangle-floor knobs. Collapses land on existing vertices (attributes stay authored), boundary vertices are locked, UV charts are paired per collapse, and a normal-flip test rejects fold-overs. The quadric is geometric only, so for alpha-cut materials a second test guards the texture map (`SimplifyOptions.uv_shift_tolerance`): a collapse may never fold a triangle's UVs, and every authored vertex it absorbs must still be mapped to its authored UV within two texels of the base-colour map — on a cutout the texture *is* the silhouette, and the ray-tracing proxy reads a coarser level than raster. A tessellated, curved leaf fails that at every collapse, so QEM removes nothing and card removal (`cards.rs`, exact surviving cards) carries its chain; a flat, affinely mapped fence collapses freely. Skinned models stay LOD0-only.

At runtime Hikari preserves Shinra's welded vertices and meshlet topology. It concatenates each LOD's vertex and meshlet slices, keeping the Morton triangle order Shinra cooked into each level; a raster LOD switch changes which meshlet run is emitted, never uploads geometry again. A derived index stream remains only for BLAS and physics.

**Raster-only camera selection is GPU-side.** `meshlet_lod` (`shaders/akari/packages/meshlet_lod.akari`) runs one lane per object and keeps a per-object `MeshletLodState { level, from, t }`. It picks the level from bounding-sphere screen coverage (LOD0 while the sphere spans ≥ 40% of the half screen height, one level per further halving) with ±0.1 hysteresis, applies the per-primitive `lod_bias` and `force_lod` overrides (inspector + scene JSON `render.lod_bias` / `render.force_lod`, or `render().applyRender` at runtime), and multiplies coverage by the global `world.mesh_lod_distance_scale` (`world().setMeshLodDistanceScale`) — coverage goes as 1/depth, so multiplying it *is* multiplying the switch distance. The host publishes the tuning (`LodTuning`, `scene/world/render/publish/lod.zig`) and per-frame `MeshletLodParams`; `lod_settings_dirty` forces one publish when a setting changes so a parked camera sees it. Cull bounds stay LOD0's so a shrunken silhouette cannot shrink its cull proxy.

**Opaque RT and raster share a render-thread selection.** After object updates and before culling/TLAS preparation, the per-instance planner evaluates the current draw camera using transformed LOD0 bounds, maximum absolute scale, authored bias/force and ±0.1 level hysteresis. It applies the instance budget minimum and sends the exact level to both the raster kernel and RT range resolver. A decision revision invalidates each waited GPU slot and the RT roster even if published proxies are unchanged. Opaque RT changes snap both representations together; a raster cross-fade against a single BLAS would reintroduce self-intersections. Selection changes move vertex/index and meshlet ranges together. Alpha-masked proxies retain publish-side bias and camera-motion stabilization, clamped by their own camera budget minimum; the independent VSM floor never controls RT. Skinned meshes remain single-level. The selected RT range is partitioned into spatial BLAS chunks.

### Cross-fade

Raster-only, alpha-masked and transparent LOD changes use `world.mesh_lod_fade_time` (default 0.25 seconds, `0` disables). Opaque RT views use the synchronized single-level switches above. Raising a budget minimum clamps obsolete fade endpoints before emission, so a previous fine level cannot overflow a newly reduced stream.

**One span, not two draws.** While `t < 1` the kernel emits both the target level's meshlet run and the previous level's (`HikariMeshletPairSpan.first/second`) for the same object row, so the fade costs no extra `HikariObjectData` row and no parallel direct draw.

**Coverage rides the visible meshlet record.** `meshlet_cull` packs the fade into word 0 of each visible meshlet record (`{late:1 | target:1 | fade:8 | work:22}`, `gpu_culling/types.zig`): zero means settled, otherwise the target level owns `fade/255` of the coverage and the old level its complement. Nothing per-frame is written to `ObjectData` or the geometry row for a fade.

**Screen door, then TAA.** The G-buffer is opaque, so each pixel picks exactly one slice: `hikari_meshlet_lod_fade` (`bindless_geometry`) turns the record into one `@flat` fade code, and `hikari_lod_fade_discard` tests it against animated interleaved-gradient noise. Only the masked G-buffer twin runs that test (see [Opaque and masked G-buffer pipelines](/docs/systems/rendering.md#opaque-and-masked-g-buffer-pipelines)): `meshlet_cull` sends a fading opaque meshlet to its bucket's fade stream, so the opaque pipeline never contains a `discard`. The two tests are exact complements — no seam, no double-shaded pixel — and the discard runs before any texture work, so the hidden half costs no bandwidth. TAA's depth verdict (2×2 footprint, continuous weight, 2%-of-depth tolerance) is far looser than the geometric error between adjacent QEM levels, so the dither converges instead of being rejected as disocclusion. Blended geometry skips the screen door: `forward_lit` / `forward_lit_rt` scale alpha by `hikari_lod_fade_alpha` for an exact dissolve.

**Shadows do not fade.** `shadow_meshlet_lod` picks one deterministic level per virtual page from projected texel size (no temporal state, so a cached page stays valid), and emits only that run. A screen-door pattern baked into a shadow map would have no temporal filter behind it to resolve.

**Edges.** A reversal mid-dissolve swaps `level` and `from` and continues from `1 - t` rather than restarting, so a camera hesitating at a threshold does not pop; any other change while a fade is in flight waits for it to settle. `force_lod` snaps. The step is `min(frame dt, 1/15 s) / fade_time` per frame (`renderer_prepare/objects.zig`), off the render frame clock rather than the sim clock, so a fade completes while the editor is paused and a hitch cannot turn a fade back into a snap. The state advances only on frames the culling kernels run; the idle scene-encode freeze ([Contract B](/docs/systems/rendering.md#contract-b--classical-deferred)) counts static publish skips and is not fade-aware.

## Swarm instancing

A swarm is a population, not a set of actors: every copy shares its template's mesh, material, colour, and shadow flag, and only the transform differs. Publish therefore emits **one run per template** — `SwarmDrawRun` = proxy + shared `ObjectData` + an instance range — plus a flat array of `SwarmInstance` (position + yaw, **16 bytes**, against `ObjectData`'s 224). The visible list interleaves variants, so the grouping is a counting sort: one pass tallies per template, a second scatters into reserved contiguous ranges, with the tally array rewritten in place into the scatter write heads.

Template shading lives in the resident primitive's `ObjectShading` row. A material replacement or late texture arrival must publish both the texture rebind and updated shading, including the sampling mask; transform-only instance runs cannot refresh that row. These updates run during render publication in Edit as well as Play and cost one row per changed template, independent of the population size.

The render thread expands those rows into the persistent scene's **swarm tail** (`PersistentOpaqueScene.rebuildSwarmTail`), the segment above `actor_count`. A tail has no stable identity — instance *i* is not the same copy it was last frame — so it is truncated and refilled rather than patched, and its rows publish `prev == curr` so a re-snapshotted population cannot invent motion vectors for TAA. `rebuildSwarmTail` returns whether the tail's *shape* changed, which is the only thing that forces the draw queue to be rebuilt: a flock that moves without gaining or losing members keeps its resolved queue.

Keeping the swarm segment separate from actor rows is what keeps the actors' stable patch path alive: a publish carrying swarm instances bumps `opaque_synced_revision` and marks the delta untracked so the pack store re-packs the stream, while the structure, the queue, and the culling staging survive. The GPU instance format is still `ObjectData`; the 16-byte row is a saving on the published frame and on per-instance publish work, not on GPU memory.

## Implemented pipeline

Owner of graph order: `graphics/rendergraph/pipeline.zig` `assemble`. Packages and `PipelineKind` tags: [Shader authoring](/docs/guides/shaders.md#pass-roles-and-packages).

### Pre-graph work (prepare / encode)

GPU traces split pre-graph culling into `gpu_culling.pair_index` (pair setup, including table uploads on D3D12), `gpu_culling.camera_lod`, `gpu_culling.camera_meshlets`, and `gpu_culling.shadow_prepare` (caster-resource preparation; page LOD/culling runs inside `shadow_depth`). LOD selection feeds the shared wave-cooperative meshlet pair queue used by meshlet submission. Metal measures the boundaries inside the existing compute encoder. Timestamps and cache counter atomics run only on sampled frames and can add measurement overhead. The profiler and native timestamp backends share a 128-pass capacity so detailed culling cannot truncate the tail of a full RT graph.

| Step | When | Notes |
|---|---|---|
| Content async compute | Material declares `async_compute` | Prepare ensures ringed producers; graph pass dispatches; lag-0 wait at G-buffer |
| Light-cluster build | Always (`light_binning` hierarchical compute: init → coarse scatter → fine build) | Logarithmic per-project (`capacity.light_grid`: 16×9×16 / 24×14×24 / 24×14×48) fine grid fed by a 6×4×6 coarse pre-bin (header + index pool) shared by deferred, forward, and RT lighting |
| Shadow meshlet cull | Shadow faces exist | Per-face/class meshlet cull and visible-triangle expansion; writes plain indirect draw counts |
| Skinned RT writeback | RT-eligible skinned primitives | Deform each packed source into its unique rigid RT stream after palette upload; successful queue-ordered submissions stamp the current per-pose receipt |
| TLAS prepare | RT master on and an RT mode needs the scene | Shared by RT shadows / AO / reflections / GI; empty opaque set ⇒ all RT fall back |
| Bindless texture namespace + raster material table | Any scene raster draw | Frame-ring resident; shared with RT hit shading |
| RT instance table | Any ray-traced visibility feature this frame | Carries geometry/material rows for alpha continuation; reflections/GI also use it for full hit shading |
| Camera meshlet culling | Mesh geometry exists and meshlet PSO is ready | Expands visible triangles into pipeline buckets; `gpu_frustum_culling=off` uses the same path in fail-open mode |

### Render-graph passes

Order below is the assembly order. Conditional rows are skipped when their gate is false. Pass names match graph / profiler labels.

| Pass name | Gate | Role |
|---|---|---|
| `shadow_depth` | Virtual views exist **and** shadow mode is raster or hybrid | After final opaque depth: mark opaque/froxel/coarse demand, allocate pages, cull and raster bounded page batches, publish residency. All caster classes retain exact coverage. |
| `content_compute:<package>:<entry>:<i>` | Visible async_compute producers | One pass per producer; writes ring slot N. Profiler / GPU timing use this unique name. |
| `gbuffer_geometry` | Always | Opaque deferred G-buffer + motion vectors; per-batch `mesh_vertex_texture` from content compute |
| `depth_pyramid` / `depth_pyramid:<start>` | Occlusion Hi-Z (`gpu_frustum_culling` and `quality.occlusion.mode != off`), contact shadows (raster or hybrid, `quality.contact_shadows` ≠ off), screen-space AO/reflections, or any GI mode | One half-res RG16F mip chain (min/max) over the active render extent, built in compute chunks of up to eight levels per encoder and addressed by pixel shift (see **Depth pyramid addressing**); feeds the correct phase this frame, the predict phase next frame, contact shadows, SSAO, SSR, SSGI, and RT GI's mid-frame SSGI fallback. Chunks after the first are named `depth_pyramid:<mip>` |
| `occlusion_cull` | GPU culling **and** `quality.occlusion.mode == two_phase` | Correct phase: re-tests predict-phase occlusion rejects against the pyramid just built |
| `gbuffer_geometry_late` | same as `occlusion_cull` | Draws the instances the correct phase recovered into the loaded (not cleared) G-buffer |
| `depth_pyramid_rebuild` / `depth_pyramid_rebuild:<start>` | two-phase GPU culling **and** another pyramid consumer (contact shadows (raster or hybrid, `quality.contact_shadows` ≠ off) / SSR / any GI mode / volumetrics) | Re-runs over the completed depth so later passes do not sample the predict-phase subset |
| `deferred_decal` | At least one decal with non-empty screen bounds **and** the PSO + bindless white fallback exist | One tiled compute rewrite of albedo / normal / ARM after all G-buffer geometry (including late). CPU-built occupied 16×16 tile lists preserve stable overlap order; only covered pixels run, and only modified pixels store into the existing G-buffer triple. Lighting, AO, and GI sample those same resources. Depth and material stay the originals. Zero visible decals ⇒ the pass is not in the graph |
| `velocity_tile_max` / `velocity_tile_max:<start>` | `features.motion_blur != off` **and** the active camera's shutter is open | Conservative search pyramid over `gbuffer_motion` (4×4 base tile, halving per mip), built only across the active dynamic-resolution sub-rect. Motion blur checks the nearest 2×2 cells at the selected coarse level so a moving silhouette can cross a tile boundary without a separate dilation pass. It bounds where a long shutter gather looks; it is never the destination pixel's motion. |
| `volumetric_density` → `medium_shadow_build` → `volumetric_inject` → optional `volumetric_resolve_rt` → `volumetric_integrate` | `volumetric_fog` on and density active | `medium_shadow_build` writes light-space optical depth for the admitted fog casters (a 1×1×1 placeholder without any), which every surface, froxel and particle receiver reads with one fetch. Pure RT uses a sun visibility cache and bounded local-light sampling; inject/resolve run on graphics with the current TLAS. Raster/hybrid retain map-backed injection on async compute when available; integration can also run there. Medium response stays current-frame. See [Volumetric media](/docs/systems/volumetric-fog.md#pure-rt-sampling-and-reconstruction) |
| `screen_space_ambient_occlusion` / `ray_traced_ambient_occlusion` | AO ≠ off | Quality-scaled screen-space compute or fixed-half-resolution RT visibility trace |
| `ao_temporal` → `ao_atrous_1` → `ao_atrous_2` → `ao_atrous_4` → [`ao_atrous_8` → [`ao_atrous_16`]] → `ao_bilateral` | AO ≠ off | Shared RTAO/SSAO half-resolution scalar variance cascade and conservative full-resolution coverage-aware `R8` visibility |
| `rt_gi_radiance_cache_update` | RT GI active | Fixed 96×384 directional world-cache maintenance; atlas ÷ `rc_update_period` four-ray lobe batches per encoded frame (4,608 low, 9,216 medium/high, 18,432 ultra) |
| `rt_gi_screen_probes` → `ray_traced_global_illumination` | RT GI active | One quality-scaled final gather per 8×8 probe, then geometry-aware interpolation with a bounded queue of two-ray exact receiver updates |
| `screen_space_global_illumination` | SSGI active | `quality.gi`-scaled screen-space diffuse irradiance + confidence |
| (`rt_gi_fast_temporal`, RT only) → `gi_temporal` → three to five a-trous stages → `gi_bilateral` | GI ≠ off | SSGI and RTGI run strides 1/2/4[/8[/16]] by `quality.gi` before full-resolution `indirect_diffuse` (RGBA16F). With AO active, the stages both tiers share may fuse as `ao_gi_atrous_*` and the deeper chain finishes alone; otherwise they use `ssgi_atrous_*` or `rt_gi_atrous_*`. RTGI also keeps its short radiance/moment history. |
| `raytraced_direct` | Full RT shadows active | RGBA16F normalized attenuation on the **trace grid**: half render resolution below the Ultra shadow tier (`ShadowQuality.rtTraceScale`), render resolution at Ultra, further scaled by the governor's indirect-resolution lever while the history stays on the tier's grid. Each trace texel shades the receiver `hikari_gbuffer_receiver` assigns it (closest depth of its footprint). Alternating diagonal half-rate pairs in smooth interiors, with opposite-diagonal supplementation at depth/normal discontinuities. Alpha is the `shadow_penumbra` contract: negative for an untraced phase, otherwise the tightest penumbra half-width (metres) the admitted rays measured from their occluder distances (`rt_occluder_distance`, the any-hit query reporting where it stopped) |
| `direct_temporal` → `direct_bilateral` | RT shadows active | Validity-aware reconstruction and history on the trace grid (every raw tap, centre included, must own the history texel's receiver). The resolve writes render-resolution `direct_lighting`: a bilinear core over the trace texels that own this pixel's receiver (judged from the temporal guide's normal and depth, one fetch per tap); a settled, zero-variance core is the answer. Otherwise a penumbra-bounded joint-bilateral kernel — reach is the measured penumbra width in render pixels (min over the raw 3×3, capped at 8), falling back to `8 / √(1 + frames)` where no ray hit anything — with whole-texel strides. On a reduced grid, pixels no core texel owns, and hard edges (core spread > 0.35 with a measured penumbra under two pixels), are marked for refinement (`HIKARI_DIRECT_REFINE_UNOWNED` / `_EDGE`; the reason feeds the counters and the RT shadows debug grid) |
| `raytraced_direct_refine` | Full RT shadows active on a reduced trace grid | Re-traces the marked pixels at render resolution with two fixed emitter samples per light, compacted per 8×8 tile, rewriting `direct_lighting` in place and keeping the mark in alpha as provenance (`HIKARI_DIRECT_RESOLVED` = answered from the trace grid). Only visibility ever leaves render resolution: `raytraced_lighting` evaluates every light's BRDF per pixel and multiplies this attenuation in; nothing but the counters and the debug grid reads alpha |
| `deferred_lighting`, `hybrid_lighting`, **or** `raytraced_lighting` | Always one | Classical deferred (VSM), bounded hybrid (RT globals + VSM punctuals), or full RT-shadow lighting; all write HDR color and sample GI into IBL diffuse when active |
| `gi_bounce_capture` | GI ≠ off **and** an HDR scene target exists | Half-res depth-owned secondary radiance (prior GI, view fog, aerial perspective, and environment specular removed) into the `gi_bounce` ping-pong for next-frame SSGI and RT-mode fallback |
| `scene_color_pyramid` | Reflections ≠ off | Box mip chain of the lit opaque scene (level 0 = half res, four levels) for cone-footprint hit reads |
| `screen_space_reflections` / `ray_traced_reflections` | Reflections ≠ off | Half-resolution stochastic producer (compute): one GGX visible-normal sample per pixel, radiance + hit distance (RT path: hybrid reuse at the cone mip + optional hit re-light) |
| `reflections_resolve` | Reflections ≠ off | BRDF/pdf neighbour reuse (ratio estimator) over the raw samples; writes the lobe mean plus the virtual reflected distance |
| `reflections_temporal` → `reflections_bilateral` | Reflections ≠ off | History reprojected through the virtual reflected point, then the full-resolution split-sum composite |
| `volumetric_composite` | Volumetrics on | Render-domain `scene * transmittance + inscatter` onto atmosphere-composited opaque HDR before transparent draws and TAA/TAAU; writes fog opacity for motion-only history reactivity |
| `transmission_capture` | A visible transparent material has transmission | Builds a four-level persistent RGBA16F pyramid from the current opaque HDR, including reflections, for rough refraction |
| `forward_transparent` | Transparent draws present | Refractive vertex-only depth prepass, nearest-depth refractive shading, then the additive lane; shares light clusters and uses RT geometry visibility in pure RT, cached VSM visibility in raster/hybrid. The RT companion traces authored volumes automatically and thin interfaces only by explicit override. Samples `transmission_capture` and the integrated volume when volumetrics owns the medium. Every transparent scene mesh is a meshlet bucket drawn through the same visible-triangle stream as the opaque scene — there is no CPU-sorted transparent item loop |
| `forward_oit` | Alpha-blended transparents present | Weighted blended OIT (smooth inverse-quartic depth weights, optical-depth revealage) into an RGBA32F accumulation + metadata pair with one additive blend state; reads scene depth, writes none |
| `oit_composite` | Alpha-blended transparents present | Resolves the three accumulation targets over the loaded HDR scene (premultiplied), writes weighted-mean motion/depth into `transparent_temporal`, and previous depth/valid coverage into `transparent_previous_depth` |
| `temporal_reconstruction` | Anti-aliasing = TAA / internal reconstruction | Output-domain history ping-pong; render-res current/depth/motion; mixed-res TAAU when scale < 1 |
| `sharpen` | Internal temporal resolve ran (not a registered plugin provider) | RCAS on the resolved output into a transient target — never into the history ping-pong. Strength scales with the preset's upscale ratio (mild at native TAA) and fades to a quarter on pixels the resolve has barely accumulated; HDR handled by Reinhard-compress → kernel → decompress, then bounded to the contributing HDR neighbourhood so negative lobes cannot create silhouette halos |
| `exposure_downsample` → `exposure_reduce` → `exposure_adapt` | `features.auto_exposure != off` and all three pipelines ready | Centre-weighted, hierarchically trimmed log-luminance reduction of the clean post-reconstruction image (full → 64×64 → 8×8 → 1×1), then cut-aware stops-per-second adaptation into a persistent 1×1 ping-pong the tonemap samples |
| `auto_focus` | DOF scheduled **and** the camera asks for auto-focus, pipeline ready | One 1×1 draw: five-tap subject median + 24-tap bilateral centre meter, then persistent diopter-space adaptation; gather/composite sample the current value |
| `depth_of_field_gather` → `depth_of_field_composite` | `features.depth_of_field != off`, pipelines ready, **and** the active camera's CoC scale is non-zero | Half-resolution golden-angle bokeh gather, then a full-resolution composite that keeps in-focus geometry pixel-sharp |
| `motion_blur` | `features.motion_blur != off`, pipeline ready, **and** the active camera's shutter is open | Cut-safe exact opaque/forward-transparent velocity plus rotational sky reprojection, with a tile-max search envelope. Each tap must have a shutter segment that reaches the destination, then passes the depth test; coarse foreground velocity therefore cannot smear background over a rectangular tile. |
| `camera_lens_visibility` → `camera_lens` | The active camera enables lens flare and at least one enabled light opts in; vignette or chromatic aberration alone schedules only `camera_lens` | CPU selection ranks the four strongest opted-in directional, point, spot, or area emitters visible in the lens; punctual rank follows apparent bulb size (`source_radius / distance`), never the light's shading cutoff radius. A 4×1 pass projects and depth-tests their source discs with 25 taps each. The HDR finish synthesizes aperture-coupled round/polygonal cat's-eye ghosts, radius-scaled spectral separation, halo, veiling glare, starburst, and a cool anamorphic streak analytically—scene pixels are never transformed into ghost shapes. |
| `bloom` | `quality.bloom` not `off`, bloom intensity active and pipelines ready | Karis-stabilised half-res bright pass and a 3/4/5-level dual-filter pyramid by `quality.bloom` (half through sixteenth / thirty-second resolution). 2·levels−1 dependent dispatches stay in one compute encoder, each binding one storage output and sampled source/detail inputs. Hardware bilinear filtering replaces manual four-load interpolation; explicit storage/sample transitions order stages and restore graph state before publication; normalised reconstruction (level weights sum to one on every tier) prevents energy growth |
| `bloom_composite` | Bloom on **and** debug grid replaces tonemap | Full-res add into a dedicated HDR target (grid samples `scene_color` directly) |
| `look.tonemap` | Any HDR post path, with debug grid off | Selected operator or passthrough to backbuffer; adds the half-res pyramid itself when bloom ran (`post.bloom_add`); triangular-PDF dither at ±1 LSB hides 8-bit banding in shallow gradients |
| `debug_visualizers` | Any non-off `VisualizerMode` | G-buffer/effects/shadow grids, HDR-nits false colour, full-screen TAA/GI diagnostics, or the meshlet id / settled views; reads diagnostic resources without modifying their histories and replaces tonemap |
| `ui` | UI execute callback present | One flattened UI draw on the backbuffer |

OIT uses two RGBA32F additive targets for HDR radiance/weight and motion/current depth/log coverage, plus one RG32F target for previous depth/valid weight (40 bytes per render pixel total). Relative weights are normalized to a maximum of one; zero-alpha fragments contribute no weighted color. Resolve divides by the actual positive weight, preserving very thin coverage. The resolved scene and motion/current-depth metadata remain RGBA16F; the additional previous-depth/valid-coverage guide is RG16F (4 bytes per render pixel). The opaque RG32F depth pair costs 4 extra bytes per render pixel relative to a single R32F depth channel.

An HDR `scene_color` target is created for tonemap, debug grid, reflections, TAA, or transparency. AO alone stays on the direct lighting path. Every HDR path uses the passthrough composite when tonemap is `none`; transmissive materials additionally build the pre-transparent pyramid. When tonemap runs, bloom is folded into that pass rather than writing a second full-resolution HDR target only for tonemap to re-read.

Transparency is intentionally outside the reflection geometry domain. Screen-space and ray-traced reflections use the opaque G-buffer/TLAS and finish before transmission capture. Transparent surfaces receive reflection in forward lighting: the raster path uses environment specular, while smooth thin interfaces trace the opaque scene when RT reflections are active. They can also refract the already-reflected opaque scene, but they neither appear as reflection geometry nor receive the opaque reflection composite afterward.

Transparent material `flags.alpha_mode` is `straight` by default or `premultiplied`. It selects the shared RHI blend state; the forward shader premultiplies its final lit RGB only for the premultiplied mode. Albedo texture alpha always contributes to coverage, and premultiplied texture payloads are normalized before lighting. **`params.transmission > 0` selects the refractive path** (no blend): a vertex-only companion first writes nearest reverse-Z depth using the material's exact vertex entry, then `forward_refractive` shades with `greater_equal` and no depth write. Hidden glass layers fail before fragment lighting, capture lookup, or refraction; alpha-only transparents accumulate afterward through weighted blended OIT (`forward_oit` → `oit_composite`), so no transparent draw depends on CPU depth sorting. This is a global material policy, not an asset flag or quality option. Content-compute is not the transmission signal. Mesh geometry and forward PSOs cull back faces on Metal and D3D12 (`pipeline_kind.cullMode`); the forward-lit shader also flips normals for back-facing fragments so closed glass shells do not shade the far wall as an opaque dielectric when both sides are drawn. The transmitting BRDF is an IOR dielectric: one F0 from IOR for direct, IBL, and the transmission mix; metalness cannot steal that F0; IBL specular is not gated by baked AO. Raster refraction selects the opaque-capture prefilter from material roughness because the capture has no hit distance; treating authored volume thickness as distance to the first visible content over-blurs embedded objects. Thin-walled (`thickness == 0`) still refracts in screen space (one pixel of world length at the surface); volumes use authored thickness for displacement and Beer-Lambert, while the RT companion obtains the real hit distance.

**Thin transmission is also its own automatic lighting model.** A transmissive material with `thickness == 0` treats the opaque capture as the already-lit diffuse/punctual/GI result behind the interface, then adds the interface's global-light dielectric highlight (with geometry visibility from the selected shadow mode), split-sum reflection, refraction, cached forward fog, emissive and temporal metadata. The raster path sources that reflection from the active environment. When RT reflections are active, the forward RT companion instead traces the reflected ray against opaque scene geometry and shades its hit, because the earlier opaque reflection pass cannot receive transparent geometry rendered afterward. A miss retains the environment result; the frame RT budget can disable the extra ray. The reflected lobe is already Fresnel-weighted, so thin transmission adds rather than re-mixes it while the transmitted capture receives `(1 - Fresnel)`. It does not walk the bounded punctual-light cluster, sample screen-space GI again, evaluate ambient diffuse again, or read fog-volume shadows per light on every glass pixel; its unused AO texture read is suppressed as well. Local lamps remain visible through the lit capture but do not add a second analytic highlight to the pane itself. Authored volumes (`thickness > 0`) retain the complete forward light cluster, surface GI and per-light medium shadow map attenuation; when RT and its refraction budget are available they also trace the refracted hit automatically, because a 2D capture cannot represent an embedded object or a curved volume's real exit. `traced_refraction` remains an override for hero-quality thin interfaces. The classification comes from the existing physical thickness value and has no scene-specific flag or user-facing quality level.

**Editor composite** is outside this graph: when the viewport is inset (`ViewportInsets`), `_engine/composite` blits the offscreen scene color into the drawable chrome region (`renderer_viewport.zig`). That blit is the `viewport_composite` GPU pass; HDR UI-into-layer is `editor_ui_layer`. Editor chrome then renders at native drawable resolution, after scene temporal processing and without scene scaling or filtering. Standalone games present the backbuffer directly.

Pass entry load operations apply equally to offscreen textures and the swapchain. In particular, the standalone HDR `ui_composite` pass loads the tonemapped backbuffer before blending its SDR UI layer over it. Overriding that request with a swapchain clear erases the entire scene while leaving a working HUD; the editor's separate offscreen composite can mask this error. `graphics/renderer/pass_targets.zig` preserves the caller's load operation and clear color for both targets.

Atmosphere textures stay cached on the device when switching sources or drawing another viewport. Each consumed view selects whether that cache is active: solid-color and authored-cubemap views bypass its bindings, cloud passes, and fog ambient. With both skybox slots empty, the Environment panel tint (or preview Backdrop) supplies a uniform background; choosing a resident cubemap selects that texture immediately.

Particle billboards, stable-order billboards, and ribbons select raster pipelines by their draw lane. Alpha emitters accumulate into the shared OIT attachment pair (`pipeline_kind.oit_formats`, RGBA32F); additive emitters draw into the HDR scene pair (RGBA16F). Both lanes blend additively, but their attachment formats require distinct pipelines. Metal and D3D12 derive those formats from `DrawLane.pipelineKind()` so changes to OIT precision apply to particles as well as mesh transparency.

The global environment is owned by `World` and has two explicit sources. `setAtmosphere` selects the dynamic clear-sky source: one canonical state feeds the visible sky, diffuse irradiance, roughness-filtered specular fallback, GI integrands, reflections, raster/RT misses, and aerial perspective. Sun and moon disks are visible-only; directional lights own direct energy and shadows. The profile's coefficients define one optical path and everything crossing it shares that one definition: the sky's in-scatter, the visible disks, and `atmosphere.sunlightColor` / `moonlightColor` handing the same result to a linked directional light. So a low sun reddens and dims the disk in the sky and the light casting the scene's shadows by the same amount, with `sun_color` remaining the top-of-atmosphere reference and the light's authored intensity the extraterrestrial scale. The disks read that extinction from a uniform the CPU already computed for the lights (`atmosphereSunLit`), so the two cannot drift apart by construction rather than by two evaluators agreeing.

The path is a ray-sphere march against `planet_radius` and `atmosphere_height` through exponential Rayleigh and Mie layers plus a linear ozone tent at 25 km, baked into three tables in the Hillaire arrangement (`atmosphere.akari`, `atmosphere_lut.akari`): transmittance 256x64 and multiple scattering 32x32, both functions of the medium alone and rebaked only on a profile edit; and the sky-view table at 192x108, which follows the sun. The environment cube (6 × 64²/128²/256² by `quality.environment`, mip chained to a 4² irradiance tail; a tier change reallocates it, keeps the previous cube bound until the new one is written, then retires it) is a *resampling* of the sky-view table plus the cloud layer marched into it, not a second evaluation — so the sky the camera sees and the sky that lights the scene cannot disagree, and the cloud layer composited into the cube reaches ambient, specular and ray-traced misses without any of those paths changing. The camera's own view of the clouds is a separate reduced-resolution compute march (`kumo_screen`) scheduled before lighting and composited by it, so one `hikari_kumo_march` serves both: the cube converges (no jitter, no history, world-origin anchor) because its tail level is the scene's diffuse irradiance, while the screen pass is anchored at the camera, stops each ray on the depth buffer, and carries a spatial-only dither so a still frame is bit-identical frame to frame. The march skips empty space: after two consecutive empty samples it probes every second step with the un-eroded base field, an upper bound on the eroded density, and a probe that finds cloud steps back one fine step and resumes fine stepping, so the fine step still resolves every boundary and only steps the fine march would also have found empty are skipped. The weather field (three octaves of value noise, kilometre-scale cells) is evaluated once per view sample and inherited by that sample's light cone, and once per column of the cloud shadow bake, whose clear-weather columns return without marching. `quality.clouds` tiers the two march budgets, the screen scale, and the cloud shadow map (256²/12 steps Low, 512²/24 Medium, 512²/32 High, 1024²/32 Ultra; centre snapped on a shared 62.5 m grid) and the shape noise volume (64³ on Low, baked band-limited: Worley octaves finer than the 128³ reference resolves at that edge — exactly the shape volume's finest erosion octave — are replaced by their mean, so placement and large-scale form are unchanged; 128³ otherwise; the 32³ detail volume is fixed, and a tier change bakes the new pair while the old one stays bound); `off` spends nothing anywhere — not the two marches, and not the costs the camera never sees either: the two noise volumes are not allocated or baked, the per-frame shadow map is not baked, `hikari_kumo_enabled` closes so no lit surface samples it, and cloud drift stops making the environment cube stale. The tier is ANDed with the authored `cloud_coverage` in one place per layer (`clouds_active` for the graph, `cloudLayer.w` for the shader) precisely so the two cannot disagree — a tier that skipped the bake while leaving the shader gate open would shade the ground under clouds it had not drawn. Anything written into the cube is clamped first (`hikari_sky_cube_clamp`): the tail level is the scene's whole diffuse irradiance, so one bright texel — a sunlit cloud edge against a dark base — would otherwise propagate into every surface in the frame through L0. The sky-view table reaches every consumer as one bindless index (`HikariLightingResources.skyView`); the visible sky pass, the cube's write kernel and aerial perspective's asymptotic source all read it and nothing else. Aerial perspective reads it through `hikari_atmosphere_haze_radiance`, which clamps the lookup to the horizon row: the table's below-horizon half models a Lambertian planet surface, so using it as haze both double-counts the ground the scene already draws and steps by about 3.4x in red across the horizon — a hard, hue-shifted line over every surface that straddles it, and over the whole frame on a scene with a raised `aerial_perspective_distance_scale`. The air between camera and a surface below the horizon scatters the same light the horizon sky does, so the horizon row is the physically correct limit as well as the continuous one. None of these resources scales with render resolution.

Curvature is what makes twilight physical rather than tuned: the horizon is a real intersection, so a low sun keeps reddening down to it, and the per-sample test for whether the path to the sun meets the ground is Earth shadow and the belt of Venus. The 96-sample CPU spherical-harmonic projection is retired — diffuse irradiance is the cube's cosine-convolved tail level, which the diffuse path already reads through its last-mip lookup. The full atmosphere profile is published as POD frame state, so a game clock changes no texture residency and allocates nothing; the sky rebuilds on a profile edit or 0.25-degree celestial movement, and a still sky rebuilds once. Aerial perspective integrates the profile's exponential Rayleigh and Mie density fields analytically from the camera to each opaque or forward surface, applies wavelength-dependent extinction, and converges toward the same directional sky radiance used by misses. It is fused into the lighting paths instead of adding a full-screen copy: base lighting receives extinction and in-scatter once, the later reflection lobe receives extinction only, and bounded fog composes last. Sky pixels remain untouched because their radiance is already evaluated at the observer. `aerial_perspective_distance_scale` converts world units to atmosphere metres (1.0 means metres), while `aerial_perspective_strength` is the bounded art-direction multiplier; either may be zero to make the shader branch an identity without disabling the procedural sky. The astronomical helper and authoring contract are in [Time of day](/docs/guides/time-of-day.md).

The alternative authored-cubemap source uses `setSkyboxColor` / `setSkyboxTexture` / `setSkyboxTextureSecondary` / `setSkyboxBlend`. Texture setters are value-idempotent: only a cubemap identity change in a side with nonzero blend weight cuts temporal history, so a runtime transition may stream or release the fully hidden side without exposing a raw stochastic-lighting frame. Dual sides mix with a single path: `mix(sampleA or solid, sampleB or solid, blend) * exposure` (`color.a` is exposure). An intentionally empty side is the authored solid environment and participates in the blend; an authored cubemap not yet GPU-ready clamps the fade toward the resident primary (no streaming flash). IBL specular and diffuse irradiance track the same blend; a solid side is represented analytically by its constant L00 SH coefficient. Missing or failed cubemap loads fall back to the solid color so hot reload stays safe. Cubemap blending remains for authored environment transitions and preview, not an astronomical day cycle. Kawa: `Scene.set_skybox_color(r,g,b[,a])`, `Scene.set_skybox_texture(path)`, `Scene.set_skybox_texture_secondary(path)`, `Scene.set_skybox_blend(t)`.

The screen-cloud grid excludes projection jitter so a stationary camera and cloud field keep identical march samples. The producer subtracts the allocation-space projection offset for its raster depth guide; lighting adds that offset when addressing the cloud grid and subtracts it again for each matching depth guide. This preserves scene alignment without resampling the density field on every temporal jitter phase.

Screen-cloud reconstruction samples the four neighbouring cloud texel centres and their matching depth guides, then combines bilinear spatial weights with depth rejection. Equal averaging of point samples produces visible blocks at reduced resolution. Taps stay inside the active render rectangle; if every depth guide rejects, reconstruction uses the nearest cloud texel.

A whole-world scene persists its default source and both source configurations in
the root `environment` block. The editor writes that block through document
history; it is resolved after actor IDs/transforms are registered and before
component `Start`, including optional astronomical rotation of linked sun/moon
lights. Runtime setters then remain authoritative—the scene default is not
re-applied every frame—and additive scene layers do not replace global
environment ownership. This makes scriptless scene setup and scripted full-day
choreography use one render path rather than two competing systems.

The medium's ambient floor is the published environment, not a constant: the
froxel passes never bind `HikariUniforms`, so `FroxelParams.sky_ambient` carries
isotropic sky radiance. The procedural source uses a CPU average of the clear-sky
field; authored cubemaps use their irradiance SH DC term with exposure applied,
falling back to the authored solid colour while a cubemap streams and
`volumetric_inject` scales it by a fixed sky-visibility fraction standing in for
the ambient occlusion the medium does not compute. Fog therefore takes its
colour from whichever environment source is active — blue under a noon sky,
warm at sunrise, nearly black at night — from the active environment. The procedural average excludes cloud shading.
The fraction is calibrated so a clear noon Earth profile reproduces the constant
floor this term used before, and it stays small deliberately: the floor is
unshadowed and direction-free, and shaft visibility is the ratio of lit to
shadowed fog, so a large one flattens the shafts.

The medium is **cloud-shadowed on the same terms surfaces are.** `lighting.akari`
applies `hikari_kumo_surface_shadow` at its one directional choke point, and
`volumetric_inject` applies the same occlusion to its own directional in-scatter
after the atlas term and before anything scatters — so a shaft of sunlight and
the ground it lands on are dimmed by the same deck. Without it a solid overcast
darkened every surface while the shafts above them stayed at full sun, which
reads as fog glowing inside its own shadow. `hikari_kumo_shadow_occlusion` is
the single implementation: the surface path reads the sun, the slab plane and
the camera out of `HikariUniforms`, and the froxel path — which deliberately
never binds that block — reads them out of `FroxelParams.cloudShadow` and
`cameraPos`, deriving the identical snapped map centre rather than trusting a
published copy of it. That row is all-zero when there is no layer, filled from
`cloudLayer.w`, which is the very word `hikari_kumo_enabled` reads and already
carries the cost tier: `clouds` at `off` stops the bake and stops the fog
sampling it in one move. The lookup is hoisted out of the light loop (one deck,
one sun, one texel) and taken at the froxel centre rather than the atlas term's
dithered point — at 31 m per texel there is no staircase for a dither to break
up. What still does *not* know about clouds is the ambient floor above: it is a
CPU average of the clear-sky field, so an overcast scene keeps a clear-sky haze
term under correctly shadowed shafts.

Fog Volume material graphs own the spatial medium: extinction, albedo,
emission, height shaping and noise. `FogVolumeUpdate.density_scale` is the
runtime weather envelope and multiplies the graph's final extinction during
froxel injection; zero therefore preserves the authored graph while making it
inert. `enabled` should also be cleared once a volume is fully faded so it is
omitted from volume staging. The scale is per actor and cloned with its World,
which keeps editor and Play weather independent.

World position is reconstructed from depth in lighting. Deferred lighting uses a Cook–Torrance metallic/roughness model. Static geometry residency is content-keyed and refcounted; scene meshes are packed into one shared meshlet work graph and emitted as one indexed indirect draw per pipeline/lane bucket over a compute-written index stream (shadows draw instanced triangle packets). Opaque G-buffer, refractive depth/shading, additive, and OIT all use that path; transparent order dependence is handled by the refractive depth contract or order-independent blend policy, not a CPU sort. The final UI pass consumes a flattened vertex stream and remains one draw call per backend.

Asset-bound static geometry uses a stable key derived from its normalized asset
stem and the store's reload generation. The authored and Play worlds therefore
join the same device-resident buffers without hashing the decoded payload. The
render-owned residency table publishes a mutex-protected key census to worlds;
when a key is present, a bind carries only identity, draw metadata, and bounds,
and skips retaining or decoding the engine mesh. CPU-access meshes deliberately
bypass this path. A render-thread check remains authoritative: eviction between
the census read and command application reports a geometry miss, demotes the
primitive to soft-pending, and retries through the ordinary decode/rebind path.
Model-family hot reload bumps the store generation before any replacement bind,
so an old resident allocation cannot satisfy changed geometry under the same
asset stem.

### Camera post

Defocus, shutter, vignette, chromatic aberration, and flare/ghost simulation are
**lens optics**, so they live on the camera that forms
the image (`scene/camera.zig`, authored on `SceneCameraDesc`, POD in
`sdk/src/camera_post.zig`) rather than in a Visual Zone: a zone describes the
world, and two cameras standing in the same volume can legitimately disagree
about focus. Project `RenderFeatures` gate the heavyweight defocus and shutter
passes. The vignette / aberration / flare finishing chain has no project toggle:
flare adds one 4×1 emitter-visibility pass, while vignette or aberration alone
stays a single full-resolution pass. Lights participate only when their
`contributes_to_lens_flare` authoring checkbox is enabled; ambient lights never
participate. The CPU rejects off-screen candidates and keeps four. Punctual
ranking and energy use apparent emitter size from `source_radius / distance`;
the light's shading `radius` therefore cannot alter either one. Directionals use
a projected mean solar angular radius and test against sky coverage; punctual
emitters test their projected source radius against linear depth. Flare geometry
is analytic and depth-occluded; it never resamples the frame into ghosts. The camera's `f_stop`
continuously changes ghost bodies from soft/round to harder six-blade polygons
and introduces a twelve-ray starburst across the cinema-lens range f/1.4–f/5.6.
Off-axis pupil clipping shapes ghosts into cat's eyes. Per-ghost support bounds
skip aperture SDF work away from the ghost, while the expensive diffraction and
streak math has bounded screen support. The chain is absent whenever the camera
flare intensity is zero or no enabled light opts in.
The Edit and unpossessed-Play fly-cams use neutral optics. The camera toolbar
button previews the primary actor as a complete lens in Edit—transform and
optics together—and returns to the parked fly-cam when toggled off. Possessed
Play also renders the primary camera directly.

Game code uses the same grouped settings for spawn, update, and readback:

```zig
const post: hi.CameraPost = .{
    .vignette = hi.VignetteSettings{ .intensity = 0.35 },
    .chromatic_aberration = hi.ChromaticAberrationSettings{ .intensity = 0.002 },
    .lens_flare = hi.LensFlareSettings{ .intensity = 0.6, .ghost_count = 5 },
};
const camera = try hi.world().spawn(&.{
    .archetype = "camera",
    .components = .{ .camera = .{ .post = post } },
});
try hi.world().updateCamera(camera, .{ .post = post });
const current = hi.world().cameraState(camera).post;
```

Sensor size is **not** a knob. The projection already fixes it at
`2 * focal_length * tan(fovY/2)`, so authoring it separately would let a
camera's optics disagree with its own frustum. The CPU folds the whole thin-lens
constant into `HikariUniforms.depthOfFieldParams.x`, leaving the shader one
multiply: `coc = x * (z - focus) / z`, signed, as a fraction of screen height.

`RenderQuality.depth_of_field` / `.motion_blur` own both the tap budget *and* the
kernel clamp, because a disk wider than its taps can cover starts to show
individual taps — raising the budget is what buys a bigger bokeh.

Depth of field remains a half-resolution gather plus full-resolution composite.
The gather uses each source sample's own signed CoC as a scatter-as-gather
admission test. Focused destinations first run eight depth-only probes across
the maximum disk; only when a near-field source can reach that destination do
they enter the colour gather. Its coverage is carried in bokeh alpha so blurred
foreground can spill over sharp geometry without making every focused pixel pay
the full 16/32/48-tap quality budget or adding another pass. Near and far
radiance accumulate separately and combine only through aperture coverage, so
opposite sides of a depth discontinuity do not average into a fringe. Tap
directions advance through baked golden-angle rotation constants; the gather
does not evaluate sine or cosine per pixel.

Per-frame gating is settled once, in `renderer_prepare`, and reaches the shaders
as a zero tap count / shutter scale rather than a flag. Assembly asks the same
question to decide whether to schedule the passes, so the two must agree: a
scheduled pass with a zero kernel is wasted bandwidth, and an unscheduled pass
with a live kernel leaves the composite reading nothing.

Colour is output-domain after reconstruction while linear depth, opaque velocity,
and transparent metadata stay render-domain under TAAU. Post effects map each
output UV to the render texel that owned the current jittered sample; a plain
proportional point fetch can select its neighbour and make silhouettes pulse.
Motion blur uses the forward pass's refracted/surface velocity and transparent
depth wherever its coverage metadata is present. Its taps are deterministic
because the pass runs after temporal reconstruction—animating their phase would
have no later history in which to converge. The rotary shutter is centred on
the resolved frame, with the authored shutter fraction describing the complete
sweep rather than each side of it. Sampling is velocity-adaptive: short motion
uses one symmetric tap pair, then adds a pair per four pixels of sweep up to the
quality tier's ceiling. The tile-max pyramid supplies only a conservative search
radius. Opaque pixels retain their exact G-buffer velocity, and a gathered source
is admitted only when its own oriented shutter segment reaches the destination;
this scatter-as-gather test is what lets moving silhouettes extend over their
background without lending one close surface's velocity to a whole coarse tile.
The selected coarse cell and the nearest cell on each axis form a four-fetch
search footprint, covering tile-boundary spill without a separate dilation pass.
Sky pixels derive rotation-only motion from current/previous camera directions,
while history discontinuities bypass the effect entirely. Off-screen and rejected
taps skip colour fetches. This preserves the tier's long-blur quality while small
camera motion no longer pays its maximum full-resolution tap budget.

The four post packages (`_engine/exposure`, `_engine/depth_of_field`,
`_engine/motion_blur`, `_engine/camera_lens`) use the `fullscreen_effect` binding layout (same 9-wide
scene-effect window as the temporal resolve). Eye adaptation also writes the
fourth SRV (`post.exposure`) on `fullscreen_texture_table` so tonemap can
multiply the adapted 1×1 without a second layout.

### Display output (HDR)

Each presentation surface resolves its own display mode: SDR, extended-range
linear (scRGB on Windows, EDR on macOS) or HDR10. The request comes from
`render.json` `pipeline.display` ([project file](/docs/guides/project-file.md)) or
from `hi.render().setDisplay`. It resolves against that surface's reported
capabilities every frame, so an HDR request on an SDR monitor runs as SDR.
`hi.render().displayInfo()` reports the capabilities, the effective mode and
why a request was downgraded, which is what an options screen needs.

| `pipeline.display` field | Default | Meaning |
|---|---|---|
| `output` | `auto` | `auto` takes HDR when the display and OS both offer it; `sdr` / `hdr` force a request |
| `prefer` | `extended` | Encoding when both HDR modes are available: `extended` (RGBA16F, OS-composited) or `hdr10` (PQ, Rec.2020, 10-bit) |
| `paper_white_nits` | 200 (80–480) | Luminance of UI white and diffuse white; `0` follows the OS SDR white level |
| `peak_nits_override` | 0 | Peak the roll-off targets; `0` follows the display's report |
| `tone_target_nits` | 1000 (100–10000) | Peak the tone curve is built for, before the roll-off |
| `sdr_preview` | `false` | Run the HDR chain and clamp to SDR at the end, to preview what an SDR player sees |

Colour pipeline:

```text
scene-referred linear (Rec.709 primaries, unbounded)
        │  exposure · bloom · grade
        ▼
  tone curve built for tone_target_nits, 1.0 == paper white
        │  display-referred linear
        ▼
  BT.2390 EETF roll-off to the current display peak
        │
        ▼
  encode ──┬─ sRGB transfer, Rec.709        → BGRA8_UNORM          (SDR)
           ├─ ×(paper_white / 80), Rec.709  → RGBA16_FLOAT scRGB   (Windows HDR)
           ├─ ×1.0, extended range          → RGBA16_FLOAT EDR     (macOS HDR)
           └─ ×paper_white → Rec.2020 → PQ  → RGB10A2_UNORM        (HDR10)
```

- **SDR is the same operator**, evaluated at peak = paper white = 100 nits with
  an sRGB encode. There is no separate SDR chain.
- **`tonemap` is a look, not an encode.** Transfer function and primaries come
  from the surface's resolved mode. `filmic` is the HDR default and is
  unchanged below its shoulder; `agx` slides its log window with the peak;
  `reinhard` becomes `peak · x / (peak + x)`; `aces` keeps its SDR shape.
- **Grading above white.** The LUT covers 0–1; values above 1.0 carry the LUT's
  white gain.
- **The EETF absorbs display variance.** macOS EDR headroom changes with the
  brightness slider; the roll-off follows it instead of letting highlights pump.
- **Rec.709 → Rec.2020 is a container conversion**, not gamut expansion.
  Wide-gamut scene content is not gamut-mapped.
- **UI is an SDR layer.** In HDR, UI draws into an 8-bit premultiplied layer
  that is linearised and composited at paper white. UI cannot go above paper
  white.
- **Per surface, never per device.** Thumbnails, preview viewports, captures and
  UI-only secondary windows stay SDR.

Toggling HDR at runtime costs about as much as a resize. The SDR display
pipelines are built at startup and never rebuilt. Switching to HDR drains the
GPU, builds a second display-pipeline set from the already-committed materials,
then reconfigures the layer or swapchain. If either step fails, the surface
stays in its current mode. Paper white, peak override and EDR headroom are
uniforms and never trigger a rebuild.

Diagnostics: the `hdr_nits` visualizer shows false-colour luminance, and the
example project's `hdr_test` scene is a calibration chart.

## Pipeline configuration

Games set `RenderPipelineConfig` in their `ProjectConfig`. The sample project uses raster shadows at high quality and screen-space reflections/AO (RT is an optional runtime knob). Live tonemapping, shadow, RT, reflection, AO, TAA, environment-lighting, and RT hit-quality changes pass through `SessionCore` / `GraphicsCommand` (see table above). `gpu_frustum_culling` is selected at project config time only.

`RenderLook` supplies the project baseline (tonemap, environment lighting, manual exposure, eye-adaptation limits, bloom). Spatial / global overrides are [Visual Zones](/docs/systems/visual-zones.md) — host/scene wiring publishes a resolved `VisualLookSnapshot` each frame.

See **Rendering contracts** above for feature independence, effective vs requested modes, and TLAS readiness. Tonemapping and requested ray-tracing behavior are project policy. A driver may expose a narrower capability set; code must test the reported feature support instead of assuming parity.

## Render graph and resources

Pooled textures carry a first-access barrier even when their declared initial and requested states match. D3D12 lowers that boundary using the native subresource state: a newly allocated storage texture starts in UAV state, while a reused history may have ended its previous frame in COMMON. Omitting the boundary lets a first shader read bypass the required transition, as with the radiance-cache stamp history. A UAV ordering barrier is used only when the actual state already matches; otherwise a transition is required.

Pass ordering and barriers use the shared RHI state vocabulary. Extend that vocabulary for compute, copy, queue transfer, or ray-tracing states; do not invent backend-local graph states. Renderer graph policy belongs above the RHI. Platform-specific code must not fork frame orchestration just to add a future Vulkan or console driver.

**Frames-in-flight + free policy:** `graphics/renderer/frames.zig` defines the shared protocol (ring slot wait → write → submit → mark; deferred free age ≥ FIF). Metal and D3D12 both implement that protocol; present depth is not a substitute for the ring wait. See [Renderer architecture — Sync points](/docs/systems/renderer-architecture.md#1-cpu-frames-in-flight-shared-contract).

**Handle ownership:** use graph/RHI handles and declared dependencies when extending the graph. Shared pass code must not free native GPU objects or poke platform resource tables — platforms lower handles in encode/destroy only.

## Shaders and debugging

Engine shaders are under `src/hikari/shaders/`; game-authored material/effect packages belong under `hikari-games/example/assets/shaders/`. Graphics debug requests use `World.renderer_debug` (`VisualizerMode`: `off`, the three buffer grids, `hdr_nits`, the TAA/GI diagnostic families, or the meshlet views — `meshlet_id` gives each visible `MeshletWork` its own colour, `meshlet_settled` paints predict-phase records green and late-phase records magenta; both decode the payload the G-buffer packs above the material flag byte, whose layout is owned by `akari/modules/meshlet_debug_id.akari` and cross-checked against the enum by a `render_debug.zig` test). Shared graph wiring lives in `graphics/debug/render_debug.zig` and `graphics/rendergraph/passes/debug_visualizers.zig`; platform drawing stays in each backend. G-buffer grid: albedo/normal/ARM/depth/material flags/motion/reconstructed virtual shadow depth/HDR/cluster load (magenta = overflow). Effects grid: AO raw/history/out, reflections raw/history/out, pre-reflection lighting, depth pyramid, AO temporal delta. Shadows grid: reconstructed virtual depth, dirty/cache-hit/denied/free counters, page boundaries, linear depth, HDR and mode badge. Hybrid displays the same virtual reconstruction; unused physical pool texels are never shown as valid depth. RT shadows grid (`rt_shadows_grid`, 3×2, only with ray-traced shadows): the direct chain at its five stages as `shadow_penumbra.akari` defines them — raw trace on the trace grid (magenta where a texel's checkerboard phase traced nothing), raw penumbra bound (red narrow → blue wide on a 1 cm–1 m log scale, grey lit), temporal history, history frames from the guide's confidence (dark new → white at the still-camera cap, pink reactive), the trace-grid prefilter, and the render-resolution resolve with its refinement marks (orange hard edge, cyan unowned receiver). Each cell is a read of a texture the chain already writes; the grid adds no pass and changes nothing it observes. TAA diagnostics are a 3×2 overview: accumulated count, stability lock, count retention, final geometry authority, input motion, and final reactivity. Count, lock, geometry authority, and reactivity come directly from the state written by reconstruction; the debug shader does not rerun depth/luma policy. Blue geometry means history was accepted (including a bounded lock bridge), red means rejected; black reactivity means the final policy kept ordinary accumulation, pink means it shortened the window. The count tile is black at reset/no usable history, red for a short unlocked window, magenta for a short window with a live thin-feature lock, yellow near one complete jitter sequence, and white after at least one complete sequence. Leaving diagnostics enabled does not change the behavior being measured. Sample game hotkeys cover the grids/HDR; the editor viewport toolbar exposes every mode.

Every diagnostic grid cell and expanded view carries a caption and a brief explanation. Missing current-frame data is labeled `NO DATA`, including internal TAA state when a vendor reconstruction provider owns the frame. Captions use the same texture-free glyphs as the VSM counters, stay readable independently of input texture resolution, and render at paper white in HDR. They are drawn only in the diagnostic output and do not alter the inspected signals or histories.

Double-sided normal-mapped surfaces use mirrored-normal semantics in raster and ray-hit shading: back faces preserve the tangent-space X/Y detail and reverse only the normal axis. This is the default expected by thin cutout geometry such as leaves and prevents card winding from changing the apparent lighting direction.

The editor profiler reports render-thread CPU work: command drain, draw batching, shadow and cluster preparation, instance packing/upload, frame preparation, and command encoding per graph pass. Optional **Entity timing** (same UI) measures play-frame entity lifecycle cost separately; see [UI and editor](/docs/systems/ui-and-editor.md#profiler).

**GPU pass timestamps** (when Profiler Recording is on): `profiler/gpu_timing.zig` + backend queries. Metal 4 uses per-slot `MTL4CounterHeap` timestamps (`writeTimestampIntoHeap` start/end pairs, resolved via `resolveCounterRange` + `queryTimestampFrequency` after the ring-slot wait); D3D12 uses a TIMESTAMP query heap + readback, resolved after the ring-slot wait (FIF). The resolved snapshot is **process-wide** (`hikari_profiler_gpu_publish` / `capture` in the platform ProfilerRegistry) so dynamic render dylibs and the editor UI share one buffer. Graph `pass_profiling` is installed whenever `-Dprofiler-timing` is compiled in (does **not** require `--profiler-timing:modules=markers`). Pre-graph GPU work (`light_binning`, `gpu_culling.prepare`, `skinning_writeback`, `raytracing.acceleration_structures`) and the editor `viewport_composite` / `editor_ui_layer` blit register the same way. Independent of `--profiler-residency` (CPU/GPU residency tabs). Results appear under a **GPU** section in the Profiler tab and overlay; until the first resolve, that section shows “waiting for first samples…”. Markers still only add Xcode/PIX labels.

**GPU frame detail** (`profiler/gpu_detail.zig`) is profiler-only: it is compiled out without `-Dprofiler-timing` and armed only while Recording, never by the governor's periodic samples. It adds two things a pass duration cannot show.

- *Timeline.* Each pass's start on the GPU clock, relative to the frame's first pass, comes from the timestamps that already time the pass, so it costs no extra query. Trace frames mark it with `gts` and carry `gpu_span_ms` (first start to last end) beside `gpu_ms` (the sum of pass times): a larger span means idle gaps, a smaller one means passes overlapped. Both resolves keep a pass's timestamp pair only when the scope stamped both ends this frame and both lie within one second of the frame's median pass end (`native/include/ProfilerTiming.h`); an unwritten entry can still satisfy end > start, and trusting one once put a pass three days before the rest of its frame.
- *Raster stages*, which say whether a pass is vertex- or fragment-bound. **Metal** writes three stage timestamps per render encoder in a timed pass (before the first vertex stage, after every vertex stage, after every fragment stage), only at the encoder's edges because a precise timestamp between draws may split the encoder. It shows `<pass>.vertex` / `<pass>.fragment` child rows. Apple's GPUs bin an encoder's geometry before shading its tiles, so the split is real, but the vertex share can include overlap with the previous encoder's fragment work, and a parameter-buffer flush interleaves the two. **D3D12** has no per-stage timestamps; it brackets each render pass with a `PIPELINE_STATISTICS` query and shows one note under the pass: vertex-shader invocations, input triangles, invocations per triangle (vertex reuse: 3 without any, lower with it) and pixel-shader invocations (divide by the pass's pixels for overdraw).

- *Draw classes.* While Recording, the camera bucket draw arguments of the main viewport are copied back (the same readback `--profiler-residency=gpu` uses) and summed by class: opaque (keeps hidden-surface removal), masked (alpha-tested), fade (an opaque bucket's LOD cross-fade stream, drawn through the masked twin with both levels) and transparent, both occlusion phases. Frames carry them as `draw`, and the Profiler GPU section shows the G-buffer classes. They are read at the frame-slot resolve, so they describe exactly the frame whose pass times they sit beside.
- *GPU frame counters.* A small per-prepare-slot buffer the frame's kernels add to while Recording (`graphics/renderer/frame_counters.zig`, names in `gpu_detail.FrameCounter`). The words are monotonic — nothing resets them — and the backend publishes the change since its previous read of the slot; Metal reads the frame itself, D3D12 copies to a readback heap and reports the slot's previous recorded frame. Frames carry them as `counters`; `direct_refine_pixels` is how many render pixels RT direct re-traced at render resolution and `direct_refine_edges` how many of them were hard edges (the rest had no owning trace texel); `volumetric_sun_froxels` and `volumetric_sun_rays` are the RT fog sun cache's froxels and the rays they traced; `reflection_rays`, `reflection_screen_reuses`, `reflection_hit_shades` and `reflection_bounce_rays` are RT reflections' primary rays, hits answered from on-screen colour, full hit shadings (mirror-chain surfaces included) and secondary rays. Each producer owns its words and imports the buffer under its own graph name, so counter writers share no dependency. The report shows each counter as a share of render pixels beside the pass that spends it, plus the ratios `volumetric_sun_rays_per_froxel`, `reflection_screen_reuse_rate` and `reflection_hit_shades_per_ray`.

Stage query heaps are created on first use. The detail crosses dynamic modules through its own registry entry (`hikari_profiler_gpu_detail_publish` / `capture`) and pairs with the pass snapshot by frame serial; a reader drops detail from another frame, and a pass with any unresolved or dropped stage record reports nothing rather than a partial value. Trace frames also carry `prof_ms` (game-thread milliseconds the previous written sample cost to capture and format, spent after `frameEnd` outside every zone) and `view` (render, allocation and output size, refresh rate and reconstruction) whenever the geometry moves. The renderer publishes that geometry through the registry only when it changes, so the header's `render_*`, `output_*`, `display_scale` and `display_refresh_hz` describe the frame the capture started on.

**GPU capture markers** (`--profiler-timing:modules=markers`) come from two sources. Graph passes are labelled by a *debug region* the graph installs around each pass — the pass code itself passes no name. Everything submitted **outside** the graph has no region to inherit, so it names itself: `gpu_culling.prepare`, `gpu_culling.occlusion_correct`, `gpu_culling.shadow_meshlet_cull`, the seven `light_binning.<phase>` kernels and `light_binning.clear_headers`, `skinning_writeback`, `content_compute`, `raytracing.acceleration_structures`, `viewport_composite`, `editor_ui_layer`, and `reconstruction.provider_encode` (which brackets a temporal provider's own vendor commands — the provider emits no markers of its own). Nested phase markers are capture-only; GPU timestamps stay on the parent producer. On D3D12 that is `command_list.beginMarker` (Zig) or `hikari_d3d12_begin_debug_event(list, name)` (native); passing a null name falls back to the region. New pre-graph submissions must name themselves or their dispatches and barriers show up in a capture unattributed, which is what makes them impossible to budget against.

See [Shader authoring](/docs/guides/shaders.md) for package layout and build integration.

## Debug drawing

`hi.debug()` carries immediate-mode world-space geometry. It is frame-scoped:
call it every frame for as long as the shape should be visible, and stop calling
it to make the shape disappear — there is no handle and nothing to clean up.

```zig
if (hi.debug()) |d| {
    d.line(a, b, .{ 1, 1, 1, 1 });
    d.ray(muzzle, forward, 25, .{ 1, 0.9, 0.2, 1 });
    d.box(center, half_extents, .{ 0.05, 0.05, 0.05, 0.85 });  // axis-aligned wireframe
    d.sphere(center, radius, .{ 0.3, 0.9, 0.35, 1 });          // three great circles
    d.point(position, 0.5, .{ 1, 0.25, 0.2, 1 });              // three-axis cross
}
```

Uses: aim rays, selection outlines, contact points, spawn volumes, trigger bounds.

How it draws: segments are projected with the frame's camera and appended to the
UI vertex stream ahead of the UI, so they render at **constant pixel width** and
the HUD stays on top. There is **no depth test** — debug geometry draws over the
scene. `hi.debug()` is null when the debug API is stripped; guard with `orelse return`.

Tuning lives on `World.debug_draw`: `line_width` (UI points), `ring_segments`
(sphere tessellation), and `max_segments` — a hard cap so a runaway loop drops
lines and bumps `dropped` instead of exhausting memory.
