# Ollin architecture & internals

This document explains how Ollin's larger systems work *inside*: the rendering
pipeline, the effect graph, and the engineering rationale behind the choices
that are not obvious from the code. It is written for contributors (and the AI
agents that help them) who are about to change one of these systems and need to
know which invariants are load-bearing and why.

It is deliberately separate from the other docs:

- **[`CLAUDE.md`](https://github.com/eaviles/Ollin/blob/main/CLAUDE.md)** is the always-loaded development guidance: the
  rules, the architecture decisions that constrain new work, the cross-cutting
  gotchas, and a one-line-per-feature capability index. It states *what* the
  invariants are; this file explains *why* they hold and *how* the machinery
  behind them works.
- **[`Docs/`](docs/index.md)** is the user-facing API reference (`Sketch`, `Drawing`,
  `Color`, ...). It documents how to *call* a feature, not how it is built.
- **[`ROADMAP.md`](roadmap.md)** and **[`DESIGN-NOTES.md`](design-notes.md)** are
  forward-looking only: what is planned and the design intent behind it.

So the split is: planned work in ROADMAP/DESIGN-NOTES, how to use a shipped
feature in Docs/, the cross-cutting rules in CLAUDE.md, the capability inventory
in [`CAPABILITIES.md`](capabilities.md), and the deep internals of shipped
systems here. When a capability needs more than its terse invariant there, the
depth lives here and `CAPABILITIES.md` keeps a pointer.

This document is populated one system at a time, so it is intentionally
partial. The map below lists Ollin's major systems and where each one's
internals are documented today; `CAPABILITIES.md` remains the complete
capability index regardless.

---

## Systems map

| System | Where its internals are documented |
| --- | --- |
| Renderer core (frame lifecycle, pipeline families, vertex-buffer ring) | This doc, *The renderer at a glance*; the rules live in CLAUDE.md *Shaders & the Metal back end* / *Rendering performance* |
| The 2D drawing paths (SDF primitives + coverage models, fringe strokes, libtess2 fills, linear-light present) | **This doc**, *The 2D drawing paths* |
| Live reload, `@Param`, stats, and the live-coding hosts | **This doc**, *Live reload and the live-coding hosts*; the rules stay in CLAUDE.md *Live reload* |
| Screen-space combine effects (SSAO, SSR, depth of field) | **This doc** |
| SDF combinators (2D VM + raymarched 3D) | **This doc** |
| Layered-effects substrate (render targets, filters, generators, compose, combine, feedback, sim fields / fluid) | **This doc**, *Layered-effects substrate* (the combine wiring under *Screen-space combine effects*) |
| 3D lighting (PBR / Cook-Torrance, IBL split-sum bake, procedural sky, PCSS + RT shadows, RT reflections) | **This doc**, *3D lighting and environments* (the reflection anti-aliasing chain under *Deferred ray-traced reflection AA*) |
| Geometry & generator catalog (Grid, the spatial index, booleans/offsets, SVG import, epicycles, curves, morphing, sampling/stippling, walks, tiling, packing, growth, WFC, CA/turmites, fields/boids/steering, attractors, IK/pendulum/N-body) | **This doc**, *The geometry and generator catalog* |
| Color pipelines (palette import/extraction, image dithering, print separations) | **This doc**, *Color: palettes, dithering, and print separations* |
| Text & glyphs (libtess2 fill, winding / overlap-clean gotchas, fringe stroke, SDF atlas) | Pending here; CLAUDE.md + `Docs/Drawing/Text.md` |
| Compute & GPU particles | Pending here; CLAUDE.md + `Docs/Shaders/Compute.md` |
| User-supplied shaders | **This doc**, *User-supplied shaders* |
| Camera rig (showcase orbit, view snaps, scene chrome) | **This doc**, *Camera rig: showcase orbit, view snaps, and scene chrome* |
| Satellites (audio, OSC, MIDI, Syphon, virtual camera, video, physics, vision, Record3D, phone) | `Docs/` per satellite + the cross-cutting satellite gotchas in CLAUDE.md |

A *pending* row means the system's deep internals are not yet written up here:
its load-bearing invariants are in CLAUDE.md and its public API is in `Docs/`,
but the mechanism-and-why depth has not been migrated. Those rows are the
migration checklist (see *Status of this document* at the end).

---

## The renderer at a glance

Ollin's drawing model is *immediate-mode GPU*. There is no retained scene graph:
every frame, `Sketch.draw()` issues bare drawing calls that forward to a
`Drawer`, the `Drawer` records geometry, and once per frame `MetalRenderer` turns
that recording into GPU work. Understanding this flow makes every other section
easier, because each effect and each pipeline plugs into the same frame.

**The Drawer is a pure recorder.** It tessellates or encodes each primitive into
call-ordered `GeometryBatch`es, tagging each with its kind (triangles, SDF,
fringe stroke, image, glyph, mesh, point cloud, SDF group) and its render target.
It never talks to the GPU. That is what lets one recording drive the live window,
the headless `image(of:)` export, and the off-screen effect layers without
change, and it is why draw order is preserved: batches composite front-to-back in
the order the sketch drew them, so SDF shapes, tessellated fills, and 3D meshes
interleave correctly. Collapsing the call-ordered batches into unordered per-kind
passes would break occlusion.

**MetalRenderer resolves a small render graph.** At `render()`/`image(of:)` time
it runs, in order: any off-screen render-target passes (each target's tagged
batches into its own MSAA-resolve pass), the filter and combine ops that read
those layers, the main geometry pass, then the post-process and present pass. A
2D sketch with no targets takes a fast path that is byte-identical to a single
direct pass.

**Geometry is drawn through several pipelines, cached by descriptor.** The pipeline families
are a tessellated-triangle path (libtess2 fills under 8x/4x MSAA), an instanced
analytic-SDF path (one quad per shape, fill/stroke/AA computed in the fragment), a
fringe-stroke path (the high-quality stroke renderer: CPU edge-expansion plus a
1px anti-aliasing fringe), a textured-quad image path, a glyph-atlas path, the 3D
mesh path, and the SDF-combinator and raymarch paths. They are built through a
cache keyed on a descriptor (shader, blend mode, sample count, pixel format), so a
new variant is a cache lookup rather than more constructor code. The CPU-to-GPU
structs these pipelines share are defined once in
`Sources/Ollin/Renderer/OllinShaderTypes.h`.

**Uploads go through a triple-buffered, semaphore-gated vertex-buffer ring.**
Writing a buffer the GPU is still reading for an in-flight frame tears the
geometry on screen, so the ring (`maxFramesInFlight` slots, a frame-boundary
semaphore) hands each frame its own buffer. This is load-bearing: do not collapse
it back to one shared buffer.

**A refresh the ring cannot take is dropped rather than waited on.** The live
frame runs on the main thread, which is also the thread AppKit and SwiftUI run
the window's own controls on, so waiting for the ring there stops the window and
not only the picture. `MetalRenderer.canStartFrame` answers the question without
waiting (a frame count moved in lockstep with the semaphore, including in the
GPU's completed handler, because a semaphore can only be asked by waiting on
it), and `SketchRunner.draw(in:)` asks it at the top, before `advance` and
`performDraw`, so a dropped refresh costs nothing and discards nothing. A take
is the exception, playing or recording: a replay consumes one recorded frame per
refresh, so it waits.

The defect this closes presented as a hang: in the scene explorer, switching the
camera to the file's own made the whole app unresponsive, and a sample put the
main thread 93% inside `nextDrawable` (the drawable is taken before the ring, so
it takes the blame). Measured headlessly at 2160², the frame under that camera
costs ~86 ms against ~21 ms from the orbit camera, and the difference is almost
all one thing: the scene's **point** light, whose caster is shadowed by tracing
a visibility ray per pixel. The close camera fills the picture with lit surface,
so the ray count rises with the coverage. The point light alone adds ~40 ms
where the spot adds ~4 ms and the directional ~7 ms, and this GPU (M2) traces in
software. Six times the cost is a low frame rate, not a hang. The freeze was the
ring, and only the ring.

**Compositing is linear-light, with a single present pass.** Geometry composites
into a linear `rgba16Float` intermediate, so the hardware blends and resolves MSAA
physically; a final present pass tone-maps and dithers that float frame down to
the sRGB drawable, which is the one place 8-bit quantization happens. Shaders
linearize their sRGB color inputs, the clear color is linearized too, and
stroke/disk coverage is remapped to perceptual alpha so thin dark marks stay dark.

The rules and invariants behind all of this (the exact coverage models, the
shared-header discipline, the shader-segment concatenation order, the
precompiled-metallib path) live in CLAUDE.md under *Shaders & the Metal back end*
and *Rendering performance*. This section orients you; those sections hold the
invariants you must not break.

---

## The 2D drawing paths

How a 2D mark becomes pixels: the instanced analytic-SDF path for the closed
primitives, the fringe-expander path for strokes, libtess2 plus MSAA for
arbitrary fills, all composited in linear light. The regression-preventing
rules are in CLAUDE.md *Rendering performance*; this section is the mechanism
and the history behind them.

### The instanced SDF path

Each analytic primitive (point markers, circle, ellipse, rect, oriented box,
circular arc, triangle, general 3-point triangle, regular polygon, star,
rhombus, vesica, oriented vesica, moon, cross, ring, trapezoid, parallelogram,
egg, heart, cut disk, uneven capsule, horseshoe, parabola, rounded X, blobby
cross, tunnel, stairs, cool S) is one instanced quad: an `SDFInstance` on the
`.sdf` pipeline, evaluated by `ollin_sdf_fragment`, which switches on the
shape tag and runs a shared fill + stroke + anti-aliasing tail. Per-shape CPU
cost is a single struct write (the `Myriad` example draws 8,100 circles at
roughly an 1,100 fps CPU ceiling). `SDFInstance` is a *tagged union*: a
`shape` tag plus generic `size`/`param0`/`param1`/`param2`/`extra` slots, and
each instance carries its own CTM (`float3x3`) so the fragment evaluates the
SDF in local space and the anti-aliasing stays ~1px under any transform
(`fwidth`); the covering quad spans `size` plus half the stroke plus a small
margin. The per-shape encodings (which param slot carries what, and which
`sd*` function each calls: the point's round/square/diamond/cross/x markers,
`drawNgon`/`drawStar` sharing one `sdStar`, the egg/heart/tunnel/stairs
Y-flips, the three-point triangle and quadratic Bezier reading their free
points from `param0`/`param1`/`param2`) live in `SDFShape` plus
`ShaderShapes.metal`; the distance functions are iq's 2D catalog, written from
the technique. (The circle approach was studied from a peer framework's MIT
shader and written independently; credited in `ATTRIBUTION.md`.)

`drawLine` and `drawBezier` originally rendered here as SDF capsule/Bezier
instances and have since moved to the fringe stroke path below; their shader
cases are retained but unemitted. Arbitrary `drawPolygon`/`drawPolyline` and
elliptical or full-turn arcs stay on the triangle path by nature (`drawArc`
runtime-branches: `rx == ry` and sweep less than a full turn goes SDF, else it
tessellates). What tessellates there is the *fill*: a tessellated arc's outline
is a path like any other, so it strokes through the fringe expander, which is
what gives it joins and caps and keeps translucent ink to one coat. The three
modes hand it the same shapes the vector recorder writes (open as a polyline,
chord as a closed loop, pie as that loop through the center), so a rendered arc
and an exported one trace the same outline.

One rule is not safely discoverable from the code, so it is recorded: the four
roundable shapes (rhombus/vesica/moon/cross) take a footprint-preserving
`cornerRadius`, and vesica/moon must `opRound` off a *full-footprint* SDF.
Insetting the vesica's waist toward zero sends its derived circle radius and
offset to ~1e7, and the shader's `sqrt(r^2 - d^2)` loses all precision (a real
glitch, fixed by the full-footprint form).

**Hollow/band mode** generalizes the ring's `opOnion` to every region shape:
`hollow(_:)`/`solid()` set a per-instance `bandWidth`, and `regionFill` onions
the shape's SDF (`abs(d) - bandWidth/2`) before coverage, so the fill paints a
constant-width band hugging the outline and an active stroke borders *both*
edges (the framed-ring look a stroke alone cannot make). The band straddles
the edge, so the covering quad grows by `bandWidth/2`. The `bandWidth` field
fit the struct's then-tail-padding, so it forced no widening. Region shapes
opt in via `SDFShape.honorsHollow`; points, lines, and the ring (already a
band) opt out, and the round-dot point opts out by hand since it shares the
`.ellipse` tag (a solid disk's area-conserving coverage is bypassed for
`regionFill` only when banding).

**Stroke alignment** (`strokeAlign(_:)`: `.center`/`.inside`/`.outside`) uses
the same coverage tail: the coverage functions take a `strokeBias`
(`0` / `-hw` / `+hw`) that shifts the stroke band off the edge
(`abs(d - strokeBias) < hw`) while the fill still stops at `d = 0`, so the
inset/outset is an exact SDF offset and `.center` (bias 0) is byte-identical
to before. The 3-state align is stored in **bits 8-9 of the `shape` tag** (the tag
is < 256, so the vertex shader masks `shape & 0xFF` before the switch), which
costs the instance no room; `.outside` grows the covering quad by another half
stroke. Hollow forces the bias to 0 (a band already has two edges);
lines/points/open-arc never compute a stroke band, so they stay centered.

### Coverage models

Coverage is split by whether a shape tiles, and the split is load-bearing:

- **Inside-biased** (`regionCoverage`): `box`, `pie`, `chord`, `triangle`,
  star/n-gon, and the non-round point markers get full coverage to the
  geometric edge with the AA halo only outside, so abutting fills (tiled
  grids, gradient bands, the rotated wedges tiling a cell in `LifeQuilt`)
  leave no seam. Moving them onto a centered ramp brings the seams back.
- **Area-conserving** (`diskCoverage` and the capsule case): the disk
  (ellipse/circle/round point) and capsule/line use a centered AA ramp, but
  any mark smaller than ~half a pixel keeps a ~1px footprint while its alpha
  scales by the true/clamped **area** (disk) or **width** (line). Sizes run
  0...n: tiny dots, small circles, and thin lines fade by area instead of
  popping in, snapping to a 1px floor, or flickering as they move. Safe
  because disks and lines never tile edge-to-edge, so they need no inside
  bias.
- **Ink-conserving stroke bands** (`strokeBandCoverage`, shared by the region
  and disk outline paths): a band thinner than ~1px holds a ~1px footprint
  with alpha scaled by the width ratio (the capsule's trick), so an outline
  thinner than a pixel fades to nothing instead of plateauing at a fixed
  hairline. The old smoothstep band floored at ~half coverage as `hw -> 0`, so
  a 0.4px and a 0.05px outline read alike. A band at or above ~1px reduces to
  the plain smoothstep edge it always was (byte-identical, snapshot-safe).

These area-conserving paths and every stroke band then run their coverage
through `perceptualCoverage` (see *Linear-light compositing* below).

### Adding an SDF shape

The tagged union has a fixed *scalar budget*: `size` (2) + `param0` (2) +
`param1` (2) + `param2` (2) + `extra` (1) on top of the per-instance
CTM/center/colors/stroke. Any shape that is a canonical form parameterized by
a size plus a ratio or two drops in as four touch-points (an `SDFShape` case,
a `Drawer.draw*` builder, an `sd*` function, a fragment `case`) with **no
renderer or pipeline change**; position/rotation/scale come free from the
CTM. One constraint: `size` is *not* a free slot. The vertex shader uses it as
the covering quad's AABB half-extent, so it carries the bounding extent and
cannot double as a geometry point. The two shapes that need three free points
beside size-as-AABB (the general 3-point triangle and the quadratic Bezier
stroke) forced the one widening so far: `param2` took the stride from 128 to
144, and the 8 bytes of tail padding that left have since been claimed by the
gradient-paint row fields. **The struct is full at 144: the next field is a
real widening.** Two things stay off this path by nature: arbitrary
polygon/polyline (variable vertex count; they stay on the triangle path), and
SDF *operators* (smooth-min, union/subtract, domain repetition), which combine
fields and live on the SDF-combinator path. The distance functions come from
iq's 2D catalog (implemented from the technique, credited in
`ATTRIBUTION.md`); hg_sdf is the source for the operator track.

### The fringe stroke path

Every stroked path (`drawLine`, `drawBezier`, `drawPolyline`, the
`drawPolygon` outline, `drawShape` contours) renders through the
AGG/NanoVG-style fringe expander (`appendFringeStroke`, written from the
technique; no SDF, no MSAA/SSAA). The path is edge-expanded CPU-side into
per-segment butt quads plus a ~1px screen-space anti-aliasing fringe
(`fw = 1/ctmScale`; coverage ramps 1 to 0 across it, GPU-interpolated so the
edge stays smooth at *any* angle at native resolution, fixing the staircase
the SDF capsule's single-sample `fwidth` left on shallow diagonals). It uses
its own `.fringe` `GeometryKind`/pipeline (`ollin_fringe_vertex`/`_fragment`)
but **reuses the triangle vertex buffer**: the coverage is stored in an `aa` field
tucked into `OllinVertex`'s existing float2-to-float4 alignment padding
(stride stays 32, the triangle path byte-identical), with the stroke's rgb
plus paint alpha in `color`. The fragment remaps **only the coverage** through
`perceptualCoverage` and keeps the **paint alpha linear** (translucent strokes
composite at their true opacity; keeping the two channels separate is *why*
the `aa` field was needed). Solid, translucent, and gradient paint all take
this path; gradient samples per path vertex like the tessellated path
(`vertexPaint`: along-path reads arc length, long segments split first so the
baked LUT tracks).

The fringe **straddles** the true edge, so perceived width equals
`strokeWidth`; geometry stayed within snapshot tolerance of the old
SDF/tessellated output, so no snapshot re-record was needed. Joins and caps
are honored via per-join outer-gap fillers (`strokeJoin` `.miter`, beveling
past the limit, `.bevel`, `.round`; `strokeCap` `.butt`/`.round`/`.square` on
open ends), so `drawLine` (single segment, cap-only; now default **butt**, not
the old always-round capsule) and `drawBezier` (flattened, then cap plus
joins) honor them too. `appendStrokedPath` (the old tessellated join-filler
path) is retained for text glyph stroking only.

**Fills stay on libtess2 plus 8x MSAA, byte-identical.** The empirical call
(native-resolution A/B): MSAA fill edges are already smooth, and a fill fringe
would reintroduce abutting-fill seams (Voronoi, LifeQuilt) or fatten shapes,
so the fringe is strokes-only.

**A cross-section carries a point on the centerline, and that is load-bearing.**
The five points are outer edge, core, center, core, outer edge, so a segment's
ribbon is four quad bands rather than three. Coverage does not change across the
two core bands, so the split draws nothing new; what it removes is a T-junction.
Every join fans out from the path vertex itself (`center` in `appendFringeStroke`,
shared by the miter, bevel, and round builders), and with no cross-section point
at offset 0 that apex landed in the *middle* of the core band's end edge. The two
edges are collinear in exact arithmetic, but the rasterizer snaps each endpoint to
its sub-pixel grid independently, and the hairline that opens between them
swallowed the occasional MSAA sample. The symptom was isolated pixels of 6/8
coverage buried inside solid ink, never runs, appearing and vanishing as the
stroke weight moved by half a point.

Worth recording, because it sent the first investigation down a blind alley: the
artifact reads as *46% coverage* if you measure the pixel in sRGB, which is
impossible for the sub-pixel gap the geometry allows, and the contradiction
looked like evidence against a seam. Compositing is linear-light, so the same
pixel is 75% coverage, exactly two samples of eight. **Measure coverage in linear
light before reasoning about how wide a gap must be.** Diagnosis came from the
positions rather than the values: every hole sat on the radial line through an
integer path vertex, outside the turn, between 0 and `coreHalf` of it, which is
precisely the span the join's apex edge shares with the ribbon.

**Consecutive segments end on their shared inner crossing, and that is what
makes translucent ink lay down one coat.** On the inside of a turn the two
segments' edges genuinely cross, at the inner miter point. A segment that ends
on its own perpendicular runs past that crossing and into its neighbor, so both
quads cover the wedge between the two perpendiculars. Opaque ink hides it. Ink
that is not opaque composites the wedge twice: a 40pt stroke at alpha 0.25 read
224 along the arm and 197 in a hard-edged diamond at a right-angle corner, which
is 0.75 against 0.75 squared in the linear light everything composites in, a
clean second coat. Spread over a dense curve the same defect became a graded band
down the inside of the turn (mean 224.6 falling to 221.5, single pixels as low as
197) and, on a spiral, a comb of dark radial spokes, one per join.

So both ribbons end on the crossing point. `innerMiter[v]` holds the direction to
expand along on the inner side, scaled so that `pts[v] + innerMiter[v] * off`
sits exactly `off` from *both* centerlines, which is what keeps the coverage ramp
correct (coverage is a function of distance from the centerline, and a miter
point preserves that distance to both segments by construction). The two ribbons
then share that edge exactly: no overlap, and no gap. This is NanoVG's
`nvg__chooseBevel` model, written from the technique.

Three things make it safe to leave everything else alone. The **outer** side is
untouched, still ending on the segment's own perpendicular, so the join filler
and the centerline seam above are unaffected. The covered **region is
unchanged** for an opaque stroke: the union of two overlapping strips is already
bounded by the two inner edges meeting at the crossing, so moving the internal
seam onto that crossing draws the same shape, which is why the whole snapshot
suite passed unrecorded. And **collinear vertices are skipped**, so a straight
polyline is byte-identical.

Where the crossing falls outside either neighboring segment the ribbon would
turn inside out, so the ends stay square there (NanoVG's inner-bevel case, the
`dmr2 * limit * limit >= 1` test against the shorter neighbor). A corner that
sharp folds over itself whatever we do, and an overlap is a kinder failure than a
crack. Near-hairpins bail the same way rather than take NanoVG's clamped miter,
which would pull the two ribbons short of each other and open a white seam.

`appendStrokedPath`, the tessellated path retained for outline-glyph stroking,
shares the rule through `Drawer.innerCrossings`, but pays for it differently.
Its segments are plain quads with no point on the centerline, so once the inner
end bends away to the crossing, the end edge no longer passes through the path
vertex, and a join filler fanning from that vertex lands mid-edge. That is the
same T-junction the centerline point fixes above, and it showed immediately: a
comb of *pale* ticks on the outside of every curve, the exact mirror of the dark
comb being removed. Splitting each segment in two at the centerline fixes it and
costs two triangles per segment. Moving the filler's apex onto the crossing fixes
it and costs nothing, because the gap is bounded by the two end edges, both of
which now run to that point, and it is still star-shaped about it. The two are
pixel-identical; the apex move ships. The fringe path cannot do the same, since
its fan has to meet the centerline point its cross-section already carries.

Cost is nothing measurable: the triangle count does not change at all, only
vertex positions, and the pre-pass is a few flops per vertex (`Patterns/Streamlines`,
M2 release, 1080², three interleaved runs: 5.516 against 5.521 ms/frame, inside
the noise of either variant). Vector export was never affected, since a profiled
stroke's outline is one nonzero-wound `Shape` and a uniform one is a native
`stroke-width`; the raster path now agrees with both.

Splitting the core is worth two extra triangles per segment (six to eight, about
21% more fringe vertices). Paid for by building each cross-section as a fixed
`Cross` value instead of the two arrays `offsets(_:).map` used to allocate per
call, four allocations per segment on a path that runs over every stroke in a
frame: a stroke-only sketch measures ~10% *faster* than before the seam was
closed (`Patterns/Streamlines`, M2 release, 1080²: 6.18 to 5.55 ms/frame).
`FringeStrokeTests` pins the seam behaviorally across joins, weights, and
curvatures, since the defect is a few pixels in a million and any whole-frame
mean difference averages it away.

#### The expander is a module the web page shares

The geometry stage of the expander (the ribbons, the joins, the caps, the
shared inner crossing, the round-cap step count) is `StrokeExpander.expand`
in the `OllinExpander` target, beside the convex fan and the tessellator glue
(`FillExpander`) and the record layout a stroke travels in on a web page
(`WebSourceLayout` / `WebSourceExpander`). `Drawer` keeps the measuring half
(dropping repeats, subdividing for a gradient or a profile, the fringe width,
the half-width and the color per point) and hands the expander plain
`Point2D`s; the move was proven byte-identical on a dump of 40,000 vertices
across every join, cap, weight, transform, gradient, profile, and symmetry
fold. The module is plain Swift over the C tessellator with nothing from
Foundation, because `Scripts/build-web-expander.sh` compiles the same files
with Embedded Swift to `wasm32-unknown-wasip1` (the swift.org toolchain and
its `_wasm-embedded` SDK; 73 KB, `--strip-all`, libtess2's `setjmp` shimmed to
a trap) and commits the result as `Resources/WebExpander.wasm` with a manifest
naming the sources' hash. A web page whose strokes travel as points carries
that module and expands them with it, so the page's vertices are the Mac's
own code's; `WebSourceTests` runs the same decoder on the Mac against the
drawer's vertices and checks the manifest's hash against the sources, so an
expander edit without a rebuild fails a test rather than shipping a page that
expands differently. The rest of the web export's mechanism is in
`CAPABILITIES.md` under *A sketch as a web page*.

### Fills and the triangulator

The vector `Shape`/`Contour` type (concave polygons, holes) fills via vendored
libtess2 (the GLU tessellator lineage), wrapped in `Shape.triangulatedFill()`
(`ShapeTriangulator.swift`, forwarding to `FillExpander.triangulate` in the
shared `OllinExpander` module) and drawn by `drawShape` on the triangle
path. The fill rule is a per-`Shape` `winding: FillWinding`
(`.evenOdd` default, where nested contours become holes and direction does not
matter, or `.nonZero`), mapped to `TESS_WINDING_ODD`/`TESS_WINDING_NONZERO`.
Convex shapes (circle, rect, ellipse, convex `drawPolygon`) keep the direct
fan/strip math; routing them through the triangulator is a pure loss.

Font glyphs are this path's hardest customer, and their two gotchas are
detailed in CLAUDE.md's *Text* bullet (glyph shapes must be `.nonZero`;
San Francisco's overlapping sub-contours need a Clipper2 union clean at a
reference scale of 1024 followed by Ramer-Douglas-Peucker simplification,
cached per glyph and size). The mechanism notes worth keeping here: the union
only merges cleanly when curves are finely flattened, and flatten density
tracks the render size (`CurveSampling`), which is why the clean must run at
the large reference scale and then rescale; and the reference-scale flatten
leaves contours ~40x denser than the render needs, which per-frame stroke
tessellation then pays for (a real 12-fps regression), hence the simplify
step. `Shape.mapPoints(_:)` preserves `winding`; rebuilding a glyph via
`Shape(contours:)` silently resets it to even-odd (that re-breaks the `e`).
The niche `glyphRun` text-on-path/per-glyph path still flattens raw, a known
follow-up.

Curved outlines (quadratic/cubic Bezier plus Catmull-Rom `curve`) sample to
points via the public `Path` builder (`Path.swift`:
`move`/`line`/`curve`/`quadCurve`/`cubicCurve`/`close`, plus `Contour` and
`Shape(curveThrough:)`), surfaced as the closure sugar `drawShape { p in ... }`
and the top-level `drawCurve(_:closed:)`. They feed the same triangulated fill
and stroked path, so a curve never touches the SDF/renderer side; the
`Contour`/`Shape` data stays polygonal and the `Path` just flattens curves
into it.

### Linear-light compositing and perceptual coverage

Geometry composites into a linear `rgba16Float` intermediate (`linearFormat`;
the sRGB `.bgra8Unorm_srgb` `ollinColorPixelFormat` is the drawable/present
format, not the geometry target), so the hardware blends and resolves MSAA in
linear space: anti-aliased edges and translucent stacks composite physically,
without the too-dark fringes of a gamma-space blend, and the float precision
keeps many overlapping translucent layers from banding as an 8-bit
intermediate would.

Three pieces keep tones from shifting, each a real bug when missed:

1. Shaders **linearize** their sRGB `Color` inputs before compositing and
   output linear into the float target; the *present pass* re-encodes to sRGB.
2. The **background/clear color is also linearized** (`Color.srgbToLinear` in
   `mtlClearColor`) so the clear value lands in the float target as linear,
   matching shaded geometry. Miss this and the background washes out lighter,
   because the clear bypasses the shader.
3. Image textures load `.SRGB: true` so each sample decodes to linear,
   matching the linearized solid colors beside them.

A triangular-PDF **dither** (a pure function of pixel position, so renders
stay reproducible) is applied in `finalizeColor`, which runs **in the present
pass** (the single 8-bit quantization point, after tone mapping the resolved
float frame), breaking the banding smooth gradients otherwise show. The image
fragment skips the dither: its source pixels are premultiplied, and it outputs
premultiplied linear straight into the float target.

**The one deliberate carve-out from pure linear light: stroke and disk/dot AA
*coverage* is remapped to perceptual alpha** (`perceptualCoverage`,
`1 - srgbToLinear(1 - c)`) before compositing. Linear blending makes a
partially-covered dark mark read lighter than its coverage (a 50%-covered
black pixel lands at sRGB ~0.74), so a thin diagonal line's
correctly-conserved ink splits across the pixel staircase and reads faint and
"beaded" (the bug that made the Molnar *Interruptions* field look chopped
after the linear-light switch). The remap lands each covered pixel at the
gamma-space darkness its coverage implies, so a 1px stroke is evenly dark at
any angle and a sub-pixel mark still fades 0...n. It touches **only partial
coverage** (`perceptualCoverage(1) = 1`, `(0) = 0`) and never a shape's own
fill/stroke *alpha*, so solid interiors and translucent/overlap blending stay
linear-light. It applies to the capsule/`drawBezier` strokes, `diskCoverage`
(fill and outline), and `regionCoverage`'s stroke band, but **not** the region
*fill* ramp, which stays plain linear so abutting fills tile seamlessly.

Paired with this, the capsule/Bezier AA footprint uses the **L2 gradient
length**, not `fwidth`: a unit-gradient distance field's L1 norm overshoots by
sqrt(2) at 45 degrees, which had been fading diagonal 1px lines as if
sub-pixel. Verified near-optimal for the raster path: 4x SSAA barely beats it;
a 1px diagonal's residual softness is fundamental to native-resolution
rasterization, which is what the export supersample (`--render-scale`, see
`Docs/Output/Export.md`) exists to spend time on and what makes it worth
little on the paths that carry their own coverage.

### Retained batches (`Batch` / `makeBatch` / `drawBatch`)

The per-frame model re-records and re-uploads every mark each frame; a
`Batch` (`Sources/Ollin/Drawing/Batch.swift`) is the escape for *static, heavy*
content: the recording happens once and the replay costs one reference batch.
Measured on an M2 at 1080^2, release: 150k circles cost ~13.5 ms/frame CPU
(~74 fps ceiling) on the per-frame path and ~0.003 ms/frame replayed, with the
frame's own arrays staying empty.

**Recording is an array swap, not a parallel recorder.** `Drawer.makeBatch`
swaps the per-frame geometry arrays, the batch list, and the gradient-row table
for fresh ones, runs the body through the ordinary funnels, moves the results
into the handle, and swaps back. That buys every 2D path (SDF instances,
triangles, fringe, images, atlas glyphs, SDF-combinator groups, point clouds)
with zero funnel changes, and it makes the captured `GeometryBatch` runs
self-consistent: the encode derives a run's count from the *next* run's start,
so recorded runs must index the handle's own arrays, never the frame's.
Swapping the gradient-row table makes the baked row indices inside recorded
`SDFInstance`s **handle-relative**: at encode the replay binds the batch's own
strip texture (`MetalRenderer.makeGradientStrip`, the shared builder behind
the frame's cached strip), so a recorded gradient can never point into
whatever rows the *current* frame happens to have. The body runs inside
`pushState`/`popState` from an identity CTM, with the target/clip stacks
swapped out, so a recording is style-scoped and context-neutral; `drawBatch`
supplies the draw-time context (target, clip level, 2D depth) on the reference
batch. Content that interlocks with per-frame passes (meshes and 3D fields,
which would silently drop out of shadow/reflection passes; particles; layer
blocks; clipping; `background`) is gated at its funnel with a one-time note,
because dropping a batch *after* recording would corrupt the next-run count
derivation.

**Replay is a reference batch plus a flag-gated uniform transform.** `drawBatch`
appends one `.retained` `GeometryBatch` carrying the handle and the CTM (nil
when identity). The encode loop hands it to `encodeRetained`, a contained
sibling of the main per-kind arms that binds the handle's persistent
`MTLBuffer`s (made once per device from the immutable arrays, so the
triple-buffer ring rule doesn't apply: nothing ever rewrites them) at each
inner run's offset. The draw-time CTM is carried in new `Uniforms` fields
(`batchTransformed` + `batchTransform`) that every 2D vertex shader applies
*to its output position only*, behind a flag test that stays 0 outside a
replay, so the ordinary paths' arithmetic is untouched (the whole snapshot
suite stayed byte-identical) and an untransformed replay renders byte-identical
to the recording (pinned by `BatchRenderTests`). Transforming the output alone
is what keeps SDF analytic AA exact under any replay rotation/scale: the
shape-local interpolants never change, and `fwidth` re-derives the on-screen
footprint. Point-cloud runs ignore the 2D transform and project through
whatever camera is active at replay. On exit the loop's uniforms binding is
restored so following batches are undisturbed.

**Vector export records commands instead.** Vector mode replaces GPU emission
per funnel, so a batch can't carry both representations without running the
body twice (which would double-consume the rng). Instead
`OllinApp.isVectorExporting` spans the *whole* `recordVectorFrame` drive
(setup and warmup included, unlike the per-frame `svgRecorder` install), and
under it `makeBatch` runs the body against a private recorder, storing
`SVGCommand`s in the handle; `drawBatch` splices them into the active recorder
with the draw-time CTM left-composed per command. Each export path builds a
fresh sketch (`handleCommandLine`'s one `make()` wrapper), so a handle never
needs both representations in one life.

---

## Layered-effects substrate

The layered-effects system (`Sources/Ollin/Effects/`) lets a sketch draw into
off-screen layers, filter them on the GPU, and composite them back with blend
modes, following the OPENRNDR `RenderTarget`/`Filter`/`compose` model. The
public surface is in `Docs/Drawing/Effects.md`; this section explains the
machinery behind it: how the deferred graph resolves, how filters, generators,
and the persistent feedback/simulation layers run, and the invariants that were
each bought with a real bug.

### The deferred render graph

The `Drawer` stays a pure recorder. `withTarget(_:)` redirects drawing by
tagging each recorded `GeometryBatch` with its `target: RenderTarget?` (forcing
a fresh batch at each boundary), and the drawer records the frame's
`renderTargets`, `filterOps`, and `frameFilters` lists. `MetalRenderer` resolves
that graph at `render()`/`image(of:)` time, in order: each geometry target's
tagged batches render into their own MSAA-resolve pass; the filter and combine
ops run (MPS or fragment passes) into pooled textures; the main pass runs and
samples the results (`target.image` composites through the textured-quad path,
`Image(renderTarget:)`); and `postProcess` filters run on the resolved frame
before present. The no-targets path is gated on those three lists being empty
and is byte-identical to a direct render.

Everything stays GPU-resident: layers are render-pass attachments and filter
inputs are sampler binds, never a CPU round-trip (the layer-as-uniform upload
that makes naive layer systems unusably slow). Layers are premultiplied linear,
so blur and bloom composite physically and tone-map plus dither still happen
exactly once, at present. A layer's `scale` renders it at fraction resolution
for fill-rate-bound effects, and layer textures are pooled per frame-ring slot.

**The layer blocks scope the drawing state.** `withTarget`, `withFeedback`, and
`withField` push and pop the drawing state around their block (the documented
`withState`-like scoping), so a `blendMode` or style set inside a layer block
never leaks to the canvas. Leaking was a real bug; the fix changed no
snapshots, and `Feedback.filtered(_:)` mirrors `SimField.filtered(_:)` (both
forward to their write layer).

**Every `GeometryBatch` begin must snapshot all the `*Start` offsets.** A
batch's vertex count for each kind is `next.<kind>Start - this.<kind>Start`, so
a batch creator that omits one offset silently zeroes the count of a *preceding*
batch of that kind. The image/glyph/particle/depthScene begins once omitted
`meshStart`, which was harmless until a 3D mesh in a render target was followed
by `drawImage`/`drawCaption` (the `SceneDefocus` case); all begins now snapshot
every offset.

### Filters and generators

`Filter` is a ~55-entry catalog in six families (blur/glow, color/tone,
stylize/optical, retro, uv-warp distortion, and the design filters; the full
per-filter list lives in `Docs/Drawing/Effects.md`). Every filter is a
fullscreen-triangle fragment pass on the `.effect` pipeline, reusing
`ollin_present_vertex` plus an `ollin_fx_*` fragment, reading and writing
premultiplied linear. Most are single-sample; a handful (`bilateral`,
`motionBlur`, `radialBlur`, `oilPaint`, `median`, the halftones) gather several
taps; only `.gaussianBlur` and bloom's internal blur use MPS
(`MPSImageGaussianBlur`). `.bloom` is bright-pass, blur, add-back.
`.gradientMap` binds a baked 256-step LUT (`rgba32Float`) as a second texture.

A few filter internals are worth pinning. `.relight` (the height-map material
pass, with matte/metal/glass/sand/liquid finishes) derives its normal from a
Sobel gradient that must be uv-normalized, or high-resolution relief flattens.
The radial warps take a `center:` in layer fractions and are byte-identical at
the 0.5 default. Among the design filters, `.flutedGlass`/`.water`/
`.paperTexture` transform the layer while `.liquidMetal`/`.heatmap`/`.gemSmoke`
read its alpha shape (draw a shape, filter it): liquid metal and gem smoke
consume a true interior-inflation field solved per frame as coarse-to-fine
Jacobi fragment passes (`poissonInteriorField`), chosen because it is both
smooth and boundary-exact where a blur leaks across concavities and a distance
transform creases at the medial axis; heatmap preps a Gaussian halo
(`blurredAlphaFields`, matching its blur-based reference).

Two conventions keep the color math honest: the distortion warps only move
texels (premultiplied values pass through untouched), while the color/stylize
filters un-premultiply, run the straight-color op, and re-premultiply. The
per-pass uniform is a packed `[SIMD4<Float>]` (`constant float4 *params`,
widened from a single `float4`) so one filter can carry several params and
colors. New filter built-ins are written from the published technique and
credited in `ATTRIBUTION.md`'s Techniques list, never in `.swift` comments.

`Generator` (`generate(_:)`) is the input-less sibling: a procedural pattern
filled into a `RenderTarget` by a no-input fragment pass (a `.generator`
`RenderTarget.Origin`), resolved ahead of the geometry and filter passes. The
catalog is the four basics (`.checkers`/`.gridLines`/`.bars`/`.noise`,
`ollin_gen_*`), the design-pattern set (`.meshGradient`/`.filaments`/
`.smokeRing`/`.colorPanels`/`.spiral`/`.waves`/`.dotOrbit`/`.grainGradient`/
`.pulsingBorder`/`.godRays`, in `ShaderPatterns.metal`), the pattern fields
(`.quasicrystal`/`.moire`/`.gyroid`/`.phyllotaxis`/`.hexPulse`, closed-form
animated fields sharing `ollin_pat_ramp`), and the escape-time fractals
(`.mandelbrot`/`.julia`, smooth iteration count through a cosine palette fold).
`generate(_:width:height:)` fills an explicit-size layer, so patterns compose
per-layer instead of being squashed into full-canvas fills (the Shader and
Visual `generate` forms have the same explicit-size overloads). One nuance: a
fine 1px pattern averages away when its layer is drawn smaller, which is why
the `dither` generator takes a `pixelSize`.

Three design-pattern invariants hold across that set. Animation is an explicit
`phase` parameter (feed `time`), so exports and snapshots are deterministic
with no hidden clock. Palettes are stored as trailing float4 rows after the scalar
rows of the packed params buffer. And the fragments blend palettes and
composite internally in sRGB (`ollin_pat_stop`/`ollin_pat_out`), converting to
premultiplied linear only on output: designer palettes mixed in linear read as
washed-out pastel (a real bug), and the mesh gradient's inverse-distance
weighting especially depends on this.

### Post-process anti-aliasing (`.antialias`)

The renderer has four anti-aliasing paths, and a layer written by a fragment
shader reaches none of them: multisampling covers the triangle path, the SDF and
fringe paths carry analytic coverage, the temporal pass covers a moving 3D
frame, and MetalFX covers an upscaled one. A `Generator`, a raymarched field, an
imported shader, or a finished chain writes a final color per pixel with no
shape behind it, so a hard edge in one is a staircase. `ollin_fx_antialias` is
the repair, reimplementing FXAA (credited in `ATTRIBUTION.md`): a range test
that returns early on a flat neighborhood, a second-difference test for the edge
orientation, a walk to both ends of the edge with a growing stride, and a
low-pass term for a lone pixel with no span to walk.

Three decisions in it are Ollin's rather than the paper's, and each was found by
measurement rather than reasoning.

**The published orientation test ties constantly here, and the tie decides the
picture.** For an ideal two-value edge the "runs across" and "runs down" sums
come out equal, in both orientations: a horizontal edge and a vertical one each
score 2. Photographic content, which the technique was written for, rarely ties;
a shader writes exactly two-value edges, so here the tie is the ordinary case.
Taking the larger with `>=` then answers *across* for every edge, and a vertical
one gets `lFirst == lSecond`, an end contrast of 0, a span of zero length, and
no offset at all. Measured: a steep staircase improved by 20% while a shallow
one improved by 51%. The tie is broken by `|lN - lS|` against `|lW - lE|`, the
plain difference across the pixel, which a two-value edge always answers; both
orientations then land within a hundredth of each other.

**The layer is linear-light, and the technique assumes display-encoded input.**
The edge walk compares each tap against the brightness of the pixel pair the
edge divides. The published form averages the two brightnesses already in hand,
which is right when the sampler that reads the half-pixel tap averages those
same encoded numbers. Here the sampler averages in linear light and the encoding
happens after, so the two disagree: across black and white, the average of the
brightnesses is 0.5 and the brightness of the average is 0.735, a gap of 0.235
against an end test of 0.25, which ends nearly every walk at its first step. One
extra tap reads the pair from the layer itself instead, and both sides of the
comparison become the same measurement.

**Brightness is read over a mid-gray backdrop, and encoded past 1 rather than
clamped there.** A layer is premultiplied, so a black shape on a clear layer
carries color 0 on both sides of its silhouette and a pass reading color alone
finds no edge where a viewer plainly sees one; compositing over a backdrop turns
the alpha step into a brightness step, and leaves an opaque layer (alpha 1
everywhere) exactly as it was. The sRGB curve is then carried on past 1 instead
of clamping, because a layer is linear and the tone map is at the present pass:
clamping makes every edge between two values brighter than white read as flat.

`AntialiasFilterTests` pins all of it against **edge wander**, the RMS distance
from the straight line a shallow edge's sub-pixel positions should lie on, read
as coverage in linear light. A 1-in-8 staircase wanders 0.286 px; through the
filter it wanders 0.140 px. Half rather than none is what a pass reading the
finished image can buy, and it is why the filter is placed rather than applied
to every layer: it cannot tell a stair-step from genuine one-pixel detail.

### Feedback (previous-frame) layers

`makeFeedback(scale:)` returns a *persistent* `Feedback` layer: made once in
`setup()` and held, unlike the per-frame `RenderTarget`, because its identity is
what carries state across frames. `withFeedback(_:) { prev in ... }` redirects
drawing into it and hands in last frame's result as `prev` (the
`withTarget(Feedback)` form reads `feedback.previous` by name instead), and
`feedback.image` composites this frame's result.

Under the hood it is a two-texture ping-pong kept in a persistent map keyed by
the layer's identity (`MetalRenderer.feedbackSlots`, separate from the per-frame
texture pools): the renderer draws into the back texture while the block reads
the front, then flips after the frame. It relies on Metal's automatic GPU-to-GPU
hazard tracking (feedback is inherently serial, so no extra semaphore), and
slots are pruned by a weak owner reference on live reload. The
`RenderTarget.Origin.feedback(Feedback)` case routes the write layer to the
ping-pong storage.

**Headless warmup gotcha:** `image(of:frame:)` must render *every* warmup frame
when `drawer.usesFeedback`, exactly like accumulation, not just `stepCompute`,
or the ping-pong never evolves; the built-up state is what a single-frame export
and the `effects-feedback` snapshot depend on. `usesFeedback` counts feedback
layers, sim fields, and SSR ops alike.

### Simulation fields

`makeSimField(_:scale:)` returns a persistent `SimField` (`Effects/SimField.swift`)
that runs a built-in `Sim` on its state each frame: the stateful sibling of the
stateless `Filter`. `Sim` is a `Sendable` value catalog like `Filter`:
`.reactionDiffusion(feed:kill:)` (Gray-Scott), `.gameOfLife` (Conway),
`.lenia(...)`, `.ripples(...)`, `.sandpile(...)`, `.multiScaleTuring(...)`, and
`.fluid(...)`. A sketch draws into the field to seed or force it
(`withField(_:_:)`, scoped like `withTarget`): the renderer renders the drawn
marks into a transient seed texture, runs `ollin_sim_inject` to composite the
seeds onto the front state, then steps the sim's `ollin_sim_*` fragment N
sub-steps per frame (reaction-diffusion 14, Game of Life 1), ping-ponging pooled
scratch into the back buffer (`runSimulation` in `MetalRenderer`). The inject
pass binds the sim's parameter rows after the texel row, same as the step, for
the injects that read one (the sandpile's pour); the pre-existing injects never
look past the texel row, so the binding change was byte-identical for them.

Sim fields reuse the feedback path: the `RenderTarget.Origin.simField(SimField)`
case routes to the same persistent ping-pong storage (`FeedbackSlot`, whose
owner is `AnyObject`), and `feedbackSlot(for:restState:)` clears a fresh pair to
the sim's *rest state* (reaction-diffusion rests at A=1, B=0; Game of Life
dead), not to transparent. The field itself is raw state, not a picture:
`SimField.image` shows it, and `SimField.filtered(_:)` recolors it through the
`Filter` catalog (the substrate payoff: reaction-diffusion output fed through
`gradientMap`).

`.fluid(...)` is the multi-field sim (a real-time incompressible flow carrying
dye), so it runs a dedicated `runFluid` pipeline (~30 passes/frame) instead of
the single-state step path: a velocity+dye splat (the mark's color becomes dye;
`withField`'s `force:` becomes velocity, converted by dividing by `dt`), curl
plus vorticity confinement, a divergence-free projection via a Jacobi pressure
solve (default 20 iterations) and gradient subtract, then semi-Lagrangian
advection of velocity then dye (Stam stable fluids / GPU Gems / the splat
recipe, written from the technique and credited in `ATTRIBUTION.md`; boundaries
are the clamp-to-edge sampler, and `dt` is a fixed constant for determinism).
Its persistent state is *two* ping-pong pairs (velocity and dye) in a separate
`MetalRenderer.fluidSlots` map keyed by the `SimField`'s identity;
pressure/divergence/curl are per-frame pooled scratch, and
`acquireFilterTexture` hands out a distinct texture per call so the ~8 scratch
passes never alias. Keeping `fluidSlots` beside `FeedbackSlot` keeps the
single-field path byte-identical (verified: no `effects-simfield` or
`effects-feedback` re-record). The brush model is one global `force` per field
per frame; `SimField.image` is the dye, recolorable and bloomable like any
layer.

#### Multi-scale Turing (`.multiScaleTuring`)

McCabe's rule (written from the Bridges 2010 paper, credited in
`ATTRIBUTION.md`): one substance in `0...1`, and at each scale an *activator*
average over a small disc against an *inhibitor* average over a larger one. The
scale whose two averages differ least wins that pixel and nudges it toward the
greater of the two; the field is then renormalized to fill its range again,
which is what stops the nudges accumulating into a runaway. Like `.fluid` it
needs more than one pass, so it runs its own `runMultiScaleTuring` pipeline, but
unlike the fluid its storage is a single ping-pong pair, so it shares
`FeedbackSlot` and inherits the flip, the prune, and the
`statefulEncodeIsRepeat` arm unchanged.

The passes: inject the drawn seeds; build a blur pyramid down to 1×1; per scale,
measure `|activator − inhibitor|` at full size and run it down the same halving
chain to the rung its `variationRadius` names; step; reduce the stepped field to
its minimum and maximum through a 4×4 chain; normalize into the back buffer.
Precomputing the variations means the step recomputes only the *winner's* two
averages, which is what keeps a symmetric field affordable (one scale's fold per
pixel rather than every scale's).

Four things about the GPU realization are load-bearing, and each was a real
defect first, found by bisecting a single scale against the paper's figures
rather than by reading the output as a whole:

- **The field starts as seeded noise, not a constant.** A uniform field has every
  average equal at every scale, so no scale ever fires: it is a fixed point, and
  a constant rest state leaves the sketch black forever with no hint why. This is
  why `feedbackSlot` grew a `fill:` override beside `restState`.
- **The pyramid is Gaussian (Burt-Adelson binomial), not a 2×2 box mean.** A
  square kernel has square preferred directions, and the labyrinth came out
  rectilinear, all right angles. The separable `(1,3,3,1)/8` about the block
  center is four bilinear taps at ±0.75 source texels with equal weight, the same
  cost as the box, and repeated down the rungs it converges on a true Gaussian.
- **A radius gathers a nine-tap disc two rungs *finer* than the radius match,
  never a single tap at the matching rung.** This is the subtle one. The matching
  rung has the right blur *width* but holds only one sample per feature, so the
  reconstruction between samples has nothing to go on and the pattern locks to
  the lattice. No amount of kernel smoothing fixes it, because the information is
  not there; the pyramid has to be oversampled relative to the blur. Two rungs
  finer puts the lattice at a quarter of the blur width, and the ring rebuilds
  the disc isotropically (each tap is itself a smooth Gaussian about a quarter of
  the radius, so the taps overlap rather than reading as a ring of blobs).
  `MultiScaleTuringTests.patternIsIsotropicRatherThanLockedToTheLattice` measures
  the gradient-energy-weighted mean of `cos(4θ)`: the defect scores 0.63, the
  shipped gather 0.11, and the counterfactual was run to confirm the test fails
  on the defect rather than merely passing on the fix.
- **Variation is averaged over a per-scale `variationRadius`, not read at a
  point.** Read at a point, a fine scale's disagreement passes through zero along
  every contour of its own structure, and since *least* disagreement wins it
  claims a dense web of pixels across the whole field and buries the coarse
  scales. (Softology's write-up notes single-point variation gives "the sharpest
  most detailed images", which is the same observation from the other side.)
  Measuring the winner distribution directly, by temporarily routing the winning
  index into a color channel, is what separated this from the amount question
  below: the coarsest scale was already winning 68% of pixels in contiguous
  regions, so selection was never the problem.

Two *parameter* facts are equally load-bearing, because renormalization couples
the scales to each other: the rungs want **equal amounts** (whichever pushes
hardest sets the field's range, and the rest are squeezed toward mid gray, so an
uneven ladder gives one scale's pattern with the others as a faint wash), and the
ladder **starts at radius 2** (a rung at radius 1 works on single texels, and
pixel-scale features read as speckle rather than detail).

Two constants are paired across the language boundary: `TuringScale.maxScales`
(6) with `OLLIN_TURING_MAX_SCALES`, and `MetalRenderer.turingPyramidLevels` (12)
with `OLLIN_TURING_LEVELS`, which sizes the `array<texture2d<float>, N>` binding;
a shallower pyramid repeats its top rung to fill it and `levelCount` keeps the
shader off the padding. Deliberate v1 cut: the winning-scale index is *not*
carried in the state, so color-by-scale (McCabe's colored plates) is not
available; it would mean spending a channel that both the display and the extent
reduce read as gray.

This sim is also what surfaced `Drawer.ensureFieldSteps`. A sim only evolves
while its layer is one of the frame's render targets, which `withField` is what
normally arranges. That is right for a sim you seed by drawing, but a
self-organizing one needs nothing drawn into it, and the natural sketch (make it
in `setup`, `drawImage` it in `draw`) would sit frozen. So `SimField.image` and
`.filtered(_:)` register the field too; the layer's transparent clear is what
makes that safe, since an unseeded frame renders a seed that composites nothing.
Existing sketches register via `withField` first, so target order is unchanged
and the whole snapshot suite passed unrecorded.

#### Abelian sandpile (`.sandpile`)

The Bak-Tang-Wiesenfeld toppling automaton (1987, the model that named
self-organized criticality; credited in `ATTRIBUTION.md`), and the counterpoint
to the Turing pipeline above: it is exactly the cheap, generic single-texture
sim the step path was built for (one gather fragment, no dedicated pipeline).
Each pass every cell holding at least four grains topples, *as many times as it
can at once*: for every four grains it holds it sends one to each of its four
neighbors and keeps the remainder, so in quarters the whole update is
`q' = fract(q) + Σ floor(q_n) / 4`. Dhar's 1990 abelian-property result is what
licenses any parallel schedule, single or k-fold, since topplings commute and
the settled pile is the same in any order. The k-fold form matters and was
found empirically (the first render used one toppling per pass): where every
cell holds fewer than eight grains, the regime a critical pile lives in, the
two are *identical*, but at a heavy source single toppling pools, because a
saturated blob's interior is net zero (lose four, receive one from each of
four toppling neighbors) and only its perimeter drains; a measured 800-frame
run settled just 13% of what was poured, with the rest stacked at the source.
The k-fold form drains a hot source exponentially instead, which is what makes
the classic drop-a-mountain-and-let-it-collapse figure renderable in seconds.
`SandpileTests` turns the abelian theorem into the verification: the GPU's
parallel sweeps interleaved with pouring must match a sequential CPU
stabilization *cell for cell*, which pins the threshold, the gather, the
inject rounding, and (in the edge-block variant, verified red against its
counterfactual) the open boundary.

The GPU realization has four more load-bearing choices:

- **Grains are stored in quarters** (one grain = 0.25) so a stable cell reads
  0, ¼, ½, ¾ directly (`gradientMap`-ready, one flat level per count) while the
  arithmetic stays exact: quarter steps in a half-float texel are exact to 2048
  grains, far above what the clamped pour (≤ 1024/frame) can pool.
- **Reads come from `.r` only.** The state is written to all three channels for
  a readable gray image, but the step and inject read the one channel: a
  luminance dot product is off by an ulp, and the step's `floor`/`fract` at the
  toppling threshold are exact operations an ulp would break.
- **The boundary is open, never wrapped or clamped.** A neighbor position off
  the field contributes nothing, and a toppling cell always loses four, so
  grains crossing the edge are simply gone. That dissipation is what lets a fed
  pile keep settling (on a torus sand only accumulates until every cell topples
  forever), and the guard must reject the position *before* sampling, or the
  clamping sampler reads the edge texel back as its own neighbor: a reflecting
  wall, the counterfactual `grainsFallOffTheOpenBoundary` was verified against.
- **The inject rounds to whole grains.** A mark pours
  `rint(luma × pour) / 4` onto the state (additive, like the ripples inject:
  replacing would erase the pile), so a soft anti-aliased fringe pours whole
  grains or none and the count never leaves the integer lattice; the rounding
  also absorbs the ulp in a white mark's luminance. Nothing erases; easing off
  the pour is the only way to stop.

`topplings` is `substeps`, a factory parameter rather than a constant because an
avalanche front moves one texel per pass, which makes it the pacing dial: watch
single waves at 1, hurry a collapse at 128. The two pour protocols look very
different and both are honest physics: the *classic relaxed figure* comes from
dropping one heavy mark on one frame and letting the field settle (the
`Sandpile` example and the Guide figure both do this), while a *held* heavy
mark keeps a molten super-critical core for as long as the torrent runs,
because each frame's pour re-fills the interior faster than the rim sheds; the
display shows it honestly as the top of the ramp, not a defect to guard
against.

### The watercolor wash (`Sim.watercolor` / `WatercolorField`)

The third dedicated multi-pass sim beside the fluid and Turing, and the largest:
the classic three-layer wash model (shallow water flowing above the paper,
pigment settling onto it, moisture creeping through it; see `ATTRIBUTION.md`
for the source paper and the division of credit). `runWatercolor` drives it
over a `WatercolorSlot` of ten persistent textures: three ping-pong pairs
(`flow` = velocity/pressure/wet mask, `pig` = suspended pigment + paper
saturation, `dep` = settled pigment), the generated `paper` height field, the
dried-glaze stack (`driedR`/`driedT`, Kubelka-Munk reflectance and
transmittance), and the `display` texture the field's `image` serves (also the
repeat-encode answer: a second encode of the same sketch frame reads it back
instead of re-stepping).

**The grid and its packing.** One texel carries a staggered (MAC) cell: `u` on
its right face, `v` on its top face, pressure and the wet mask at the center.
Every flow or pigment tap goes through a bounds-rejecting helper that reads
off-canvas as dry, motionless paper; the clamp sampler would reflect the edge
texel back as its own neighbor, and the rejection is also what pins velocities
at the canvas edge for free. A face bordering a dry cell is pinned to zero in
every pass that writes velocities, which is the paper's boundary condition
(water never leaves the mask) applied at write time rather than as a separate
pass.

**Two sign decisions are load-bearing.** The velocity update applies the
viscous term as `A + μB` (B is the five-point Laplacian): the model's
continuous equations carry `+μ∇²u` and its own design conditions demand damped
flow, while the printed pseudocode's sign reads inverted, and anti-diffusion
detonates the wash within seconds. The divergence relaxation likewise runs in
the divergence-*reducing* direction (`δ = −ξ·div`), in gather form: a face
carries its own cell's correction minus its right/top neighbor's, and the
per-cell corrections also accumulate into pressure, so water added anywhere
pushes water everywhere.

**Time stepping.** The paper's adaptive Euler step (Δt chosen so no velocity
crosses a texel) becomes four fixed substeps of dt = 1/4 with velocities
clamped to ±1, the ripples precedent. That pairing is what lets the pigment
advection be a pure nine-tap gather with no conservation rescale: each face
moves at most a quarter of a cell per substep, so the four outflows can never
exceed the cell's own pigment, concentrations stay non-negative, and both
sides of a face compute the same transfer from the same snapshot. The
settle/lift exchange with the deposit layer is split into two passes
(`transfer_dep`, `transfer_pig`) that read one snapshot and derive identical
per-pigment deltas, so the pair conserves pigment exactly; the capillary
absorption folds into the pigment half (wet paper drinks toward its
height-scaled capacity, damp paper left behind dries slowly).

**Backruns and the two lifecycle verbs.** The capillary layer is the paper's:
moisture diffuses from wetter to drier paper *that is already damp* (a
receiver below the dampness threshold takes nothing, so blooms stop at dry
paper), and paper saturated past a threshold joins the wet mask. Two
calibration facts matter. `dry()` bakes the wash into the glaze stack
(compositing the wet layer onto `driedR`/`driedT` as *fresh* textures swapped
into the slot, never rewritten in place under an in-flight frame) and must cap
the remaining saturation *below* the mask-expansion threshold, or the fully
saturated sheet re-wets its whole old footprint on the next step and `dry()`
never sticks. And a baked wash has nothing left to push, which is why the
paper's backrun needs its own staging verb: `blot()` lifts the standing water
but leaves the pigment parked and the sheet damp (the paper authors this state
as initial conditions), and a *held* clean-water touch then floods back
through the damp paint, pushing it into the pale bloom with the dark branching
rim. A single tap only nudges, because one frame's pressure equalizes through
the relaxation almost immediately; the held brush is a sustained pressure
source, and interactively that is exactly how a wet brush behaves.

**Rendering.** The optical passes run in display-space sRGB, the space the
pigment coefficients are specified in (and the space the source work's own
arithmetic ran in), converting to linear only at the output write; the CPU
reference (`KubelkaMunk` in `WatercolorSim.swift`, which also powers the
`overWhite:`/`overBlack:` inversion and the tests) and `ollin_wash_km_layer`
are the same closed forms and must stay in step. A layer's coefficients blend
across the palette in proportion to each pigment's share of the total
thickness (`x_k = g_k + d_k`); the wet layer composites over the dried stack,
and the whole stack over the sheet's own reflectance. The pipeline is
deterministic end to end (the paper generates from a seeded fragment; every
pass is a fixed function of state), so unlike the atomic-scatter compute sims
it carries a pixel snapshot (`watercolor-sim`), with `WatercolorSimTests`
pinning what a mean diff averages away: the darkened edge, the dry-brush gaps
(the inject's height gate applies to *both* halves, or dry-brush gaps hold
flat unsimulated pigment), the freeze after `dry()`, the backrun-vs-off
counterfactual, and byte-exact replay.

### Compose DSL, combine ops, and `aside`

`compose { layer { ... }.post(_:).blend(_:).scale(_:) ... }` is the declarative
surface over the substrate: it makes a `renderTarget` per `layer`, draws into it
via `withTarget`, chains the `.post` filters, and composites bottom-to-top in
declared order under each layer's `.blend`. It is pure sugar (a user can
hand-write the same `renderTarget`/`withTarget`/`filtered`/`drawImage`);
`ComposeLayer` is the public value type and `@ComposeBuilder` the result
builder, so `if`/`for` build layers. One load-bearing detail: `layer(_:)`'s draw
closure is `@escaping @_implicitSelfCapture` (it runs synchronously inside
`compose` but is stored in the builder), so bare draw calls inside a `layer { }`
need no `self.`; without the attribute the p5-style feel breaks. Verified: the
attribute propagates across module boundaries to example and test targets.

`Combine` (`Effects/Combine.swift`) is the two-input sibling of `Filter`:
`base.combined(with: aux, op)` reads two layers, covering what one-input filters
cannot. It is a `Sendable` value descriptor like `Filter`, but it cannot hold
the reference-type `RenderTarget`, so the aux is carried alongside it and the op
records a `RenderTarget.Origin.combine(base:aux:op:)` case resolved in the same
`filterOps` list as filters; record order guarantees both inputs fill first (the
wiring is detailed under *Shared substrate* in the next section). Six ops:
`.mask` multiplies base by aux luminance/alpha with optional invert
(premultiplied luma, so coverage is honored); `.displace` offsets the base UV by
the aux's RG recentered to within `amount`; `.mix` is a premultiplied
cross-dissolve; `.defocus`, `.ambientOcclusion`, and `.screenSpaceReflections`
are the screen-space effects detailed in the next section. The two inputs may
differ in `scale` (each is sampled by normalized uv).

`aside { }` is the combine's `compose` sugar: it builds the same value as
`layer { }` (its `.blend` unused; an aside never composites), and
`.masked(by:)` / `.displaced(by:amount:)` / `.mixed(with:amount:)` /
`.defocused(by:)` feed it to a layer. A layer's processing is an ordered
`[Step]` (filter or combine) so posts and combines interleave, resolved by one
recursive `resolveComposeLayer`; that is what lets an aside carry its own
`.post` chain (a blurred mask edge, for example).

Substrate credits (recorded in `ATTRIBUTION.md`, never in `.swift`):
AsyncGraphics (architecture and the cross-dissolve/key/displace shape), ofxFX
and orx-fx/OPENRNDR (the catalog, the compose model, the `aside` idea), and
Apple MPS (first-party, in use).

---

## Light in a flat sketch (radiance cascades)

`Combine.light` is a two-input combine like the screen-space ones below, but its
work is a ladder rather than a pass. The base layer is the scene (its alpha stops
a ray), the aux layer is the lamps (its rgb gives light off), and the result is
the light arriving at every pixel. The technique is Sannikov's radiance cascades,
written from the published description and credited in `ATTRIBUTION.md`; the
mechanism lives in `MetalRenderer+Effects.radianceCascades` and
`ShaderRadiance.metal`, and the invariants are in `CAPABILITIES.md`. What follows
is the reasoning and the measurements behind them.

### Why a ladder

To resolve a lamp you need probe density near it and angular resolution far from
it, and the two requirements pull in opposite directions. The cascade structure
satisfies both by splitting the field into rings of distance: rung `i` samples
with probes `s0·2^i` apart, `4^(i+1)` directions each, over the span
`[s0·(4^i - 1)/3, s0·(4^i - 1)/3 + s0·4^i]`. Probes divided by four and
directions times four cancel exactly, so every rung is one texture of the same
size, and the whole ladder costs one rung times the rung count. That is also why
the cost is independent of scene complexity: nothing in the loop reads a
primitive list.

One texture holds one rung, cut into `2^(i+1)`-texel square blocks, one block per
probe, a texel's place inside its block naming its direction. The probe grid is
padded up to a multiple of `2^(rungs-1)` so every rung can halve it, and the
merge reads the rung above **texel by texel** rather than through the sampler
(neighboring texels there are different directions, so a filtered tap returns a
ray that was never cast).

### The bilinear fix, and the measurement that justified it

The plain merge (each rung's ray running from its own probe to its own probe's
interval end, then bilinearly interpolating the four upper probes' matching rays)
leaves a gap of up to a probe spacing between where one rung's ray stops and
where the rung above it starts. The relative size of that gap is `2·2^-i`, so it
shrinks with the rung and never disappears. It draws itself as a ring of light at
every hand-over distance, plus blotches at the coarse rungs; the first render of
this feature showed both plainly at 85 px and 341 px on a 1080 canvas, exactly
the rung boundaries for a one-pixel probe spacing.

The published correction is to trace **one ray per merge neighbor**, each ending
where that neighbor's own ray begins. It costs four marches instead of one on
every rung but the top, and it removed the rings, the blotches and the petals in
one change.

The regression net is a physical one rather than a smoothness one, because the
artifact is a step rather than a bump: a disc lamp of radius `R` seen from `r`
away covers `2·asin(R/r)` of the circle of directions, so the average light
arriving is exactly `asin(R/r)/π` of the disc's own. Measured over r = 80…370 px
on a 512 canvas, the reading is **0.902…0.952** of that exact answer with the fix
and **0.900…1.108** without it. Running a few per cent under is the disc's
antialiased rim; running *over* is light nobody emitted, which is a stretch of
each ray counted twice. `theLightMatchesWhatTheGeometryAllows` fails on the
one-line sabotage.

### The march, and why the distance field is shared

Rays sphere-march the shipped `measuredDistanceField`, built once per call from
the *combined* coverage of scene and lamps (a lamp is a thing in the world, so it
occludes) and reused by every rung and every bounce. That is what makes an empty
room cost one step: the field says how far the nearest edge is, so the ray jumps
that whole distance.

Two details are not free to change. The step is `max(d - 0.5, 0.5)` because the
field is kept in half floats, which space whole numbers a unit apart past 1024; a
step longer than the truth walks a ray through a thin wall. And the emission is
read **1.5 px inside** the surface the ray stopped on, so an antialiased rim
hands back the shape's own light rather than a fraction of it.

The top rung's ray is cut at the requested `reach`. Without that cut the parameter
would round up to the next whole rung, and a reach of 90 px would light 340.

### A ray is a thin cone, and why a finer ladder could not fix the penumbra

The first shipped march traced each ray as a line: it either met a lamp or missed
it. Seen from the rung that resolves it, a lamp is a small number of rays wide (the
pointer lamp of the `Effects/Light` example, 32 px across at 584 px, spans nine of
the 1024 directions of the rung that sees it), so one probe counted eight of them,
its neighbor ten, and a penumbra crossing that lamp moved in steps of a ninth.
Drawn as light, those steps are cells the size of that rung's probe spacing, 32 px:
a mottled, blocky penumbra, read at the desk as a compressed photograph. Halving
the probe spacing (`.detail`) did not cure it, and the arithmetic says why: the
rung that sees a lamp at distance `D` has probes about `sqrt(3·D·s0)` apart, so
halving `s0` shrinks the cells by only a root of two, and the finer ladder puts the
same lamp one rung up where the rays are twice as dense and the steps half as
tall, still cells. The paper's penumbra condition (an angular step smaller than the
lamp's angular size) was met; what failed was the reconstruction, a point sample of
a hard-edged function.

Each ray is now the thin cone it stands for, half the rung's angular spacing wide,
widening from the distance its rung begins at. A surface the ray passes counts by
the share of the cone's width it covers under a straight-edge model: the field's
signed distance `d`, divided by the sine of the angle between the ray and the
direction to that edge, is where the edge crosses the cone's cross-section, and it
covers `(h - across) / 2h` of a cone `2h` wide. That division is what makes a wall
met head-on count for nothing until the axis crosses it (the edge is ahead, not
beside) and a wall run along count by the width it takes. Once the axis is inside
a surface the march jumps to where a straight edge would cover the whole cone and
reads the field again: still inside and deep enough is a full hit; out past a face
looking straight back the way it came in is a strip crossed, opaque however thin;
out past any other rim is a lamp's limb grazed, which keeps the share it reached
and carries on past the lamp. The share is read from the local angle at every
sample rather than the entry's, because halfway along a lamp's chord the nearest
rim is straight beside the ray and the depth alone is the share. Three refinements
bound the cost: a ray within 45° of head-on is blocked outright (a strip crossed
that squarely blocks all of it, and a lamp more than three cones wide has the depth
to), the jumps are capped at three (a wall run along at a grazing angle, which a
lamp's chord never is), and the largest share along the ray is the one kept, since
a thin cone grazes one thing. A lamp narrower than about three cones is the one
case that stays wrong, and it errs bright: its middle chords read as a strip. That
is the regime the penumbra condition never covered.

The probes that pin it use a four-pixel lamp, about one ray wide at the rung that
sees it, where a line is at its worst: the shadow's edge far below a bar climbs
without a dip (0.054 of the peak with a line, 0.018 with the cone), and a ring of
equal distance reads evenly (0.113 of its mean with a line, 0.072 with the cone).
The disc-lamp falloff moved from 0.902…0.952 to 0.926…0.972 of exact, since the
rim is now read by the share it covers rather than missed.

The cone's reads are what it costs, and the field pays them back. Every ladder run
marks the field's alpha with whether the surface nearest each pixel emits anything
(`ollin_light_mark`, one fullscreen pass, run per bounce because a bounce turns
lit walls into emitters), so a ray that meets or grazes a dark wall, which is most
of what a ray meets, reads nothing at all, where the line read the scene at every
hit. Measured old against new **in one process, alternating through live shader
reload** (`reloadShaderLibrary(fromDirectory:)`), three rounds each: 0.94…1.02× at
`.performance` and `.default`, 1.03…1.12× at `.detail`. That harness is the finding
worth keeping. Two others gave wrong answers first: frames submitted back to back
with three in flight overlap on the GPU, so one command buffer's span carries its
neighbors' work and the cone read as 1.2…1.5× dearer; and one shader per process,
drained between frames, drifts 30% run to run with the GPU's clock, which made the
cone read as 1.6…2.2× before the same protocol read the old shader against itself
at 1.0×. A GPU cost comparison here has to run both sides in one process.

### The bounce, and what it can and cannot say

A bounce is a second run of the ladder with the emission raised by what each
surface received. The obvious form of that (`emission += albedo · radiance` at
the same pixel) hands back nothing, because a probe inside a shape meets that
shape at once, so the field inside a wall is dark by construction.

The measured field is what steps back outside: its direction channels point at
the nearest edge, so a surface pixel reads the light standing just off its own
face. That is the light a ray meeting that face came from, which is the quantity
wanted.

The cost is one artifact, and it is cosmetic only. Every interior pixel of a
solid leans on its own nearest face, so a wide round shape lit unevenly shows its
rim smeared radially, with a seam where two sides meet. No ray ever reads deeper
than a shape's shell, so this decides only how a solid shows its own lit face,
never how it lights anything else. Five taps spread along the edge, the spread
growing with depth, turn most of the fan back into the gradient it should have
been; the seam at the middle survives and is documented as a limit.

### The tiers

Quality resolves to probe spacing (4 / 2 / 1 px) and march budget (16 / 24 / 48
steps). Measured M2 release at 1080², one bounce, the whole term including the
field: **11.3 / 23.4 / 79.6 ms**, and 7.8 / 13.1 / 42.0 with no bounce. Running
out of march steps is a graceful failure: the ray reports meeting nothing and the
rung above it fills in, so a budget too small leaks light rather than tearing.

`.detail` is what an export resolves `.default` to, so an exported frame pays the
one-pixel spacing. Those figures predate the cone, which measures at 0.94…1.12× of
them by the in-process comparison above. It differs from two mostly at a lamp's own rim (max 94/255
there, 0.73 mean over the frame), which is the right thing for a still and the
wrong trade live.

---

## Screen-space combine effects

Ambient occlusion, screen-space reflections, and depth of field are all
**two-input (or three-input) combine ops** in the layered-effects graph
(`Sources/Ollin/Effects/Combine.swift`). They read a rendered color layer plus a
depth layer (and, for AO and SSR, a surface-normal layer), and they run as a
short chain of fullscreen fragment passes on the `.effect` pipeline. They share
one substrate, so understanding that substrate first makes each effect simple.

### Shared substrate: the G-buffer, depth, and the combine path

A combine op records a `RenderTarget.Origin.combine(base:aux:op:)` node, resolved
in the same `filterOps` list as ordinary filters. Record order guarantees both
inputs exist before the op runs (they must already have been drawn to be
referenced). The op binds `[base, aux]` (and `normals` for AO and SSR) and runs
its passes into pooled textures before the main pass samples the result.

Two opt-in attachments make 3D-aware effects possible without taxing 2D sketches:

- **Depth.** A render target that a 3D scene is drawn into carries a
  `depth32Float` MSAA attachment that resolves (`.min`) to a sampleable buffer.
  `target.depth` exposes it as a gray layer (0 near, 1 far) via an
  `ollin_fx_depth_normalize` pass that linearizes clip-space depth over the
  camera's near/far. It is sRGB-encoded so the perceptual decode the effects use
  reads it back exactly. The attachment is gated by `RenderTarget.needsDepth`
  (set from `drawMesh`/`drawPointCloud`/`drawDepthScene`), so a pure-2D target
  carries no depth and is byte-identical to before. The opt-in is two-tier: the
  attachment only when 3D is recorded, the resolve-and-normalize pass only when
  `.depth` is actually read; particles in a target still skip it.
- **Normals.** A dedicated single-purpose mesh pass (`encodeMeshNormals` plus
  `ollin_mesh_normal_vertex`/`_fragment`) writes raw view-space normals into a
  mesh-normal G-buffer. It is a separate pass rather than a second attachment on
  the shared geometry pass, which would force every 2D pipeline to be
  MRT-compatible. It is gated by `RenderTarget.needsNormals`, set in
  `Drawer.recordCombine` when an `.ambientOcclusion` or
  `.screenSpaceReflections` op reads a 3D `base`. So a 2D sketch, or a 3D scene
  that runs neither effect, is byte-identical and pays nothing.

The camera parameters the effects need to reconstruct view-space position
(near, far, FOV, and the unified perspective/ortho/intrinsic form) are stamped
on the depth layer at normalize time as `RenderTarget.depthReconstruction` and
read back through the combine's `aux` target. This is the one piece of non-texture
wiring through the `filterOps` loop, which otherwise only sees textures.

The normal G-buffer began as a single-sample pass and went through two fixes
worth knowing before you touch it, because both are easy to reintroduce:

1. **It must be MSAA-resolved.** As the lone single-sample buffer next to an
   8x-resolved scene depth, a silhouette pixel toggled between the mesh normal
   and the cleared background as the camera moved sub-pixel, which made the AO
   shimmer along edges. Resolving it as MSAA turns that binary snap into a smooth
   sub-pixel slide. (The SSAO accept threshold is correspondingly `> 0.001`, not
   `> 0.5`, so any coverage uses the resolved normal instead of the
   depth-reconstruction fallback.)
2. **The resolve must be depth-aware, not a hardware box-average.** At a
   mesh-versus-background edge a plain resolve is correct: the background samples
   are cleared zero, so the average is the front normal scaled by coverage. But
   at an *internal* silhouette (a near surface's edge against a farther one) a
   box-average blends front and back normals into a tilted one that varies along
   the edge, which tilts the AO hemisphere into a faint dashed occlusion line.
   `ollin_mesh_normal_resolve` averages only the front surface's samples (the
   nearest covered depth, then the samples within half the covered depth span),
   so the stored normal matches the `.min`-resolved scene depth. It stores
   coverage-scaled values (the box-average convention, so it stays identical at
   mesh-versus-background edges; SSAO renormalizes the direction on read).

**No view-space Y-flip.** The stored normal is in the same view space as
`ollin_ssao_viewpos`, whose `-ndcY` already lands the frame y-up. A flip is *not*
needed and would be wrong. This is counter-intuitive enough that a plausible
"the normal needs a Y negate" claim has been made and is incorrect; verify by
rendering the G-buffer (ground and tops read cyan, +y up and +z toward camera)
and checking that AO lands on contact creases, not on box tops.

### Ambient occlusion (`.ambientOcclusion`)

`ollin_fx_ssao` reads the aux as depth, reconstructs view-space position, takes
the surface normal from the G-buffer (or reconstructs it from depth when no
meshes are present), then samples a hemisphere kernel oriented to that normal and
counts a sample as occluding when the visible surface there sits in front of it
(within `radius`, via a depth-discontinuity range-check that rejects across
silhouettes). A second pass, `ollin_fx_ssao_blur`, multiplies the base by a
gently blurred AO. As a post-effect it darkens the final image, not just an
ambient term.

Four choices are load-bearing, each of which fixed a specific worse artifact and
should not be undone while chasing flicker:

- **A dense low-discrepancy Fibonacci hemisphere kernel with no per-pixel
  rotation.** Textbook SSAO rotates a sparse/random kernel per pixel to break the
  coherent-silhouette banding it would otherwise paint onto flat faces (the
  "projected squares" look), then blurs the resulting noise away. But that
  screen-space noise crawls and flickers in crevices as the camera turns. A dense
  near-isotropic Fibonacci kernel avoids the banding with no rotation at all, so
  the estimate is geometry-locked: it moves with the surface rather than crawling
  in screen space. Measured at roughly 2x less flat-face noise than the rotated
  form, and flicker-free.
- **A continuous (branchless) Duff basis** for orienting the kernel to the
  normal. A hard axis branch (such as `abs(N.x) < 0.9`) makes the whole AO term
  pop as a face crosses the branch boundary; the continuous basis removes that.
- **The depth-aware MSAA normal resolve** described above.
- **A 7x7 Gaussian depth-aware blur** in pass 2. It is a smooth, not a denoise:
  depth-weighted so it does not bleed AO across silhouettes, and wide enough to
  feather the thin continuous contact line the depth-aware resolve leaves at an
  internal silhouette so it reads as a soft contact rather than a drawn line.

The depth-reconstruction fallback (used when the target holds no meshes, for
example a hand-drawn depth map) samples paired depth neighbors a few texels out,
not one: a 1-texel stencil is comparable to the 16-bit depth layer's
quantization step, so its reconstructed normal stair-steps and bands the flat
faces (the "dirty faces" bug).

Techniques: hemisphere SSAO (Crytek, john-chapman, LearnOpenGL), normal-from-depth
(Wicked Engine), the Fibonacci kernel, and the continuous basis (Duff). Credited
in `ATTRIBUTION.md`'s Techniques list; written from the technique, not ported.

Remaining work: any residual sub-pixel edge shimmer (8x MSAA is not infinite;
temporal accumulation is the lever), and routing a raymarched SDF field's
analytic 4-tap normal into the G-buffer (marginal for a smooth field, where
depth reconstruction already works; it would only help fields with sharp
subtract/intersect creases).

### Screen-space reflections (`.screenSpaceReflections`)

SSR reflects the rendered scene off its own surfaces, reading exactly the three
inputs (`base`, `aux` depth, `normals`) that SSAO already wires, so it needs no
new render-graph plumbing and is byte-identical when unused. It runs four passes:
trace, spatial resolve, temporal accumulation, composite.

**Trace** (`ollin_fx_ssr`) rebuilds view-space position and normal, reflects the
eye ray, and traces the reflection ray in screen space. The single most important
invariant here is the **McGuire-Mara adaptive stride**: cover the whole ray in at
most `steps` coarse steps (with a binary refinement that pins the crossing inside
the resulting coarser stride). This makes both the screen parameterization honest
and the reach resolution-independent. A hit is accepted on a **depth-interval
crossing**: the ray's depth interval across a step brackets the surface depth,
widened behind by `thickness`, with back-face rejection (`dot(R, sceneNormal) <
0`). A crossing test, not a near-the-ray distance and not a single-point depth
compare, is what rejects a ray that merely grazes a silhouette beside an object:
such a ray never crosses that object's depth.

The march **starts half a stride out** rather than on the receiver's own depth:
an interval anchored exactly on it flips between hit and miss with any depth
gradient across a pixel (self-hit speckle). Half a stride is the bias that gives
the first interval the same start-to-width ratio as every later one, so its
self-hit geometry matches the rest of the march while a hit landing within the
first stride (a reflection right at the contact between an object and its
mirror image) still registers. The earlier form skipped the whole first stride
instead, which detached every reflection about a pixel from its object. The
step count is also **floored at 4**, because a ray whose whole screen span is a
pixel or two (one heading nearly along the view axis) would otherwise be
covered by a single coarse interval and could effectively never hit: a dead
zone that punched pixel holes in view-aligned reflections.

This pass was the source of a long, costly debugging loop, and the failures form
a clear lesson. An earlier hand-rolled version stepped a fixed world-space
distance and forward-projected each step, which samples unevenly under
perspective and aliases into a diagonal moire. Patching it produced, in turn, a
budget-capped screen-space stride (which streaked long rays vertically) and a
"cylinder" artifact (a sphere's floor reflection rendered as a constant-width
band instead of a foreshortened ellipse). Two resolution-dependent sampling bugs
hid behind the cylinder, both rooted in stepping a fixed *pixel* count: the depth
fraction over-read to the ray's far end when the budget capped the step count,
and a fixed pixel budget truncated any reflection spanning more pixels than the
budget, cutting its far end flat. The McGuire-Mara adaptive stride fixes both at
once.

The lesson, which generalizes to any change in this area: a *resolution-dependent*
artifact points at the sampling/parameterization, not at the hit test. Render the
same frame at several output resolutions side by side to localize it, and
reproduce the actual conditions where it appears (the truncation was invisible in
every export and only showed at the live window's larger screen resolution). And
when a published technique has a canonical reference, implement that reference
completely rather than substituting a simpler-looking variant and patching back
toward it. The shipped trace is the canonical screen-space ray from LYGIA's
`lighting/ssr` and the McGuire-Mara port, studied from the references rather than
hand-rolled.

**Spatial resolve** (`ollin_fx_ssr_resolve`) is a 3x3 conservative-smoothing
despeckle plus a variance-aware gloss blur whose kernel is **dense
(contiguous-texel), never strided**. This is load-bearing: a strided kernel steps
over the fine grazing-contact streaks and never smooths them.

**Temporal** (`ollin_fx_ssr_temporal`) reprojects last frame's reflection by
camera motion (reconstruct the receiver's view-space point, lift it to world via
the scene camera's `inverseView`, project through the previous frame's
`viewProjection` to its prior uv), neighborhood-clamps the sampled history to the
current reflection's 3x3 AABB to reject ghosting, then EMA-blends at
`resolveSSRAlpha`. History is an `SSRHistorySlot` ping-pong keyed by the SSR op's
**call site** (`#fileID:#line`, captured by the `.screenSpaceReflections`
factory) plus an occurrence index for same-site ops, not by an owner identity: a
sketch makes its `makeRenderTarget()` fresh each frame (unlike a persistent
`Feedback`/`SimField`), so there is no stable object to key on. It is not a
frame-wide ordinal either: an ordinal shifts when an earlier SSR op is recorded
only conditionally, briefly handing a later op the wrong history. Only same-site
ops (one call in a loop) can still shift among themselves, the structural-identity
limit. `usesFeedback` counts an SSR combine so the headless/export warmup
converges.

**Composite** upsamples. The march, blur, and temporal passes run at
`resolveSSRScale` (half-res on `.performance`, full-res otherwise and always on
export, so snapshots hold). `quality` maps to `resolveSSRSteps`, which is a
*precision* parameter (more coarse steps for a tighter crossing), not a reach cap;
reach is resolution-independent regardless.

The v1 model is a post-process over color, depth, and normal, so every surface
reflects, modulated by Fresnel and angle, with no per-material reflectivity
channel (a mask as a third input is a deferred follow-up). Documented limits,
which are inherent to screen-space tracing rather than bugs: off-screen or hidden
geometry cannot reflect (`edgeFade` hides the frame-edge cutoff); a reflection
beyond `maxDistance` fades at its tail; raymarched SDF fields and point clouds
fall back to depth-reconstructed normals because only meshes feed the G-buffer;
and the contact-seam streaks of a near-mirror reflecting a curved object's
grazing silhouette are SSR's inherent limit, which ray-traced reflections answer.

Techniques: linear screen-space tracing (McGuire-Mara, Sugu Lee, 3D Game Shaders
for Beginners), reprojection temporal accumulation with neighborhood variance
clamping (Karis, Pedersen-Playdead), and a stochastic-SSR spatial resolve
(Frostbite, AMD FidelityFX SSSR). Credited in the README; written from the
technique.

### Depth of field (the shared gather)

Both depth-of-field passes run one gather: `depthOfField()` on the canvas and
`.defocus` over a layer (`ollin_lens_gather` and the `ollin_fx_lens_*` passes in
`ShaderCombine.metal`; `applyDepthOfField` and the `.defocus` arm of
`applyCombine` encode them). Each feeds it a prepass of its own and a law of its
own. The canvas prepass reads the frame's resolved depth (nearest sample per
pixel) and sizes a blur by the thin lens, `R F |1/s - 1/d|`, with `F` in the
texture's own pixels. The layer prepass reads the aux depth map perceptually
(`linearToSrgb(luma)`, matching the depth-feed read, so the gray a sketch draws is
the depth) and sizes it by the ramp: zero inside `focus +- range`, growing with
the distance from the band to `maxBlur` at the map's ends (the earlier ramp reached
`maxBlur` one `range` past the band, so a narrow band made a masked blur). Five passes follow in either case: the prepass
writes `(blur, depth, blur in front of the focus)`; a `k x k` tile pass takes the
widest blur, the widest blur in front of the focus, and the tile's color spread;
a 3x3 neighbor max turns that into how far anything can reach each tile; the
gather; and a 3x3 median where the pixel itself is blurred. A pixel nothing
reaches, or whose neighborhood holds one flat color, keeps itself. The two laws
share the gather through `DofLaw`: a signed blur, negative in front of the focus,
affine in the inverse of the distance for the lens and in the depth on the ramp,
and a sameness of depth that is a hundredth of the distance for the canvas and a
hundredth of the whole range for a normalized map, where a ratio means nothing
near zero.

**The gather is a scatter written as a gather.** Each tap is a disc of light of
its own blur radius `c`, landing on the pixel with the density `1 / (pi c^2)`
times the area the tap stands for, wherever the disc covers the pixel (a
smoothstep half a pixel either side of the rim). The spiral is equal-area per tap
(tap `j` sits at `r^2 = start + 2 s (j + 1/2)`, the middle of its own annulus, so
the ring of neighbors one pixel out is sampled as well as any other), and it runs
in two zones when a wide blur in front of the focus sets the reach past the
pixel's own: half the taps out to its own blur, where a small source has to be
found, the rest over the ring beyond. So a small highlight keeps its light as it
opens into a disc, and a thin line in focus keeps its weight against a blurred
background. An average of the taps that reach, which is what the layer's earlier
running-average gather was, gives both away: measured on the study scenes, a near
highlight kept 27% of its light and peaked at six times its mean, a near bar kept
half its light, a thin bar in focus 4%. Taps read whole pixels (a filtered read
between a line and its background invents a blur size neither has), distances run
between pixel centers, and the pixel's own light is its own term. The whole spiral
is moved per pixel by up to half a tap spacing, by a fixed function of position so
exports repeat; unmoved, the taps that find a small source shift slowly from pixel
to pixel and leave smooth blotches the median cannot see, moved, they leave a
grain a pixel across that the median takes out.

**Three layers by distance against the pixel's own, and a share.** A tap nearer
than the pixel and in front of the focus is a veil: its densities add up to how
much of the pixel it covers, and it composites over the rest by that much. The
pixel's own surface, when it is itself in front of the focus, covers the pixel by
its share of the disc nothing in front takes, its own area against the holes the
scene behind shows within its blur; counted as one minus the holes instead, a
curved surface's nearer part counted twice, once in the veil and once here, and a
blurred ball's edge came out a step more opaque than its outside, an outline.
Behind the focus the same share holds, with a farther tap held to the pixel's own
blur so a sharp edge keeps the background's blur from crossing it, and a nearer
surface at or behind the focus is a second veil composited over by its cover
(averaged by density instead, a bar a tenth as blurred as the backdrop behind it
kept 93% of its light). A tap at another distance is
another surface only when its signed blur lands off the line the pixel's own
surface draws across the screen: a plane is affine in inverse depth, so the blur
extrapolates exactly along the gentler of the pixel's two one-sided differences on
each axis. Without that line a floor running toward the eye read its own farther
taps as holes, harmless while what filled them was the same floor and a dark
smear once the hidden layer did. Splitting at the focal plane alone fails twice,
a subject in focus bulging a little in front of the plane counting as foreground,
and two foregrounds at different distances mixing rather than one hiding the
other, which is why the sort is against each pixel's own distance.

**The hidden layer** is the canvas's answer to what a blurred foreground hides.
The frame holds one surface per pixel, so the 3D scene is drawn a second time
keeping only what lies behind the first layer (`encodeHiddenLayerPass`, a depth
peel against the main pass's resolved depth under its own jitter, through `encode`
with its `peel` argument), and the gather reads that layer under a pixel in front
of the focus. The peel is a function-constant variant of every 3D fragment
(`kOllinPeel`, `ollin_peel_rejects`): a fragment stays out when it is no farther
behind the first layer than a hundredth of its distance plus what its own slope
carries across a pixel and a half (a sphere's rim reads behind its own nearest
sample by the depth its slope spans within the pixel), and a face turned away from
the eye stays out where the kind has an inside. It runs only when something
stands in front of the focus, and only over the pixels those meshes cover: each
mesh batch and list-placed instanced batch carries a world box, the near ones'
projected corners scissor the pass, and a kind with no box counts as near and
everywhere. In the gather a hidden tap is known when it lies behind its tap's own
surface (an unpeeled kind stands there as itself, and a frame without the pass
binds the frame itself, so both read as unknown and the stand-in guess holds);
under the pixel's own surface it is behind that surface whatever its distance
says (under floor boards the layer holds the next boards' lit sides within a
percent of the top, and sorted as the surface itself they darkened the floor),
and it joins the holes' light only when its mean inverse distance stands in front
of the holes' own content (a ball's top against the backdrop with the floor hidden
behind it, yes; a gap between boards showing the next board's side, no); whatever
share of that light the layer does not take, the holes' own stand-in fills, kept
aside for the purpose, so a pixel never falls back to its own color there (a near
ball behind a nearer one kept a hard notch inside the nearer one's rim before).
Behind the focus the layer is read only under a veil tap, since what a near ball
hides scatters past its rim onto the scene beside it, and never under a tap behind
the focus: added to that side's averaged model it took a far bar to 93% of its
light. Against an accumulation-buffer lens on a floor scene
(512 pinhole renders of the same raster through `Camera3D.intrinsic`, the eye moved
over the aperture and the principal point shifted so the focal plane holds, the one
reference that shares the raster's shading) the green cast where a ball's top meets
the horizon fell from +0.0137 to +0.0036 and the whole frame from 0.0064 to 0.0050
RMS. A raymarched field peels by marching from behind the first layer's distance,
walking out of its own body first where the field is that layer; a mesh carries
whether it is closed (the generators' word, unknown read as closed), so a plane or
a strip seen from its back joins the layer while a sphere's inside stays out.

**The layer prepass resolves a drawn map's rim to its nearer side.** A depth map's
silhouettes are anti-aliased, so every outline carries a sub-pixel band of
in-between depth, which a canvas depth resolved to the nearest sample never has.
A pixel whose depth lies strictly between the nearest and the farthest within two
pixels of it, past one gray level so a smooth ramp is untouched, takes the nearest.
Left in, the band flung the color under it across its own in-between blur, a
hard-edged halo around a sharp object, and where it crossed the focus it stayed
sharp as a one-pixel ring around every defocused mark. The earlier prepass
answered both with two sizes, a neighborhood max the pixel received by and a
radius-2 min it scattered by, and the max consumed a thin line in focus along with
the seam. That seam was the most stubborn artifact in the effect, and the lesson is
general: an artifact that survives every change to subsystem X is not in X. It
survived every gather rewrite because it lived in the depth, and a debug
visualization of the prepass channels made it visible in one render. Returning an
intermediate raw out of a pass is worth reaching for early; the hidden layer's
own floor regression was found the same way, by rendering the layer itself.

**The shape of the opening is a parameter of the gather, not a second path.** An
out-of-focus point of light is a picture of the opening its light came through, so
`blades` / `irisAngle` / `catsEye` enter at exactly one place: the reach test asks
whether a tap's disc spans its distance in units of how far the opening reaches
*that way* (`ollin_dof_aperture`), so the distance is divided by that reach. A
round opening with no cat's eye reaches 1 everywhere and takes the arithmetic the
canvas takes. Two decisions inside that helper are worth keeping:

- **A polygon is taken at the *area* of the round opening it replaces**, not at its
  radius: `R = sqrt(pi / (n sin(pi/n) cos(pi/n)))`, from `n R^2 sin(2 pi / n) / 2 =
  pi`. So changing the blade count changes the shape of a highlight and not how
  large it reads, which is the behavior a blade *slider* wants. The cost is that the
  corners poke past `maxBlur`, so the tile size and the gather's rim go out to
  `maxBlur * R`.
- **Cat's eye is the aperture intersected with two discs pushed apart** by `pinch`
  along the line to the middle of the frame, `pinch` growing with the pixel's
  distance from that middle and capped at 0.9 so an opening never closes entirely.
  This is the geometry of optical vignetting: what an off-axis image point sees of
  the aperture is what the barrel's own openings leave of it, front and rear, which
  is why the shape is a symmetric lemon rather than a disc with one flat side.
  Solving `|t u -/+ pinch f| <= 1` for the larger root gives the reach in closed
  form, `sqrt(pinch^2 c^2 + 1 - pinch^2) - pinch |c|` with `c` the cosine between
  the tap direction and the line to the middle, so it costs one dot product and one
  square root per tap. It changes *shape* only; the gather normalizes by its
  accumulated weight, so nothing darkens (`.vignette` is the filter for that).

**`blades` defaults to the camera, not to round.** `DepthReconstruction` already
carries the camera geometry stamped on a 3D target's depth layer for the ambient
occlusion and reflection combines, so it carries `apertureBlades` too, and
`.defocus` falls back to it. That is what makes one line, `camera.apertureBlades =
6`, shape the live blur, every lens-flare ghost, and the path-traced export's own
highlights together. A hand-drawn depth ramp carries no camera and stays round
unless the call names a count itself.

**The envelope is the tap budget, and the smallest sources leave it.** The spiral
is equal-area per tap, so the spacing between taps is `sqrt(pi / budget)` of the
rim: about 13% at `.default` (192 taps) and 8% at `.detail` (512). A source
smaller than that spacing is found by some taps and missed by others; the moved
spiral and the median even most of that out, and a point-sized source was still
lost almost whole (a one-pixel lamp kept 0.2% of its light at `.performance`, 64%
at `.default`). So a small bright source is taken out of the gather's input and
drawn afterwards as a disc of its own (`ollin_lens_sprites_collect`, one thread per
2x2 block, and `ollin_lens_sprites_vertex`/`_fragment`, one additive quad per
block in block order, never an append cursor, so the draw's rounding is the same
from run to run). A pixel qualifies when it is blurred by three pixels or more and
brighter than three times the mean of the dim samples on a ring four pixels out,
with at most two bright samples on the ring and never an opposite pair: a dot of
five pixels passes from every one of its pixels (its rim sees its own far side
side by side), a line through the pixel lights the opposite pair and fails, and a
dot of seven lights more and gathers as before. The pixel keeps the ring's dim
mean and hands the rest to its block's sprite, which sits at the light-weighted
center of what the block took, with its light-weighted blur, distance, and radius
of gyration; the gather spreads the mean, the sprite spreads the rest at the
gather's own density, `1 / (pi r^2)`, so the light stays in balance (a five-pixel
highlight kept 99.5% of its light at `.performance` against 94.5% gathered, and
its disc's interior stayed within 2% of flat). The disc's edge is softened by
`sqrt 2` times the radius of gyration, since a source of some width blurs into a
disc with an edge that wide; the ring's mean is of its dim samples only, since a
bright one is the source's own far side and counting it left a tenth of the light
to gather as blotches. What stands nearer than a source covers its disc by the
share of a ring at the occluder's own blur whose samples are nearer: all of it
deep inside the occluder, half at its edge, and all of it for a sharp occluder,
which cuts the disc at its silhouette (a sharp bar in front of a far highlight
leaves the disc on one side and nothing over the bar). The `.defocus` layer
filter runs the same collect and draw under its ramp law and iris. Measured on
400 lamps at 1080 by 1080 on an M2, the pass costs nothing the clock can see: the
frame read 0.3 to 1 ms faster with it at both tiers, within the run-to-run
spread, since a flattened frame lets more of the gather pass over a flat
neighborhood than the collect and the draw cost. A
regular polygon's corners stick out by `1 - cos(pi/n)` of the rim, 13.4% for a
hexagon, so at `.default` a hexagon's corners sit at the sampling limit, which is
why the Guide figure uses five blades at `.detail`. `quality` is a `RenderQuality`
tier the renderer resolves to a per-GPU tap budget (`resolveDofTaps`); the budget,
not the radius, is what it controls. The budget was tuned with data from
`Scripts/benchmark.sh dof` (`DofBenchmarkTests` sweeps tap counts via the internal
`dofTapsOverride` hook and the effects-aware `benchmarkGPUMilliseconds`); take the
numbers back to back on a cool machine, since single runs drift by 50% under
thermal load, which is enough to invert an A/B.

The pixel snapshots pass within tolerance across every one of the fixes above,
because a mean-per-channel comparison averages away defects that live in a band
around each silhouette, which is where all of them live. `DefocusTests` and
`DepthOfFieldTests` pin them directly instead: a near spread invents no color, a
sharp subject rejects the backdrop, a lightly defocused midground keeps its edge,
a foreground silhouette softens on both sides, transparency blurs its coverage,
`maxBlur` 0 is a pass-through, a bar spreads by the thin lens and keeps its light,
a highlight opens into an even disc, a blurred ball has no outline, and the floor
hidden behind a blurred ball's top shows through it. Reach for a behavioral probe
here, not a whole-frame diff, and measure a change against a reference that
shares the raster's shading before trusting a number.

---

## SDF combinators

The combinators compose signed-distance *fields* so that shapes **merge** instead
of stack. This is the field-combining axis that the per-instance SDF primitive
path (one shape per quad) structurally cannot express. There are two parallel
implementations: a 2D path (`Sources/Ollin/Drawing/SDF.swift` plus
`Renderer/ShaderCombinator.metal`) and a raymarched 3D sibling
(`Sources/Ollin/Drawing/SDF3D.swift` plus `Renderer/ShaderRaymarch.metal`). The
user-facing API is documented in `Docs/Drawing/Combinators.md`; this section is
how they work.

### The SDF VM (2D)

The typed `SDF` value type (leaf shapes plus combine, modify, and transform ops)
flattens on the CPU to a flat `SDFNode` program. This is a new render path beside
the per-instance `SDFInstance` path: its own `SDFGroupInstance` struct, a node
buffer, a `GeometryKind.sdfGroup`, and a `.sdfGroup` pipeline. It cannot use the
per-instance path because `SDFInstance` is full at 144 bytes.

`ollin_sdfgroup_fragment` walks the program per pixel with two fixed-depth stacks
(cap 16): a **value** stack of (distance, color) for the combine and modify ops,
and a **point** stack for the transform and domain scopes (an XFORM node pushes
and transforms the point; the matching RESTORE_P pops it, and for a scale scope
multiplies the child distance back). Smooth ops use iq's polynomial smooth
minimum, whose blend factor also lerps the two operands' colors, giving the "melt
two colors" look; hard ops pick by min/max.

Several invariants are load-bearing:

- **Leaves carry no transform.** All positioning (a shape's intrinsic anchor,
  `.at`, `.rotated`, `.scaled`, domain repeat and mirror) is expressed as XFORM
  nodes, so method-chain order is exact: `a.at(p).repeated()` tiles the moved
  shape, while `a.repeated().at(p)` shifts the tiling.
- **The leaf distance is `ollin_sdf_distance`**, the per-shape region-SDF decode
  factored out of `ollin_sdf_fragment`'s switch. The two are kept in sync **by
  hand**: a new region shape goes in both, and the per-instance fragment switch is
  left untouched so single-shape snapshots stay byte-identical.
- **Polar/radial repeat is the shared sel-5 XFORM** in both `ollin_sdf_xform`
  (2D, folding around the origin) and `ollin_sdf3d_xform` (3D, folding around an
  axis). It is the single-evaluation kind of fold: wedge-symmetric content is
  exact, and asymmetric content shows a seam.
- A field past the stack or node caps (depth 16, roughly 256 nodes) is logged and
  skipped, never mis-drawn. Only closed region shapes are supported; lines, open
  arcs, and Beziers have no interior and are skipped with a one-time log.

A scoped block form (`smoothUnion(k:) { drawCircle...; drawRect... }`, and the
hard ops, mirror, and repeat variants) is sugar: bare region draws inside the
block are captured at `Drawer.appendSDF` as leaves instead of drawn, and the
block reaches every fillable region shape. The buffers thread through all nine
`encode` call sites (live, accumulation, export, effect-targets), so combinators
work on every path.

The stateful `sculpt { }` block (built for the live-coding loop) layers a
mutable combine state over that capture: its `add()` / `carve()` / `blend(_:)`
verbs change the per-child mode and melt mid-block. The CombineFrame snapshots
the state per captured child, a nested block lands under the state at its
close, the verbs address the innermost *sculpt* frame through nested blocks,
and each sculpt frame keeps its own state.

2D anti-aliasing reuses the single-shape coverage tail; the covering quad is the
whole-tree conservative AABB the flattener computes. 2D gradient paint is
supported (a solid color or a linear/radial gradient on `fill`/`stroke`, sampled
in field space at the fragment's `in.field`, so one ramp paints the whole merged
region or outline). Along-path paint has no single path on a merged field and
falls back to solid; a solid-leaf field is byte-identical to the per-shape path.

Per-axis sizing exists in both 2D and 3D: `stretched(x:y:[z:])` is iq's
`opElongate`, a point-domain warp that stays an *exact* SDF and is the preferred
per-axis tool; `scaled(x:y:[z:])` is a true non-uniform scale and is only a
conservative bound (it rescales the child distance by the minimum scale component
for a safe Lipschitz march, so blends distort under strong anisotropy).

### Raymarched 3D (`SDF3D` / `drawSDF3D`)

The 3D sibling composes 3D fields (sphere, box, rounded box, torus, capsule,
cylinder, cone, octahedron, ellipsoid, plane, plus the same ops) that merge the
same way, sphere-traced as one surface through the active camera and
depth-composited with the rasterized meshes. It is a parallel path beside the 2D
one: its own `SDFNode3D` and `SDF3DGroupInstance` structs, a
`GeometryKind.sdfGroup3D`, a `.raymarch` pipeline, and the `ShaderRaymarch.metal`
segment.

The most important implementation fact is that **the march runs in world space**.
Each step maps the world point into the field's local frame via its
`inverseModel`, so the 4-tap gradient normal and the written `[[depth(any)]]`
depth come out world-space with no normal matrix, and the depth (the world hit
point through the same view-projection the meshes use) lets meshes z-test and
interpenetrate the field. Each field draws as one fullscreen triangle bounded by
its world AABB, so a ray that misses bails in constant time. Shading reuses
`meshLitColor` unchanged, which is why the snapshot-pinned mesh shading never
moves when a field is added.

The infinite plane (`SDF3D.plane(normal:offset:)`) is the one unbounded leaf: it
has no finite AABB, so the flattener marks the whole field `unbounded` (propagated
through combine), and the fragment marches the near..far span instead of an AABB
slab. It is value-type-only (it has no mesh primitive). The leaves are iq's
3D distance functions, and `ollin_sdf3d_eval`'s switch must stay in sync with
`SDF3DShape`. The sculpting tier added five more (`line` between two free points,
`hexPrism`, `pyramid`, `cappedTorus`, `link`, tags 10 through 14); `line` is the
one leaf not centered at the origin, so the flattener bounds it from its two
endpoints plus the radius rather than a symmetric half-extent, and `pyramid`
wraps iq's fixed-half-unit-base form in a uniform scale (exact) with a re-center.

Two sculpting op families use the same node kinds. The **joint and detailing
ops** (the chamfer/stairs/columns union/subtract/intersect trios, OP selectors
7 through 15, plus `engrave`/`groove`/`tongue`/`pipe`, selectors 16 through 19,
from hg_sdf under its MIT option) live in both `ollin_sdf_combine` and
`ollin_sdf3d_combine` (the 2D and 3D switches share the encoding; the op's
second scalar, the stairs step count, column count, or groove/tongue width,
is stored in the OP node's spare `extra`, and the staircase helper uses a GLSL-style
floored modulo, `ollin_emod`, because Metal's `fmod` truncates and would break
the pattern for negative operands). Their color is a crisp pick rather than a
melt (the nearer operand for the joint trios and `pipe`, the body for
`engrave`/`groove`/`tongue`), which is what makes a joint read as fitted parts. Columns keeps the reference's own
band guard; the detailing ops are value-type-only (the morph precedent), while
columns gets block forms too. They all assume the two surfaces cross frankly
(near right angles): near-parallel faces within the joint radius echo the
pattern past the seam, the technique's documented envelope, so the docs steer
usage there rather than the code trying to guard it (an extra band guard on the
chamfer/stairs trios was tried and traded the echo for a visible field
discontinuity).

The **distortions** are twist and bend (XFORM selectors 8/9, iq's `opTwist` /
`opCheapBend` as point-space scopes) and sine/noise surface displacement (MOD
selectors 2/3, iq's `opDisplace`; the MOD case reads the live query point, and
the noise flavor reuses the shader library's 3D `valueNoise`). All four produce
distance *bounds*, not exact fields, so the flattener attaches a conservative
Lipschitz rescale that keeps the sphere trace from overshooting: twist/bend
compute `1/(1 + rate·reach)` from the child's just-flattened bounds and store it
on the scope's RESTORE_P distance scale (the same slot a non-uniform scale
uses), while displacement bakes `1/(1 + amplitude·frequency·C)` into the MOD
node's `geo0.x` (C is the displacement's own slope bound: √3 for the sine
product, ~2 for value noise). Strong settings therefore march slower instead of
holing the surface.

The same scoped block form reaches 3D: inside a block with a camera, the
SDF-able mesh builders (`drawSphere`, `drawBox`, and so on) route through
`Drawer.drawMeshPrimitive`, which captures an `SDF3D` leaf instead of
tessellating, positioned by the relative model matrix decomposed to translate,
rotate, and uniform scale (the only similarity an SDF respects). A non-primitive
`Mesh` inside a block is ignored with a one-time log.

Silhouette anti-aliasing is analytic: the fullscreen pass gets no MSAA at the
hit/miss edge, so the primary march tracks how close the ray came to the surface
relative to the pixel's own cone, and composites a grazing near-miss by that
coverage over what is behind it. 3D gradient paint is screen-space: a gradient
`fill` paints the whole merged surface by each hit's projected screen position.

Materials and environment light reach fields with mesh parity. The material
is set per batch: `ensureSDF3DBatch` records the active `material(_:)` as the
batch's `finish` and breaks the batch when it changes, exactly like
`ensureSolidMeshBatch` (the generic `ensureBatch` records no finish, so fields
routed through it shaded with the default material no matter what the sketch
set, which is why the split-out exists). On top of `meshLitColor`, the raymarch
fragment then adds the same image-based ambient the mesh fragments add: the
split-sum `ollin_pbr_ibl_ambient` for a physically-based finish (including the
traced-reflection swap when `rtReflections` is set, for which it binds the same
caster acceleration structure, flat mesh buffer, and per-geometry offsets the
mesh path binds), and `ollin_ibl_flat_ambient` (diffuse irradiance) for the
standard and toon finishes, Gooch excepted. All of it is gated on
`lighting.iblEnabled`, so a frame with no environment is byte-identical. The
half-res pre-pass mirrors the whole arrangement: `resolveFieldLighting` carries
the same IBL setup (and `rtReflections` flag) the main encode builds, and
`encodeRaymarchHalfRes` binds the same IBL textures and trace inputs, so a field
lights identically at any resolution tier.

### Field shadows

Shadows are gated on `castShadows()`, so a field without it is byte-identical.
The matrix of field-to-mesh and mesh-to-field shadowing is complete across light
types, and every path is byte-identical when off (a no-caster scene samples a
dummy all-lit map or skips the trace, so the factor stays 1):

- **Field self-shadows.** The raymarch fragment marches a penumbra ray toward the
  caster light (`ollin_sdf3d_softshadow`, iq's standard `min(k*h/t)` form) and
  feeds it to `meshLitColor` via a `fieldShadow` param. Use iq's standard form,
  not the previous-step-refinement variant: the refinement divides by zero on a
  receding ray and false-shadows lit faces.
- **Field casts onto meshes (directional/spot).** The field also renders into the
  2D shadow map, so rasterized meshes receive its shadow.
- **Field receives mesh shadows.** The raymarch fragment samples the 2D map, the
  point-light cube, or the ray-traced mesh acceleration structure at its hit, so a
  mesh's shadow lands on a field surface as it would on another mesh.
- **Field casts under a point/RT light.** A point caster has no 2D map to render
  into, so the lit mesh fragments march the field(s) inline toward the light and
  fold the result into the mesh's own point-shadow.

### Performance and the quality dial

A field is a fullscreen sphere-tracer whose cost is **bound to pixel count, not
step count** (a benchmark sweep is roughly flat across 32 to 256 steps). So the
`raymarchQuality(_:)` / `raymarchResolution(_:)` dial scales the field's internal
*resolution* (full on `.detail`, half on `.default`, quarter on `.performance`,
or a custom fraction), via a reduced-res pre-pass that the main pass composites
(bilinear color, point-sampled depth so the silhouette stays crisp).

The resolved fraction is a **marched-pixel budget at full coverage, not a fixed
downscale**. Because the AABB clip bails missed rays in O(1), the tracer's real
cost tracks the pixels the fields *cover*, and a fixed fraction had it exactly
backwards: a dollied-out field (small on screen, cheap to march) got the fewest
real pixels and dissolved into upsampled blur (the zoom-out-blur bug). So
`encodeRaymarchHalfRes` estimates the fields' projected screen coverage
(`fieldScreenCoverage`: each world AABB's eight corners through the camera, and
per box the smaller of the corners' clipped NDC bounding rect and their convex
hull's area, summed; a plane or a corner at/behind the camera counts as full
coverage) and traces at `min(1, fraction / √coverage)`: the marched-pixel
count never exceeds `fraction² × canvas`, the cost the fraction already implies
at full coverage, while a small field gets traced dense. At or above scale 1 the
pre-pass is skipped entirely (the inline march is crisper *and* cheaper).
The estimate's honesty is load-bearing, because every ounce of conservatism
holds the internal resolution down: the flatteners grow a combine's AABB by
each op's *provable* outward reach (a polynomial smooth op's surface bulges at
most k/4 past its operands, padded to k/2 for bound-type child SDFs; chamfer
and stairs seams stay within k/2; columns, pipe, and tongue genuinely reach k;
the subtract, intersect, and morph families cannot leave the hard op's region
at all, and morph's k is a blend fraction, not a length). The blanket
k-per-nested-op grow this replaced compounded on a three-op metaball into a box
whose projection read near-screen-filling four radii out, so the adaptive scale
never rose and the dollied-out blob stayed at the reduced resolution: the
zoom-out blur back again, from the estimate rather than the formula. The hull
term earns its keep on the oblique views an orbit camera spends most of its
time in, where a corner-on box's NDC bounding rect over-covers its hexagonal
silhouette by about 2×.
Deterministic (pure function of camera + AABBs, no temporal state), so it holds
on export too. Two supporting pieces: the pre-pass targets are **grow-only** and
the pass renders into a **viewport subrect** (a continuous dolly drifts the scale
every frame; reallocating per frame would thrash), with the upsample mapping UVs
into the subrect and clamping half a texel inside it so bilinear never reads the
cleared texels past it; and the pre-pass's analytic silhouette AA sizes its pixel
cone by the **internal** resolution (`Uniforms3D.raymarchScale`), where sizing it
to the full-res pixel under-blurred the low-res image and the upsample magnified
the aliasing into a staircase. The inline path binds scale 1, which is
bit-identical to the pre-fix expression, so full-res renders and snapshots are
unchanged.

Field shadows are the expensive case, because the point-light cast is
per-receiver-pixel: a screen-filling floor under a field can take roughly half a
frame. Two levers trim it: a conservative AABB early-out in the inline field
march (inflated by the soft-shadow penumbra reach, so a grazing near-miss still
marches and the result is byte-identical), and a half-res field-shadow pass
(`encodeFieldShadowHalfRes`) wired to the same `resolveRaymarchScale` dial.

**The automatic tier scales the live preview only.** With no explicit setting,
export resolves to `.detail` (scale 1.0) and marches and shadows full-res, so
default-tier snapshots stay byte-identical and exported art is never downscaled;
an explicit `raymarchResolution` fraction (or explicit tier) is honored on export
too, coverage-adaptively. The dial follows the shared `RenderQuality` model, where
`.default` doubles as automatic: live `.default`, export `.detail`, overridable
by the `--render-quality` flag. All four quality parameters (shadows, defocus, ambient
occlusion, and raymarch resolution) target frame-rate bands.

**The tier is a GPU sampling budget only; geometry dials never join it.** Every
`RenderQuality` consumer changes how the *same* image is sampled (shadow rays,
bokeh taps, AO samples, SSR steps, the raymarch's internal resolution), which is
exactly what licenses the export auto-upgrade and the byte-identical
default-tier snapshots. The CPU geometry features' cost dials
(`MeshGrowth.maxVertices`, `subdivided(_:levels:)`, isosurface / `Metaballs`
`resolution`, an erosion's droplet count, `medialAxis(of:spacing:prune:)`'s
sampling step) look similar
but are kept out on purpose, for three structural reasons. First, geometry is
data the sketch reads back: vertex counts drive loops, positions seed per-point
decisions, results feed physics and coloring, so a tier that moved one of these
dials would change the *piece* (and how much of the seeded RNG stream the
downstream code consumes), not the fidelity of its rendering, and the
seed-plus-params reproduction promise (`ExportMetadata`) would break. Second,
the tiers resolve per-GPU by design, and a hardware-relative form is
unacceptable for geometry: the same seed must produce the same artwork on every
machine. Third, there is no shared axis: a vertex ceiling, a refinement count, a
grid density, and a droplet count each mean something the artist should choose
in domain terms, and a tier-to-value mapping picked by the framework would
itself be an artistic decision. Even the dials that converge toward an ideal (an
isosurface's `resolution`, subdivision `levels`, `medialAxis` spacing) fail the
first two tests. The framework's taxonomy already covers these: seeds are
identity, parameters are tuning, and a geometry dial is a parameter, served by `@Param`
and `--export-sweep` rather than by a tier.

---

## Instanced meshes and mesh fields

The user-facing surface is `Docs/3D/Instancing.md`; this section is the
machinery behind `drawMesh(_:instances:)` and the retained, GPU-culled
`MeshField`, and the two findings that shaped them.

### The two tiers

The per-frame instanced path (`.meshInstanced`) is the animation tier: the base
mesh expands once per call into its own ring array (LOCAL space, the model
matrix deliberately NOT baked, which is the whole saving over the per-mesh
path's per-copy re-bake), and an 80-byte `OllinMeshInstance` per copy (model
matrix + tint) is stored in a second ring. Rebuilding the placement list every frame
is the intended idiom, so nothing here is retained. The `MeshField`
(`.meshField`) is the world tier: everything uploads once into the field's own
per-device buffers (the `Batch.GPUResources` model), and the per-frame work
moves to the GPU entirely.

Both vertex shaders derive the normal transform from the model's linear part as
the adjugate (three column cross products), normalized after. That choice is
load-bearing for the compute-written forms: a kernel fills only model + color,
and non-uniform scale still lights correctly with no inverse-transpose to
precompute. The A/B against the CPU path's true inverse-transpose held under
mean 0.5/255 with non-uniform scales in the fixture.

### A copy in the traced scene

The traced passes see one *instance* acceleration structure. Instance 0 holds
every mesh drawn on its own (their vertices already carry their placement, so
its matrix is the identity), and each copy of an `[MeshInstance]` draw is one
more instance over its base mesh's own small structure, carrying that copy's
matrix. So a copy costs a matrix in the structure rather than a triangle list,
which is the same saving the raster path takes, and one structure serves the
reflections, the traced point and area shadows, the GI probes and the photons
alike.

Three decisions carry the weight:

- **One vertex pointer.** The hit fetch reads a single mesh buffer, so the
  instanced base vertices are appended to it after the plain ones rather than
  kept in their own buffer. That is a copy of each *base* mesh per frame, never
  per copy, and it saved binding a second pointer to ten tracing entry points.
  `tracedMeshVertexCount` reserves the room wherever the buffer is asked for; a
  buffer without room leaves the copies out instead of writing past its end.
- **A table with a header.** The per-geometry base-vertex array grew a prefix:
  its instance count, then one five-uint record per instance (the copy's base
  vertex, its tint as three float bit patterns, and the material slot its whole
  run shares), then the old array. `ollin_rt_hit_lookup` reads it back, and a
  record marked `OLLIN_RT_PLAIN_MESH` reads the geometry table instead, which is
  what instance 0 always does. The prefix is what let the per-copy tint arrive
  without a new binding, and later the material slot as well.
- **The normal needs the copy's placement.** A copy's vertices are in its base
  mesh's own space, so the interpolated normal is mapped by the transpose of the
  query's world-to-object matrix. That is the inverse transpose, so an unevenly
  scaled copy still shades square to its surface.

Two forms stay out, both for the same reason. The compute-buffer form keeps its
placements on the GPU, and a `MeshField` holds hundreds of thousands of them, so
neither can have its instance descriptors written one at a time by the CPU each
frame; they want a kernel writing that buffer, next to the culling pass that
already fills one.

### The material slot, and why the record has a fifth uint

The path-traced export resolves a hit's finish through per-geometry tables, and
every copy of one base mesh shares a single geometry, so `geometryId` cannot
tell two instanced draws apart. The answer is the record's fifth uint: each run
appends its finish (and its empty map set) to those tables past the plain
geometries and carries that index, while a plain-mesh hit sets the slot to its
own `geometryId` inside the lookup. One field serves both, and every reader is
unchanged in form. Two details are easy to get wrong. A copy's finish enters
with the map gates lowered, because its raster draw binds no textures and an
unbound bindless slot holds the white stand-in, which a raised gate would read
as a real normal or roughness map. And a batch the traced scene turns away (an
over-budget field, a matcap prop, a run whose kernel would not build) must keep
rasterizing over the traced layer, or it disappears from the export entirely;
`pathTracedCopyBatches` is the set the composite arm consults to tell the two
apart.

The one thing a copy cannot be there is a *mesh light*. The emissive CDF weighs
each glowing triangle by its area in the world, and a copy's triangles are
unplaced, so the scan runs over the plain geometries alone and a copy's own
emission is credited at full MIS weight instead of against a strategy that never
competed for it. That is unbiased and grainier, which is the trade the docs
state rather than hide.

### The field's frame

`encodeMeshFieldCulling` runs at drive level (after `encodeCompute`, before any
render pass, same command buffer, so hazard tracking orders everything): one
blit zeroes the per-entry visible counts, then per field a cull kernel (one
thread per copy) tests the entry's local bounding sphere through the copy's
matrix and the draw-time field matrix against six Gribb-Hartmann planes, and
appends survivors into the entry's own region of a compacted-index buffer
(atomic per-entry counters; regions tile the copy list, so entries never
collide). An encode kernel (one thread per entry) then writes one
`MTLDrawPrimitivesIndirectArguments` per entry:

```
draws[e] = { vertexCount:   entry.vertexCount,
             instanceCount: visible[e],
             vertexStart:   entry.vertexStart,     // into the shared vertex buffer
             baseInstance:  entry.compactOffset }  // into the shared compacted buffer
```

`baseInstance` is the trick that keeps the draw arm stateless: `[[instance_id]]`
starts at `baseInstance`, so the field vertex reads
`instances[compacted[iid]]` and every entry's draw uses identical whole-buffer
bindings; the arm binds four things once and issues one indirect draw per
entry. CPU cost is per KIND of mesh, never per copy.

### Why indirect draws and not an indirect command buffer

The design started as a classic ICB (compute-encoded `render_command`s,
`inheritPipelineState`, one `executeCommandsInBuffer`), and the mechanism
worked in isolation. It died on a fact worth keeping: on a ray-tracing device
the lit mesh fragment carries the RT intersector, and Metal refuses that
fragment in any ICB-capable pipeline ("Fragment shader cannot be used with
indirect command buffers"). The choices were a hand-synced RT-free fragment
twin (fields would stop receiving ray-traced effects) or GPU-written indirect
draw arguments, which carry the identical payload with no fragment restriction.
Indirect draws won: full shading everywhere, one code path, and the CPU still
never sees a copy. Any future ICB use belongs on RT-free shaders (a depth-only
pass is the natural fit). A second lesson from the same debugging session: a
failed `try? pipeline(key)` skips a batch silently, which made the field
invisible while its culling A/B "passed" on two empty images; every new
pipeline key now gets an explicit build test.

### Culling the shadow pass, exactly

Camera-frustum culling alone recovered only 1.5x on the 240k-copy benchmark,
because the shadow pass still drew every copy. The fix is a second cull of the
same shapes against the LIGHT's frustum (the 2D map's own view-projection),
which is exact by construction: anything it drops, the map's rasterizer would
have clipped anyway, so casters behind the camera still shadow the view and the
picture cannot change. The cube (point) pass stays uncculled: it looks in six
directions at once. With both culls the benchmark lands at 18.5 ms vs 50.8 ms
unculled (2.7x), CPU ~0. One gotcha cost real time: within a single compute
encoder, a later dispatch inherits earlier `setBuffer` bindings, and the
shadow-cull dispatch read the just-bound draw-arguments buffer as instance
matrices until the instance buffer was explicitly rebound. The
culled-vs-unculled A/B surfaced it as a single 37-pixel shadow sliver.

---

## 3D lighting and environments

The user-facing surface is in `Docs/3D/3D.md`; this section is the machinery
behind the physically-based finish, the image-based lighting (IBL) bake and its
caches, the procedural sky, and the shadow paths. The ray-traced reflection's
anti-aliasing chain has its own section (*Deferred ray-traced reflection AA*).

### The physically-based finish

`Material.physicallyBased` (and the `.metal(roughness:)` / `.dielectric(roughness:)`
sugar plus the brushed/polished/plastic built-ins) is an energy-conserving
Cook-Torrance microfacet BRDF: GGX distribution, height-correlated Smith
visibility, Schlick Fresnel (the glTF 2.0 / Filament forms, reimplemented from
the math), evaluated over the same analytic lights as every other material. The
surface color stays `fill` (a metal tints its reflection by that color and has
no diffuse). Structurally it is `shadingModel 3`: one new `case` in
`meshLitColor`'s switch plus two `OllinMaterial` fields (`metallic`/`roughness`)
that every other model ignores, so all non-PBR materials render byte-identical.

### Area lights (linearly transformed cosines)

`rectLight` / `diskLight` / `tubeLight` (`Light.rect/.disk/.tube`, GPU kinds
3-5) shade a glowing surface analytically: a linearly transformed cosine (a 3×3
transform of the clamped-cosine distribution) approximates the GGX lobe per
(perceptual roughness, view angle) and still integrates in closed form over the
light's shape (Heitz/Dupuy/Hill/Neubelt's technique; per-shape citations in
`ATTRIBUTION.md`). The fitted inverse transforms and their (norm, average
Fresnel, clipped-sphere form factor) terms ship as two 64×64 float32 tables
bundled from the authors' BSD-3 reference release
(`Sources/Ollin/Resources/LTC/`, provenance + conversion recipe in the notice
beside them; the `ltc.js` export carries more precision than the repo's
half-float `.dds`). `ensureLTCTables()` loads them once into `rgba32Float`
textures bound at fragment textures 8/9 on every `meshLitColor` carrier (the
solid and textured mesh fragments, the raymarch fragment, and the half-res
field tier), with a never-sampled stand-in when absent and
`OllinLighting.ltcEnabled` gating the read, so a frame with no area light is
byte-identical and a corrupt bundle degrades to a one-time stderr note instead
of garbage shading.

Per shape, in the shader (`Shader3D.metal`, the `ollin_ltc_*` block):

- **Rect** uses the paper's *exact* path: transform the four corners into the
  view-aligned shading frame and clamped-cosine space, clip the polygon to the
  horizon (the 16-configuration table), and sum the analytic edge integrals
  (the rational theta/sin-theta fit replacing `acos`). The corner winding is
  chosen so the integral is positive on the side the light faces, which is why
  `packLight`'s frame must stay right-handed (`tangent × bitangent = facing`);
  `twoSided` folds the back side in as `abs(sum)`. The clipless
  vector-form-factor variant (cheaper, approximate near the horizon) was
  deliberately not used: the exact clip is the reference demo's default and the
  per-pixel branch cost is fine at eight lights.
- **Disk** transforms the disk's bounding quad, where it becomes an arbitrary
  ellipse: an eigendecomposition finds the principal axes, a cubic solve (the
  numerically robust split form of the trigonometric method) its horizon
  clipping, and the tabulated horizon-clipped sphere (table 2's `w` channel)
  maps the resulting form factor to the final integral. **The sqrt arguments
  clamp at zero**: they are provably non-negative in exact math (the
  discriminant of an all-real spectrum, Cauchy-Schwarz on the moment matrix,
  the root signs of a visible ellipse), but where a receiving surface grazes
  the disk's plane the float versions dip a few ulps under zero and every NaN
  reads as black speckle scattered over the grazing region (a real bug, found
  by a median-outlier sweep over the first example render and bisected to the
  disk path; the clamps cleared 1086 outlier pixels to the 73-pixel geometric
  baseline with no other change).
- **Tube** runs the analytic line integral (horizon-clamping the segment,
  then the position/tangent antiderivative pair) in clamped-cosine space,
  scaled by the transform's width factor, times the tube radius. The width
  factor needs the transform's inverse-transpose, taken from the columns'
  cross products (the normal-matrix identity), so the sparse matrix never
  needs a general inverse. No end caps, matching the reference default. Two
  guards the reference lacks: the perpendicular-foot distance floors at 1e-4
  (a surface point on the extended axis divides by zero) and a vanishing
  endpoint cross product skips the width factor (the segment seen edge-on).

Diffuse always evaluates with the identity transform (an untransformed clamped
cosine is exact Lambert), so only the specular half reads table 1. In
`meshLitColor`'s loop the area branch `continue`s past the whole punctual path:
the PBR model blends table 2's norm and Fresnel channels by F0; the standard
model maps `specularSharpness` onto the equivalent GGX width (`alpha = sqrt(2/(s+2))`,
so perceptual roughness is its fourth root) and scales the norm channel by
`mat.specular` (inert at zero, the finish rule); toon quantizes the soft
diffuse into its cel bands (the highlight stays smooth; a soft light has no
hard cel edge); Gooch takes highlight only, keying its tone axis off the
panel's direction. The subsurface finish's back term re-evaluates the diffuse
integral with the normal flipped, so the translucent bleed carries the panel's
real falloff. `intensity × color` is the emitting surface's radiance (the
LTC-native convention: a full surrounding hemisphere of radiance 1 returns the
albedo exactly), which is why area lights fall off physically while the
punctual kinds keep their no-attenuation model, and why a thin tube runs at
intensities in the tens. Rect and disk panels cast shadows from their real
extent (the caster search reaches them after the punctual kinds; see
*Shadows* below), where a tube never casts (radial emission has no facing
axis to shadow from). The RT-reflection hit shade evaluates the **exact LTC
diffuse** per area light (`ollin_ltc_diffuse`, the identity-transform
integrals kept in step with `ollin_ltc_light`'s dispatch), so a panel-lit
surface reads the same in a mirror as head-on: only the disk's
horizon-clipped-sphere factor reads a table, so the single amp texture is passed into
the trace (threaded through `ollin_pbr_ibl_ambient` to the inline path, and
bound at texture 9 of the deferred trace pass, whose lighting also resolves
`ltcEnabled`, or the panel would go dark only in its deferred reflection).
Specular at a hit stays the traced second bounce, matching the punctual
lights' Lambert-only hit treatment. Pinned by the RT-gated `area-reflections`
snapshot and the panel-on/off `AreaReflectionRenderProbes` differential. The
head-on view (V ≈ N) takes a deterministic fallback tangent instead of
normalizing a zero vector (the reference leaves this case unguarded; the
head-on LUT row is fitted isotropic, so any tangent is exact).

### Light shaping (IES profiles and cookies)

A point or spot light can carry an `IESProfile` (a fixture's measured angular
intensity, parsed from the industry's LM-63 photometric files) and a spot a
`LightCookie` (a projected image). Both are per-light data that end up as
layers of two `texture2d_array`s bound at fragment textures 10/11 on every
lit carrier, sampled inside `meshLitColor`'s punctual branch and the
`ollin_rt_direct` hit shade.

**The parser treats the file as a number stream, not lines.** LM-63 nominally
caps lines at 132 characters, but real exports break that freely (Lagarde's
survey found 4,000-character candela lines), so `IESProfile` finds the
`TILT=` line, tokenizes every whitespace/comma-separated number after it, and
counts: a skipped `TILT=INCLUDE` block, the 10 + 3 header fields, the two
angle lists, then one candela block per horizontal angle with the vertical
angle varying fastest. Lateral symmetry is encoded in the *last* horizontal angle
(0 = axially symmetric, 90 = quadrant, 180 = bilateral, 360 = full wrap, plus
the rare 90-first/270-last plane), expanded at sample time by folding the
query azimuth into the stored wedge. Only Type C photometry parses (the
architectural convention; A/B are automotive/floodlight aiming conventions
Ashdown reports never meeting in practice), values normalize to peak 1 (the
light's `intensity` stays the brightness parameter, unitless like the rest of the
punctual model), and every failure path returns `nil` with a one-line stderr
reason, including header counts, which go through a guarded `Double`→`Int`
conversion because a malformed exponent would otherwise trap the process.

**The bake resamples the wedge onto a uniform sphere.** The CPU sampler
interpolates the non-uniform measured angles piecewise-linearly; `bakedTable`
evaluates it at texel centers into a 256×64 (θ 0…π across, φ 0…2π down)
`r16Float` layer. The shader then needs no knowledge of symmetry or angle
lists: u clamps, v wraps (the sampler's `t_address::repeat` carries
`atan2`'s signed azimuth straight through). Directions outside a file's
measured vertical range are dark *by design*: a downlight file ending at
90° sends nothing above the fixture's horizon, which is why the example's
wallwasher tilts its whole axis at the wall the way a real one aims, rather
than expecting azimuthal data to reach up.

**`LightCookie` is a value, not an `Image` reference.** `Light` is
`Equatable, Sendable`; `Image` is neither, so the cookie resamples the
image's premultiplied pixels bilinearly into its own fixed 512² buffer at
init (with a precomputed content hash for cheap equality and caching) and
carries no reference back. That one decision keeps `Light` a plain value,
makes the GPU upload a byte copy into an array layer, and pins the "build it
once in `setup()`" usage shape. A GPU-backed image has no CPU pixels and
fails to wrap (snapshot it first).

**Frame plumbing follows the LTC pattern exactly.** `makeLighting` dedupes
the frame's profiles and cookies into `Drawer.usedIESProfiles` /
`usedLightCookies` (rebuilt on every call, so the several per-frame
`makeLighting` invocations agree) and packs each light's layer indices plus
its roll into the new `OllinLight.shaping` float4 (-1 = none; a
zero-initialized struct would silently point at layer 0). The renderer bakes
the arrays keyed by content-hash lists (an unchanged frame rebinds the same
textures; a changed one gets a *fresh* allocation, never replaced in place,
since in-flight command buffers retain the old) and raises `iesEnabled` /
`cookieEnabled` at the same three sites `ltcEnabled` is resolved (the main
encode, `resolveFieldLighting`, and `encodeReflectionPass`, the last because
the deferred trace builds its own lighting and would otherwise lose the
pattern only in reflections). The stand-in for an empty slot is a dedicated
1×1 `texture2d_array`; the 2D gradient-strip stand-in the other slots use
is type-invalid where the shader declares an array.

**Sampling applies to the light's local copy.** `ollin_apply_light_shaping`
multiplies the profile's scalar and the cookie's rgb into the loop-local
`L.color`/`L.specular`, so every shading model below (standard, toon, Gooch,
PBR), the SSS wrap, and the `incoming` sheen accumulation pick the shaping up
with no per-model edits, and `ollin_rt_direct` reuses the same helper so
reflections can't drift. The tangent frame (`ollin_light_frame`) uses the
shadow code's up-reference convention but winds `right = axis × ref`, the
**projector convention**, under which a cookie reads un-mirrored as seen from
the light looking along its beam; the first cut wound it `cross(ref, axis)`
and the probe caught the mirrored image. The cookie maps the outer cone's
footprint to the texture square (`tan` of the half-angle recovered from the
packed `cosOuter`), so its edges land exactly at the cone edge and the edge
clamp is invisible: every point outside the [0,1]² square is also outside
the cone the `smoothstep` already zeroed. One `roll` spins profile azimuth
and cookie together, like turning a fixture in its yoke.

Pinned by `IESProfileTests` (parse, symmetry folds, candela ordering, bake,
packing) and `LightShapingRenderProbes` (the ring profile darkening the axis
while lighting its ring, the half-black cookie's orientation, and a
half-turn roll swapping it); the `light-shaping` snapshot covers the whole
path with inline-authored fixtures. All bundled `.ies` files are authored
for Ollin; manufacturer files are freely *distributed* but not clearly
*licensed*, so none ship.

### Atmosphere (fog and volumetric light)

`fog(_:density:heightFalloff:)` and `volumetricLight(_:anisotropy:)` are per-frame
drawer state packed into three `OllinLighting` tail fields (`fogColor` with the
gate in `.w`, `fogParams`, `fogParams2`), so the constants reach every lit carrier
plus the deferred reflection trace with no new plumbing; the gate at 0 leaves every
branch untaken (whole suite byte-identical, verified). The packing sits *ahead of*
`makeLighting`'s `.off` early return on purpose: fog is a property of the air, so a
`noLights()` scene still fogs (only the shaft march needs the lights).

**The compositing model respects call order with no stored scene depth.** Every 3D
shading fragment fogs *itself* over its own eye-to-surface path (the exact
closed-form height-fog transmittance from Quilez, with series limits at zero
falloff and horizontal rays so nothing pops at the horizon), applied after the full
shading tail so reflections and IBL ambient dim too. The empty air is a fullscreen
draw in the skybox slot (`PipelineKey.fogAir`, the same always-pass/no-write
recipe, encoded right after the skybox): it marches to each pixel's own far-plane
distance and outputs premultiplied `(ambient + in-scatter, 1 − T)`, so the
backdrop shows through by exactly the transmittance, meshes depth-composite over
it, and 2D drawn later is untouched. Splats and particles alpha-composite over the
beam-painted backdrop, which is why beams read through a point cloud that itself
takes no fog term. The RT reflection hit shade applies the analytic leg to the
surface-to-hit path inside the *shared* trace (`ollin_rt_reflection_trace`), so
the inline and deferred paths can't disagree; an environment miss keeps the sky
clear (documented envelope).

**The march (`ollin_fog_inscatter`) is the Tóth-Umenhoffer single-scattering
integral, and three of its pieces are load-bearing, each a real observed failure
first.** (1) *Per-spot ray∩cone span bounding*: strata spread over the whole ray
straddle a beam a fraction of a stratum wide, and the beam dissolves into a woven
noise lattice (or, un-jittered, vanishes outright); bounding the sub-march to the
analytic cone crossing puts every stratum where the beam is. Only the transverse
case is bounded (a ray running within the cone angle of the axis takes the full
range under a quadratic near-field warp `t = tEnd·u²`), and the mirror-nappe
interval is rejected by an axis-side test. (2) *The light-leg extinction*: without
`exp(−τ(light→sample))` a ray traveling inside a cone accumulates without limit and
the frame washes out; with it the integral is bounded by ~σs/σt. Beams-only mode
(no `fog`) substitutes a 0.05 reference density for both the scattering
coefficient and that leg, so beams still form and still bound while the view path
keeps zero dimming. A directional light takes the leg only under height falloff
(the slant path from the sky then has a finite closed form: the crepuscular
dimming); uniform fog has no finite sky path, so it is skipped there. (3)
*Per-step jitter decorrelation*: one shared per-pixel offset moves all strata
together, and that coherent error reprints the jitter pattern as a lattice across
the beam, so each step re-reads the gradient noise at a per-step pixel shift. The
jitter stays a pure function of (pixel, step): exports and snapshots reproduce
with no temporal history and no warmup.

Per step, a spot evaluates its cone falloff and the shared
`ollin_apply_light_shaping` (IES profile + cookie on the light's local copy), so
a gobo's panes read as bars of bright air with the same projector-convention
mapping the surfaces use; visibility is one un-filtered `sample_compare` tap
(`ollin_fog_shadow_tap`, constant bias only: an air sample has no normal and is
never its own occluder; outside the caster's box counts as lit). Directional and
spot participate; point and area kinds sit out (no distance falloff means an
omnidirectional glow has no shape to march), and only the 2D-map caster carves
shafts, the same one-caster rule as surfaces.

**Budgets and encode sites.** The step budget is carried in `fogParams2.x`, resolved by
the renderer (`resolveVolumetricSteps`: 16/32/64 by tier through
`effectiveQuality`, absolute 8…128) at *both* lighting-resolution sites: the main
`encode` and `resolveFieldLighting`, so the half-res field tier marches like the
full-res pass. The air cap is carried in `fogParams2.y` (the camera far plane, packed by
the drawer). A frame with no batches at all early-outs of `encode` before the air
draw, so beams need at least one mesh in frame (documented). Measured M2 1080²
export tier: the two-spot example costs ~6 ms/frame over its no-volumetrics
sibling; the analytic-fog-only path is arithmetic in the fragment tail
(`fog` snapshot unchanged in cost). Verification: `VolumetricStateTests` (packing,
resets, clamps), `FogRenderProbes` / `VolumetricLightRenderProbes` (distance and
height ordering, the air wash, unlit fog, cone confinement, the carved shaft via
on/off differencing, beams-only leaving off-beam surfaces alone), and the `fog` +
`volumetric-light` snapshots.

**Aerial perspective** (`aerialPerspective(density:haziness:heightFalloff:sun:)`)
is the fog integral split by wavelength: the classic real-time outdoor-scattering
model (Hoffman-Preetham, credited in `ATTRIBUTION.md`), using the same slots
under **mode 2 on the fog gate** (`fogColor.w` = 0 off / 1 fog / 2 aerial, so
every existing `> 0` gate stays armed and the carriers pick the model with one
compare against 1.5). The shared `ollin_fog_optical_depth` computes one scalar τ
(the green channel's; height falloff included), and `ollin_aerial_split`
exponentiates it per channel through the Rayleigh RGB ratios normalized to green
(`OLLIN_AERIAL_RAYLEIGH` = 0.428/1.0/2.442, the measured sea-level coefficients'
1/λ⁴ ratios, mirrored by hand from `Drawer.aerialRayleighRatios`) mixed with a
gray aerosol fraction (`haziness`), then forms the in-scatter's closed form
`(βR·Φ_R(θ) + βM·Φ_HG(θ))/β_ext · E_sun` per channel, with Φ_R the molecular
phase `0.75(1+cos²θ)` and Φ_HG the shipped `ollin_hg_phase` at g = 0.76 - so the
final composite is `rgb·T₃ + L_in·(1−T₃) + marched beams`, the same shape as
fog's. **The codegen rule from the GI work applies:** `ollin_apply_fog` stays
verbatim and aerial is a *twin function beside it* (`ollin_apply_aerial`), the
call sites branching, because a grown fragment re-contracts under fast math -
verified by recording all 182 snapshot references with the feature in the tree
against a clean-HEAD worktree record: **pixel-identical across the board**. Two
appended `OllinLighting` tail fields carry the rest (`aerialSun` = world sun +
g, `aerialLight` = sun radiance + haziness); the sun resolves at pack time
(explicit → the `.sky` environment's sun through its rotation - the skybox
samples at R_y(rotation)·ray, so content sits at R_y(−rotation) in the world,
sign confirmed against the drawn sun disc → the first directional light negated
→ a default 35° elevation). Its light is the scene's: under a `.sky` it is the
sky's horizon radiance averaged over 64 compass directions, over the phase mix
averaged the same way, at the backdrop's exposure and intensity
(`Drawer.aerialAirLight`), so the far veil averages to the horizon the backdrop
draws and the haze's halo still leans it sunward; without a sky it is white
dimmed by the molecular slant-path extinction (airmass ≈ 1/sin e) times the
first directional light's linear color and intensity, so the frame is linear in
its light and a horizon sun still feeds the veil sunset color. A nil `density` derives as 0.35 / the
eye-to-target distance (the contact-shadow default's rule). **The air veil steps
aside behind a skybox** (the renderer flags `fogParams2.z` when the skybox drew;
the air fragment then adds only the marched beams): the sky already is this
scattering integral carried to infinity, so painting the scene-scale veil over
it would double-count - with no backdrop the air paints the implied horizon
glow, its three-channel transmittance necessarily flattened to a mean alpha
(exact over the usual flat clear). Long paths *saturate* toward the sky tone by
design (the closed form's equilibrium); the veil on a mid-distance silhouette
runs blue because blue accumulates fastest at small τ, while the saturated limit
tends toward the phase-mixed sun color - both ends verified against the Hosek
horizon (silhouettes melt into the sky with no seam). Verification:
`AerialStateTests` (mode packing, clamps, the framing-derived density, the
three-way sun resolution with the rotation formula pinned, last-call-wins,
per-frame reset, the warm low-sun radiance, the ratio constants) and
`AerialRenderProbes` (off restores byte-identity, the blue-leaning veil,
distance ordering, sunward brightening, haziness graying, height falloff, the
sky-pixel byte-hold behind a skybox, the bare-air glow, beams scaling with the aerial
density, determinism - the sky-skip, flat-ratio, and flipped-lobe sabotages each
verified red), plus the `aerial-perspective` snapshot.

### The IBL bake

`environment(_:)` lights the PBR materials from a surrounding HDRI via the
split-sum approximation (Karis). The renderer bakes once per resolved source
(cached; `MetalRenderer+IBL`, the `ShaderIBL.metal` segment): equirect to cube,
a diffuse irradiance cube, a GGX-prefiltered specular mip-cube, and a one-time
BRDF LUT, all fullscreen passes into cube faces that run before the geometry
pass at the render/image/benchmark sites. The prefilter is a 1024-sample GGX
importance bake; 256 samples left swirl/mottle artifacts on rough metals around
concentrated bright lights.

**Load-bearing gotcha: mip the equirect *before* the equirect-to-cube pass.** A
high-resolution equirect into a 256-square cube face is a large minification,
so the cube sample needs valid mips; without them it reads empty and the IBL
goes black (a real bug, fixed).

The EXR decode runs through ImageIO, which does not decode dwab compression, so
the bundled set is PIZ half-float. Inf/NaN sun texels are clamped on load, and
a sin-weighted average luminance is computed then for **per-environment
auto-exposure**: the bundled set ranges roughly 800x in raw brightness, so each
environment is scaled to a common target and any HDRI "just works".

At shade time the mesh fragment adds the split-sum ambient
(`ollin_pbr_ibl_ambient`, fragment textures 4/5/6) on top of `meshLitColor`'s
direct lighting when `OllinLighting.iblEnabled`; the non-PBR materials take the
diffuse irradiance as their ambient (`ollin_ibl_flat_ambient`; Gooch keeps its
own tone ramp), so one environment lights every material. All of it is gated,
so a frame with no environment is byte-identical. The raymarched fields add the
same two branches (see *SDF combinators*).

The environment also draws as a **skybox** backdrop by default
(`ollin_ibl_skybox_*`, a view-ray equirect sample before the geometry with
depth disabled, matching the reflections); `.lightingOnly()` opts out. The
backdrop is bicubic-reconstructed (a 4-tap Catmull-Rom, so the magnified
low-resolution slice is not a blocky bilinear grid) with a resolution-aware
`backgroundBlur` (`nil` = automatic: a gentle soft-focus at 1K easing to sharp
at 4K; overridable 0...1).

### Environments, downloads, and caches

An `Environment` built-in is one of 8 CC0 HDRIs bundled at 1K (instant,
offline, CI-safe) or one of 12 download-on-demand sources fetched from Poly
Haven on first use; a user source is `.hdri(path:)`, `.hdri(downloadURL:)`, or
the procedural `.sky`. `highRes(.twoK/.fourK/.eightK)` upgrades a built-in's
*backdrop* to a sharper version; the lighting never needs more than 1K.

The download cache is `EnvironmentCache`
(`~/Library/Caches/Ollin/Environments/`, overridable via
`OLLIN_ENVIRONMENT_CACHE`, with an injectable `fetch` so the unit test mocks
it; no network in CI). A `.remote(url:fallbackResource:)` source resolves in
the renderer: cached goes straight to bake; export **blocks** on the download
so exported art is always full-resolution; live kicks it off and shows the
bundled-1K placeholder (or the neutral sky) until it lands. The blocking form
(`downloadBlocking`) parks the calling thread on a semaphore until a worker's
task lands the file, which is right on the export path's main thread and wrong
anywhere `async`: from a test body it held one of the pool's few workers, and
was the other parked thread in every sample of the 2026-09-06 CI wedge. An
`async` caller uses `download(_:)`, which suspends instead; both forms and the
live kick-off share one download body, and `Scripts/preflight.sh` refuses the
blocking call under `Tests/`. The fetch prints
throttled byte/percent progress and then a decoding note to the terminal, via a
classic `URLSessionDownloadTask` on a delegate session, because the async
`download(from:delegate:)` convenience does not deliver the `didWriteData`
progress callback; the decode note strips the `<hash>-` cache prefix via
`EnvironmentCache.displayName`. `Scripts/clear-caches.sh` wipes the environment
and compiled-model caches by default, with targeted flags (`--environments` /
`--compiled-models` / `--models` / `--build`, `--all`, `--dry-run`).

**The processed equirect is cached as a blob.** A 4K PIZ decode is the
multi-second cost (the bake itself is cheap GPU passes), so the decoded float
pixels are written beside the download as a raw-float16 blob
(`EnvironmentCache.equirectBlobFile`, `<hash>.equirectf16`); a relaunch skips
the decode entirely (a 4K blob is ~64MB, smaller than the 92MB float32 source,
loaded by memcpy, round-tripping pixel-identical). The bundled 1K set stays on
its compact EXR, which decodes in a blink and needs no blob.

**The decode/blob-read runs off the render thread**: `equirectBytes` in a
`Task.detached`, handed back through an `OSAllocatedUnfairLock` into
`equirectReady` (`loadEquirectBytes`/`processEquirect` are `nonisolated`, since
`MetalRenderer` is `@MainActor`), with the bundled placeholder shown until it
lands, so the live window never blocks. The export path loads synchronously
(`blocking`) so exported art is full-resolution and the snapshot stays
byte-identical.

The in-memory bake cache is LRU-bounded (~512 MB of baked maps; the frame's own
entry never evicts; an evicted source re-bakes from its blob in a blink), and
orphaned decoded pixels (`equirectReady` entries whose environment moved on
before they baked) are pruned after a few resolves, so a gallery cycling many
HDRIs stays bounded (`IBLCacheTests`).

### The procedural sky

`.sky(turbidity:sunElevation:groundAlbedo:)` is a zero-asset Hosek-Wilkie
daylight dome. The RGB coefficient dataset and config code are vendored at
`External/CHosekWilkie` (3-clause BSD, trimmed to the RGB path);
`ollin_hosek_rgb_configs` cooks the per-channel config CPU-side,
`ollin_ibl_sky_gen` evaluates the per-texel radiance into the equirect, and the
same cube/irradiance/prefilter/skybox bake runs on it. A neutral `.sky()` is
also the default placeholder while a non-bundled HDRI downloads, so the scene
is lit by a sky instead of unlit (unless an explicit bundled `placeholder:` was
given).

Two performance decisions are load-bearing. The sky equirect is generated on
the frame's own command buffer with **no CPU read-back**: auto-exposure's
average luminance is integrated analytically from the coefficients
(`SkySun.averageLuminance`) instead of read back from the texture (the read-back
path was the 3-fps bug). And an animated sky re-bakes every frame at a
**reduced sample budget** (the `params.z` budget in
`ollin_ibl_irradiance`/`_prefilter`: a coarser hemisphere step plus 32 GGX
samples for `fastSky`; 0 selects the default fine bake, so the HDRI path stays
byte-identical), which keeps a moving sun smooth with no quantization stepping.
`rotated(_:)` spins the sun for free: it is a shade-time uniform, never a
re-bake.

**The sky's sun** (`Environment.sunlight(_:)`) is a directional light the
drawer derives from the same model. The vendored solar radiance function (the
2013 addition, published in spectral form only) gives the beam's radiance
across the disc; `ollin_hosek_solar_irradiance` integrates it over the disc with
four Gauss-Legendre nodes in the limb-darkening cosine (exact for the quintic
limb polynomial), and `SkySun.beamIrradiance` sums that spectrum through the
CIE 1931 observer to XYZ and the sRGB matrix to linear RGB. The RGB sky dataset
is the XYZ one through that same matrix (within a few percent), so the beam and
the sky share one unit and their ratio is the model's. `SkySun` is the one
place both sides read: the bake's exposure and the light's scale both come from
`SkySun.exposure(sunLit:coverage:)`, and the bake keys on the frame's decision
(`IBLKey.sunLit`), so the light lands in the backdrop's unit exactly. While the
sun lights the frame, the beam joins the average as its luminance over 4 pi (a
photographed sun's pixels count that way in a loaded map), the clear sky's
stand-in disc leaves the equirect (a radius of 0 in `sky[10].w`), and the
skybox fragment draws the true disc analytically (half of 0.51 degrees, its
mean radiance, area-conserving below a pixel), since a 1K equirect texel is
wider than the sun. A cloudy sky keeps its baked disc (the clouds in front dim
it) and its beam keeps 1 minus the coverage. The drawer joins the light after a
set's own lamps in `activeLights(in:)`, so everything that reads the packed
list sees it; `.automatic` joins only a set with no directional light of its
own, and the frame's own set decides the bake and the backdrop.

**Volumetric clouds** (`Environment.clouds(_:)`, the `Clouds` value) bake into
this same generation, which is the design's whole payoff: the backdrop, the
IBL chain, and every reflection see one weather, an overcast dims the scene's
light by construction, exports are deterministic (the cloudscape is a pure
function of the dials, no temporal history), and a still sky costs nothing per
frame while a drifting one uses the shipped animated-sky re-bake. The plain
`ollin_ibl_sky_gen` stays **verbatim** (the fast-math codegen rule) and a
cloudy sky selects the `ollin_ibl_sky_gen_clouds` twin, which reproduces the
clear sky and then marches a spherical shell (1.5-4 km over an earth-radius
floor, so the horizon compresses formations the way a real sky does; 64
IGN-jittered steps, span capped at 24 km, 5 cheap taps toward the sun per lit
sample). The density chain is the published recipe: a procedural weather field
(the reference hand-draws its map, so the stand-in **thresholds a billow fbm
into blobs with hard cores and real gaps**, the window sliding down as coverage
rises - the soft field read as thin veil everywhere, a real first-render
defect), the rounded-base/tapered-top height pair, a **tiling** 128-cube
billow-carved base volume and 32-cube eroding detail volume (periodic
gradient-lattice + cellular kernels, `ollin_cloud_noise_*`, dispatched once per
process and held - a fixed lattice, so every machine bakes identical fields),
and the transmittance shaping trio (attenuation clamp, forward lobe + sun-halo
against a soft back lobe, depth-driven edge darkening). Three tunings were
measured, not guessed: the view extinction's mean free path at ~140 m (550 m
read as vapor), the clear-sky ambient inside the march thinning with coverage
(without it an overcast barely dimmed the floor the probes measure), and the
IGN jitter keyed on the fragment's **own pixel position** (a hardcoded texel
scale left pairs of texels sharing offsets when the cloudy bake doubled to
2048×1024, printing stripe combs across the deck). The bake-cache key widened
from `Environment.Source` to `IBLKey` (source + clouds, clouds nil for non-sky
sources so a stray value can't fragment an HDRI's slot), and the skybox's auto
soft-focus turns off for a *cloudy sky specifically* (its bake is the content's
own resolution; the gate checks the source is really `.sky`, a rule the
HDRI-ignores-clouds probe caught as a live defect when it keyed on the clouds
value alone). Verification: `CloudStateTests` + `CloudRenderProbes` (coverage
claims more sky by a measured blue-lead-ratio classifier, overcast dims the
floor, phase moves the weather, an HDRI ignores clouds byte-identically, two
bakes match byte-identically - the cache-key sabotage verified red), plus the
`sky-clouds` snapshot.

### Shadows

`castShadows()` routes by light type: directional/spot render a 2D shadow map;
a point light is ray-traced on an RT GPU (inline `intersection_query` behind
the `OLLIN_RT_SHADOWS` compile gate) with a mid-point cube fallback elsewhere;
a rect/disk area panel is ray-traced on an RT GPU with a 2D-map fallback (below);
`shadowQuality(_:)` maps hardware-relative ray counts through `RenderQuality`.

**A frame casts from a list of lights, not one.** `Drawer.makeLighting` resolves
up to `OLLIN_MAX_SHADOW_CASTERS` (4) casters into `OllinLighting.shadowCasters`,
each an `OllinShadowCaster` (stride 112) carrying its own view-projection,
texel size, penumbra radius, linearization constants, sample budget, and kind.
Three decisions make the change cheap to reason about:

- **Slot 0 is the primary caster**, chosen by exactly the priority the single
  caster used (directional → spot → point → rect/disk), and the single-caster
  fields on `OllinLighting` mirror it field for field. That mirror is what let
  the list land without touching anything downstream: a one-caster frame packs
  exactly what it packed before the list existed, which is why the whole
  snapshot suite passed unrecorded. The systems that used to read those fields
  alone now read the list (below); the mirror stays because it is also the
  cheapest way to keep a single-caster frame byte-identical.
- **A 2D caster's map layer is its slot index.** The shadow map became a
  `depth2d_array` sized to the frame's caster count (a fresh texture when it
  must grow, never a resize in place), one depth-only pass per caster into its
  own `depthAttachment.slice`. Because slot 0 owns layer 0 whether or not it is
  a 2D caster, every helper that reads the primary caster names layer 0 as a
  constant, and a cube or traced primary simply leaves that layer cleared.
- **A point light casts from any slot.** Both of its paths grew a dimension
  rather than a second mechanism. The cubes are a `texturecube_array` holding a
  cube per point caster, ranked in slot order (so a cube *primary* is cube 0 and
  the helpers that follow the primary keep naming it as a constant), and the one
  layered pass renders them all: the vertex adds a per-caster `cubeBase` to its
  face index, so the same six-face draw fills any cube of the array. On a
  ray-tracing device every point caster traces against the frame's one
  acceleration structure instead, and the fragment computes them together:
  `meshRTShadowAll` returns a `float4` of per-slot factors and the shading tail
  reads its own slot. Two things had to move with it. The structure is bound
  whenever *any* caster traces, so the two sites that flip `shadowKind` to 2 ask
  whether the **primary** is a kind that traces, rather than whether a structure
  exists: a directional key beside a traced point light keeps its own 2D map.
  And one structure serves every caster, so an area primary beside a point
  caster takes the built one rather than building a second copy of the same
  geometry. `finalizeShadowCasters` then drops any extra caster whose own
  resource was not produced, so a frame missing a map, a cube, or the structure
  falls back to casting from the primary alone.

#### The rasterized cube, and why it was wrong for so long

The mid-point cube is what a GPU without render-stage ray tracing renders for a
point caster, and it is the one 3D path an Apple-silicon machine never takes.
Nothing exercised it: the snapshot named `point-shadows` renders *traced* here,
so it pinned the traced picture and said nothing about the fallback. It was
wrong in three separate ways at once, and each one is now a probe in
`CubeShadowTests` that was watched fail with its fix removed.

**Forcing the path.** `MetalRenderer.rayTracingAvailable(on:)` answers false when
`OLLIN_NO_RAY_TRACING=1` is in the environment, and both places that decide the
render path read it: the renderer's own `rayTracedShadows`, and the shader
compile that sets `OLLIN_RT_SHADOWS`. So the fallback can be rendered, measured,
and tested on the machine the framework is developed on. `CubeShadowTests` runs
under it, and also runs unasked on a GPU that genuinely cannot trace.

**Every face was stored upside down.** The six face view matrices use the
standard cube-face basis, which is written for an API whose framebuffer origin
is the *bottom* left. Metal's is the top left, while a cube face is addressed
from the top left in both, so rendering those views unchanged flips each face
vertically. A receiver then samples the mirror of the direction it meant: a box
over a floor threw its shadow behind itself, and the floor's own occlusion came
from the wrong place, which rippled it. Working the render's `u` and `v` out
against the cube-lookup table shows `u` already agreeing on all six faces and
`v` inverted on all six, so the fix is one row: negate the projection's y. The
pass culls nothing, so the winding it also flips is free.

**The far plane was fitted to the camera, not to the light.** The packing had
only `Drawer`, so it sized the cube from the camera's framing radius, which says
nothing about how far the light reaches. A caster further from the light than
the camera is was clipped straight out of the cube: it lit up and threw nothing.
The fit belongs in the renderer, because only the renderer knows it is rendering
a cube at all, and a traced frame must not pay for the scan: `pointCasterFar`
takes the frame's mesh-vertex AABB, measures the farthest corner from the light,
adds 2% (so the farthest surface still stores under the "nothing here"
sentinel), and the value travels to the fragment through
`MetalRenderer.pointShadowFars`, keyed by the casting light, which
`finalizeShadowCasters` writes into each cube caster beside the facts it already
carries back. The scan itself runs once a frame and every point caster
shares its box, so a second cube caster costs a corner measurement, not a second
walk over the geometry. A traced frame renders no cube, leaves the table empty,
and keeps every packed field exactly as it was.

**A copy has no vertices to scan, so it is bounded by its sphere instead.** The
scan above reads `drawer.meshVertices`, which holds the plain meshes and nothing
else: an instanced batch expands its base mesh once, in local space, and each
copy is a matrix in another buffer, while a `MeshField` keeps both in buffers of
its own. So a frame whose casters are *all* copies had an empty box, fell back
to the camera-derived guess, and threw no shadow from anything past it. The
missing sources join the same box in `pointCasterBounds`. An instanced batch
takes its base run's local bounding sphere once, then folds `center` through
each placement matrix with the radius stretched by that matrix's
`largestColumnScale` (the longest of its three column lengths, so the sphere
still holds what it held under non-uniform scale or shear). A field answers with
`localBounds()`, the same per-copy sphere fold worked out in field space and
**cached against the field's `generation`**, since a retained field is placed
once and asked every frame; the box's eight corners then go through the
draw-time matrix, which can turn it as well as move it. The per-copy formula is
deliberately the cull kernel's own (`ollin_field_cull` scales `en.radius` by the
largest column of `fieldModel * model`), so a copy is bounded here exactly as it
is bounded there. The one caster left out is a **compute-written placement
buffer**: those matrices never come back to the CPU, by design, which is what
`CasterBounds.complete` reports so the caller keeps the camera-derived guess as
a floor for that frame. `CubeShadowTests` carries a probe per source (a plain
mesh, an instanced copy, a field copy), each reading the same distant pillar's
shadow wedge and each verified red with the fold-in removed.

**The bias was measured in the wrong place, and then was the wrong shape.** A
cube texel covers `2·d/N` world units at distance `d` from the light, and the
packed `texelWorld` is that figure taken once, where the camera looks. Used
whole, it under-biases every surface further out. Worse, a surface the light
*grazes* crosses many texels' worth of distance inside one texel, and the 20-tap
PCF reaches three texels out, so the slack has to cover the distance crossed
over that whole spread: it grows as `tan` of the incidence angle, without bound
as the light nears the surface plane. So the fragment now works its own texel out
from its own distance (`OLLIN_POINT_SHADOW_RESOLUTION` moved into the shared
header for it, retiring the two Swift copies), and scales the *depth* bias by
that tangent, capped at 12. Only the depth bias is scaled: moving the sample is
what notches a box's bottom corners, and a surface square on to the light has a
tangent of zero and the bias it always had. Past the far plane a receiver reads
**lit**, which is the envelope the 2D casters already state for a receiver
outside their fitted frustum.

Measured against the traced render of the same scene, the fixed cube path sits
at a mean 0.34 to 0.40 per channel, where the old one put the shadow on the
wrong side and combed the floor. On this machine the whole path is inert, so all
of it is snapshot- and figure-neutral.

The lit mesh loop resolves a light's caster slot by scanning the list, and
every per-caster term is indexed by that slot (`rtShadow[cs]`,
`fieldShadow[cs]`, `meshFieldShadow[cs]`, `rtThickness[cs]`). Facts the
renderer settles after `makeLighting` (the flip to the traced path, the ray or
tap counts, whether a map was produced at all) are carried back into slot 0 by
`finalizeShadowCasters`, which also budgets taps for the extra casters and
drops them when no map was rendered.

#### Everything downstream follows the list

Each system that once read the primary caster's mirrored fields now walks the
caster list, and each pays only for the casters a frame actually declares (the
loops break at `shadowCasterCount`, so a one-caster frame does exactly the work
it did before). The shape is the same everywhere: **a `float4` indexed by slot**,
which is why `OLLIN_MAX_SHADOW_CASTERS` being 4 is load-bearing beyond the cap.

- **Fog and volumetric shafts.** The march looks up the caster for the light it
  is integrating and taps that caster's own map layer. A cube or traced caster
  is skipped, because an air sample has no map to read there, which is exactly
  what the single-caster path did for those kinds.
- **Contact shadows.** `ollin_contact_march` was split out of the fragment, and
  the mask pass runs it once per caster, writing each factor into the channel
  its slot names. The mesh fragments multiply the whole `float4` into
  `meshFieldShadow` and read channel `cs`, so the mask carries a term per light
  instead of one factor smeared across all four channels.
- **Subsurface transmittance.** The gate moved from "this light is the primary"
  to "this light is a caster", and each caster reads its own layer, its own cube
  (`cubeIndex`), or its own traced thickness. `meshRTThickness` became
  `meshRTThicknessAll`, the same per-slot `float4` shape as `meshRTShadowAll`,
  and it walks only for a traced caster whose light is a point light.
- **The marched-field cast.** `meshFieldShadowFactor` marches the fields toward
  each point/ray-traced caster and returns a channel each; a 2D caster's channel
  stays lit because the fields render into that caster's own map layer already.
  The half-res pre-pass carries all four channels, and `resolveFieldCasterCount`
  asks whether *any* caster needs the march, not whether the primary does.
- **The raymarch carrier.** A field is in no map, so it marches its own
  self-shadow toward every caster and takes the darker of that and what that
  caster's map, cube, or trace puts on it, one channel per slot. Slot 0 holding
  `-1` is still the sentinel that tells the shading tail "this is a mesh".
- **Global illumination.** The probe pass's direct term traces occlusion for
  every caster with that caster's own strength. This one had the loudest failure
  mode: a bright light outside a sealed room lit the room's interior surfels
  through the wall, so the bounce field leaked light the raster had correctly
  blocked (measured 188 against 108 on the interior wall).

Two systems deliberately do not follow the list. **Caustics** emit from one
caster, because a frame traces a fixed photon budget and dividing it between
lights makes each pattern noisier rather than richer. The **path-traced export**
never read the caster list at all: it traces next-event visibility for every
light, which is the physical answer and the reason its shadows are soft. That
leaves one honest inconsistency, recorded in `DESIGN-NOTES.md` rather than
silently fixed: a light with `castsShadow: false` throws nothing in the raster
and still throws in the traced export, so a preset's fill casts there.

**Per-light opt-out, and why the presets use it.** `Light.castsShadow`
(default true, with a `castsShadow:` argument on every factory and bare call
and a chainable `castingShadow(_:)`) is how a light declines. Every curated
`LightingPreset` sets it false on its fill and rim lights. That is not
housekeeping: a fill exists to open the shadow side, so a shadow of its own
works against the rig, and without the flag every preset would suddenly cast
two or three shadows and pay a depth pass for each. It is also the measured
answer, since it is what returns the Guide's 26 three-dimensional figures to
their committed pixels.

**Area (rect/disk) casters shadow from the panel's real extent.** The caster
search reaches them after the punctual kinds (directional → spot → point →
rect/disk), so every existing scene keeps its caster. On an RT device each lit
pixel traces visibility rays to deterministic points spread over the panel's
actual surface (`shadowFactorRayTracedArea`: the golden-angle Vogel disk laid
in a disk's own plane; antithetic ±p pairs of the R2 low-discrepancy lattice
over a rect, so any sample budget stays mean-centered on the panel), which
also reproduces a strip's anisotropic penumbra. Each ray counts by what its
point sends the receiver unshadowed (the cosine at both ends over the distance
squared, the form factor's integrand), and the factor is the visible weight
over the whole weight: the ratio estimator, under which the analytic panel
light times the factor is the shadowed light. The plain count of visible
points kept 0.213 of the light under a blocker's edge where the share that
arrives is 0.091, since the far part of a panel lights a point less than its
near part and the count weighed them alike. Under the export's shared passes
(below) a pass's ratio over its own rays would not do: at sixteen rays over
sixteen passes a pass traces one ray, its ratio is that ray's 0 or 1, and the
passes average back to the plain count. So every pass sums the whole set's
weights, which are geometry alone and cost no ray, and returns the pass count
times its own visible weight over that sum, whose mean over the passes is the
whole set's ratio exactly (the export averages the passes in linear float, so
a pass reading above 1 is safe). Elsewhere the panel renders a
spot-style perspective map from its center, aimed at the camera target (a
panel lights its whole front hemisphere, so unlike a spot there is no cone to
aim by; the same framing proxy as the directional box) with the frustum fit to
the scene sphere, and the PCSS penumbra radius is the panel's half-extent in
map texels (a disk's radius; a rect's geometric-mean half-extent, so a thin
strip doesn't blur like a square of its long side), capped at 40 texels where
the fixed tap budget would spread into dither. `shadowSoftness` stays the one
dial across every caster: for an area caster it scales the *physical* extent
(0 → the hard legacy 3×3 / all rays at the center, 0.5 default → the true
size, 1 → twice), packed CPU-side into `shadowDepthA` for the map path and
swapped into `shadowDepthB` when the renderer flips the caster to the traced
path (`shadowKind` 2), since the map path needs that slot for the perspective
linearization term. The shadow dims the LTC integrals inside the area branch
of `meshLitColor` (both `diffI`/`specI`, so every shading model and the SSS
back term dim consistently); a no-caster frame multiplies by exactly 1.0 and
stays byte-identical. Tubes never cast: radial emission has no facing axis to
render a map from, and the caster search skips them. The traced path is
pinned by the RT-gated `area-shadows` snapshot; the map path (unreachable on
an RT machine) by the path-agnostic `AreaShadowRenderProbes`, which compare
castShadows()-on/off renders so the per-pixel difference isolates the shadow.

**PCSS (soft shadows).** `shadowSoftness(_:)` (0 hard, 0.5 default
contact-hardening, 1 very soft) runs the directional/spot 2D map through
Percentage-Closer Soft Shadows: blocker search, penumbra estimate, then a
variable-kernel Vogel-disk PCF (`shadowFactorPCSS`; the Fernando/NVIDIA
technique, studied from openFrameworks' shadow shader), sharp at contact and
blurring with distance. It is gated on `shadowDepthA > 0`, so `shadowSoftness(0)`
routes to the legacy hard 3x3 tap and is byte-identical.

**The penumbra ratio must be formed in light-linear depth.** A directional
ortho map uses the plain separation `(n_r - n_b)`; a spot perspective map
linearizes via `(n_r - n_b)/(n_r + A)` where `A` is the projection's `[2][2]`
term (carried as `shadowDepthB`; the `[3][2]` term cancels), and the negative
`A` also flags the spot path. `OllinLighting` did not grow for any of this: the
spare kind-0 fields `shadowDepthA`/`shadowDepthB`/`shadowSamples` carry the
light size (in texels), the linearization term, and the tap budget, and the
blocker search reuses the already-bound nearest `shadowCubeSamp` for raw depth
reads (no new sampler or pipeline).

One parameter unifies every caster: the same softness drives the RT point caster's
area radius (`dist * 0.06 * softness`; the 0.5 default reproduces the old
`dist * 0.03`, so the `point-shadows` snapshot is byte-identical). The 2D tap
budget follows `RenderQuality` via `resolveShadowTaps2D` (performance 24 /
default 40 / detail 72, GPU-independent fixed counts since these are cheap
texture taps, not RT rays; export resolves `.detail`). Shadows are
soft-by-default, and the penumbra change measured sub-tolerance (~0.2 mean
against the 2.0 snapshot threshold, a localized shadow-edge change), so
`mesh-shadows`/`spot-shadows`/`rt-reflections-3d` stayed within tolerance and
were not re-recorded (the SSAO silhouette-AA precedent).

### Ray-traced reflections: integration and plumbing

The trace itself, the two-bounce hit shading, and the anti-aliasing chain are
in *Deferred ray-traced reflection AA* below. The integration facts live here:

- **It integrates into the IBL specular, not a post-process.**
  `ollin_rt_reflection` (in Shader3D, inside `#if OLLIN_RT_SHADOWS` beside
  `traceShadowRay`) traces one closest-hit ray (`R = reflect(-V, n)`, origin
  `worldPos + n * rtReflectionBias`) against the mesh acceleration structure
  and replaces `ollin_pbr_ibl_ambient`'s `prefiltered` environment sample with
  the hit's radiance (a miss returns the environment sample, so a ray that
  leaves the scene shows the sky), compositing through the same Fresnel/BRDF
  weighting. The primary surface's roughness then blends the sharp mirror
  toward the prefiltered environment (one ray cannot blur).
- **Per-hit material data is baked per vertex.** Metalness and roughness are stored in
  the spare `OllinMeshVertex` w slots (`normal.w` metallic, `position.w`
  roughness for a PBR lit mesh), inert for the primary render since the lit
  vertex shaders read only xyz.
- **It reuses the shadow-caster accel.** `buildShadowAccel` also returns a
  per-geometry base-vertex offsets buffer (`meshGeoOffsetBuffer`, the coalesced
  `runStart`s) so a hit's `(geometryId, primitiveId)` plus barycentric
  coordinates fetch its triangle from the flat non-indexed `OllinMeshVertex`
  list (accel + offsets + flat mesh buffer bind to the mesh fragment at buffers
  3/7/6, RT-gated). `encodeShadowPass` was decoupled so the accel builds when
  shadows *or* reflections are active (a directional/spot caster builds the 2D
  map and the reflect accel; a no-caster reflections-only scene builds just the
  accel); `ShadowMaps.reflectAccel`/`reflectGeoOffsets` carry it, and
  `OllinLighting.rtReflections` (set by the renderer when present) gates the
  shader, so everything is byte-identical when off.
- Every solid mesh is reflectable (the accel covers all of them;
  `castShadows()` is not required). Reflections need an environment (the
  integration point and miss fallback) and an RT GPU (`rayTracedShadows`), are
  off-by-default, and no-op on a non-RT GPU (the environment reflection
  remains). The `rtReflectionBias` self-hit epsilon is sized from the scene
  scale in `makeLighting`.
- **v1 limits:** two bounces (the third order terminates at the environment);
  full resolution (a half-res `RenderQuality` tier, deeper bounces, a dedicated
  all-mesh accel, and the glossy-cone denoise are the follow-ups). The
  per-hit-material gap is closed: the PBR finish through the baked vertex slots,
  and the stylized ones through a compact per-geometry `OllinRTFinish` record
  stored in the hit table's own buffer (see *Stylized finishes through the trace*).

### Stylized finishes through the trace

A reflection shades the surface it finds, and that shade is the physically-based
one, so a surface whose look comes from somewhere else used to arrive in a mirror
as the plain diffuse body underneath it: a cel-shaded prop lost its bands, a Gooch
one its warm-cool ramp, a velvet one its rim. Metalness and roughness are stored in the
spare `OllinMeshVertex` w slots, but a shading model and its tones do not fit
there, and they are per batch rather than per vertex anyway.

- The finish travels as **`OllinRTFinish`** (seven `float4` rows, stride 112): the
  shading model with its cel-band count and Blinn-Phong pair, the Gooch warm and
  cool tones, the rim color/strength/exponent, and the fake-subsurface tint and
  strength. One record per acceleration-structure geometry, then one per copy
  group, indexed by the hit record's material slot, so a copy resolves the finish
  of the draw that placed it.
- The records **are stored in the hit table's own buffer**, past a four-word-aligned base
  named in a two-word header, rather than taking a binding of their own. That is
  the rule the hit records already follow, and it is why none of the tracing entry
  points gained an argument. The block is written **guarded by that base, never by
  the flag that set it**: a block written at offset 0 lands on the header it is
  reached through, and a table corrupted that way still renders a plausible
  picture, which is how the first sabotage run read as a run-break artifact rather
  than as the corruption it was.
- A geometry has to be finish-uniform for a hit to resolve one, so a change in the
  record breaks the accel's coalesced run, the way `causticMats` and
  `pathTraceMats` already do. The **compact record is the key**, not the whole
  `OllinMaterial`, so two batches that differ only in something the trace ignores
  stay one geometry. A frame that declares nothing stylized writes no table, breaks
  no extra run, and shades exactly as it did, with a zero base as the gate.
- The shade itself mirrors `meshLitColor` term for term: cel bands and a snapped
  highlight per light, the Gooch tone from the first punctual light with the
  ambient withheld (`ollin_rt_ambient`, matching the primary path's own gate on
  that model), the wrap term accumulated in the light loop, and the rim applied
  after it. The light-driven terms go through the same exposure divide as the direct
  lights.
- **The two layered physically-based lobes are stored in the same record** (`sheen` = the tint
  premultiplied by its strength plus the lobe roughness, `coat` = intensity plus its own
  roughness; stride 112), applied by `ollin_rt_layer_lobes` at the first hit and at the
  pair's second surface in the order the primary shade layers them. The sheen keeps the
  *environment* sample even here, its lobe being wide enough that the prefiltered
  environment is the honest integral, and the coat reuses the radiance the trace already
  found along that surface's mirror direction, which is the same single-trace tradeoff
  `ollin_pbr_ibl_ambient` takes. The sheen's directional albedo comes from the same LUT
  the primary shade reads, so the trace carriers bind it at **texture 12**, the slot every
  `meshLitColor` carrier already uses. Both are physically-based only, the gate the
  primary shade applies, and inert at zero.
- **Still outside the trace:** every *light highlight*, since a traced hit computes none
  at all (the standard model's Blinn-Phong, the coat's own hotspot and the sheen's, and
  an area panel's stylized highlight and subsurface wrap, the last needing a second
  back-facing LTC integral); iridescence and sparkle, which need their own fields; and
  the deeper surfaces of a `reflectionBounces` chain, which keep the plain shade the tail
  walk gives them. Decals keep their own documented envelope. Adding the standard model's
  highlight is the one deliberately withheld: it would move every existing reflective
  frame.

### Glass: transmission and refraction

The transmissive material uses the PBR finish (shading model 3), not a new
model: `transmission` / `ior` / `thickness` / `attenuation` are stored in the
`OllinMaterial` tail, every new branch gates on `transmission > 0`, and the
normal-incidence Fresnel moved from a hard-coded `0.04` to a CPU-packed
`mat.f0` that packs the *exact literal* `0.04` at the default IOR 1.5 (the
computed `((0.5)/(2.5))^2` rounds to a different float; `MaterialTests` pins
it), so every pre-glass frame is bit-identical. The moving parts:

- **The transmitted lobe replaces the diffuse one** (the glTF
  `KHR_materials_transmission` form): in `ollin_pbr_ibl_ambient` the diffuse
  part becomes `mix(kD·diffuse, Ft·(1−E)·base, transmission·(1−metallic))`
  where `E = F0·brdf.x + brdf.y` (with its multiple-scattering share) is the
  specular lobe's share of the energy and `kD = (1−E)·(1−metallic)`, the
  body's share whether it shows its color or the view through it, and
  `meshLitColor`'s punctual + LTC diffuse scale by the same `diffKeep`
  factor. `diffKeep` engages **only when `iblEnabled` is up**: with no
  environment there is nothing to transmit, so transmission is inert and the
  material shades as the plain dielectric (pinned byte-equal by
  `GlassRenderProbes.withoutAnEnvironmentGlassIsAPlainDielectric`).
- **The base path is environment refraction** (`ollin_env_refraction`, all
  GPUs): refract at entry; a solid (`thickness > 0`) marches the analytic
  interior span `thickness · −(N·R)` and refracts back out through a
  curvature-blended exit normal (`normalize((N·R)·rr − n·0.5)`, the published
  rasterizer approximation of the unseen far interface), a thin wall exits
  parallel to the view ray; the sample reuses the GGX-prefiltered mips at the
  material's roughness with the blur fading as IOR → 1 (`mix(rough, 0,
  saturate(3/ior − 2))`); Beer-Lambert absorption is `pow(attColor, span /
  attDistance)` with the attenuation color floored at 1e-4 per channel on the
  CPU (a zero channel would hit `pow(0, 0)` NaNs under fast math).
- **The RT upgrade uses the same `rayTracedReflections()` opt-in**, one
  switch upgrading mirrors and glass together. `ollin_rt_refraction` traces the
  *refracted* entry ray through the same accel: a solid's interior leg
  finding a **back face** found its real exit (refract out there and trace on;
  the traced interior span feeds Beer-Lambert exactly), a **front face** inside
  is an embedded object seen through one interface (shade it where it is); a
  thin wall continues the straight view ray, hopping through its own shell's
  back faces (bounded at 4 hops). Total internal reflection at an exit carries
  straight on (a bounded fudge; a real internal bounce recurses without
  bound). Hits shade through `ollin_rt_hit_radiance`, the hit shade *extracted
  from* `ollin_rt_reflection_trace` so mirror and glass shading cannot drift.
- **A body the accel structure doesn't hold supplies its own far interface.**
  `buildShadowAccel` admits solid `.mesh3D` batches only, so a raymarched field
  owns no triangle the interior leg could hit: the walk would take whatever
  stands *behind* the field for its exit and absorb over that entire run. Green
  glass on a 0.95-radius sphere read G137 as a field against G189 as the mesh,
  and slid to G59 when the slab behind it moved twice as far away, which is how
  the mechanism was isolated (the mesh never budged, and turning absorption off
  made the two byte-equal, so absorption was the only term involved). The fix
  keeps the exit where the geometry is known: the raymarch fragment refracts the
  same entry leg, sphere-traces the *inside* of its own field until the surface
  comes back (`ollin_sdf3d_interior_exit`, stepping by `-d` since the field reads
  negative in there), and hands the exit point plus its inward-facing normal down
  through `ollin_pbr_ibl_ambient` into `ollin_rt_refraction`. Three properties
  make it safe: the supplied exit stands in **only while it is nearer than any
  traced hit**, so a mesh embedded in a glass field still shows through the entry
  interface; the mesh path passes a zero `bodyExit` and takes the traced branch
  **byte-identically**; and only a transmissive solid under a live trace pays for
  the extra march. The environment path never had the defect, its span being
  analytic on both shapes.
- **The interior march starts *on* the surface, and both naive readings of that
  are wrong.** The first draft crawled (the sphere-trace step *is* the interior
  distance, which is ~0 at the entry) and tested nearness to decide it was out
  (the distance is within an epsilon of zero from either side). What actually
  decides the test there is the sub-epsilon residual the outward march happens to
  leave behind, and that residual **bands radially**, since the outward march's own
  step sequence does. The body came out printed in fine concentric rings, each ring
  a radius where the residual tipped the first test the other way and the march
  returned nothing. So the march now carries a **floor under every step**, which is
  what keeps it moving, and counts the exit **only once the ray has really been
  inside**; it returns a distance rather than a failure, because the caller's
  fallback is the very mistake the march exists to prevent, and a budget that runs
  out answers with what it marched, bounded by the body's own extent. **The rings
  were invisible to a patch mean** (the center patch matched the mesh to 0.6/255
  through them) and only a per-pixel comparison sees them, which is why the probe
  compares the two bodies pixel by pixel over a **disc that stops a few pixels
  short of the rim** rather than a square: a square's corners reach the
  silhouette, where the outermost pixels legitimately differ (the AA models
  disagree, multisampled against analytic, and the field's single inline
  reflection ray meets the rim-compressed mirror image a mesh's supersampled
  deferred layer averages; the rim's own shading agreement is pinned separately
  by `aFieldRimShadesLikeAMeshRim`, see *Deferred ray-traced reflection AA*).
  `GlassRenderProbes.aGlassFieldAbsorbsLikeAGlassMesh` pins the
  agreement, the independence from what stands behind, and the absence of banding
  (verified red against the unfixed walk *and* against the ringing march).
- **Documented v1 envelope:** the refraction trace is inline (single-ray) even
  when reflections run deferred; refracted content is usually minified, so
  aliasing stays acceptable where a mirror's would not (revisit if glass
  shimmer shows up in motion). A traced hit does not re-enter transmission, so
  glass seen in a mirror or through other glass reads as an opaque shiny body,
  and glass still casts an opaque shadow (a transmission-aware shadow term is
  a follow-up). Roughness on the traced path blends toward the env-refraction
  sample by the reflection wrapper's constants (a single ray cannot frost).

### Clearcoat and sheen

The two layered lobes on the physically-based finish (`Material.clearcoat` /
`Material.sheen`), pure shading additions in `meshLitColor` + `ollin_pbr_ibl_ambient`
with no new pipeline. Both branch on their packed fields, so a material carrying
neither shades byte-identically (verified against the whole snapshot suite).

- **Clear coat** is a second Cook-Torrance lobe: GGX at the coat's own perceptual
  roughness, the cheap Kelemen visibility `1/(4·LoH²)`, Schlick Fresnel at a fixed
  0.04 (an IOR-1.5 lacquer film). The base's direct + ambient terms scale by
  `1 − Fc` (the energy the film reflects away), and the base's F0 re-derives for a
  coat-to-surface interface, `((1 − 5√f0)/(5 − √f0))²` blended by the coat
  intensity. **That remap sends the default dielectric 0.04 to exactly 0** (an
  IOR-1.5 base under an IOR-1.5 film has no interface), which is physically right
  and practically surprising: a fully-coated rough dielectric *loses* its broad
  base specular and gains a narrow film highlight, so its disc-mean brightness
  goes *down*. The render probes compare **peak** brightness for exactly this
  reason (the first mean-based drafts failed on correct physics).
- **Sheen** is the inverted-alpha sine distribution ("Charlie") with the cloth
  visibility denominator, no Fresnel, tinted directly by the sheen color (strength
  premultiplied into the packed rgb; roughness in w). Layering follows the
  directional-albedo scaling: the base scales by `1 − max(tint)·E(NoV, roughness)`
  and the lobe itself is scaled by E in the ambient, which is what keeps a strong white
  sheen from adding energy out of nowhere.
- **The sheen LUT** carries E: there is no closed form, so it bakes by numerical
  integration (uniform hemisphere, 1024 samples) into a 64² `r16Float` texture,
  `ollin_ibl_sheen_lut` in the IBL bake family. It is environment-independent but
  needed under plain lights too, so it triggers off the frame's *materials* rather
  than the environment bake: `ensureSheenLUT` sits beside the three `resolveIBL`
  call sites and scans the drawer's batches for a nonzero packed sheen color. It
  binds at **fragment texture 12 on every `meshLitColor` carrier** (solid /
  textured / raymarch / half-res), stand-in strip otherwise, the LTC/IES/cookie
  discipline.
- **Area lights:** the coat runs a second LTC fetch + integral at the coat
  roughness (its norm + average-Fresnel split evaluated at F0 0.04), gated so a
  coatless material pays nothing; the sheen lobe is too broad for the GGX-fitted
  tables, so it takes `E · diffI`: the directional albedo times the panel's
  exact cosine integral, honest because a near-Lambertian lobe's response to a
  panel *is* its albedo times the cosine-weighted solid angle.
- **Ray-traced reflections:** the coat's ambient gather reuses the frame's traced
  radiance (same mirror direction; the coat is usually the smoother lobe, so the
  traced scene beats a second prefiltered sample; no second trace is cast). Sheen
  keeps the prefiltered environment sample under RT (a wide lobe's honest
  integral). The RT *hit* shade carries neither lobe (per-vertex data has no
  room for them), so a coated or sheened surface seen in a mirror shades as its
  base material there, the glass-in-glass envelope's sibling.

### Subsurface scattering (the separable diffusion)

`Material.scattering` / `scatteringRadius` / `scatteringColor` (presets `.skin(radius:)`
/ `.marble(radius:)`) is real subsurface scattering as a screen-space diffusion,
written from the published separable-subsurface-scattering technique (credited in
`ATTRIBUTION.md`): the exact 2D diffusion kernel is well approximated by two 1D
convolutions, so the whole effect is two fullscreen passes over the linear
pre-tonemap frame, inserted between the geometry resolve and the whole-frame
`postProcess` filters in every render path (live, headless/export, and the GPU
benchmark; the accumulation surface and the texture hand-off keep their existing
no-deferred-pass envelope). The stage is content-gated: a frame with no scattering
material returns the resolved texture untouched and encodes nothing.

- **The kernel is built on the CPU** (`MetalRenderer.scatterKernel`, nonisolated,
  cached by quantized profile): 25 taps whose offsets are importance-distributed
  (sign-kept o² over ±3 profile units), each weighted by the trapezoidal span it
  covers times the diffusion profile there. The profile is the published
  sum-of-Gaussians fit of measured skin reflectance, one channel's curve reused
  for all three and stretched per channel by `scatteringColor` (which is what
  makes one curve serve skin, marble, or a green jade); the fit's narrowest term
  is direct bounce, accounted by `scattering`, so it's dropped. Weights normalize
  to unit sum per channel, then the un-scattered share folds back into the center
  tap: energy-conserving at any strength, and *exactly* the identity at strength
  0. Pure function of (falloff, strength) → deterministic exports and a cache
  that never invalidates. Up to 8 distinct profiles are packed into one frame's params rows
  (extras reuse the last, noted once).
- **The mask pass** is a dedicated mesh re-encode (the `encodeMeshNormals` /
  reflection-G-buffer pattern: never a second attachment on the shared geometry
  pass), single-sample, writing (projected step in uv units of the height axis,
  mark, view-space depth, profile index) with its **own depth**, so every solid
  mesh rasterizes and an occluder in front of a scattering surface suppresses it.
  Its pipeline (`isScatterMask`) has **blending off**, load-bearing: the alpha
  channel carries the profile *index*, which `.normal` blending would multiply
  into the color channels (a profile-0 surface would write rgb × 0). Matcaps
  occlude but never scatter (they bypass lighting and material entirely);
  wireframes and the grid chrome are skipped, matching the sibling passes.
- **The blur** (`ollin_sss_blur`, one fragment, two encodes with the direction in
  the params) early-outs on unmarked pixels, so within a scattering frame every
  non-scattering pixel is **bit-exact** (a linear sample at an exact texel center
  is the texel; pinned by the probes, not a tolerance). The step is the mask's
  projected radius over 3 (the kernel offsets span ±3), the horizontal pass
  divided by the aspect; ortho projections skip the depth division (detected by
  `projection[3][3] == 1`, exact for ortho).
- **The depth-gap guard is measured against the scattering radius, not a
  screen-space constant.** The reference implementation's guard
  (`300 · dtpw · width · Δd`) is *proportional* to the radius, tuned for a radius
  around 1% of the object: at any larger radius it saturates on the surface's own
  curvature and silently turns the whole blur off (the first probe render showed
  a max channel diff of 5/255, a real bug). Ollin's guard un-projects the mask's
  step back to a world-space radius (`2 · step · wFactor / P11`) and cuts a tap
  fully at four radii of depth gap, which is exactly the reference's behavior *at
  its own scale* made scale-invariant. Background taps read the cleared depth 0,
  a hard gap by construction, so background never bleeds into a surface; the blur
  writes only marked pixels, so the glow never leaks past the silhouette either.
- **`OllinMaterial` grew 192 → 224** (`scatter` float4: falloff ratios raw, not
  linearized, since they're relative widths, floored 0.001 against the kernel's
  per-channel divide; radius in w; plus `scatterStrength` and explicit tail
  pads). The lit mesh fragments read the fields only inside the transmittance
  branch below (gated on `scatterStrength > 0`, so every existing shading path
  is untouched); the batch's `finish` is how the mask pass and the
  kernel collection see them, and the existing material-change batch break means
  no new break rule.
- **The transmittance term** (the translucency half: light through a thin backlit
  body, written from the published shadow-map translucency technique, credited in
  `ATTRIBUTION.md`) lives in `meshLitColor`'s punctual loop, not the blur: only
  the shadow-casting light transmits, because its depth is the one thickness
  gauge the frame has. Thickness per caster kind: a **2D map** (directional/spot)
  projects the receiver shrunk two map texels along its normal (the silhouette
  fix, made scale-invariant the way the shadow biases are) and **gathers the
  transmittance over the diffusion's own entry footprint** (`transmitGather2D`;
  the light-space gathering of the translucent-shadow-maps work, credited in
  `ATTRIBUTION.md`): a fixed 13-tap equal-area spiral spread laterally in
  *profile units* (one unit = a third of the scattering radius, taps out to 2.4),
  each tap's occluder depth read through the manual bilinear whose corners
  linearize *before* blending (`transmitOccluderDistance`; the plain sampler is
  nearest, and a nearest tap terraces a steep thickness gradient into texel
  bands; blending perspective depths first bends the ramp; depths convert to
  world via `OllinLighting.shadowLinearize`, the caster projection's
  [2][2]/[3][2] packed by `makeLighting`, of which the PCSS ratio needs only
  [2][2] and an absolute distance both), and the same five Gaussians as the slab
  profile summed over each tap's 3D through-body path (depth² + lateral², both
  per-channel falloff-stretched), **normalized per Gaussian so a
  constant-thickness slab reduces exactly to the slab form** (which is why the
  flat-slab probes and snapshots did not move). The gather exists because a
  single-tap read **re-exposes the caster mesh's own tessellation** at grazing
  light angles: the smooth interpolated normal hides the facets in every N·L
  term, but a depth *difference* through the steep transmit exponential does
  not, and near the light-space silhouette the facet-truth error is amplified to
  a visible fraction of the scattering radius (the 2026-08-11 audit's
  "transmittance stripes", first misattributed to shadow-texel quantization
  until a 3x-tessellated twin erased the bands while the texel math said 0.6
  px/texel). Two details are load-bearing: the footprint is sized by the
  **scattering radius in world units, never in map texels** (a texel-sized
  filter cannot span a facet), and the spiral **rotates per point by a
  world-anchored hash** at a fraction of the footprint scale (13 taps quadrature
  a depth field with facet steps in it, and a fixed spiral leaves that error
  spatially structured, a blocky moire against the tessellation; the rotation
  turns it into fine surface-glued noise the screen-space diffusion blur
  absorbs, and world-anchoring keeps it still under a static camera and
  identical between two renders of one frame). Measured on the distilled audit
  scene (row-mean detrended residual over the terminator band): 1.36 mean / 3.8
  peak striped, 0.44 / 1.6 gathered, a 96x48-tessellated reference at 0.15-0.33
  either way, pinned red-first by `grazingLightLeavesTheTerminatorSmooth`. What
  remains after the gather is the polyhedron's *own* translucency at the
  profile's genuine lateral resolution: the narrow Gaussians (the thin-body
  transport) have footprints a fraction of the scattering radius and cannot
  smooth entry structure wider than themselves, so a coarsely faceted body under
  a tight radius keeps soft facet shading, and the answer there is tessellation
  (the fine-tessellated twin is the reference, not a wider filter). A
  **cube** caster subtracts its stored linear distance; a **ray-traced point**
  caster traces one closest-hit ray from just inside the surface
  (`meshRTThickness`, computed by the solid/textured fragments under the same
  material gate so a non-scattering surface never pays it); both keep the single
  read (the audit measured the 2D path; the same gather ports if either shows
  the exposure). The profile `ollin_sss_transmit` is the **closed-form
  slab integral of the same Gaussian sum the diffusion kernel tabulates**
  (integrating each normalized 2D Gaussian over the plane at depth s leaves
  w·e^(−s²/2v)), kept in sync with `scatterKernel` by hand, with world thickness
  entering in the kernel's own units (×3/radius, the ±3-unit span). The
  irradiance is the paper's reversed-normal wrap `max(0.3 + dot(−N, L), 0)`, so
  lit faces take nothing (no double count with the diffuse) and the handoff
  crosses the terminator smoothly. Three placements are load-bearing: the term
  reads the **pre-shadow attenuation** (a backlit surface stands in its own
  body's shadow, and dimming by that factor would erase exactly the light being
  transported) while still using the cone gate and the shaped/tinted light
  copy; it lands **before the screen-space blur**, which diffuses it together
  with the reflectance (the published treatment); and it multiplies by
  `scatterStrength`, the PBR metallic kill, and `diffKeep`, so every gate that
  makes the blur inert makes the term inert too (no caster, strength 0, a field
  carrier's own `fieldShadow`, an area-panel caster: each path byte-identical,
  probe- and suite-pinned). One practical note: the transmitted rim on a deep
  body is a tight bright feature, so an overdriven radius prints the blur's tap
  comb around it exactly as it does around a tight highlight; at documented
  radii it cannot show (measured on the slab probe: ripple period matched the
  kernel's outer-tap spacing, and an honest radius flattened it to the dither
  floor).
- **Envelope:** solid and textured meshes on the main canvas. A mesh drawn into a
  render target and a raymarched SDF field each degrade to their plain shading
  with a one-time note (the RT-reflections main-canvas precedent); 2D content
  drawn *over* a scattering surface in the same frame sits on marked pixels and
  is blurred with them (the reference approach shares this; a scattering frame
  is a 3D scene in practice). Specular on a scattering surface is blurred with
  the diffuse: the reference *demo* actually renders speculars to a separate
  target and re-adds them after the blur, and that separation is the known
  upgrade here. Its visible form: a radius several times the documented
  guidance, on a polished surface with a tight grazing highlight (a huge HDR
  near-delta), prints the kernel's 25 discrete taps as a faint replica comb
  around the highlight. At documented radii the tap gaps stay near a pixel and
  it cannot show (probed live at 1.6K; the first example draft overdrove its
  radii 3 to 5x and combed).

---

## Normal mapping (the surface-map tier's foundation)

The first slice of the advanced-materials arc: a tangent-space normal map on the
textured-mesh path, plus the tangent machinery every later surface map (parallax
occlusion, detail maps) builds on.

**The pipeline is a twin, not a branch.** `ollin_mesh_nm_vertex` /
`ollin_mesh_nm_fragment` mirror the textured pair verbatim with the perturbation
added, selected by a `normalMapped` variant on the mesh `PipelineKey`. The shipped
textured functions are untouched, so unmapped textured frames are byte-identical
*by construction* rather than by a verified gate (the fast-math codegen rule:
growing a shipped function's control flow re-contracts its expressions and moves
ulps; a twin can't). The cost is ~40 duplicated fragment lines, taken knowingly.

**The vertex tangent is stored in the spare 8 bytes.** `OllinMeshVertex` had 8 bytes of
tail padding reserved since the 3D work; the tangent packs into it as four
Float16s (`OllinHalf4`: `half4` under `__METAL_VERSION__`, `simd_ushort4` of bit
patterns on the CPU side), so the stride stays 64 and every existing path is
layout-identical. Direction xyz + handedness w, world-space (the model's linear
part baked in at `drawMesh`, like a surface direction, not the normal's
inverse-transpose), renormalized in the fragment after interpolation.

**Tangents are MikkTSpace, vendored, with the glTF sign.** `External/CMikkTSpace`
(zlib-style notice) is the reference generator the glTF spec names and normal-map
bakers target; matching its exact basis is the point, which is why it's bundled
rather than reimplemented. `Mesh.generatingTangents()` runs it over the indexed
mesh through corner callbacks and folds the per-corner results back per vertex by
exact bit equality (MikkTSpace welds internally, so grouped corners return
bit-identical values); a genuine disagreement at a shared vertex (a mirrored-UV
seam) splits it, retargeting the indices, deterministically (face-order
processing, encounter-order appends).

**The handedness sign was measured, not assumed, and it's the arc's one real
finding.** MikkTSpace's raw `fSign` makes `fSign · cross(N, T)` point along
**+∂p/∂v** (down the map image) on every surface (measured on a hand-built quad
and the sphere generator's front, both `b·∂p/∂v ≈ +1`). The glTF spec's normal
maps are explicitly green-up (+Y), which needs the shader bitangent pointing
image-*up*, so the ecosystem stores `w = −fSign` in files (the flip the
mikktspace-wasm / glTF-transform docs warn about). Ollin stores the negation too,
so authored glTF `TANGENT`s pass through unchanged and generated tangents land in
the same convention. Verified three ways: the Khronos `NormalTangentTest` +
`NormalTangentMirrorTest` render with every mirrored-handedness tile matching the
real-geometry reference column; a green-up dome map on a plain generated quad
reads as raised domes; and `MeshTangentTests` pins the green-direction contract
as a render probe. Two dead ends worth remembering: a "missing terminator" chased
for an hour was perspective parallax between spheres at ±x (byte-identical when
re-rendered at matched positions), and a winding-vs-normals-inconsistent
hand-built test quad flips `fSign` and will gaslight any convention probe built
on it.

**The map is data, not color.** `Image.linearTexture(for:)` is the raw-bytes twin
of the color texture cache (`.SRGB: false`, or a plain `.rgba8Unorm` upload for
authored pixels): decoding a normal map's bytes as sRGB color bends every stored
direction (127 would decode to 0.187 and tilt the whole surface by −0.6 along
t+b). Separate cache slot, so one `Image` can serve both reads.

**Gates.** `MeshMaterial.normalScale` doubles as the off switch and the per-batch
gate: the drawer sets the finish uniform's `normalScale` (a claimed
`OllinMaterial` tail pad) only after verifying aligned uvs *and* tangents, encode
keys the twin pipeline off it, and 0 routes down the plain textured pipeline
byte-identically (probe-pinned). A map attached without its basis degrades
honestly: geometric normals plus a one-time note. Shading uses the bent normal
everywhere (all shading models, punctual + area lights, IBL, GI); the ray/offset
machinery (field shadows, traced shadows, transmittance thickness) keeps the
geometric normal, a map being surface detail, not surface position. Skinned
meshes pose tangents with the blended joint matrix's linear part, so relief
stays put on a bent limb.

---

## The PBR map set (surface maps)

The second slice of the advanced-materials arc: metallic-roughness, occlusion,
and emissive maps on `MeshMaterial`, sampled per pixel on the mesh path. One
pipeline serves the whole set (`ollin_mesh_maps_fragment`, sharing
`ollin_mesh_nm_vertex`), with the normal-map bend folded in behind its own
`normalScale` gate, so map combinations don't multiply pipelines.

**Per-pixel metallic/roughness forced the tail question, and the answer is the
biggest hand-synced twin in the codebase.** `meshLitColor` (~470 lines) and
`ollin_pbr_ibl_ambient` (~140) read `mat.metallic`/`mat.roughness` from the
per-batch constant buffer at a dozen sites spread through the LTC area
lights, the punctual Cook-Torrance branch, the transmission diffKeep, and the
IBL split-sum; a per-pixel value cannot be passed through a `constant` reference, and growing
the shipped functions with override parameters is the fast-math re-contract
gamble the codegen rule exists to forbid. So `meshLitColorMapped` and
`ollin_pbr_ibl_ambient_mapped` are *mechanical substitution copies*: the body
verbatim, every `mat.metallic` read replaced by `pxMetal`, every
`clamp((float)mat.roughness, …)` by `clamp(pxRough, …)`, and the flat ambient
terms scaled by `pxAO`. The sync rule is stated at both definitions (edit the
original, re-copy, re-substitute) and the copies were made by scripted
extraction + diff review, not retyping. The cost is real and was taken
knowingly: the alternative (parameterizing the shipped tail) risks ulp movement
across every 3D frame ever rendered, and the aerial/GI precedents both paid the
twin price for the same reason.

**Composition semantics are glTF's.** The sampled channels *multiply* factors:
`px = finish(material(_:)) × MeshMaterial.metallic/roughness × map`, with the
drawer folding the mesh material's own factors into the batch finish only when
the map is bound (`mrGate`), so a loaded file's `metallicFactor × texture`
intent survives under the sketch's `material(.physicallyBased(metallic: 1,
roughness: 1))` and lower finish values stay an artistic scale on top.
Occlusion is indirect-only (`1 + strength·(ao − 1)` on the flat ambient inside
the twin tail, and on the IBL ambient / GI bounce / flat-IBL adds at the
fragment level; direct light untouched, the spec's rule, probe-pinned by the
direct-light byte-equality counterfactual). Emissive is `factor × sRGB map`,
added after all lighting and before the atmosphere, so fog veils emission like
any other surface radiance; a constant factor with no map routes down the same
pipeline against a white stand-in, which is also how a black emissive factor
with an emissive texture correctly emits nothing (the glTF default). The gates
are carried in the finish uniform like `normalScale` (`mrGate`, `occlusionStrength`, and
an `emissive` float4 claiming the last two tail pads plus one appended row),
zero on every other batch, so unmapped frames keep their exact codegen by
construction.

**The surface's own color as its emission.** `emissiveColor` is one color a
mesh. A vertex-colored mesh under a white factor gets the same light added
everywhere and washes toward white; under a colored factor it takes one tint
whole. The second emission term, `emissiveIntensity`, multiplies the fragment's
resolved base instead: the fill times the base color times the vertex color
times the texture, with the detail and decal layers already applied. Each pixel
then glows in the color it is drawn with. The term is a float after the
`emissive` float4 in the finish, it routes down the same surface-mapped pipeline
under the constant factor's uv rule, and it joins the same `c.rgb += emissive`
add, so a zero changes no byte. The tracer computes the same product on the
hit's albedo after the base map has multiplied it, and keeps the result in a
field of its own (`selfEmissive`). The emissive-triangle CDF weighs the factor
alone, so next-event estimation never samples this share of the glow. Adding it
to the MIS-weighted factor term would therefore lose part of it at every hit
where a next-event pass had competed, so the hit adds it at full weight instead.
That is the same rule a glowing instanced copy follows, and it is unbiased for
the same reason: only one strategy can find this light. The USD writer has one
emissive slot per material. It writes `factor + intensity × baseColor` in linear
there, and for a vertex-colored mesh with no other emission it also connects the
slot to the vertex-color primvar reader the diffuse slot uses. The value stays
beside the connection, as the diffuse slot does it, for a reader that only looks
at values.

**Sampling color spaces split by meaning.** Metallic-roughness and occlusion
are data (`Image.linearTexture(for:)`, the raw-bytes cache slot the normal map
introduced); emissive is color (`texture(for:)`, sRGB-decoded). All four map
slots are always bound on the maps pipeline (real texture or the white
stand-in), the gates keeping unbound slots unsampled and validation happy.

**USD reads the set through connections, with a repack fallback.** A preview
surface's `metallic`/`roughness`/`occlusion` inputs resolve to `UsdUVTexture`
taps (image + the tapped output channel + the texture's own scale/bias). Taps
already in the standard ORM layout (one image, roughness at g, metallic at b)
reuse the image directly (the `USDAssetStore` now caches decoded images by
authored path precisely so two taps at one file compare `===`), and anything
else repacks channel-by-channel into that layout at load (untapped channels
white, the multiply identity, so an authored constant factor still carries).
Factors round-trip through the taps' per-channel `scale`, occlusion strength
through scale s / bias 1 − s on its channel (exactly `1 + s·(ao − 1)`), and the
normal map's strength through its decode: scale (2s, 2s, 2, 1) / bias (−s, −s,
−1, 0) is the exact spelling of `s·(2c − 1)` on x/y, so `normalScale` survives
a round trip losslessly and the plain (2, 2, 2, 1) decode reads back as 1. USD
normal maps also fold in here (there is no authored-tangent attribute in USD),
generating the MikkTSpace basis after the skinning pass with glTF's
vertex-split guard: a generation that would split a mirrored-UV seam is
accepted only on nodes with nothing (skin/morph/part arrays) aligned to the
vertex order. The writer mirrors every one of these encodings, the reader's
stated inverse, and `usdchecker --arkit` accepts a package carrying the full
set (test-pinned).

**Envelope.** The maps live on the primary surface: a reflection hit still
shades from the per-vertex metalness/roughness the `OllinMeshVertex` w slots
carry (constant per batch), and emission isn't seen in mirrors, the same
per-hit envelope every surface map has. Matcap and wireframe ignore the set
(one bakes its lighting, the other draws edges).

---

## Height maps: parallax occlusion and displacement

The third slice of the advanced-materials arc: a height map on `MeshMaterial`
(`heightTexture`/`heightScale`), read two ways from one datum (**white is the
authored surface, darker carves in below it**), so the shading fake and the
real geometry agree about where the relief lives.

**Parallax occlusion is built into the maps fragment, not a new twin.** The march
lives in `ollin_parallax_uv` and a `mat.parallax`-gated branch at the top of
`ollin_mesh_maps_fragment`; that fragment is slice-2 code, exempt from the
verbatim rule (its own header says it may branch freely), so no third copy of
the shading tail was needed. The shifted uv feeds *every* map sample below it
(base color, normal, metallic-roughness, occlusion, emissive), which is the
whole point: the maps move together the way a carved surface's would. The
drawer routes a height-mapped mesh to the surface-mapped pipeline
(`usesSurfaceMaps` gained `parallax > 0`), verifies uvs + tangents like the
normal map (degrading honestly without them), and packs `finish.parallax`
only then, so the gate doubles as the encode and shader switch and mapless
frames keep their codegen. Verified empirically after the growth: the full
snapshot suite holds unrecorded and a guide-figures run has zero churn, so
the branch didn't move ulps on gate-off frames. Texture slot 21, sampled as
data (`linearTexture`), white stand-in otherwise.

**The march is the published two-phase intersection.** A linear search steps
the eye ray down through the normalized relief volume (8…32 layers by view
angle, more at grazing where each step crosses more texels), then one secant
step treats the field between the last two samples as a straight line and
lands the crossing. Height samples inside the loop use explicit `level(0)`:
the loop's exit varies per pixel, where implicit derivatives are undefined
(the data texture carries no mips anyway). A white entry sample returns the
uv untouched (the exact null, probe-pinned byte-identical on the same
pipeline), and the `1/e.z` stretch is floored at 0.1 so grazing rays smear
boundedly (the technique's silhouette envelope, documented rather than
hidden).

**The uv step's signs are the tangent-frame fact from slice 1, derived rather
than assumed.** With the eye projected onto the frame as
`e = (V·T, V·B, V·N)`, one unit of relief depth shifts the sample by
`scale/e.z · (−e.x, +e.y)`: `u` *against* the eye (a recess shows its far
side), and `v` with the **opposite** sign because the stored-handedness
bitangent `w·cross(N, T)` points up the map image while `v` grows down it,
the same green-up convention the normal map measured. The direction probe
pins both axes against a flat control (a recessed dot must shift toward the
camera on screen), and a sign-flip sabotage fails each axis independently.

**Parallax is shading only, and the tests state it.** Depth, silhouettes,
shadow rays, transmittance thickness, and reflections all keep the flat
surface; `parallaxNeverMovesTheSilhouette` pins the coverage mask equal
against the flat control, which is also the teaching contrast with
displacement.

**Displacement is the weld-aware CPU sibling.** `Mesh.displaced(by:scale:)`
samples the same image at the uvs (clamp-to-edge bilinear over the red
channel, mirroring the GPU sampler) and moves vertices along averaged
normals by `(h − 1) · scale`, white pinned at the authored surface, the
shared datum. It works per *welded position group* (the `MeshWelding` seam):
one averaged normal and one averaged height per group, so the flat-shaded
generators' coincident corners move as one and a uv seam cannot tear the
mesh open (the sabotage that samples per-vertex instead fails the seam
test). Smooth normals recompute over the welded displaced surface and
republish per original vertex; a carried tangent basis regenerates
(MikkTSpace) for the new shape.

**USD carries the map; glTF cannot.** glTF core and the ratified
`KHR_materials_*` extensions have no height-map slot, so the map is
Ollin-authored API on that side. The USD writer feeds the preview surface's
`displacement` input from a `UsdUVTexture` tap authored as scale `s` / bias
`−s`, the exact spelling of `s·(h − 1)`, so a renderer that really
displaces carves the same relief, and the reader recovers
`heightScale` from the tap's channel scale (a lossless round trip,
validator-checked with `usdchecker --arkit`; a constant, unconnected
`displacement` is a uniform offset with no relief in it and stays unread).
The reader also generates tangents for a displacement-connected material,
the normal-map rule.

---

## Triplanar projection

The fourth slice of the advanced-materials arc: texture for meshes that have
no uvs at all (a marched isosurface or metaball skin, a grown or reconstructed
shell, a subdivision result). The base texture is projected flat along each of
the three world axes and the three reads blend by how squarely the surface
faces each axis; `MeshMaterial.triplanarScale` is the tile's world size, and
`Mesh.triplanarTextured(_:normal:scale:)` is the whole attach (no uvs, no
tangents, none generated).

**It is built into the surface-mapped fragment, not a new twin.** The projection lives
in `ollin_triplanar_surface` and a `mat.triplanar`-gated branch at the top of
`ollin_mesh_maps_fragment`; that fragment is slice-2 code, exempt from the
verbatim rule, and the exemption was re-verified empirically after this growth
(the full snapshot suite holds unrecorded and a guide-figures run has zero
churn). The gate packs as tiles per world unit (`1 / triplanarScale`) into the
`OllinMaterial` tail pad the parallax slice left, so the struct's stride is
unchanged and every gate-off batch keeps its exact codegen. The drawer routes
a triplanar mesh to the surface-mapped pipeline unconditionally (no uv or
tangent verification, the point of the feature), forces the uv-mapped gates
off, and refuses the rest of the map set with a one-time note: v1 projects
the base color and the normal map only, and parallax is the technique-level
cut (its march walks one uv space; a triplanar surface has three). The
fragment samples through its own repeat-addressing constexpr sampler, since
the mesh sampler clamps.

**The sampling space is world space, decided rather than inherited.** The
drawer bakes the model matrix into vertex positions CPU-side, so the fragment
only has post-transform `worldPos`; carrying a pre-transform position would
mean widening the 64-byte mesh vertex or a per-batch inverse matrix, and the
world anchor is also the technique's classic behavior (abutting meshes
continue one another's pattern, which the example's box cairn teaches). The
envelope is stated everywhere it matters: a mesh animated through the
transform stack slides through the standing pattern, and
`theProjectionIsAnchoredToTheWorldNotTheMesh` pins it as a test. A `space:`
parameter can widen the API later without breaking it.

**The per-axis frames are derived in Ollin's own conventions, not
transliterated.** Each axis's uv is chosen so the picture reads upright and
unmirrored from either side of the surface: v runs *down* the image (the
top-left texture origin, so both wall projections take `-y` and the top one
takes `+z`), and u flips with the sign of the normal's axis component (the
back of a wall would otherwise mirror). Each frame is right-handed
(right x up = facing), which is what lets the normal map keep its ordinary
green-up decode. The blend weights are the normal's components to the fourth
power, renormalized: a fixed sharpening, so a glancing projection (whose
texture smears into streaks) hands over quickly. The normal map combines per
plane by expressing the geometric normal in the plane's own frame, adding the
map's tangent-plane push onto it, and multiplying the height components (the
whiteout combine), then carrying the result back to world space; a flat map
hands back exactly the geometric normal on every plane, so the bend vanishes
where the map does. One sign here was a probe-caught lesson: the u flip keys
on the *normal's* side, not the viewer's, so the back of a single-sided quad
(interpolated normal still facing away) legitimately mirrors, and the
unmirror test had to author a genuinely back-facing wall, the closed-mesh
case the flip exists for.

**Neither glTF nor USD has a triplanar slot** (checked core + ratified
extensions on one side, the preview-surface schema on the other; it's a
rendering technique, not a material property), so the projection is
Ollin-authored API only, and the spatial exporter's recorder notes that the
projected maps stayed behind and strips them, exporting the surface in its
plain color. `TriplanarTests` pins the feature against counterfactuals (the
no-uv mesh shows the picture, both wall orientations and the top frame read
upright and unmirrored, the 45-degree seam *mixes* the two projections
instead of hard-picking one, the projected normal map pushes lighting along
the frame axes with no tangent basis anywhere, the world anchor, the off
switch, determinism), with the sign flips, the hard-pick, the top-frame v,
and the green direction each sabotage-verified red.

---

## Detail maps

The advanced-materials arc's fifth slice, and the smallest by construction:
a finer second texture pair (`MeshMaterial.detailTexture` /
`detailNormalTexture`, attached by `Mesh.detailMapped(_:normal:scale:strength:)`)
tiled `detailScale` times across the base uv and applied in the surface-mapped
fragment behind its own gates, using the branch-freely exemption the parallax
and triplanar slices established (the shipped textured/nm fragments stay
verbatim; growing `ollin_mesh_maps_fragment` held the whole snapshot suite
unrecorded again).

**The color map is data with 128 gray the neutral.** The sample multiplies the
base as `mix(1, d·2, strength)`; sampled through `linearTexture` (no sRGB
decode), byte 128 reads 0.502 and the multiplier sits within half an 8-bit
step of the identity, so an author's mid-gray from any paint app is the
neutral. Reading it as color would put the neutral at sRGB ~188, which nobody
would guess. (A flat-128 map is therefore *near*-identity, within the
dither's step; the exact byte-identity switch is `strength: 0`, which the
drawer treats as "never raise the gates," routing down the plain textured
pipeline.)

**The normal blends by Reoriented Normal Mapping** (Barré-Brisebois/Hill),
so the fine grain follows the surface the base normal map describes instead
of overwriting it: with the detail flat, the algebra collapses to exactly the
base normal (the identity the overwrite blend fails, and the probe pins).
The detail branch *recomputes* the bent normal from scratch (one redundant
base-map sample, paid only when detail is on) rather than restructuring the
shipped base-normal resolve above it, keeping that code textually untouched.
Strength scales the detail's tangent-space xy before renormalizing, the
standard fade.

**Packing:** `detailScale`/`detailAmount` claimed the two free tail pads
and `detailGates` (color-bound / normal-bound flags) appended one row,
growing `OllinMaterial` 256 → 272 (offsets 248/252/256; the drawer packs
them only for a verified uv-mapped mesh, never with triplanar, so the union
gate is just `detailScale > 0` in `usesSurfaceMaps`). Textures 22 (detail
color) and 23 (detail normal) joined the mesh path, both data reads, white
stand-ins bound when a gate is down. The maps carry no mips, like the whole
mesh texture path, so a very high tile count shimmers under minification:
the documented envelope, with the example keeping its default in the range
the framing shows. Neither glTF nor USD has a detail slot; the spatial
exporter notes-and-strips (the triplanar treatment). `DetailMapTests` pins
tiling-at-scale, the 128 neutral, the RNM identity and composition, and the
strength-0 byte-identity, with the tiling and reorientation
sabotage-verified red.

---

## Projected decals

A picture stamped through an oriented box onto whatever lit mesh surfaces
sit inside it: `Decal` (the LightCookie construction: one 512-square
premultiplied resample at wrap time, content hash, plus the source aspect so
a placement can default its height) and the per-frame `drawDecal(_:at:...)`
placement, capped at `OLLIN_MAX_DECALS` (8) with repeated placements of one
image sharing a texture layer. The CPU builds each box's world→unit-box
transform once (`Drawer.placeDecal`: the light-cookie projector frame with
its measured `right = axis × ref` order, three rows over the box size), so a
fragment pays one row-dot per axis, an inside test, a facing fade, and one
array sample per decal.

**The routing is the design's crux, and it keeps byte-identity by
construction.** The decal loop lives only in the surface-mapped fragment
(the branch-freely one); a frame with placed decals routes *every* lit mesh
batch through that pipeline at encode time (`meshSurfaceMapped ||=
placedDecals present`; wireframe, matcap, and the grid keep their own), so a
plain solid or textured mesh receives a stamp with its map gates all zero,
and a frame with no decals keeps its exact prior routing, no shipped
fragment grown at all. The trade, stated in the docs: a frame *with* decals
shades its plain meshes through the maps fragment (equivalent shading,
different codegen), which is the feature's own envelope, not a regression
surface. Solid meshes use the nm vertex safely because `OllinMeshVertex()`
zero-fills, so unwritten uv/tangent fields are deterministic.

**In the fragment,** box space is `[-0.5, 0.5]³` with +y the image's top
(the cookie orientation rule; v = 0.5 − p.y), the composite is premultiplied
with one scale on both halves (`base·(1 − s.a·k) + s.rgb·k`, k = opacity ×
fades) so a fade can't fringe the sticker's edge, and two fades guard the
box's own geometry: a facing fade (`smoothstep(0.05, 0.35, N·−axis)`) that
melts the stamp off surfaces edge-on to the projection instead of smearing
it down them, and a depth-end fade over the last tenth of the box so a
receiver near the far planes never hard-clips. The decal list is carried in its own
small uniform (`OllinDecals`, fragment buffer 2, count-gated) beside a
content-hash-cached `rgba8Unorm_srgb` texture array at texture 24 (the
cookie array's twin, `shapingStandIn()` when empty). Decals modify albedo
only, before the per-pixel surface resolve, so the stamp takes the surface's
finish and every shading model downstream is untouched; reflections show the
undecaled base (the surface-map envelope), and fields/particles/point clouds
sit outside by design. `DecalTests` pins placement against the renderer's
own projection (a drawn marker at the same world position), the depth bound,
the edge-on fade, call-order compositing, the solid-mesh routing, and
transparency, with the translation sign, the depth clip (both guards at
once), the facing fade, and the loop order each sabotage-verified red.

---

## Deferred ray-traced reflection AA

The ray-traced reflection is one closest-hit ray per reflective pixel, which makes
a *reflected* silhouette (a pillar's mirror image meeting a polished floor, the
"reflection horizon") a 1px-hard edge no spatial filter can soften: the edge lives
inside the traced image, past where MSAA can reach. The fix is stochastic
supersampling of the reflection layer, restructured from an inline per-fragment
trace into a small deferred chain (`encodeReflectionPass`, main canvas only):

1. **Reflection G-buffer.** Re-encode the main canvas's solid mesh batches
   (the mesh-normal pass's dedicated-re-encode pattern; wireframes and the grid
   chrome skipped, matching the accel) into a single-sample MRT pair (world
   normal + coverage, and the per-vertex metalness/roughness the baked
   `OllinMeshVertex` w slots carry) plus its own `depth32Float`. Single-sample
   on purpose: the layer is supersampled by jitter, not by MSAA, so it costs a
   fraction of the SSAO normal pass's readable-MSAA memory.
2. **Trace** (`ollin_rt_reflect_trace`, RT-gated in ShaderCombine so it can call
   the Shader3D helpers; segment order): per pixel, reconstruct the surface
   point through `inverseViewProjection` at a **sub-pixel-jittered** NDC (the
   pixel's own full-precision depth), reflect the eye ray off it, and trace via
   `ollin_rt_reflection_trace`, the hit-shading half shared with the inline
   `ollin_rt_reflection` (a thin wrapper over it), so the two paths cannot
   drift. Output is premultiplied by hit (miss = 0), so the average's alpha
   carries fractional scene-vs-environment coverage. The jitter is an R2
   sequence phase-rotated per pixel (`hash12`), a pure function of
   (pixel, index), so exports reproduce bit-exactly. With `glossyReflections()` the
   ray also leaves along a microfacet drawn from the surface's own distribution
   (see *The glossy lobe* below); without it, glossiness stays with the mesh
   fragment's roughness blend toward the prefiltered environment (the inline
   path's rule), because one ray cannot blur.
3. **Temporal resolve, live** (`ollin_rt_reflect_temporal`): reproject through
   the previous frame's view·projection (reconstructing the world point from the
   G-buffer's own full-precision depth rather than the normalized depth layer),
   rein the history in with a **variance-clipping box** (mean ± γσ over the
   current 5×5 neighborhood, the moment-based form), and blend as an EMA on
   `resolveSSRAlpha`-style tiers. Both γ and the blend follow the pixel's own
   reprojected motion: a still pixel earns a wide box (γ 6) and a longer memory
   (the tier's 0.88 becomes 0.97), a moving one falls back to γ 1 and the base
   tier. Three parts of that box are load-bearing, each measured on the glossy
   example's satin floor with a static camera: a **min/max clamp holds the
   accumulation at the single-frame speckle forever** (the box re-injects the
   current frame's noise into the history every frame; the standing grain reads
   double and the pattern decorrelates), the moments need **5×5, not 3×3** (the
   glossy layer's energy is sparse, and a window that catches no firefly reads
   σ 0 and crushes the history to a point), and the clip runs on the **color
   channels only** (alpha is near constant, so its σ-sized extent would drag
   every channel to the current mean through the clip's shared scale). The
   history is one `SSRHistorySlot` (`rtReflectHistory`), guarded by
   `statefulEncodeIsRepeat` so the live frame-grab can't double-step it,
   reallocated on a size change. A static camera reprojects to identity and the
   single jittered ray converges to the supersampled reflection (measured on the
   window: standing grain 1.37 → 0.43, frame-to-frame change 3.24 → 0.53, with
   0.38 the export's own figure); under motion the tight box bounds stale
   history and it degrades toward the single-ray look (full view-dependent
   reflection reprojection needs a depth history and was declined as
   impractical, per the published technique's own author).
4. **Headless/export supersample.** `image(of:)` runs the same chain with
   `supersample: true`: N deterministic jittered rays averaged **within the one
   frame** (perf 4 / default 8 / detail 16; export resolves `.detail`), no
   history slot touched. A single exported frame is anti-aliased with no warmup,
   a video export cannot flicker, and an export beside a running window never
   advances the on-screen accumulation, which is also why reflections need no
   `usesFeedback` warmup.

The lit mesh fragments sample the finished layer by screen position
(`position.xy · rtReflectionScale / texSize`, the fieldShadowScale rule, fragment
texture 7) when `rtReflectionDeferred` is set, compositing `hit.rgb + prefiltered
· (1 − hit.a)` through the same Fresnel/BRDF weighting, identical math to the
inline form when coverage is 0 or 1. **Render targets and the raymarched fields
keep the inline single-ray trace**: a render target's lighting never sets the
deferred flag, and the main encode hands the `.sdfGroup3D` batches their own copy
of the frame's lighting with `rtReflectionDeferred` cleared. That clearing is
load-bearing, and it was a defect before it was a rule. A field's pixels are not
in the deferred layer (its G-buffer re-encodes mesh batches only), so the
raymarch fragment passes a zero `deferredReflection` stand-in; while the full-res
inline field path still bound the shared lighting with the flag set, the
ambient's deferred branch composited `0 + prefiltered · (1 − 0)` and the traced
reflection silently no-opped for every field in a deferred frame. Nothing
crashed, and no snapshot moved, because no snapshot scene combines a field with
`rayTracedReflections()`. The visible symptom was the silhouette: at grazing
incidence the reflection is most of the picture, and the raw environment there
outshines the traced scene, so a field showed a 3-4px blown-white rim band plus one
dark pixel (the reflection sweeping a dark environment region just past the
body), while the mesh beside it, compositing the traced slab, descended smoothly.
The isolation is worth recording. A patch mean and the snapshot tolerance both
average the band away (it was found by eye); per-pixel scanlines showed it, and
the decisive instrument was a diagnostic return inside `ollin_pbr_ibl_ambient`
itself (NoV, prefiltered luminance, and `brdf.y` written out as RGB), which
showed the mesh and field paths agreeing on every shading input except the
prefiltered radiance, an order of magnitude apart at matched NoV. A 4096²
render settled that the band was not an unaveraged sub-pixel highlight (it kept
its angular width under supersampling), and a no-tracing render settled that it
was not field-specific (the mesh rim blows out identically without tracing:
that band is the honest picture of a hot environment at grazing Fresnel under
the clamp tone map, on either geometry). `GlassRenderProbes.aFieldRimShadesLikeAMeshRim`
pins the fixed rim with an edge-aligned scanline comparison, verified red
against the flag leak. The residual envelope: the rim compresses the mirror
image, so the field's single inline ray can read one bright pixel where the
mesh's supersampled layer averages (noted in `Docs/3D/Combining.md`). Measured against
a 2× supersampled ground truth, the deferred export lands ~32% closer (contact-
region RMSE) than the single-ray form, with the remaining delta shared with
everything else 2× supersampling touches.

### The glossy lobe

`glossyReflections()` trades the mirror ray for a *lobe*, which is the difference
between a satin floor showing a blurred sky and one showing a blurred room. Four
pieces, each of them load-bearing:

1. **The ray is drawn from the visible-normal distribution.** The trace builds a
   tangent frame at the surface, samples a microfacet through Heitz's routine
   (`ollin_rt_sample_vndf`, `alpha = rough²`, written from the paper) and
   reflects the view ray off it. At roughness near zero the drawn microfacet *is*
   the normal, so a polished surface keeps its mirror ray and every near-mirror
   frame keeps its look; a microfacet that would send the ray under its own
   surface is discarded rather than traced into the floor.
2. **The trace writes the ray beside the radiance.** A second attachment carries
   the world direction and the hit distance (0 = a miss, which has only a
   direction), because the resolve reuses a neighbor's hit *point*: a ray that
   found something close by leaves at a different angle one pixel over. The pass
   is MRT either way, so one fragment serves both paths and they cannot drift.
   The distance is returned through an out-parameter on `ollin_rt_reflection_trace` defaulted
   to null, which folds away for every caller that does not ask.
3. **The resolve is a ratio estimator over the neighborhood**
   (`ollin_rt_reflect_resolve`, written from the published stochastic
   screen-space-reflection resolve). Every pixel around this one sent a ray into
   a lobe that overlaps this pixel's, so each is a sample of this pixel's
   integral once re-weighted for the surface *here*: `D_local(H)·NoL` over the
   density the neighbor drew its own ray with (`G1(V)·D(H) / (4·N·V)`, the
   visible-normal density over the reflection operator's Jacobian). Dividing by
   the **summed weights rather than the count** is what keeps it unbiased where
   neighbors differ in roughness or face another way, and it is why the weights
   may carry any constant at all. Two behaviors fall out of the weight rather
   than a rule: a polished pixel gives an off-lobe neighbor a weight near zero
   and resolves to its own ray, and a rough pixel spreads over the whole
   neighborhood. Eight taps on a 1.25-texel ring, the pixel's own in the middle:
   both numbers are measured, and *wider is worse*, because a far neighbor's ray
   is mostly rejected and the reach buys more of the weight's swing than usable
   rays.
4. **Every surface along the chain is widened to the cone that reached it**
   (`minRough`, threaded through the trace, the hit shade, and the tail walk,
   defaulted 0 so the mirror path folds it away). This is the piece without
   which the feature does not ship: a satin floor's rays land on a mirror ball,
   read the one pinpoint light in it, and come back many times brighter than the
   ray beside them, which is white speckle over the whole floor. The cone travels
   at its **full width**; a fraction of it was measured and leaves the speckle in
   (grain 1.53 against 0.82 on the example's floor).

Two decisions worth not re-deriving. **Truncating the lobe toward its center was
tried and rejected**, though it is the published answer to the noise in a
microfacet distribution's tail: measured against a 256-ray reference it makes the
picture quieter and *less* right (2.4% error unbiased, 4.5% at bias 0.4, 6.2% at
0.7, where not having the feature at all is 7.0%). That trade was made for a
budget of one ray at half resolution; this path spends four times as many and
regularizes the path instead. And **the export runs the whole trace-and-resolve
pair once per ray** rather than looping rays inside the trace, because the ray
layer has one slot per pixel and cannot describe an inner loop's average; the
resolves accumulate into ping-ponged targets, each adding its own share. That
costs four times the mirror tier's rays (16 / 32 / 64 by quality), which is where
the grain stops reading as speckle at native size, and about half a second on a
1080-square frame.

The mesh fragment's composite changes with it: the traced layer now carries the
spread the surface's roughness calls for, so it stands on its own up to the
sketch's ceiling (0.75) and fades to the environment across the last fifth of the
way there, rather than the mirror path's `[0.12, 0.55]`. The reflection
G-buffer's "worth a ray" flag widens to the same ceiling through a `constant
float` on its fragment. Both gates protect the hand-back past the ceiling, which
is why a probe of it only reads red with both removed.

The hit shade itself is **two-bounce by default** (`reflectionBounces` below
lengthens it): the first hit's specular traces a second
closest-hit ray and shades that surface (env-terminated at the third order,
`ollin_rt_fetch_surface` + `ollin_rt_direct` shared by both bounces) rather than
sampling the environment blindly. The second trace is load-bearing where two
reflectors meet (the mirror floor at a polished pillar's base): an unoccluded
environment sample at the first hit pipes the HDRI's bright lower hemisphere
straight through the floor, and the grazing-compressed reflected silhouette
concentrates the leak into a razor-thin bright streak along the base that no
anti-aliasing can remove, because it is consistently-shaded content, not an
edge (the debug that proved it painted occluded-secondary hits red and lit up
exactly the streak). Shading the actual second surface dims the corner by the
product of the two surfaces' own reflectances, as a real mirror corner does.
Both bounces sample the environment through `ollin_rt_env_lobe`, which widens
the prefilter mip by grazing incidence (effective roughness
`rough / max(NoV, rough)`; a microfacet lobe stretches by ~1/NoV), so
grazing-lit reflected content spreads its energy the way the surface's own
distribution does. Two dead ends worth recording: an irradiance-probe fallback
for occluded secondaries reads as flat pastel blobs on curved reflections (the
irradiance cube integrates the same bright HDRI floor that leaked), and lobe
widening alone dims the streak but cannot remove it (the leak is radiometric,
not a filtering problem).

A rough first hit **fades its traced bounce back into that lobe** by
`smoothstep(0.12, 0.55, rough)`, the same rule the inline wrapper applies to the
primary surface. One ray carries no lobe width, so without the fade a matte
floor seen in a mirror, or through glass, showed a crisp reflection of its
surroundings that a direct view of the same floor never shows (the first
sighting was the glass example's `roughness: 0.8` floor mirroring the striped
wall behind it). The miss branch already *is* that lobe, so it needs no blend,
and a hit below the ramp is untouched: the near-mirror snapshots
(`rt-reflections-3d`, `area-reflections`) did not move.

`reflectionBounces(_:)` lengthens that chain past the pair (clamped 2…8 on the
CPU, `OllinLighting.rtReflectionBounces`, a second bound on the loop in the
shader). **The pair keeps its nested form and the tail is a separate walk**, and
the reason is arithmetic rather than taste. Metal has no recursion, so a chain
of arbitrary length has to run forward, carrying a throughput. Forward
accumulation can express everything the nested pair does except one step: the
roughness fade writes `mix(deeper, lobe, blend)` *after* the deeper term is
known, and the forward form has to distribute it into `blend · lobe` added now
plus `1 - blend` folded into the throughput. That is the same number in exact
arithmetic and a different order of operations in floating point, so rewriting
the pair as a two-step loop would move every reflective frame by a bit or two.
The branch is `> 2`, which leaves a default frame executing the prior
instructions: the 52 Guide figure probes and the three ray-traced figures
re-render byte-identical.

The tail (`ollin_rt_specular_tail`) shades each further surface with the
expressions the pair uses (its own diffuse and direct light, its Fresnel handed
to the surface behind it), takes the receiving surface's roughness for each
environment lobe, and ends at that lobe when the budget runs out, so a truncated
chain reads as a dimmer image rather than a hole. Cost is one closest-hit ray per
reflected pixel per step, and each surface dims the one behind it by its own
Fresnel, so a two-mirror corridor converges: measured on the probe corridor, the
deep panel reads 176.7 at the pair, 98.8 at three surfaces, 131.0 at four, and
125.2 from five to the ceiling, the alternation of a tunnel whose far end
switches between sky and corridor. The refraction walk shares the hit shade, so
traced glass gets the same depth for free. The offline path tracer is unaffected:
it carries its own `PathTracing(maxDepth:)` chain.

---

## Global illumination (the probe field)

`globalIllumination()` is dynamic diffuse GI in the probe-field form (the DDGI
papers; Techniques list): a grid of irradiance probes over the scene (a fixed
512-probe budget spread per axis by the fitted volume's aspect, see below),
re-traced every frame against the same acceleration structure the
shadows and reflections share, and sampled by every lit carrier as the diffuse
ambient. The pipeline is four fragment passes in `encodeGIPass`
(`MetalRenderer+Targets.swift`), all in `ShaderGI.metal` (a new segment after
ShaderCombine: it reuses Shader3D's RT surface fetch and ShaderEffects'
`PresentOut`), compiled only under `OLLIN_RT_SHADOWS`:

1. **Trace** (`ollin_gi_trace`): one texel per (ray, probe) into a transient
   surfel texture. The texture's first `OLLIN_GI_FIXED_RAYS` (32) columns are
   the **fixed statistics fan**: spherical-Fibonacci directions with *no*
   rotation and no shading (distance and facing only), traced solely for the
   relocation pass. The remaining columns are the radiance fan: a
   spherical-Fibonacci fan under a per-update random rotation (Shoemake
   quaternion from the hashed seed, so an update is a pure function of its
   seed). A radiance hit shades through `ollin_gi_shade_hit`: the surface's
   own glow (the emissive factor plus its color times `emissiveIntensity`,
   the two terms the lit fragment adds), the scene's direct light through
   the surface's lobes (light shaping applied, the exact LTC diffuse for
   panels) *times one visibility ray to the casting light* (without that
   shadow ray, bounce light walks through the very wall whose shadow the
   primary shading draws), plus the *previous* update's probe field through
   the same lobes (each update deepens the bounce by one). The lobes follow
   the path tracer's parity rule: a physically based finish shades through
   the microfacet BRDF (the Lambert body over π weighted by what the lobe
   leaves over, the environment split's own form, and the GGX lobe with its
   multiple-scattering share priced from the split-sum table, which the pass
   bakes if no environment has), and every other finish keeps the
   un-normalized Lambert the raster draws it with, so a wall bounces the
   light the raster lights it with and no more (before the rule every finish
   bounced as the un-normalized Lambert, so a physically based wall bounced
   π times its share). A metal's body is its (1 − metalness) share and its
   lobe carries its color; a see-through body gives up the share it
   transmits under an environment. The material behind a hit comes from a
   per-slot `OllinGIGeo` table (the glow, the shading model, f0,
   transmission; stride 32) the accel build fills beside the caustic one,
   indexed by the hit record's slot, so a copy resolves the draw that placed
   it; the build breaks a coalesced run where the record changes. A miss
   samples the environment's radiance times the IBL exposure (sky light with
   occlusion, for free); a backface hit stores zero radiance and a
   **negated, 80%-shortened** distance (the sign is the relocation pass's
   flag, the shortening the leak guard).
2. **Blend irradiance** (`ollin_gi_blend_irradiance`): per octahedral texel,
   the cosine-weighted mean of the radiance fan (`Σ wL / Σ w`, which is
   E/π, the IBL irradiance cube's own convention, so the sample drops into
   `diffuse = irradiance * base` unchanged; the fixed columns are skipped,
   since directions that never rotate would bias the estimate), encoded
   `pow(x, 1/5)` (the 2021 paper's perceptual gamma) and hysteresis-blended
   into the previous atlas. **Live only**, the reference's asymmetric
   temporal-response pair, and the asymmetry is the point: a *darkening* past
   0.25 cuts the hysteresis by 0.75 (a switched-off light must not ghost),
   while a *brightening* whose luminance jumps past 0.10 is rate-limited to a
   quarter step per update. With a sun disc in the fan, one update catches it
   on a couple of rays and the next on none, so a bright spike is estimator
   variance to be absorbed, not news to be trusted; before the rate limit, a
   sky-lit scene's whole field visibly pulsed (a real live-window defect,
   measured at ~25/255 swings on a lit sphere). Headless skips the pair
   entirely: its iterations converge a progressive mean, and either heuristic
   would bias the estimator it is converging.
3. **Blend depth** (`ollin_gi_blend_depth`): mean distance + mean squared
   distance under a power-50 cosine lobe, at 16×16 per probe against the
   irradiance's 8×8, over the radiance columns (same skip).
4. **Relocate** (`ollin_gi_relocate`): per-probe statistics off the surfel
   texture's **fixed columns only**. Every decision below sits on a
   threshold, and a threshold fed the rotating radiance fan flickers: a probe
   hovering near one oscillated between positions every update, and every
   surface its cage touched visibly pulsed (the second real live-window
   defect; freezing relocation was the diagnostic that isolated it). With
   the fixed fan each step is a pure function of (geometry, previous
   offsets), so the walk settles. The branches are the reference's three: a
   probe seeing >25% backfaces steps out through its closest backface; a
   surface-pressed one (closest frontface under 0.3× min spacing) backs away
   along its farthest frontface unless the two oppose; a comfortable one
   drifts back toward its grid anchor by the clearance it can spare, so an
   offset never outlives the geometry that earned it. Proposals commit only
   inside the 0.45-of-spacing ellipsoid (a proposal outside is refused whole,
   never clamped onto the shell); offsets live in a probeCount×1 ping-ponged
   texture the trace and samplers both read.

Sampling (`ollin_gi_sample`, Shader3D, textures 13/14/15 on every lit carrier)
is the papers' four-term weight: trilinear cage × soft backface wrap ×
Chebyshev variance visibility from a self-shadow-biased point
(`(0.2·n + 0.8·ω_o) · 0.75·minSpacing · 0.3`, precomputed into
`giCounts.w`), small weights perceptually crushed; decode is `pow 2.5` per
probe with the blended result squared (interpolation stays in roughly
perceptual space). Where it lands: under an environment the probe sample
*replaces* the irradiance-cube diffuse inside `ollin_pbr_ibl_ambient` and the
flat env ambient (the probes integrate the same environment, occlusion
included); with no environment it adds a GI ambient the scene never had.
Specular is untouched (GI is diffuse; mirrors are the RT reflections' job).

**The volume is auto-fitted and held, and the grid's shape follows it.** The
mesh batches' vertex AABB (folded over the same batch walk the accel build
uses, target-drawn meshes included, so the volume covers exactly what the
probe rays can hit and a scene drawn entirely inside a render target still
gets a field), padded 15% per side, sizes the volume. The per-axis probe
counts derive from that padded volume at fit time (`giAxisCounts`): a binary search
for the smallest near-isotropic spacing whose counts, each clamped to 2…16,
multiply to at most the 512 budget, so a cube resolves to the original 8×8×8
while a flat tabletop spends the same budget as 16×2×16 instead of stacking
unused vertical rows. The grid is then laid so the scene's bounding faces
sit half a cell inside its outer planes (`giFitVolume`): on an axis with three
probes or more the raw extent spans two cells fewer than there are probes,
with one cell of pad split between the two sides, and a two-probe axis keeps
the 15% pad. A face on, or a hair off, a probe plane is what put a ragged
dark seam on a room's open end: ten probes along x and z put a plane 0.04 of
a cell outside each wall's outer face and the open end, so a row of probes
sat at the walls' front edges seeing black outside and unlit outer faces,
with about half the trilinear weight on the inner face there, and the
Chebyshev test flipped them in and out along its 16-texel depth tile's grid.
Mid-cell, the nearest plane is half a cell behind the edge and the next half
a cell in front of it; the seam is gone and the room's walls read closer to a
path trace (the back wall 1.08 of the trace against 1.29 before, measured
2026-10-12). Counts are held with the volume (probes must not move
for hysteresis to mean anything), the atlases allocate once at the 512-probe
capacity so a refit never reallocates, and the sampler/blend shaders were
grid-shape-generic from the start (`giCounts.xyz` drives all index math).
Live, the volume *holds* until the raw bounds escape it or shrink well inside,
and a refit restarts the field. Headless/export ignores the held state
entirely: the volume refits from the frame's own bounds and
`resolveGISchedule()` whole trace+blend+relocate iterations run from scratch
in two phases (6+6 / 10+8 / 16+12 by tier): a burn-in whose updates replace
the field outright, so the bounce recursion reaches its depth (each update
deepens it by one), then the averaged updates, each a fresh fan over the
converged recursion, folded by a progressive-mean hysteresis (`k/(k+1)`
counted from the burn-in's end). A progressive mean from the first update
weighed the one- and two-bounce fields of the first updates with the deep
ones for good, and a light room read 0.77 of a 64-deep path trace that way
(albedo 0.8; 0.5 happened to read 1.03, two errors canceling), where the
burn-in puts the light room's bounce against the dark room's within 3% of
the trace's own ratio (measured 2026-10-12). Seeds are the iteration index,
so a frame is a pure function of itself:
byte-stable snapshots (`gi-3d`), flicker-free video, and a same-frame
re-encode can't double-step the live accumulation (`statefulEncodeIsRepeat`
guards the live path like the other stateful passes; `beginStatefulEncode`
runs the frame-scoped reset ahead of the GI pass, which now precedes the
effect-target encode so layers sample the same update). Rays per probe
resolve per GPU like the shadow rays (hardware RT 64/96/192, software
32/64/96), through the sketch's `globalIlluminationQuality(_:)` tier (a
persistent `RenderQuality` setting, `.default` following the automatic
live/export split; the headless iteration count follows the same tier). The
grid deliberately does *not* follow the tier: more rays refine the same
estimator, but a tier-driven grid would move the probes themselves, and
`.default`'s automatic export lift would then light an export differently
from the live window.

Three bugs from the first render session, each now a pinned rule:

- **Never `mix` toward an unread history.** The first update ran hysteresis 0
  against the uninitialized previous atlas, and `mix(fresh, old, 0)` is
  `fresh + 0·(old − fresh)`: NaN wherever the garbage was NaN. The whole
  field poisoned in two channels (the pool read green); the blends now never
  read `previous` without history.
- **Cap the visibility moments at cage scale.** Distances stored raw to the
  volume's far cap let far geometry dominate a texel's variance so completely
  that near-surface statistics collapsed into a hard step at the mean,
  printing a dark blob under every probe near a wall. The reference's rule
  (clamp stored distance to 1.5× spacing: the Chebyshev test only ever asks
  about a probe's own cage) is load-bearing, not an optimization.
- **Relocation is not optional.** A room built from wall slabs parks whole
  probe rows *inside* the slabs (the smoke test's ceiling row sat at y 4.04
  in a 4.0–4.2 slab), and an embedded probe's backface statistics darken a
  blotch of every surface its cage touches. The 2021 optimizer walks them
  out in a few updates; diagnosing it was a textbook counterfactual chain
  (Chebyshev off → *brighter dots* at probes, trilinear-only → smooth but
  dark → the weights were fine and the probes were wrong).

**Camera-anchored cascades give a vast scene a near field.** The fitted volume
spends its 512 probes over the whole scene, so a terrain leaves ~50-unit gaps
between probes and the bounce near the camera smears. Past a coarseness
threshold (fitted spacing above half the eye-to-target distance, calibrated so
a room with the camera inside stays single-volume with a comfortable margin),
`giCascadeLadder` derives up to `OLLIN_GI_MAX_CAMERA_CASCADES` (3) extra
volumes that halve the scene spacing down toward a quarter of the working
distance, each an isotropic-spacing window of at most 8x8x8 probes centered on
the eye: axes whose span already covers the scene's slab pin there instead (a
terrain's cascades never scroll vertically), and per-axis counts clamp to the
slab, so a flat scene's cascades spend a fraction of their 512-probe slots.
The atlases stack one 512-probe slot per cascade (the same widths, so
`ollin_gi_atlas_uv` and the carriers' three texture bindings are untouched;
probe index = slot · 512 + local), allocated at the ladder's capacity: a
capacity change only ever happens at a refit, and the single-volume allocation is
exactly the shipped one, so a room-scale scene keeps byte-identical sampling
UVs. Live, a cascade scrolls in whole probe planes as the camera moves (the
infinite-scrolling-volume model, studied via NVIDIA's public RTXGI SDK: a
truncation dead zone of one plane, physical texel = (grid + phase) mod counts,
so a stationary world lattice point keeps its texel), and a small pass over
the offsets ping-pong invalidates exactly the scrolled-in planes by zeroing
the offset and its w validity; the blends read that per-probe validity and
blend an invalidated probe like a refit's (fresh at full weight, the
reference's clear-then-zero-hysteresis semantics in one step), so the field's
interior never re-converges. The ladder itself holds live until the working
scale drifts past half/double its derivation value AND the fresh derivation
differs structurally; headless refits ladder and volumes from the frame's own
camera and bounds every frame (no scroll machinery at all), so an export stays
a pure function of the frame, and the quality tier still never touches the
grid. Sampling walks the table finest-first: the finest covering cascade wins,
fading over its outermost cell into the next coarser (the ladder is nested by
construction, so the blend partner always covers the band), with the scene
volume the fallback that covers every shaded point; the interior of the finest
window costs one 8-probe cage like before, the band two. Two codegen rules
came out of the byte-identity work: `ollin_gi_sample` and `ollin_gi_trace`
stay verbatim as the single-volume fast paths with cascaded twins beside them
(`ollin_gi_sample_volume`/`_cascaded`, `ollin_gi_trace_cascaded`, selected per
frame by whether cascades exist), because fast-math re-contracts a function's
unchanged float expressions when its control flow grows, and even the ulp
shifts a dithered byte; and the trace bisect that found it (whole-file revert,
then per-function) is cheaper than reasoning about which expressions fuse.
Verified: the room and mirror byte-identity scenes render pixel-exact against
their pre-cascade references, a full snapshot re-record matches a clean-HEAD
re-record on all 180 reference files byte-for-byte, and the vast-scene
counterfactual (the test seam forcing the single volume on the same frame)
moves the near structure's bounce by up to 149/255 where the fine cascades
resolve the floor's light. The live scroll behavior was measured by the
screencapture frame-diff protocol (2026-08-11): a 160-unit sunlit gallery
(three-rung ladder, spacings 6.9/3.5/1.7) under a camera advancing in 6-unit
steps with 2.6 s holds between them, 150 captures binned by an on-canvas hold
marker, an env-guarded debug print confirming every step scrolled all three
rungs (45 scroll events; a repeat-position control hold scrolled nothing).
Within-hold consecutive diffs: steady-state floor 0.05-0.09/255 mean (p99 =
1), the no-scroll control hold identical to it, post-scroll first pairs
0.1-0.6/255 typical with one worst case of 1.8/255 (p99 28), every hold back
at the floor within one to two seconds and never re-elevated. The worst
transient localizes to a single near-camera bounce-lit face (the amplified
diff shows one box side, no probe-plane-shaped band, no scene-wide term):
that face swung ~8/255 (~6%) over the first third of a second after a scroll
and crept back at the rate-limited pace, which is the asymmetric
temporal-response pair behaving as designed, a one-shot settle rather than an
oscillation. Continuous slow drift (1.5 u/s, the finest rung scrolling a
plane every ~1.2 s) shows no fade seams or banding in the stills.

**Mirror interiors and render targets gather too.** The traced hit shade
(`ollin_rt_hit_radiance`, shared by the reflection and refraction walks) swaps
its irradiance-cube diffuse for a probe-field sample at the *hit* when the
field is active, pre-divided by the IBL exposure because the whole traced
radiance is scaled by it on composite (the `ollin_pbr_ibl_ambient` rule), so a
surface seen in a mirror or through glass carries the same bounce as its
direct view; the atlases are passed into the deferred trace pass (textures 13/14/15
in `encodeReflectionPass`, whose lighting packs the field like the LTC/shaping
mirroring) and thread through `ollin_pbr_ibl_ambient` into the inline trace.
This is distinct from the recorded flat-pastel-blobs dead end above, which was
about substituting irradiance for the *specular* second bounce; the traced
specular stays traced. Render targets take the resolved field through
`encodeEffectTargets(gi:)` (the GI pass now encodes ahead of the effect
layers, so a layer samples the same update the main pass does).

Behavioral net: `GlobalIlluminationTests` (bounce-fills-the-unlit-ceiling,
red-wall dye vs a repainted twin, no leak into a sealed box vs a light moved
inside, intensity scaling, on-then-off byte-equality, two-render
byte-determinism, the mirror-interior and render-target counterfactuals, the
quality parameter's export-tier bytes, the exact per-axis-count derivation, and the
cascade tier: the vast-scene near-field counterfactual against the test seam's
forced single volume, the room-stays-single byte-equality, cascaded two-render
determinism, and the exact ladder/scroll-math derivations), all RT-gated
except the CPU unit tests, the toggle/mirror/target/parameter/cascade claims each
verified red by sabotage. The two live stabilizers (the fixed relocation
fan and the temporal-response pair) deliberately carry no test of their own:
both defects exist only in the live hysteresis loop, which every deterministic
probe restarts from scratch, so the verification is the live A/B measurement
(window captures diffed frame-to-frame: sphere pulsing mean ~4.4 → ~0.5,
worst-band over-8/255 fraction 53% → under 1%), the same honesty rule the
artificial-life sims follow. The remaining envelope: GPU point clouds and
particles don't gather (they barely have surfaces to), documented in
`Docs/3D/3D.md#global-illumination`.

---

## The path-traced export

`--path-traced` renders the still/sequence/video exports' 3D content with an offline unidirectional path tracer (`Renderer/ShaderPathTrace.metal`, driven by `MetalRenderer+PathTrace.swift`), while everything around the solid meshes keeps the raster pipeline. The mechanism, and the decisions that took debugging to reach:

**Scheduling: the trace owns its command buffers and completes first.** `encodePathTracePass` runs at the top of the headless one-frame render, before any of the frame's own encoding: a setup buffer (environment bake through the cached `resolveIBL`, shaping tables, the acceleration-structure build), then one compute dispatch per sample chunk, each committed and waited. Three reasons over "encode it into the frame's buffer": a minutes-long single command buffer risks the GPU watchdog and can report no progress; the chunk boundary is where the still export's rewriting `path tracing N/M samples` line comes from; and the shared per-frame rings the build fills (`meshGeoOffsetBuffers`, the accel object itself) are re-filled afterwards by the frame's own shadow pass, which is only safe because the trace has already finished. Chunks adapt toward roughly a second of GPU work each (`cb.gpuEndTime - cb.gpuStartTime`).

**The scene handoff generalizes the caustics pattern.** `buildShadowAccel(pathTraceMats: true)` breaks its coalesced geometry runs wherever the batch finish changes (a bytewise compare; the finishes are packed from the same inputs, so bytewise is exact) and uploads one full `OllinMaterial` per geometry, parallel to the base-vertex offsets. A hit therefore resolves position, interpolated normal and vertex color, the baked per-vertex metal/rough, and the whole finish (emissive, `f0`, shading model). Matcap batches are excluded from the traced scene entirely and keep rastering: a matcap is the unlit emissive-look finish, and the glowing prop a sketch draws exactly over an area light would otherwise swallow every next-event visibility ray to that light (the AreaLights example found this on the first render: the whole panel's light went missing).

**The composite is one premultiplied draw with a depth write.** The kernel accumulates radiance sum and hit count into an `rgba32Float` texture (fixed per-pixel order across dispatches, so the export is byte-deterministic) plus a pinhole primary depth. In the geometry pass, the first solid mesh batch draws a fullscreen composite instead (later solid batches skip): source-over premultiplied, so silhouette edges carry fractional coverage and blend over the backdrop and any 2D drawn before, and the fragment writes the traced depth, so wireframe, the grid, matcaps, point clouds, strands, raymarched fields, and instanced meshes still raster over or behind it correctly. A primary miss contributes nothing; the skybox or clear color behind the composite is what shows.

**Radiance conventions are raster parity, not a private physical system.** Punctual lights keep the raster's units (intensity-premultiplied color, no distance falloff, un-normalized Lambert for the legacy finishes, the microfacet terms for the physically-based one); area lights are physical with `color` as radiance, and a tube emits by its silhouette ribbon (length × diameter; the full lateral surface overshoots by ~π, measured against the raster's LTC look); the environment samples the same equirect at the same normalization-times-intensity the skybox and IBL use; with no environment the flat ambient becomes the miss field, which makes an ambient-only scene an exact furnace (`PathTraceTests` pins the mean within 3/255; note `Color(white: 0.5)` is sRGB, so the field is its linearized 0.214).

**Variance came down in three distinct steps, each a published technique, each fixing a different mechanism.** First, environment importance sampling with the power heuristic (two-level luminance CDFs over a 512×256 latitude-weighted grid, cached per equirect texture; one shared lobe-pdf function feeds both sides of the heuristic, or the estimator biases): this fixed the missing light, not the sparkle. Second, filtered importance sampling: lobe-side environment reads pick the mip whose texel footprint matches the sampled probability's solid angle, and the environment strategy reads the level its tables were built from, so one lamp texel far brighter than its table cell cannot spike. Third (the one that actually killed the single-pixel glitter), path-space regularization: the light list's evaluation at an *indirectly seen* vertex floors the surface roughness at 0.25, because a near-mirror NEE term toward a point light carries the microfacet distribution's full peak with no probability division, and one bounce-sample that happens to align with a light spikes by tens of thousands (no sample count cures a bounded-mean, unbounded-tail estimator). Only the light list: a listed light is a point no lobe can sample, so its term has no partner, while the environment's draw and the emissive meshes' draw are each paired with the continuation's pickup through the power heuristic, and a pair is one estimate only while both halves price the same lobe. With the floored lobe on the drawing side and the sharp one on the pickup side, a metal ball at roughness 0.08 seen past a pane of index 1 traced 12% brighter than the bare ball (2026-10-16). "Indirectly seen" counts scatters, not depth: a thin pane crossed straight is no scatter. The continuation lobe stays exact, so mirror-in-mirror imagery is unaffected; only a light's glint seen *in* a mirror softens, which the raster reflections (Lambert-only at hits) never showed at all. The depth-isolation experiment that found it: depth 1 was clean, depth 2 sparkled with both environment terms disabled, so the only unbounded term left was light NEE at bounce vertices.

**The grain filter is a separate pass over what the trace wrote down about the surface.** The estimator's own variance work above is where the noise *comes from*; this is what is done with the noise that is left. While it traces, the kernel fills two guide layers at the *first* hit only: the surface's base color after its maps plus the running square of each sample's luminance, and the shading normal plus the primary distance. They are written only while `pt.meshLights.w` is up (the `denoise` flag, which is off unless asked for), and are a one-pixel stand-in otherwise, so the ordinary render allocates nothing extra and writes nothing extra. `encodePathTraceDenoise` then runs four kernels in one waited command buffer: `prepare` turns the sums into per-pixel means, divides the radiance by the surface color (demodulation, so nothing painted on a surface is ever blurred, only the light on it) and computes the unbiased sample variance of the mean, `/(N-1)`, which is why a single-sample render skips the pass outright; `ollin_pt_denoise_atrous` runs five times over a ping-pong pair, doubling its tap spacing each time so a 32-pixel reach costs 25 reads per pass, weighing every tap by normal agreement (power 128), *relative* distance (0.02 of the center's own distance, opened by the tap spacing), and luminance difference divided by the measured variance (3x3-blurred first, since a single pixel's estimate of its own spread is itself noisy); `finish` multiplies the surface color back and writes into `accum` **in accum's own units** (radiance times the coverage count), which is why the composite fragment needed no change at all. Variance is carried in the alpha channel through the chain and is filtered with squared weights. Three things worth keeping: the guide albedo is forced to 1 on a mostly-transmissive hit (what shows through glass is not its tint, so dividing by that tint would smear the view behind it); the settings were swept rather than copied, and wider luminance blending (4 -> 8 -> 16) buys about 1.5% RMSE while costing about 30% of the kept high-frequency detail, so the published sigma of 4 over five passes stands; and every integrator probe in `PathTraceTests`, plus the `path-traced-3d` snapshot, names `denoise: false` explicitly rather than leaning on the default, because they measure the sampling and not the filter.

**The lens is eight lines in the ray generator.** `Camera3D.aperture`/`focusDistance` sample a disk over an orthonormal basis around the view axis and re-aim at the shared focus point; the composite's depth stays the pinhole center-ray depth so the raster kinds composite stably. The camera frame comes from the same `makeUniforms3D` the raster uses (unjittered), so perspective, orthographic, and intrinsic projections all frame identically.

**Glass is a stochastic mix, and the estimator weight collapses to one Smith term.** A physically-based hit with `transmission > 0` routes that fraction of paths (a weight-free stochastic mix, so partial transmission shades correctly) to a microfacet dielectric: sample a micro-normal from the same visible-normal distribution the opaque lobe uses, price it with the exact unpolarized dielectric Fresnel at the relative index (entering 1/ior, leaving ior), reflect with probability F, otherwise refract about the micro-normal (`refract` returning zero is total internal reflection, which reflects). A thin pane (`thickness` 0) never bends: both faces cancel, so the ray passes straight and only the reflection lobe uses the micro-normal. A solid tracks a `medium` state: the crossing in multiplies the surface tint and arms `attenuation`, every interior segment pays `attenuationColor^(d/attenuationDistance)`, and the crossing out disarms it (tint on entry only, or a slab tints twice). The property that makes visible-normal sampling with stochastic Fresnel selection worth the ceremony: the sampled estimator's weight reduces to the outgoing Smith G1 alone (the distribution, the Fresnel, and both Jacobians cancel), so polished glass carries weight 1.0 exactly, which is why a clear solid sphere in the ambient furnace renders the field to within the test's 4/255 and any future energy bug in the branch moves that mean.

**The MIS bookkeeping travels as one flag.** A glass vertex runs no next-event pass (it is specular-dominated; a light NEE through a near-delta lobe is exactly the glitter the regularization exists to prevent), so a reflection or a refraction there sets `prevNEE = false`. Every "did another strategy already have a chance at this light" weight keys off that flag: a ray that leaves the scene after such a vertex takes the environment at *full* weight (the power-heuristic share would silently halve everything seen through a window from the camera), and an emissive mesh found through glass adds its full glow. **A thin pane crossed straight is the exception, and it carries the flag through:** its crossing does not turn the ray, so the light past it lies along the very line the vertex before it ran its shadow rays through (`ollin_pt_transmittance` passes glass), and that vertex's strategies have already counted their share of it. Resetting the flag there handed the light behind a pane in full to the continuation beside that share, and a ball behind a pane of index 1 traced 8% brighter at roughness 0.5. The crossing keeps `prevPdf` and `prevNEE` and runs the leg's length on (`legCarried`, which the emissive pickup's solid-angle pdf reads), and `straight` counts such crossings so the regularization and the bounce bound gate on scatters (`bounce = depth - straight`). A solid's refraction turns the ray, so its reset stands, and with it the double count that `anIndexMatchedBlockChangesNothingInTheTrace` pins (*Photorealistic 3D* in `DESIGN-NOTES.md`). Opaque vertices set the flag and leave their continuation pdf in `prevPdf`, and the emission-side weight at a BSDF-found emissive hit is computable at the hit alone because of the CDF property below.

**Transparent shadows are a closest-hit walk, gated to cost nothing without glass.** Next-event visibility (`ollin_pt_transmittance`) walks the shadow segment hit by hit: an opaque surface kills it, a transmissive one multiplies the tint (albedo times transmission per thin sheet or solid entry, Beer over interior spans) and steps past, bounded at eight crossings. It is a straight line on purpose: a shadow ray cannot refract, so the bent, focused version of this light is exactly the live `caustics()` feature's territory. A thin pane's sheet takes `1 - F` at the macro normal and the index the continuation crosses it with, so the light a vertex sees through a pane and the light a path finds past it agree; a solid's interfaces still skip the Fresnel (the standard shadow-ray approximation; the few-percent error reads as slightly bright glass shadows, never as a seam). A scene-level flag (`meshLights.z`, set when any traced geometry transmits) keeps glass-free frames on the original cheap any-hit ray.

**Textures reach hits as bindless resource IDs, sharpened by a ray cone.** The accel build's run-coalescing also breaks on surface-map identity (gate-aware: a slot's texture only counts while its finish gate is up, so two batches differing only in an undrawn map still coalesce), and a parallel buffer of `MTLResourceID`s, five per geometry (`OllinPTTexEntry`: base color, normal, metallic-roughness, occlusion, emissive; the 1x1 white stand-in in an unbound slot, so the base sample is the identity and needs no gate, and the gated slots are simply never read unbound), is read in MSL as a five-texture argument-buffer struct, with `useResources` marking the real textures resident. Base and emissive bind the color (sRGB) texture, the value-encoding maps the raw data view, the raster encode's own split. The hit's uv interpolates from the same flat vertex buffer the fetch already reads. The mip is the ray-cone estimate: the CPU unprojects the center pixel and its neighbor once to get the footprint at the eye plus the spread per unit of travel (projection-agnostic, so orthographic cameras get a constant footprint and perspective ones a growing one), and the kernel divides the cone's width at the accumulated path distance by the triangle's own texel density (texture-space area over world-space area, per-map resolution folded in per slot), the standard texture-LOD scheme for rays, which have no screen-space derivatives.

**The surface maps compose at a hit exactly as the raster surface-mapped fragment composes them, with three consistency rules the estimator needs.** `ollin_pt_apply_maps` runs once per opaque hit: the base color multiplies the baked vertex tint; the normal map bends through the barycentric-interpolated vertex-tangent frame (bitangent rebuilt as sign · cross(N, T), the bend computed on the raw interpolated frame and flipped to the viewed side after, so a back face shows the same relief mirrored); the metallic-roughness channels multiply the *finish-table* factors rather than the baked vertex w slots, because the vertex slots carry only the drawing-state finish while the finish table carries the drawing-state × mesh-material product the raster's per-pixel resolve multiplies; a triplanar geometry projects base color and normal map through the raster's own `ollin_triplanar_surface` (its lod-less reads land on level 0, which is also where the raster reads its mipless image textures). The consistency rules: (1) the hit keeps a geometric normal (`OllinPTHit.Ng`, the pre-bend ray-facing normal) for every epsilon offset and a below-horizon break on the sampled continuation, the raster's own "a map is surface detail, not surface position" split, and since `Ng == N` on unmapped surfaces those scenes are untouched; (2) the occlusion ramp (1 + strength·(ao − 1)) dims the *environment share only*, applied to the environment NEE term at the vertex and, through a carried `aoPrev`, to the lobe-side environment/ambient pickup when the ray it produced misses: both halves of that MIS pairing scale by the same factor, which is what keeps the combined estimator consistent, while direct-light NEE, emissive hits, and surface-to-surface bounce light stay undimmed (the trace already occludes those with real geometry, and dimming one MIS half without its partner would bias the pair); (3) an emissive map modulates the radiance a mesh-light sample or a BSDF-found emissive hit carries, but the triangle *selection*, its area pdf, and the reverse MIS weight all stay priced on the bare factor: the CDF ran on factor luminance, an sRGB map's texels never exceed 1, so the factor pdf's support covers the glow and the estimator stays unbiased with no per-triangle map integral.

**The emissive-triangle CDF makes the reverse pdf free.** The CPU lays out every glowing geometry's triangles with a running power CDF (emissive luminance times area, two-sided to match the constant term a direct hit adds). Because a triangle's selection probability is proportional to luminance times area and the point within it is uniform, the pdf over the light's *area* at any sampled point is just luminance over total power, independent of which triangle, so a BSDF-found hit on an emissive surface can price the light strategy's pdf from its own material and hit distance, with no per-triangle lookup structure. The forward side samples the CDF by binary search, prices the point by the area-to-solid-angle conversion, runs visibility through the transparent walk, and folds in through the same power heuristic the environment pair uses.

## Temporal anti-aliasing

`temporalAntialiasing()` is the frame-wide form of the accumulation the
deferred-reflection pass already did: jitter the 3D projection by a sub-pixel
offset each frame and integrate the resolved frames, so edges refine past the
fixed MSAA sample positions and the screen-space/traced effects' residual
shimmer is absorbed with them. Written from the published treatments (the
jittered-supersampling resolve with neighborhood rectification, moment-based
history clipping, and the luminance-compressed blend; Techniques list in
ATTRIBUTION.md). Per-frame state like `rayTracedReflections()`, main canvas
only, any Metal GPU.

**The jitter is one NDC translate premultiplied onto the projection**
(`MetalRenderer.jittered(_:by:)`: clip.xy += jitter · clip.w), which is exact
for perspective, orthographic, *and* intrinsic projections where the classic
add-to-`[2][0]` trick is perspective-only, and touches neither z nor w, so
depth values, `depth(at:)`, and every depth test are unchanged. The offsets
are the low-discrepancy (2,3) radical-inverse sequence
(`taaJitterOffsets`, built from the public `halton`), the live path cycling
the first 8 by frame count, the export supersample taking the first N in
order. One `taaJitter` parameter threads it through every main-canvas 3D
pass so nothing misaligns: `encode`'s `makeUniforms3D`, the reflection
G-buffer, the scatter mask, and both half-res field pre-passes; render
targets always pass zero (a target has no accumulation to resolve a jitter
with), and `encodeMeshNormals` stays unjittered with them.

**Live** (`applyTemporalAA`, between the subsurface diffusion and the frame
filters): the main pass gains a `.min` depth resolve (memoryless MSAA depth
resolves from tile memory; a TAA-off frame attaches none), and the resolve
shader (`ollin_fx_taa_resolve`) reprojects the history by camera motion with
closest-depth 3×3 dilation, resamples it with a 9-tap Catmull-Rom (bilinear
alone re-blurs the accumulation every frame), rectifies it against the
current 3×3's moments in luminance-compressed YCoCg, and blends it as an
adaptive EMA into an `SSRHistorySlot` twin (`taaHistory`), guarded by
`statefulEncodeIsRepeat` like the other stateful passes. A frame's current
sample is first reconstructed at the *unjittered* pixel center (a 3×3
Gaussian, the published Blackman-Harris fit e^−2.29r², weighted by each
tap's distance from that center), which re-centers the shifted render and
keeps the nonzero-mean jitter sequence from wobbling the whole image.

**Headless/export** never touches the history (the deferred-reflection
precedent): `image(of:)` renders the geometry N times under the fixed
sequence (4/8/16 by tier via `resolveTAASamples`; export's automatic tier is
`.detail`, so 16) and averages them in linear light through
`ollin_fx_weighted_sum` ping-pong passes, the pre-passes (shadows, GI,
effect layers, sims, the deferred reflection) running once outside the loop.
A single export is anti-aliased with no warmup, a video can't flicker, two
renders are byte-identical, and an export beside a running window can't
double-step the on-screen accumulation. The benchmark path takes the live
shape (cost parity).

**A traced shadow inside those passes traces its share of one set.** A traced
point or area caster samples fixed points on the light (the Vogel disk, the
antithetic R2 pairs), so tracing the whole budget in every jittered pass traced
the same points N times: 16 passes of 16 rays at `.detail` on a software-RT GPU
were 256 rays a pixel for 16 places on the light, 25 s a frame on 1,000 spheres at
1080×1920. `encode(taaPass:)` hands the pass to `resolvedLighting`, where
`shareTracedShadows` divides each traced caster's count by N (rounded up, so the set
is never shorter than the budget, and at least one ray) and sets
`OllinLighting.shadowPass`/`shadowPassCount`; the fragment then traces samples
p, p + N, p + 2N, … of the set `count · N` long. Interleaving rather than
blocking keeps each pass spread over the whole light, and pairs the Vogel radius
(rising with the index) with the jitter's radical inverse, a Hammersley-like
pairing. The frame then costs the set, 2.3 s on that scene, and looks the same: the
penumbra keeps its 16 steps, 0.8 dB further from a 64-point render than the old
frame, which had paired every point with every sub-pixel offset. The other shape
in the design note, turning the disk per pass at the old count, would have bought a
smoother penumbra at the old cost; the cost was the complaint. The rect set keeps
its order (an odd length's center first, then each pair's +p and −p), and a pass
count is always even, so the passes trace every pair whole. One pass is the old
loop exactly, so every frame outside the export's loop is byte-identical.

Three live defects were found and fixed by frame-diff measurement (the
static-trellis scene in an OllinLive window, screencapture bursts diffed
frame-to-frame, statistics at the scale of the question), and each is now a
pinned rule:

- **Remove the jitter from the reprojection entirely.** The first cut
  reconstructed through the current *jittered* inverse view-projection,
  reasoning the unjitter "falls out of the matrix math". It does not: a
  static camera then reprojects the history at uv − j every frame, the
  history is Catmull-Rom-resampled at a different sub-pixel offset per frame,
  and its content random-walks with variance Var(jitter)/(1 − feedback²),
  which read as px-scale oscillation on every hairline edge (trellis
  frame-to-frame p99 14–33/255). Both reprojection matrices are unjittered
  (the published remove-the-jitter velocity rule); the jittered depth makes
  the reconstruction at most half a pixel off, which only perturbs the
  velocity under camera motion, never the still case. The same fix went into
  `ollin_rt_reflect_temporal`, whose G-buffer `u3` now carries the frame's
  jitter under TAA.
- **The rectification box must widen when the pixel is still.** At a fixed
  γ = 1 the μ ± σ box is rebuilt from each jittered frame, so the box itself
  oscillates with the jitter phase and drags a perfectly converged hairline
  edge back and forth (the clamp-sawtooth failure the survey literature
  documents). The clip's γ is velocity-adaptive (2.5 at rest easing to 0.75
  by 15 px/frame of motion): a still pixel's reprojection is exact, so its
  history deserves the wide box; a fast mover gets the tight one.
- **Key the adaptive feedback to the clamp, not the sample.** Feedback keyed
  to the luminance difference between the history and the *instantaneous
  jittered sample* permanently distrusts exactly the pixels that need the
  longest memory (a hairline edge's sample disagrees with its own converged
  mean every frame by construction). It keys instead on how far the clip
  just moved the history (untouched → 0.97, dragged a full σ → 0.88), so a
  converged edge holds still and a real change still refreshes fast.

The verification record (the artificial-life honesty rule: the live loop
restarts under every deterministic probe, so these numbers *are* the test):
TAA-off control diffs exactly 0.0000 mean / 0 max frame-to-frame; converged
TAA-on canvas mean 0.06–0.19/255 (p99 2–7), the pathological hairline-rod
crop mean 0.17–0.49 (p99 3–11, over-8/255 fraction 0.4–1.7%, down from
0.74–2.2 mean and 8–33 p99 before the three fixes), flat regions exactly 0,
and a moving camera shows no ghost trails while a 2D overlay stays within
1/255. The deterministic net is `TAATests`: the kernel-discrimination probe
(the export average must match its own analytic construction, built by
box-reducing an 8× reference at each declared offset, and match it *better*
than the plain box kernel, which pins offsets, signs, sequence, and weights
at once; margins set so even a full sign flip, whose point-reflected set has
a similar kernel, reads red), flat-interior energy conservation (a wrong 1/N
is 4 quantization steps), on-then-off byte-equality, two-render export
determinism, the 2D no-op gate, and the exact-pixel-shift/depth-untouched
jitter unit test; the discrimination and conservation claims verified red by
sabotage. A deliberate measurement note: a *box-filtered* ground truth is
not the export average's limit (its kernel is the pixel box convolved with
the jitter set, slightly wider than the pixel), and judging AA against a
gamma-space downsample is the classic linear-light trap; the test's
reference is built in linear light with the matching kernel.

**The mover-velocity buffer (`withMotion`)** gives world-space movers exact
history. Camera motion already reprojects per pixel from depth, but a mesh
that moves on its own (a spun `rotate`, a physics body, an animated scene)
leaves no record of where it was, so its pixels reprojected to stale content
and only the rectification clamp stood between them and ghosting, at the
price of the moving edges' refinement. The wrinkle is immediate mode: a
sketch re-records geometry every frame with baked world-space vertices and no
persistent identity, so there is no model matrix to diff. `withMotion { }`
supplies the identity: the block's call site plus its occurrence index among
same-site calls this frame plus the draw index within the block (the
`SSRSlotKey` rule; a frame-wide ordinal cross-wires histories the moment an
earlier block goes conditional), or an explicit name via `withMotion("…")`.
The drawer remembers each keyed draw's model matrix across frames
(`moverHistory`, pruned when a key skips a frame so a two-frame-old placement
is never read as one frame of motion) and, from a mover's second frame on,
records its vertex range with `previousOfCurrent = prevModel ·
inverse(curModel)`: the transform that takes this frame's baked world
positions back to last frame's placement.

**A mesh that changes shape has no transform to diff**, so `drawMesh(_:previous:)`
takes the record from the sketch instead: last frame's vertex positions, in the
mesh's own count and order (the array a ribbon sketch already holds from one
frame to the next). The drawer expands them in the mesh's index order exactly as
the vertex loop expands the positions, so the two lists pair one to one, and
bakes each through last frame's model matrix when the mover's key has one (a
mesh that both moves whole and bends writes the sum of the two motions) or this
frame's when it does not. The range records with `previousStart` into a
per-frame `moverPreviousPositions` list and `previousOfCurrent` at the identity,
and a first sighting records, unlike the transform form, because the sketch has
said where the vertices were. The velocity pass uploads that list itself, into a
ring of two slots per frame in flight advanced per encode (it can run twice a
frame, once for the temporal resolve and once for the blur or the upscaler, and a
slot must never be rewritten under a frame still reading it), and draws such
ranges through a second vertex function that reads each vertex's previous world
position from vertex buffer 4 and projects it through the same unjittered
previous view·projection. Nothing else changes: the same occluder phase, the
same fragment, the same sentinel, so one displacement stated either way writes
the same field (a probe holds every texel within 0.05 px). The Sketch entry opens
an implicit `withMotion` block keyed by its call site when none is open, and
joins the open one otherwise, so the cross-frame identity rule above holds for
the deforming form too. A count that does not match the mesh notes once and
falls back to the transform-only form rather than pairing positions wrongly.

The buffer itself is designed once for its three consumers (TAA now, MetalFX
temporal upscaling and motion blur ahead): **rg16Float at render resolution,
value = previous − current in pixels, y-down, both view·projections
unjittered**, which is the MetalFX motion-texture contract verbatim (scale 1,
and the convention Karis/Playdead compute; jitter does *not* cancel in the
difference, the same remove-the-jitter rule as the reprojection), and the
pixel units McGuire's motion-blur tile pyramid wants. Population is **sparse,
movers-only, with the depth-reprojection fallback for everything else** (the
UE-lineage pattern, vs the full-screen camera-baseline pass Playdead/Unity
write): every pixel no mover wrote keeps the shipped, live-measured camera
path *verbatim* (`ollin_fx_taa_resolve`'s fallback block is untouched), and a
frame with no declared movers encodes nothing at all, so the off path is
byte-identical by construction. The full-screen camera-baseline fill is the
recorded MetalFX prerequisite (that consumer sees only the texture and can't
run our depth fallback). The pass (`encodeVelocityPass`, right before the
resolve so both read the same previous view·projection off the history slot)
is single-sample with its own depth, two phases in one encoder: everything
that is *not* a mover draws depth-only first (nil fragment, color writes
masked off; wireframes skip, the normal-G-buffer rule), so a hidden mover
loses the depth test instead of writing velocity through its occluder; then
each mover range draws with its `previousOfCurrent`, the fragment
differencing the two interpolated clip positions after the divide
(perspective-exact). Unwritten texels keep a sentinel clear
(`OLLIN_VELOCITY_NONE`; a NaN from a degenerate mover transform compares
false and reads as unwritten too). The resolve samples the buffer at its
existing 3×3 closest-depth neighbor (the closest-fetch dilation rule, so a
mover's AA halo follows the mover), and a written texel replaces the matrix
reprojection with `prevUV = uv + v · texel`. A stationary mover under a
moving camera writes the camera's own motion (the total-motion contract,
probe-pinned), so written-vs-fallback never disagree about what motion *is*.

Verification: the registry, the pass, and the resolve's velocity branch are
pure functions of their inputs, so unlike the accumulation loop they carry a
deterministic net (`VelocityBufferTests`, 13): the call-site/occurrence/named
identity rules, skip-a-frame pruning, wireframe and render-target gates, and
render probes reading the texture back over a 1:1 orthographic scene (the
exact pixel delta with the exact sign, sentinel elsewhere, written-zero vs
sentinel, the camera term applied to a still mover, the occluder holding a hidden
mover back), plus a crafted-texture probe of the resolve branch itself
(velocity toward the history's white half vs its black half vs the sentinel's
identity fallback). The velocity-sign, occluder-drop, and resolve-sign
sabotages each read red exactly where expected. The live *payoff* (a mover's
edges keeping their accumulated refinement mid-flight) has no deterministic
probe, so it was measured by the same screencapture protocol as the three
live defects above (2026-08-11): a static-camera probe scene ran the example's
orbiting thin bar with `withMotion` on and off (an env-var branch, two
launches of one binary), 30 window captures each, and per-frame edge metrics
over the bar. The median high-pass residual of the column-coverage profile
(spatial aliasing) read 0.006 with exact motion vs 0.048 without, 3-10x apart
at every matched bar length (0.007 vs 0.024 at the longest, ~0.005 vs ~0.065
at the shortest usable), and the centerline's second-difference RMS 0.16 vs
0.54; a static control rod in the same frames read identically in both runs
(0.006-0.008), and neither mode showed any ghost trail (the fallback's cost
is refinement, never a smear: the clamp holds). The crops match the numbers:
the exact-motion bar mid-flight reads like the converged static rod, the
fallback bar is visibly sawtoothed along both silhouettes.

Envelope: render targets and the accumulation surface keep plain MSAA; 2D
overlays over a *moving* 3D scene use the scene's reprojection (measured
within 1/255 in the static and moving checks). `withMotion` covers solid /
textured / matcap meshes on the main canvas; a skinned or otherwise
vertex-deforming mesh is approximated by its node's rigid motion (exact
per-vertex history needs a previous-position stream, the engines' skinned
answer), wireframes, point clouds, GPU particles, and raymarched fields write
no velocity (and fields don't occlude the velocity pass), and a mover hidden
by one of those non-mesh occluders can still write velocity there, where the
clamp absorbs it as before.

---

## Specular anti-aliasing

Temporal AA above fixes the *shape* half of aliasing. `specularAntialiasing()`
fixes the *shading* half, and the two are independent: MSAA takes 8 samples of
the shape and 1 of the shading, so a highlight narrower than its pixel is
sampled once whatever the sample count. The mechanism is one line of arithmetic
in the lit mesh fragments (`ollin_ndf_filter_kernel` + `ollin_ndf_filtered` in
`Shader3D.metal`), described with its invariants in the CAPABILITIES bullet.
What belongs here is the measurement record, because three of the four
measurements a reasonable person reaches for first are wrong, and the shipped
envelope came out of that.

**An absolute flicker metric rewards a dark picture.** The first probe measured
the mean absolute second difference of luminance over a run of frames (a smooth
pan cancels in a second difference; a speck that switches on for one frame does
not). Filtering spreads a sparse clipped speck into many mid-tone pixels, so the
*absolute* number rose 56% while the picture visibly improved. The metric has to
be normalized or, better, measured against a converged reference.

**The reference is real; the probe was not.** `exportRenderScale = 4` plus the
headless temporal-AA supersample averages in linear light *before* the tone map,
so it is a true ground truth. But the first three probes had no aliasing to fix:
rendered at scale 1 and at scale 4, the far band's mean moved 0.5%. A fix for
aliasing can only be judged on a scene that is shown to alias, and that check is
two renders.

**A bright light hides the problem and inverts the fix.** With roughness 0.10
and an intensity of 4, the specular peak is around 30,000 and the tone map clips
it to a flat white plateau. A flat plateau does not alias, so the plain render is
already close to the truth, and widening the lobe only spreads that plateau: the
filtered frame lands *further* from the converged one. Sweeping the intensity
shows the sign flip cleanly (RMSE against truth, steep probe: off 19.8 /
filtered 54.8 at intensity 4; off 4.30 / filtered 2.96 at 0.004). This is the
shipped envelope and it is stated in `Docs/3D/3D.md` and the Guide rather than
left for a user to discover.

**What the shipped test measures.** Five frames a quarter of a degree apart of a
rippled sheet far enough away that each pixel covers several ripples, under a
light dim enough to stay in range. Crawl (mean absolute second difference,
linear light, ×255): converged 0.039, unfiltered 0.148, filtered 0.112 at
strength 1 and 0.087 at strength 2. The excess over the converged figure is the
artifact, and a third of it goes at strength 1.

**What it buys, stated in numbers.** On the example's own frame, against a
sixteen-sample reference of the same frame: the plain render's mean luminance
sits 11% under it (it misses most of its specks) and the filtered one 17% over it
(a widened highlight spreads further than the surface really scatters). Per-pixel
RMSE is higher filtered than plain, because the reference is speckled and the
filtered frame is smooth. Neither number is the point. The technique trades
still-frame accuracy for temporal steadiness, and the crawl figures above are
where it is judged.

**Debugging that worked.** Rendering the kernel itself as color bands (blue
under 1e-4, green, yellow, orange, red at the cap) over a plain sphere gave a
smooth radial ramp with a thin orange rim in one render, which is textbook, and
settled that the code was right while the scene was wrong. Tint the term and
render; do not tune.

---

## Motion blur

`motionBlur(shutter:)` is the velocity buffer's second customer: the published
plausible-motion-blur reconstruction filter (the TileMax/NeighborMax
dominant-velocity pyramid plus a classified per-pixel gather; Techniques list
in ATTRIBUTION.md), run as four fullscreen effect passes over the resolved
linear frame, after the temporal-AA resolve and before the frame filters, so
the blur reads settled edges and a bloom or grade reads the streaks. Per-frame
state like TAA, the same envelope (main canvas, active 3D camera, any Metal
GPU), and a photographic dial: `shutter` is the fraction of a frame interval
the virtual shutter stays open, 0.5 the film-standard 180-degree look, the
half of which enters the math because the published spread is the
*half*-velocity (a streak centers on the instant rather than trailing it).

**The chain.** Pass 1 (`ollin_mb_fill`) builds the full-screen velocity field
the paper assumes: where the mover pass wrote a texel that per-object motion
wins, everywhere else the pixel's world position (reconstructed from the
`.min`-resolved depth through the unjittered inverse view-projection)
reprojects through last frame's view-projection, which is the temporal
resolve's own fallback math per-pixel. The result is scaled by the
half-shutter and magnitude-clamped to the published `[0.5px, k]` (a whisper of
motion rounds up to half a pixel so the gather's center weight stays bounded;
nothing streaks past the tile radius the pyramid assumes), and the pixel's
camera-space depth is stored in the same texel's z. This fill is, incidentally, the
full-screen motion texture MetalFX's scaler needs, minus the shutter scale and
clamp: the camera-baseline-fill prerequisite exists once this runs. Passes 2
and 3 reduce that field to each k-pixel tile's largest velocity, then each
tile's 3x3-neighborhood largest, so every pixel knows about any mover whose
streak can reach it (velocities are clamped to k, one tile, so 3x3 suffices).
Pass 4 is a scatter-as-you-gather reconstruction (the improved sample
contribution published as a successor to the original gather, over the same
tile pyramid): (S - 1) / 2 pairs of taps, k + 1/2 + jitter steps either side
of the pixel along the neighborhood's dominant velocity, so none lands on the
center. A soft depth compare splits each tap into *behind* and *in front*, the
two halves summing to one; a tap behind counts where the pixel's own spread
reaches it (the background its streak uncovers, estimated from the visible
background nearby), a tap in front counts where the tap's spread reaches the
pixel (a foreground streaking over it). The weights are **not renormalized**:
their mean over the taps is the share of the shutter the streaks spent at this
pixel, and the pixel's own color fills the rest, `sum/N + (1 - weight/N) *
center`. Every result is a convex combination of what was read (the
never-outshines invariant by construction), and a lone mover keeps its light.
There is no sorting and no tap ordering. k is resolution-relative
(`height/36`, clamped 16...64, reproducing the published 20 px at 720 tall)
and S resolves 9/15/27 from the frame-wide automatic quality (the TAA
no-per-parameter rule, so export's automatic `.detail` lifts it).

**Where the cross-frame state lives is the design decision.** The previous
camera is kept on the *Drawer* (`previousCamera3D`, saved in `beginFrame`
before the per-frame reset), not on a renderer history slot, because
`beginFrame` runs once per `draw()` on every path: live, the same-frame
repeat, and the headless export drive, which already calls `performDraw()`
for every warmup frame, all read the same value with no new machinery. That
is what makes the export deterministic for free (exported frame k reads frame
k-1's camera and movers exactly like the live window) and what makes repeats
correct: by the time a repeat re-encodes, the TAA slot's stored matrix has
already advanced to *this* frame's, so the blur re-encodes the mover pass
itself through the shared `encodeMoverVelocity` core against the drawer's
matrix (the identical value the slot held when the first encode ran). The TAA
entry point (`encodeVelocityPass`) keeps its exact gates and its slot matrix,
so the shipped TAA path is untouched down to the byte; when both features run
on a live frame the blur simply reuses the TAA pass's texture, and the two
sources cannot disagree because the matrices are equal by construction.

**Byte-identity is layered.** Off, nothing runs (the guards return the
resolved frame). On but still, the chain skips on the CPU: the previous and
current view-projections compare float-equal and no mover range was recorded,
so a static scene with the blur on renders byte-identically to one without
it. On with a still *mover*, the mover pass writes zero motion, every
neighborhood's dominant velocity sits under the half-pixel floor, and the
reconstruction's early-out copies each pixel through by nearest-sample read,
which is value-exact. Frame 0 has no previous camera and returns unblurred by
definition. The backdrop rule is deliberate: a pixel at depth exactly 1 (2D
drawing, the clear color, the environment skybox) writes zero velocity, the
temporal resolve's own background treatment, so 2D drawing over the backdrop
never smears under a camera move (over the 3D scene it takes the motion of
the surface under it; see below); the cost is that the sky does not streak
under a pan, the documented envelope. The soft-depth extent is 1% of the eye-to-target
distance (the `sceneScale` proxy the sparkle cells and RT bias already use),
not a fixed world constant: the published 1mm-10cm figures assume meter-scale
scenes, and a tuned screen-space constant hiding a scale assumption is the
subsurface-scattering lesson repeated.

Degenerate inputs need no guard in the gather: the depth compare's halves are
saturated sums that never divide, a spread compare multiplies a velocity by
the taps per pixel of a neighborhood whose velocity passed the half-pixel
early-out, and a tap that rounds onto the center pixel counts like any other.
The gather jitter is `hash12` of the pixel position, the dither's
position-pure rule, so two renders of one frame are byte-identical and video
exports cannot shimmer.

**The reference, and the review that replaced the first gather (2026-10-08).**
The blur first shipped with the original three-case gather, which
renormalizes: the taps' weights and an inverse-velocity center weight are
divided out at the end, so a pixel inside a mover keeps nearly its full value
while the ramps beside it are added on. The deep-review triage measured the
gain (a 24-pixel box at 20 pixels a frame: +5% of its light at the default
shutter, +25% at a full one), and a 1D model of that gather reproduced it to
the tenth of a percent (+4.8%, +25.2%) where the scatter-as-you-gather form
came within 0.3%. The oracle is the subframe export (below): the same box
drawn at forty moments across the shutter and averaged is the analytic streak
to within a hundredth of the box's value. Against it, region by region, the
old gather missed the inner ramps by about 20% of the box's value at either
shutter (signed: +0.40 and +0.85 of a row's light); the new one stays under
2% in every region and within 0.2% of the light. On the example scene (three
orbiting spheres, a turning camera) the error around the movers fell 2 to 3
times (RMSE 0.019 to 0.009 on the fast sphere, 0.011 to 0.004 on the other
two, at the default shutter). Three findings ride with it:

- *The mirrored background reconstruction was measured and left out.* The
  published form gives both taps of a pair the nearer, faster one's weight, so
  the visible background beside a streak blurs like the one it uncovers. On a
  black ground it changes nothing; on stripes it raised the outer ramps' error
  at a full shutter from 0.0005 to 0.008; on the example it smeared a still
  column beside the fast sphere and took that sphere's error from 0.020 to
  0.035, worse than the old gather. The reference shows the visible
  background sharp, so the plainer form is the closer one.
- *An edge pixel half covered by a mover can keep the background's velocity.*
  The mover pass rasterizes one sample a pixel while the color is
  multisampled, so a pixel whose center falls just outside the mover carries
  the mover's partial color with no motion. With edges on pixel centers this
  costs about 1% of a 24-pixel box's light on the leading side; with edges on
  whole pixels the streak holds to 0.16%. A multisampled velocity resolve is
  the fix, filed rather than built.
- *2D drawing over the 3D scene takes the scene's motion.* The velocity fill
  reads the depth under a pixel, and 2D drawing writes none, so a caption over
  a moving floor streaks with the floor. The old gather's center weight hid it
  at small spreads, which the new one blurs as the true box they are (the
  floor itself matches the reference either way). `withOverlay` is the
  answer, and the example's caption uses it.

**The subframe export.** `--subframes N` (`OllinApp.exportSubframes`, with
`--shutter` / `OllinApp.exportShutter`) is the accumulation buffer: each
written frame is drawn at N moments across a shutter centered on the frame's
instant, each moment at the middle of its share of the opening
(`SubframeShutter`), and the moments are averaged in linear light. The fold
sits in `renderedFrame` after the overlay and before the linear capture and
the present: a moment's frame is added into a single-precision running mean
(`foldSubframe`, ping-ponged through `ollin_fx_weighted_sum` at 1/N), a
moment before the last commits without presenting, and the last presents the
mean, so the one 8-bit quantization point stays where it is. The drives
(`renderSubframeImage` for a still, the moment loop in `renderFrames`) treat
every moment as a draw: `time` steps through the opening, `deltaTime` is the
time since the previous draw (a simulation stepped by it stays exact), and
`frameCount` counts each draw, the rule `--settle` and drawn slow motion keep
(ruled 2026-10-08), which is also what keeps the renderer's per-frame
machinery (sample streams, the repeat-encode stamp) seeing distinct frames. A
moment before time 0 is drawn at 0. The shutter is read before each frame
from the sketch's last `motionBlur(shutter:)`, so a sketch's own setting
carries, and the canvas blur stands down while a moment is drawn
(`motionBlurActive`). A picture the GPU carries from draw to draw would step N
times a frame, so it is refused by name after the first draw that shows it
(`subframeRefusal`, ruled 2026-10-08).

Verification (`MotionBlurTests`, 12, over 1:1 orthographic scenes so
expectations are exact pixels): streak-direction twins (a horizontal mover
streaks along x and not y, the vertical twin the reverse), the tile-pyramid
reach (a background pixel in the tile *next to* a small fast mover, whose own
tile max is zero, still gathers the streak), camera-only blur with no
`withMotion` in sight, shutter scaling at a pixel only the wide spread
reaches, the still-frame / still-mover / frame-0 / 2D byte-identity gates,
backdrop stillness under a pan, and two-render determinism. The neighbor-max
skip, velocity-axis swap, and mover-texture drop sabotages each read red
exactly where expected (the mover drop failing the mover streak while the
camera streak stays green). The snapshot `motion-blur` pins the whole chain at
frame 2 on any Metal GPU. `FrameInvariantTests` holds the light
(`theStreakConservesLight`, within 1% at shutters 0.5 and 1) and the region
error against the subframe reference (`theStreakMatchesTheSubframeReference`,
under 2% of the box's value), both red under a planted renormalization and a
reversed depth compare. `SubframeExportTests` (9) holds the reference to the
analytic streak column by column, a flat shape's light, a still picture
byte-identical, the clock of every moment, the first frame at time 0, the
sketch's shutter carrying, the canvas blur standing down, the refusal by
name, and the recipe; five plants (the fold's weight, a trailing shutter, the
blur not standing down, the feedback refusal, the clamp at zero) each read
red.

---

## Temporal upscaling

`temporalUpscaling(_ quality:)` is the velocity buffer's third customer and the
jitter machinery's second: the live window renders the whole frame at a reduced
size and Apple's MetalFX temporal scaler (`MTLFXTemporalScaler`) reconstructs
the full-size canvas from the sub-pixel-jittered history. The tier maps to a
render fraction (`.performance` half size per axis, `.default` two-thirds,
`.detail` three-quarters, clamped to the device-reported scale range), so
per-pixel cost falls by the square while the scaler's own accumulation keeps
edges temporally anti-aliased. Per-frame state like `temporalAntialiasing()`,
main canvas + active 3D camera only, and gated on
`MTLFXTemporalScalerDescriptor.supportsDevice` (elsewhere a one-time note and a
normal full-resolution frame).

**The integration point is the resolve chain, not a post-process.** In the live
`render()`, every geometry-side pass sizes to the render (input) size: the MSAA
target and resolve, the depth buffer and its forced `.min` resolve, the clip
stencil, the reflection G-buffer, GI, contact shadows, the half-res field
pre-passes, the subsurface diffusion, and the mover-velocity pass. The scaler
then replaces `applyTemporalAA` (the scaler *is* the jittered accumulation;
feeding it Ollin's already-averaged output would integrate twice), bridging to
the drawable's full size, and everything downstream (motion blur, the frame
filters, the present) runs at the output size unchanged. The logical canvas
(`viewport`) never changes, and the geometry encode sets no explicit
`MTLViewport`, so shrinking the attachments is the whole resolution change: no
coordinate math anywhere is touched, and with the feature off the two sizes are
equal and the frame is byte-identical by construction.

**The scaler's inputs are the contracts already shipped.** The motion texture
is a full-screen fill (`ollin_fx_velocity_fill`): the `ollin_mb_fill` recipe
with the shutter scale and magnitude clamp removed, compositing the mover
pass's texture over depth-reprojected camera motion as *raw* previous-minus-
current pixels, y-down, both view-projections unjittered, which is the scaler's
documented convention at `motionVectorScale = 1` (the velocity buffer was
designed to it). The jitter offsets handed to the scaler are the pixel shifts
the jittered projection applied to the content (x right, y down, the table's
own units: "the offset to sample to return to the reference frame"); the
sequence is the shared Halton table extended from 16 to 32 entries, cycling
`min(32, ceil(8·s²))` phases for scale s (a scaler reconstructing s× the pixels
per axis needs ~8·s² distinct sub-pixel positions), while TAA keeps cycling its
first 8 and the export supersample its first ≤16, so both are untouched
(`theJitterTableKeepsItsShippedPrefix` pins the prefix). Depth is the same
`.min` resolve TAA reads (0 near, 1 far, `isDepthReversed` false), color the
linear pre-tonemap resolve with auto-exposure metering the HDR range.

**Cross-feature seams, each explicit:** while the scaler runs, `taaHistory` is
dropped (a later TAA-only frame must not reproject through a stale matrix);
motion blur runs at the output size reading the render-resolution depth and
mover texture by normalized coordinates, with the mover's pixel values rescaled
by the size ratio through a `moverScale` parameter whose (1, 1) default
multiplies exactly (the plain path stays byte-identical); a same-frame repeat
returns the slot's existing output without re-encoding, because the scaler's
internal history must advance exactly once per frame (the `taaHistory` rule);
and scaler creation failing flips the support flag so the note prints once
instead of retrying every frame. **Headless never touches the scaler**: a
sketch asking for upscaling exports as the full-resolution temporal-AA
supersample (`headlessTemporalAAActive`), pinned byte-identical to asking for
`temporalAntialiasing()`. The export is the deterministic full-quality
equivalent of the live preview, and the GPU benchmark keeps measuring the
full-resolution cost.

Verification splits along the honesty rule: the deterministic pieces
(`UpscalingTests`, 9) pin the gating, the tier mapping, the jitter-table
prefix, the fill's three behaviors over crafted textures
(`debugUpscaleFillReadback`: raw mover passthrough with no shutter scale,
exact camera reprojection, depth-1 backdrop zero), and the headless
equivalence; the fill and gate sabotages each read red. The live scaler is a
stateful platform object, so its verification is measurement (2026-08-11, the
TAA frame-diff protocol: a static RT-reflection scene in OllinLive, window
captures diffed over the canvas crop): converged frame-to-frame mean
0.08-0.11/255 (max ~30), inside the shipped TAA-on stillness band, i.e. no
jitter-sign shimmer, and *stiller* than the same scene un-upscaled (max ~120),
because the scaler absorbs the deferred reflection's residual sparkle the way
TAA does; hot-toggling off, then `.detail`, then `.performance` through a
reload swaps slots cleanly; and the FPS A/B on that scene read 28.6-30.6 fps
at full resolution vs 57.7-60.2 (vsync-capped) at `.performance`.

---

## Frames between the drawn ones

`frameInterpolation()` is the velocity buffer's fourth customer, and the first
feature that changes *when* the sketch runs rather than what it renders. Apple's
`MTLFXFrameInterpolator` (macOS 26) takes two rendered frames plus the depth and
motion of the newer one and builds the picture between them, so the live window
shows two pictures for every one `draw()` produced.

**The scheduling is the whole design, and it lives in two places.** In
`MetalRenderer.render`, after every filter has run, `applyFrameInterpolation`
copies the finished frame into one half of a ping-pong pair, encodes the
interpolator against the other half, and returns the *made* frame for the
drawable. The frame just drawn becomes `heldFrame`. In `SketchRunner.draw(in:)`,
the next refresh sees `hasHeldFrame`, calls `presentHeldFrame`, and returns
before the clock, `advance`, or `performDraw` are touched at all: that refresh
runs no sketch code, which is where the time comes from. So the display carries
`drawn(k-1)`, `made(k-1, k)`, `drawn(k)`, `made(k, k+1)` and the sketch is
sampled at half the refresh rate on a clock that never stopped running in real
seconds.

Ordering is the reason a drawn frame waits. The made frame belongs *between* the
two, so it has to be shown first; presenting the drawn frame on the refresh that
produced it and the made frame afterward would run time backwards. That is the
one-refresh input latency, and it is inherent rather than an implementation
detail to optimize away.

Three smaller decisions carry weight. The pair of color textures alternates
because the older frame is still being read while the newer one is written, and
the newer one is read again a refresh later by the present pass. `isDepthReversed`
is set false (the platform default is true; Ollin's depth runs 0 near, 1 far),
and `jitterOffset` is zero because the color handed over is always a resolved
frame, from the temporal-AA resolve or the upscaler's output, and so already sits
on the reference grid. `presentHeldFrame` takes no slot in the frame ring and
signals none: it is the present pass alone over a finished texture, so the ring's
count stays balanced.

Composition falls out of the sizes. The descriptor separates the depth/motion
size from the color/output size, which is exactly the split the upscaler already
creates, so with both on the interpolator reads the reconstructed full-size color
and the render-resolution depth and motion, and the motion field is the upscaler's
own (`encodeFXVelocityFill`, shared by both, is where that field is written once).

The gates are split by who can see the condition. The renderer checks the sketch's
request, an active 3D camera with a field of view (`verticalFieldOfView`:
perspective, or derived from a pinhole calibration's focal length; orthographic
returns nil), and device support. The runner sets `hostAllowsInterpolation` for
what the renderer cannot see: a take recording or playing wants every drawn frame,
a `noLoop()` sketch may never be asked to draw again, and a wall of displays wants
one frame on all of them. A reload calls `dropHeldFrame()`, since both the held
frame and the interpolator's history belong to the run that ended.

**One wiring bug is worth recording, because the seam test could not see it.** The
depth resolve is allocated only for the features that read it, and the condition
listed temporal AA, motion blur, the upscaler, and the lens flare. Interpolation
was not on it, so the live path found no depth, took the early return, and made
nothing at all while every probe over crafted textures passed. What caught it was
a test that drives `renderer.render` against an off-screen `MTKView` and asserts
the pattern of refreshes (`drawn, made, held, made, held, made`), with a sketch
that never asks as the counterfactual. An off-screen `MTKView` does vend
drawables in the test process, which is what makes that whole-path probe possible
without a window.

Verification is deterministic where it can be. `debugFrameInterpolationReadback`
walks crafted frames (a bar stepping 8 px per frame) through the interpolator and
measures where the made frame puts the mark: halfway, within 1.5 px, from the
third made frame on. The first two repeat the drawn frame, which is the platform's
warm-up and is pinned as such, since it is also what makes starting up
artifact-free. Forcing `shouldResetHistory` every step collapses every made frame
onto the drawn one and reads red. A frame grab is pinned byte-identical to not
asking at all, and so is an export until it asks for made frames itself.

**A second wiring bug hid behind the first, and only an export could find it.**
`encodeFXVelocityFill` writes the whole-frame motion field into the texture it is
handed and *returns the mover pass*, whose unwritten texels carry the sentinel
`OLLIN_VELOCITY_NONE` (-16384) that the fill itself reads as "use the camera
reprojection here". The upscaler beside it binds `slot.motion`, the filled field.
The interpolator bound the return value. So a frame that declared no mover handed
the interpolator no motion at all, which looks fine, and a frame that declared one
handed it -16384 pixels of motion everywhere the mover was not, which warps the
whole picture. On screen at 60 fps that is a shimmer nobody had named. Measured
against the same moments drawn for real, the made frame landed about eighty times
further from the truth than simply holding a neighbor would, and the floor's
silhouette flattened into a straight line across the frame. The counterfactual
that isolated it was a scene with the same motion and no `withMotion` call, which
came back exact. Both paths bind `slot.motion` now.

**Slow motion in an export** (`--slow-motion N --made-frames`) is the same
interpolator driven from the headless side, and the shape differs in three ways
that matter. It keeps its own slot, since the two drives never run together and an
export starts with no history to carry. The render and the made frame are separate
command buffers: `keepForMadeFrame` copies the finished picture into the pair and
holds the resolved depth as the render commits, and `exportMadeFrame` runs the
fill, the interpolator, the present pass, and the read-back afterward, because the
export drive is synchronous frame by frame anyway. And `newest` moves only when a
gap is actually consumed, so a warmup frame's copy is harmless: it overwrites the
same half of the pair, and the first captured frame is still paired with the frame
before it.

The first gap of a clip cannot be filled. Encoding that pair three times moves the
first real in-between frame from the third gap to the second, and encoding it six
times does no better, so the interpolator wants a distinct pair rather than a
count. The head of a made clip therefore holds one picture twice, which is the
same thing the live window does when it starts, and the export documents it rather
than hiding it. What a made frame is worth was measured the same way: about six
times closer to the truth than either neighbor (mean 0.13 of 255 against 0.67 to
0.97), at about 11 ms against 20 ms for a drawn frame at 1080 square.

---

## The canvas sample pass (`CanvasSampler`)

The seam behind LED mapping (and anything else that turns rendered pixels into
a few hundred output values a frame): a `package`-access `CanvasSampler` in
`Sources/Ollin/Renderer/CanvasSampler.swift` that samples N points from the
rendered display texture with one small compute dispatch and reads back an
N-entry byte buffer. It uses the rendered-*texture* extension hook
(`wantsRenderedTexture` / `frameRendered(_:texture:)`, the frame-sharing seam),
so the grab is only paid while a consumer is registered, and a headless export
(which never fires the hook) costs nothing. The design point is
the readback size: the whole-frame `CGImage` grab moves megabytes per frame
where a map of a few hundred LEDs needs a few hundred bytes, so the CPU never
touches the frame, only the sampled results.

Mechanics, and the decisions inside them:

- **Self-contained kernel, compiled from an inline source string** on first use
  (`makeLibrary(source:)` on the texture's device), deliberately *outside* the
  shader segment concatenation: it shares no helpers or types with the drawing
  shaders, and passing plain `float4` / `uchar4` buffers on both sides leaves no
  shared struct layout to drift (the one-header rule exists for structs spelled
  twice; here nothing is spelled twice).
- **Per-point box radius.** Each point is `(x, y, radius, 0)`; the kernel
  averages the `(2r+1)²` texel patch with clamped taps, so off-canvas points
  read the nearest edge texel and every LED can carry the patch it stands for.
  Radius is clamped to 256 so a wild value can't stall the GPU.
- **Linear-light averaging, display bytes out.** Reads on the sRGB texture
  decode to linear, the mean runs on linear values, and the result re-encodes
  through the sRGB curve to bytes, so a half-black half-white patch reads as
  the gray that *looks* halfway (the compositing rule), and a radius-0 sample
  round-trips the stored byte exactly (`CanvasSamplerTests` pins both, with
  the sRGB-space average as the counterfactual). Alpha averages and quantizes
  straight; it carries no gamma.
- **Synchronous by design.** The pass runs on its own queue and waits for
  completion before returning, for the same reason frame-sharing does: the
  renderer takes the handed-out texture back when the hook returns and may
  write a later frame's grab into it on its own queue, and Metal's hazard
  tracking doesn't span queues. The wait is a few hundred threads deep and
  lands on the main thread between frames, where the delivery runs.
- **Buffers persist; points re-upload only on change.** The point list is
  uploaded when assigned (a static map costs one upload total), and the output
  buffer is reused across frames.

The first consumer is `OllinDMX`'s `LEDMap` (`package` access reaches it
because the satellites live in the same package), which owns the mapping
question: where strips/matrices/loose points sit, each LED's default radius
(half its spacing or cell, banded 1...32), the whole-LED universe packing, and
the fixture-path color conversion. A future serial/laser tier can reach the
same seam; promoting it to public API is cheap if a sketch-facing use appears.

---

## User-supplied shaders

A sketch writes its own fragment shader and runs it through the effect graph.
The contract is Ollin-native, not raw Metal: the user defines
`float4 shade(float2 uv, ShaderInfo info)` (uv 0...1 top-left, returning
straight sRGB), and Ollin generates the surrounding fragment plus a fullscreen
`ollin_user_vertex`. The public surface is `Docs/Shaders/Shaders.md` and the
per-function `Docs/Shaders/ShaderLibrary.md`; this section is the wiring.

**Routing reuses the effect-graph enums.** `Shader` is a `.shader(Shader)` case
on `Generator`, `Filter`, and `Combine.Kind`, so input count *is* the variant:
`generate(.shader(s))` (0 inputs), `layer.filtered(.shader(s))` (1 input, read
via `sample(info, uv)`), `a.combined(with: b, .shader(s))` (2 inputs, adding
`sampleAux(info, uv)`). All three resolve through the existing
`encodeGenerator`/`applyFilter`/`applyCombine` with no new render-graph
plumbing. The input layer(s) are passed *inside* the wrapper's `ShaderInfo` as
`texture2d<float>` plus `sampler` members (valid MSL, passed by value to
`shade`), read back as straight sRGB (`ollin_layer_sample` un-premultiplies and
applies `linearToSrgb`) so the user works in one color space.

**Compile model.** Each shader composes its own source (the `OllinShaderLib`
segment, the variant wrapper, the user source) and compiles as its own
`device.makeLibrary(source:)`, cached by `fnv1a(composed)` through `MTLLibrary`
to `MTLRenderPipelineState` (the `userShader*` caches), so it compiles once,
not per frame. A *failed* compile is cached too (`userShaderErrors`) so a
broken shader does not retry every frame; all three caches clear on a
framework-shader reload. `ShaderInfo` is built per frame from
`frameComputeUniforms` (snapshotted from `drawer.computeUniforms` at the top of
`encodeEffectTargets`, so the filter/combine call sites need no `drawer`). The
bound uniform struct `OllinShaderUniforms` is scalars-only (no array) so Swift
fills it with the plain memberwise init; user params (up to 32 floats, read via
`param(info, i)`) are passed in a separate `float4` buffer at index 0.

**The readers are functions, and that is load-bearing.** `param`, `sample`,
`sampleAux`, `sampleRaw` and `sampleAuxRaw` were function-like macros until
2026-08-27. They spelled the right thing but obeyed no scope, and `sample` is a
member function on every Metal texture type: Ollin's own segments call it 497
times, and survive only because the macros were emitted into the user-shader
compile unit and the framework library is a different one. Inside a user
shader, a `#define sample(info, p)` rewrote any two-argument `t.sample(s, uv)`
in anything the shader included into a member that does not exist, and reported
it as three errors naming `ollin_layer_sample`, `in0` and `in0samp`, none of
which the author wrote. A three-argument call (`t.sample(s, uv, level(0))`,
which is what the sampler rule asks for) failed in the preprocessor instead,
and a helper of the author's own named `sample` could not be declared at all.
That reached nothing until the include resolver made third-party source
reachable, and then it hit 15 files of the first library tried. As free
functions the same spellings resolve by overload: a member call is never a
candidate, and a helper of any other signature is simply another overload. They
take `thread const ShaderInfo &` rather than a value, since the struct carries
the whole parameter block, and `param` has an `int` and a `uint` overload so a
literal and a loop counter both bind. Verified: every one of the 52 call sites
in the repo already passed `info` first, nothing used `param(...)` as an
assignment target (the one thing a macro could do that a function cannot), and
the whole snapshot table stayed byte-identical.

**Friendly errors are the headline.** `Shader.init` captures its call site
(`#filePath`/`#line` default args) and the wrapper emits
`#line <startLine> "<file>"` before the user source, so Metal reports
diagnostics at the real, IDE-clickable location: the sketch's own `.swift` for
an inline string (a multiline literal's content starts at `line + 1`; a
no-newline string sits on the call line, so a one-content-line literal reads
one off, an accepted quirk that `ShaderErrorTests` pins), or the `.metal` file
itself for a resource (line 1). `cleanShaderDiagnostics` strips the
`Compilation failed:` header, rebases `program_source:` lines to the same
`file:line` as a fallback, and drops compiler-internal `note:` lines pointing
at `/System/` framework headers (a line naming the user's file is always
kept). The call-site location is included in the composed-source hash, so identical
shaders at two call sites cache separately (fine; same-site rebuilds like
`Visual`'s still hit). The error surfaces two ways: stderr (once per source
hash, for a plain `swift run`) and a pull-model channel to the host: the
renderer holds `currentUserShaderError` (reset each frame, set on failure),
`SketchRunner` reads it after `render()` on the main thread and forwards via
`onUserShaderError` (deduped), and `LiveSession` maps it to its `shaderError`
feeding the existing `CompileErrorState` overlay. The channel is deliberately
separate from the Swift compile-error channel so a shader error and a Swift
error never clear each other (`reloadShaders`' framework-shader gap is closed
the same way).

**`OllinShaderLib` is one source of truth, used three ways.** The helper
segment (color/hash/noise/sdf/domain plus `palette`/OKLab, including the
once-compute-only `curlNoise`/`discSample`/3D `valueNoise`/one-out hashes,
folded into its hash/noise sections) is built on by the framework's own
segments, spliced into every user shader, and spliced whole into every compute
kernel by `composeComputeSource` (the separate compute prelude is gone: one
helper set, `hashNM` names, everywhere). `using: [.noise, .sdf]` keeps only the
marked sections (`// OLLIN_LIB_BEGIN/END <module>`, with the noise-needs-hash
dependency resolved); it is a compile-time lever only (the GPU dead-code
eliminates unused helpers anyway), default `.all`. The 2D `sd*` distance
catalog (`sdEllipse`/`sdRoundBox`/`sdSegment`/`sdStar`/`sdHeart`/`sdBezier`/...
plus the `dot2`/`ndot` helpers) lives in the lib's `sdf` section, moved
verbatim out of `ShaderShapes` (the block was self-contained, so the framework
library still resolves it and all snapshots stayed byte-identical);
`ollin_sdf_distance` and the fragment switch in `ShaderShapes` still call
them, so framework shapes and user shaders share one copy.

**Hot-reload covers both forms in OllinLive.** An inline-string shader reloads
with the sketch (a `.swift` edit). A `.metal` *resource* shader reloads on its
own: `Shader(resource:in:)` reads lazily (it stores the bundle-resolved path,
not the content; `Bundle.module` resolves to the sketch dir under OllinLive
too), the renderer reads and caches the file, and a watched-`.metal` change
routes through `LiveSession.handle` to `SketchRunner.invalidateUserShaders()`
(clears the source/library/pipeline caches; forces one frame if `noLoop`), so
the next frame re-reads and recompiles with no swiftc pass (verified
end-to-end: edit reloads, a break shows the line-accurate overlay error, a fix
recovers). A framework-segment `.metal` (under the repo's `Renderer` dir) still
routes to the full library reload; the dispatch tells them apart by path.

**`#include "…"` is resolved by hand, for everything compiled at runtime.**
`makeLibrary(source:)` has no include search path, so a quoted include would be
an error rather than a file. `ShaderIncludes` (`Sources/Ollin/Renderer/`) reads
the directive, splices the named file's text in its place, and writes a `#line`
on each side, so a mistake inside an included file is reported against that
file at its own line. It is one resolver with three callers, differing only in
how a spelling is turned into a file: the built-in segments look themselves up
in the resource bundle (`bundleShaderFile`), a live shader reload looks in the
repo directory being edited, and a user's shader or compute kernel resolves
against the folder its own source came from (`resolveFromFilesystem`, with the
kernel's folder coming from the `sourcePath` `ComputeKernel` now carries).

Two properties do the work. **A file is spliced once per compile**, tracked by
resolved path, with the root seeded into that set so a file naming itself is
caught as a cycle rather than read a second time; a cycle and a nest past
`depthLimit` are reported instead of followed. And **the segment list carries
no order**: each `Shader*.metal` opens with an `#include` naming what it needs,
`resolveAll` walks the roots sharing one already-spliced set, so a dependency
lands ahead of whatever named it whatever order the roots arrive in.
`shaderSourceNames` is therefore a plain alphabetical set, and adding a segment
is a name in that list plus its declarations in the file.
`theBuiltInSegmentsCompileInAnyOrder` compiles five orders on the device and is
verified red by sabotage. Writing it found two dependencies nobody had ever
declared, because the old fixed order happened to satisfy them: `Shader3D` uses
`ShaderShapes`' `diskCoverage`, and `ShaderPathTrace` uses `ShaderIBL`'s
equirect helpers. A problem the resolver finds is raised as the shader's own
`ShaderCompileError` before the compiler is asked, since a missing helper would
otherwise arrive as a page of undeclared identifiers pointing away from it. The
composed library text is memoized per `Modules` set
(`userShaderLibraryText`), because the user-shader compose runs once per shader
per frame and the bundled library cannot change mid-process.

**`ollin check`** (`ShaderCheck` plus the `OllinCheck` target) is the same path
without a sketch: resolve, infer the variant from the layer readers the source
calls, compose through `composeUserShaderSource`, compile, and build the
pipeline too, since a shader can compile and still be refused there. It reports
through `cleanShaderDiagnostics`, so the line numbers are the ones a running
sketch would print.

---

## Scene import (`loadScene` / `drawScene`)

Structure-preserving import beside the merged `loadMesh`: `Scene(contentsOf:)`
(sugar `loadScene`) reads a glTF/GLB file's default scene as a tree of
`SceneNode` values (name, authored local transform kept verbatim as a matrix,
an optional `Mesh` in node-local space with that node's primitives merged) plus
the authored cameras (core glTF) and punctual lights (the `KHR_lights_punctual`
extension), each resolved through its node's world transform into ordinary
`Camera3D`/`Light` values. Every other format `loadMesh` reads degrades
honestly to a single-node scene with no cameras or lights. User-facing API:
`Docs/3D/Scenes.md`.

**The loader split.** `MeshLoaderGLTF.swift` factored its file/buffer plumbing
into an internal `GLTFDocument` (GLB chunk split, buffer resolution, the
accessor readers, image bytes, material resolution) shared by both consumers.
`Mesh.loadGLTF` kept its own instance-walk-and-merge loop *untouched* (it bakes
world transforms into positions as it appends, and computes smoothed normals
after baking, so rebuilding it over per-node local meshes would not be
byte-identical); the scene path adds `GLTFDocument.localMesh(at:)`, the same
merge rules with no world baking. `MeshLoaderTests` pin that `loadMesh` output
is unchanged.

**Drawing.** `Drawer.drawScene` is a walk: save `modelMatrix`, post-multiply
the node's local transform, `drawMesh` the node's mesh (via the deform branch
below when the node skins or morphs), recurse, restore. Reusing `drawMesh`
wholesale is the point: materials, lights, shadows, reflections, wireframe,
batch/export/combine gating all apply with zero new rules, and
`SceneLoaderTests.drawSceneMatchesManualTransforms` pins that a drawn scene is
byte-identical to the equivalent manual transform-stack calls.

**Per-material submeshes.** A multi-material mesh (glTF primitives with
distinct materials; a USD mesh partitioned by `materialBind` `GeomSubset`
children) keeps its node's `mesh` *whole*, one merged vertex order, and adds
internal `SceneMeshPart`s on the node: per-material triangle lists indexing
that same mesh, plus the material each uses. The one-vertex-order design is
what keeps deformation untouched: morph deltas and skin weights stay aligned
with the merged positions, posing happens once, and the draw then emits one
ordinary `drawMesh` per part, a struct copy of the posed mesh with the part's
`indices` and `material` swapped in (the vertex arrays share storage through
copy-on-write, so nothing is duplicated). Loaders build parts only when the
split is real: the glTF merge records each primitive's index span and groups
spans by material index in first-appearance order, emitting parts for two or
more distinct looks (each material resolves once, shared with the merged
mesh's own pick); the USD build maps authored faces to part slots (first
subset claims a contested face, out-of-range face indices are ignored,
unclaimed faces form a remainder with the mesh's own binding, and a subset
family other than `materialBind` never partitions) and collects each face's
fan triangles into its slot during the one triangulation walk, in both the
indexed and the expanded forms. A single-material node carries no parts and
draws on the untouched path, byte-identical, which the unchanged snapshot
corpus pins; `partsVertexCount` guards a mesh swapped under the node back to
the whole-mesh draw (the skin-alignment treatment), pinned by render probe
beside the parts-vs-manual byte-equality probe in `SceneLoaderTests`.

**Resolution choices, and why:**

- **Cameras.** Eye = the node's world translation; the view direction is the
  world -z column (the glTF aiming convention); the world +y column keeps
  authored roll. `Camera3D` needs a *target*, so the eye looks at the scene
  bounds' center projected onto the view axis (an orbit-friendly pivot), one
  unit ahead when the scene is empty or behind the camera. Orthographic `ymag`
  is a half-height, so Ollin's `height` is `2 * ymag`.
- **Light intensities normalize per kind, brightest = 1.** glTF stores
  physical units (lux for directional, candela for point/spot) that only mean
  something under distance falloff, which Ollin's punctual lights don't model;
  raw candela values would blow out any scene. Per-kind (not global)
  normalization keeps the authored balance within a kind without letting the
  incomparable units fight each other. Colors arrive linear and re-encode to
  sRGB, the base-color-factor treatment.
- **Cameras and lights are attached to their nodes.** The loaders never resolve a pose:
  they attach internal node-local specs (`SceneLightSpec`, everything but the
  pose: kind, color, per-kind-normalized intensity, cone and area extents,
  emitting down local -z; `SceneCameraSpec`, projection + clip range), and
  `scene.cameras`/`scene.lights` are computed properties that walk the tree's
  *current* transforms on every read (`Scene.visitWorlds`, pre-order,
  matching the old collection order), so a carrier moved by hand or by an
  applied animation carries its camera or light. One shared
  `SceneLightSpec.resolve(world:)` serves both formats (position from column
  3, direction from -z, a rect's extents scaled by the axis lengths, a tube's
  endpoints transformed whole), so glTF and USD cannot drift; the camera
  getter re-derives the scene-center target from the *posed* bounds, which is
  render-safe because moving a look-at target along the view ray leaves the
  view matrix unchanged. Intensity normalization (per kind, brightest = 1)
  happens once at load over the specs (`normalizeLightSpecs`), so reads do no
  cross-light work and later node edits can't re-scale the mix. Assigning
  either array stores a fixed override (`fixedCameras`/`fixedLights`, and a
  get-modify-set tweak of one element is an assignment): the hand-set,
  world-space values win from then on and stop following the nodes, the
  documented trade that keeps "tweak a light after loading" working. USD
  composes prim worlds in double during the walk, but specs resolve through
  the Float node tree; the drift is sub-ulp for authored transforms and the
  whole snapshot suite passed unchanged.
- **Value semantics with an in-place subscript.** `scene.node(_:)` returns a
  copy (nodes are values); `scene["name"]` get/set finds the first depth-first
  match and writes back through the tree, so
  `scene["part"]?.rotate(dt, axis: .unitY)` composes *inside* the authored
  transform and spins the node about its own pivot. The authored matrix is
  never decomposed, so nothing is lost to a TRS round-trip.
- **`Scene` takes the bare public name** (the `Image`/`Environment`
  precedent), which is why the hosts' `var body` spell `some SwiftUI.Scene`.
- Malformed input follows the parser posture: `init?` never traps, a cycle in
  a corrupt node graph is skipped by a visited set, unnamed nodes get `""`.

**Animation (`SceneAnimation` / `scene.apply(_:at:)`).** The file's `animations`
array parses into `SceneAnimation` values (`3D/SceneAnimation.swift`): channels
group into per-node `Track`s (sorted by node index, the determinism rule), each
holding up to three `Sampler`s (translation/rotation/scale) and an optional
morph-weights sampler (below). A sampler stores
keyframe times plus `SIMD4<Float>` values, three per keyframe for cubic
splines (in-tangent, value, out-tangent, the file's element order), and samples
by the spec's formulas: STEP holds the earlier key, LINEAR lerps (rotations
take the spec's shortest-arc slerp, the arc angle from the dot's absolute
value with its sign folded into the second endpoint, degrading to normalized
lerp near parallel), CUBICSPLINE is the Hermite blend with tangents scaled by
the segment duration, quaternion results normalized. Outside the keyframe
range the nearest key's *value* element holds (the spec's clamp), which is
what makes one-shots end in their final pose. Rotation outputs may be
normalized integers (the spec allows four quantized encodings); the reader
decodes them by the spec's int-to-float equations.

Applying is where the "never decompose" rule earns its keep in reverse: a
sampled pose can't be written into a bare matrix without knowing the
un-animated components, so each `SceneNode` built from a TRS-authored glTF
node carries its authored components (`trs`, set from the file's own fields,
never derived from the matrix) plus its file node index (`sourceIndex`, the
identity tracks target). `apply` swaps sampled components into that base and
recomposes T·R·S; a matrix-authored node has `trs == nil` and stays untouched
(the spec forbids animating one). The `position` setter keeps `trs.t` in sync,
so a hand-set position survives a rotation-only track; a hand `rotate` on an
animated node is overwritten by the next `apply`, standard animation-system
semantics. Applying is absolute (a pure function of the animation and time),
so per-frame application converges and export determinism follows from the
sketch clock. `SceneAnimationTests` pin the sampler math against hand-computed
values (the Hermite 2.25 case, the 45-degree slerp midpoint, the quantized
decode) and the apply semantics; the `animated-scene` snapshot pins the whole
pipeline over the bundled orrery at a fixed sample time.

**Skinning and morph targets (the deforming tier).** The posing math lives in
`3D/SceneSkinning.swift`; the loader half extends `GLTFDocument.localMeshData`
(what `localMesh` now wraps) to merge, aligned with the merged vertex order,
the per-vertex `JOINTS_0`/`WEIGHTS_0` attributes (u8/u16 joints; float or
normalized-int weights via the existing `readVec4` decode; kept all-or-nothing
across primitives, the UV rule) and the morph targets' per-vertex POSITION /
NORMAL displacements (kept only when every primitive declares the same target
count, which the format requires). Deform data is stored on the `SceneNode`, not the
public `Mesh`: `skinIndex` + `vertexJoints`/`vertexWeights` + `morphTargets`
internal, `weights` public (the hand-drivable blend-shape surface; a weights
animation channel writes the same property). Skins parse into internal
`SceneSkin` values (joint *file node indices* plus inverse bind matrices, read
by the new `readMat4`). Morph displacements are where sparse accessors and
zero-filled no-buffer-view accessors matter (exporters store only the moved
vertices), so `readVec3` grew both, shared by every VEC3 read.

Posing happens at `drawScene` time with no new user API. A scene with skins
first resolves every file-indexed node's *scene-root* transform in one walk
(`nodeWorldTransforms`; a skinless scene skips it, and the unskinned walk is
structurally unchanged, so `drawSceneMatchesManualTransforms` still pins
byte-identity). Per node, `morphedMesh()` blends displacements at the current
`weights` (nil when there's nothing to do, so plain nodes take the untouched
path), then a skinned node poses through `skinnedMesh(_:skin:worlds:)`: joint
matrix `j` = root-relative world of joint node `j` × its inverse bind matrix,
each vertex blending up to four of them by weight (renormalized over the
influences actually used; a malformed joint index drops out; a zero-weight
vertex keeps bind pose), normals through the blended inverse-transposes. The
result lands in scene-root space because the format's rule is that a skinned
mesh's *own node chain is ignored* (placement comes entirely from the joints),
so the posed mesh draws under the model matrix of the `drawScene` *call*, not
the walk's composed one; a misaligned skin falls back to the undeformed draw
with a one-time note, never a mis-draw. Morphs apply before skinning (the
spec's order). The weights animation channel stores `k` scalars per keyframe
(`k` = the mesh's target count), and for CUBICSPLINE each keyframe groups all
in-tangents, then all values, then all out-tangents; `WeightsSampler` mirrors
the vector sampler's clamp/segment logic over that flat layout.
`SceneSkinningTests` pin the parse (u8 joints, inverse binds, sparse deltas),
the posed blend against hand-computed positions, default-weight precedence
(node over mesh), the cubic weights layout, byte-determinism, and, by render
probe, the ignored-node-transform rule; the `skinned-scene` snapshot pins the
whole pipeline over the bundled tidepool at a fixed sample time.

**The USD leg (`SceneLoaderUSD.swift`).** `Scene(contentsOf:)` routes the USD
family (`.usdz`/`.usdc`/`.usda`/`.usd`) through Ollin's own parser end to
end: one `USDStage` parse builds the node tree in authored order with
per-prim identity, rebuilds each mesh from its authored data, and resolves
preview-surface materials (package textures included), cameras, UsdLux
lights, the baked transform animation, and the UsdSkel deforming tier. The
walk's mechanism and schema rules live in § *The USD parser core* below
(under *The native scene walk*); what belongs to this section is that the
result is an ordinary `Scene`: `drawScene`, the subscript, `apply(_:at:)`,
and the skinning pose path apply unchanged, cameras resolve through the same
shared pose tail as glTF
(`Scene.resolveCamera(projection:near:far:world:sceneCenter:)`: -z view
axis, up column, bounds-center target), and `Mesh.loadUSD` (the merged
`loadMesh` leg) is the same walk with node transforms baked into the
vertices, the glTF merged-loader treatment. `SceneLoaderTests` pin the walk
over inline `.usda` fixtures; the `usd-scene` snapshot pins the whole
pipeline over the bundled court.

The bundled demo assets (`Examples/3D/Geometry/LoadedScene/scene.gltf`, the
animated `Examples/3D/Geometry/AnimatedScene/scene.gltf`, the deforming
`Examples/3D/Geometry/SkinnedScene/scene.gltf`, and the USD trio
`Examples/3D/Geometry/LoadedScene/stage.usda` / `AnimatedScene/stage.usda` /
`SkinnedScene/stage.usda`) are generated by
`Scripts/make-sample-scene.swift` / `make-animated-scene.swift` /
`make-skinned-scene.swift` / `make-usd-scene.swift` /
`make-usd-animated-scene.swift` / `make-usd-skinned-scene.swift` (the
`make-textured-cube.swift` precedent): geometry, cameras, lights, keyframes,
skins, and morph targets all authored in-repo, so the assets carry no
third-party license. The USD scripts author their designed display colors as
*linear* literals (the preview-surface convention), so the spec-correct
color read renders them exactly as designed.

---

## The USD parser core (OllinUSD)

USD is Ollin's native scene format, read by Ollin's own parser rather than
the platform importer (which drops lights, animation, and skinning,
alphabetizes children, and reads colors as display values). The parser core
in `Sources/Ollin/3D/USD/` is the substrate of that arc, now complete: it
opens all three containers and yields one raw prim/attribute tree,
`USDStage` (layer metadata plus `USDPrim`s carrying metadata,
`USDAttribute`s with defaults / time samples / connections,
`USDRelationship`s, and children, all in authored order). It is
**internal**: the consumer stages described below (the scene walk and its
light / animation / skinning legs) turn this tree into the public `Scene`,
and no raw type leaks. The supported envelope, verified against real
exports, is flattened single-layer files and self-contained packages,
variants at their defaults, no cross-file composition. Everything is
clean-room from the OpenUSD source's *structure* (credited in
`ATTRIBUTION.md`, never translated); the study notes live in the format
facts below.

The raw tree keeps the file's own shape rather than applying USD semantics:
tuples (vectors, quaternions, matrices) are component lists in file order,
tuple arrays are flat component arrays with an arity (`point3f[]` →
`.floatTupleArray(3, …)`), scalar widths widen (half/float → `double`, every
int width → `int`/`uint`) but float-typed *arrays* keep `Float` so bulk
geometry preserves exact bits for cross-checking, and a type the parser
doesn't decode records `.unsupported(typeName)` and moves on. One
consequence worth knowing: an *untyped* metadata value keeps the literal's
shape, so the same field can arrive `.stringArray` from text and
`.tokenArray` from crate; consumers normalize, the raw tier doesn't.

- **`USDTextParser`** (`.usda`) is a recursive-descent byte scanner,
  deliberately a *superset* of the grammar (newlines are free where the spec
  requires them; `;` and newline both separate statements). Values coerce by
  the declared attribute type; nested tuples flatten (a `matrix4d`'s rows).
  It parses all three comment forms, the full escape table (`\x` hex, octal,
  unknown-escape-drops-backslash), triple-quoted strings and `@@@…@@@` asset
  literals, `attr.timeSamples = { t: v, … }` blocks, `.connect` targets, and
  list-edit qualifiers. Out-of-envelope constructs are consumed
  string-aware and recorded `.unsupported`, never a throw: variantSet
  blocks, references/payload/inherits/specializes/subLayers, relocates,
  splines, array edits.
- **`USDCrateReader`** (`.usdc`, versions 0.8-0.10; the three versions share
  one structural layout, 0.9/0.10 only add value types) reads the bootstrap
  (`PXR-USDC` magic, version bytes, TOC offset), the TOC's six sections
  (TOKENS/STRINGS/FIELDS/FIELDSETS/PATHS/SPECS), and value data addressed by
  64-bit ValueReps (bits 63/62/61 = array/inlined/compressed, bits 48-55 the
  type, low 48 the payload: an inline value or an absolute offset).
  Structural sections are LZ4-wrapped (`USDLZ4`: a chunk-count byte, then
  raw LZ4 blocks Apple Compression decodes as `COMPRESSION_LZ4_RAW`) and
  mostly integer-coded (`USDIntegerCoding`: a running sum of deltas, each
  the shared common value or an explicit int picked by a 2-bit code stream;
  the 64-bit variant's widths are int16/int32/int64, one step *wider* than
  the 32-bit variant's). The PATHS section is a pre-order traversal
  (`jumps` of -2/-1/0/+n for leaf/child-only/sibling-only/both; a negative
  element-token index marks a property path), decoded iteratively with an
  explicit stack so deep scenes can't overflow. Authored order comes from
  traversal order, overridden by the `primChildren` / `properties` token
  vectors when present. Format traps pinned by the study and honored in
  code: an inlined AssetPath scalar carries a *token* index while its array
  elements carry *string* indexes; Vec2h is the one vector that inlines
  with raw half bits (the others inline as signed per-component int8s,
  matrices as int8 diagonals); TimeSamples is doubly indirected (a forward
  offset to the times rep, then a forward offset to the per-sample rep
  list); compressed float arrays are either all-integers (`'i'`) or a
  lookup table plus indexes (`'t'`); an array rep with payload 0 is the
  empty array; a sub-16-element "compressed" array stores raw.
- **`USDZipArchive`** (`.usdz`) parses the zip central directory itself
  (stored entries per the usdz spec, deflate tolerated, ZIP64 out of
  scope); the package's default layer is the first entry with a usd
  extension in archive order. `USDStage.load` sniffs content, never the
  file extension.

Verification is all local (`USDParserTests`), leaning on the fact that
**Model I/O exports both `.usda` and `.usdc` (crate 0.8)**: the same asset
is exported both ways and the two independent parser paths must agree
(structure exactly, floats to text precision), and both must agree with what
Model I/O reads back (vertex counts, transforms). System vectors cover crate
0.9 (the CoreUSDEdit shaderball) and 0.8 packages (the PencilKit pen usdz
files); a reparse is pinned byte-deterministic through a canonical dump. One
oracle lesson encoded in the tests: for a file Model I/O didn't author, its
`MDLMesh.vertexCount` is the *expanded* per-face-corner count (equal to our
`faceVertexIndices` count), not the authored `points` count, which stays
internally consistent (max index + 1 == point count). The shaderball also
demonstrates the arc's premise: Model I/O alphabetizes the children our
reader returns in authored order.

### The xformOp evaluator and UsdLux lights (stage 2)

The first consumer of the raw tree is the transform evaluator
(`USDXform.swift`, internal): `USDPrim.localXform()` composes a prim's
authored xformOps per `xformOpOrder` into one column-vector matrix, and
`USDStage.visitPrims` walks the tree handing every prim its world transform
(skipping abstract `class` prims). The conventions it encodes, each verified
against the reference implementation's structure and pinned by
`USDXformTests`:

- **`xformOpOrder` lists ops outermost first** (`["translate", "rotateXYZ"]`
  rotates, then translates), which with column-vector matrices means
  composing the listed ops left to right. Ops authored on the prim but
  absent from the order don't apply; no order means the identity.
- **USD matrices are row-vector** (translation in the fourth row), so a
  `matrix4d`'s four rows load directly as simd columns; the convention
  transpose falls out of the flat read.
- **Rotation angles are degrees**, and a three-axis rotate's value is always
  (x, y, z) angles; the op name (`rotateXYZ` … `rotateZYX`) only picks the
  application order, name order first.
- The **`!invert!` prefix** inverts its op (the `translate:pivot` /
  `!invert!translate:pivot` idiom is the pivot mechanism), and
  **`!resetXformStack!`** discards both the inherited stack and any ops
  listed before it. Single-axis forms (`translateX`, `scaleY`, `rotateZ` …)
  and arbitrary `xformOp:<type>:<suffix>` names are handled; a dangling
  op token is skipped, never fatal.
- **`orient` quaternions are (real, i, j, k)** in the raw tree. That is the
  text authoring order, and a crate amendment keeps the containers agreeing:
  crate files store quats as the raw in-memory struct, *imaginary first*
  (i, j, k, w), the one tuple shape where the two containers genuinely
  diverged, so the crate reader reorders scalar and array quats to
  real-first at decode (no local tool authors a crate `orient`, so this leg
  is spec-derived; the composition itself is pinned in text form).

The cross-oracle for the whole stack: Model I/O composes the example
stage's camera ops itself, and `USDXformTests` pins our matrix against its
`MDLObject.transform.matrix` element for element.

The evaluator's first customer was **UsdLux lights** (stage 2 of the arc):
light prims don't survive the platform importer at all, so
`SceneLoaderUSDLights.swift` reads them from the raw tree into ordinary
`Light` values on `Scene.lights`, as node-attached `SceneLightSpec`s the
scene walk attaches where it builds each light prim's node, so visibility
and purpose gate them and the pose resolves through the tree's current
transforms on every read. The mapping is complete over Ollin's light
kinds:

| UsdLux prim | Ollin light |
| --- | --- |
| `SphereLight` | `.point` |
| `SphereLight` + `shaping:cone:angle` | `.spot` (half-angle in degrees × 2 → `coneAngle`; `shaping:cone:softness` → `penumbra`) |
| `DistantLight` | `.directional` |
| `RectLight` (`width` × `height`) | `.rect` (extents scale with the prim's transform; local y is the `up` hint) |
| `DiskLight` (`radius`) | `.disk` |
| `CylinderLight` (`length`, `radius`) | `.tube` (the length runs along local x) |

Every kind emits along its prim's local -z (the camera convention, same as
glTF). Attributes read the `inputs:` prefix with bare pre-21.02 fallbacks;
schema defaults apply (intensity 1, except DistantLight's sunlight-scale
50000; disk/sphere/cylinder radius 0.5; rect 1 × 1; cylinder length 1).
The glTF punctual treatment carries over: colors re-encode linear → sRGB,
and each kind normalizes intensity × 2^exposure to its brightest = 1
(photometric units mean nothing without distance falloff). The usdz leg is
covered by a test-built stored zip (64-byte aligned via the extra-field
padding scheme the reference writer uses), which both our reader and Model
I/O accept; `SceneLoaderTests` pins the full rig, and the example stage's
authored rig (one light of every mapped kind, `make-usd-scene.swift`) is rendered in
the `usd-scene` snapshot.

### Transform animation (stage 3)

The second consumer of the raw tree is transform animation
(`SceneLoaderUSDAnimation.swift`): the xformOp `timeSamples` no platform
importer carries become ordinary `SceneAnimation` tracks, so the shipped
`apply(_:at:)` plays a USD file on the sketch clock with no new user API. A
flattened layer has no named clips, so a stage with any sampled xformOp
yields exactly one (unnamed) animation for its whole timeline;
`scene.animations.first` is the way in.

The design choice, made over mapping plain translate/orient/scale ops
one-to-one onto track kinds: **bake**. For each animated prim, the composed
local matrix is evaluated at the union of the prim's authored sample times
and decomposed into translation/rotation/scale keys (mirrored basis folded
into a negative x scale; consecutive quaternion keys kept on one hemisphere
so the sampler's shortest arc is the baked arc). Baking reuses the stage-2
evaluator wholesale (`localXform(at:)` samples each op's attribute at a time
code instead of reading `authoredValue`), which is what lets *every* authored
op form play through the existing three track kinds: pivot pairs
(`translate:pivot` + `!invert!`, whose baked translation keys orbit),
single-axis ops, mixed orders, whole-matrix ops. The one envelope edge: an op
stack whose composition genuinely shears (matrix ops interleaved with
non-uniform scales) keeps only its TRS part. Each animated prim's rest pose
(the decomposed stack at rest) installs as the node's TRS base, the field
`apply` requires; since every baked track carries all three samplers, the
base never actually shows through.

Per-attribute sampling (`USDAttribute.sampled(at:)`) encodes a spec fact
worth pinning: **interpolation is a runtime stage setting, never authored in
a file** (`UsdStage::SetInterpolationType`; linear is the default every
consumer sees), so baked tracks are LINEAR. Under linear interpolation the
lerp-capable types blend componentwise, quaternion types slerp (the
reference's rule for `GfQuat*`), and everything else holds, which is the
held/STEP semantic exactly where it can occur. Time codes convert to seconds
through the layer's `timeCodesPerSecond` (falling back to `framesPerSecond`,
then the spec default 24), offset by `startTimeCode`, so a Maya-style
frame-101 opening still starts at zero.

**Tracks bind by per-prim identity**: the bake returns entries keyed by each
animated prim's absolute path (`USDBakedTrack`), and the scene walk maps
them onto the `sourceIndex` it assigned that prim's node, installing the
rest pose by the same identity, so a name duplicated across branches
animates exactly the prim that authored the samples. (The interim era, when
the platform importer still built the node tree, bound these tracks by first
depth-first name match through a `Track.nodeName` channel; the native walk
retired that channel entirely, and `apply` matches `sourceIndex` alone.)

Verification closed the stage-3 vector gap: macOS ships `/usr/bin/usdcat`,
a *reference crate writer*, so `USDAnimationTests` converts a hand-authored
animated usda to a real crate 0.8 file at test time (tool-gated, like the
system-asset vectors) and pins the two containers baking identical tracks,
which exercises the crate TimeSamples decode (the doubly-indirected layout,
previously spec-only) against reference output. The bundled kinetic mobile
(`make-usd-animated-scene.swift`, one part per authored form: rotateXYZ
spin, nested counter-spin, translate bob, quaternion orient tumble, the
pivot-idiom pendulum, scale breathing) is rendered in the `usd-animated-scene`
snapshot at a fixed sample time, and a Metal-gated probe renders frames one
authored lap apart byte-identical (the loop-wrap guarantee the example's
`loopDuration` promises).

### UsdSkel skinning and blend shapes (stage 4)

The third consumer of the raw tree is the UsdSkel tier
(`SceneLoaderUSDSkinning.swift`): skeletons, skin bindings, and blend
shapes resolve into the deforming node data `drawScene` already poses
(`SceneSkinning.swift`), so a rigged USD file bends and blends with no new
user API and no new pose math.

A probe settled the architecture before any code. The platform importer
does surface UsdSkel API (an `MDLSkeleton` object with correct joint paths
and bind matrices, a packed joint animation, a morph-deformer component),
but two findings disqualify it as the data path. First, the joint vertex
attributes it reports are wrong: an elementSize-1 binding authored as
`[0, 0, 0, 0, 1, 1, 1, 1]` with unit weights came back as scrambled
four-wide garbage (`[1, 0, 0, 1]` weights and the like). Second, its
vertex layout for a deforming mesh is unstable ground: it keeps the
authored points for a skinned mesh but expands per face corner for an
unskinned one, and Ollin's own normal generation
(`addNormals(creaseThreshold:)` in `readMDLMesh`) then splits vertices
freely (8 authored points became 36), so per-point data (joint weights and
blend-shape offsets, all authored against the points array) has nothing
stable to land on.

So a deforming mesh is **rebuilt from the raw tree** on its authored
points, kept indexed: faces fan-triangulated in authored winding (reversed
under a `leftHanded` orientation), authored vertex-interpolated normals
honored (anything else smooths across faces, the `loadMesh` treatment),
vertex-interpolated `primvars:st` carried with v flipped to the top-left
convention, and the bound preview surface's diffuse color. This rebuild,
taken in stage 4 for deforming meshes only, became the seed of the general
mesh read: the native walk (stage 5 below) runs the same builder for every
Mesh prim, with the deforming form as its keep-indexed case.

**Joints synthesize into ordinary `SceneNode`s.** Each Skeleton prim
becomes a container node at the prim's evaluator world transform, holding
one node per joint nested by the joint paths' own hierarchy (parent = the
longest strict prefix present in the list), each node's TRS base its local
rest transform: authored `restTransforms`, or derived from the world-space
binds (`inv(bind[parent]) · bind[joint]`, the root against the skeleton
world) when a file authors none. Containers append *after* the tree nodes,
their joint identities numbered past every prim index (the walk hands the
skinning pass its next free index), so the one `sourceIndex` scheme covers
prims and joints without collision: SkelAnimation joint tracks bind by that
identity, and a joint poses by hand like any node
(`scene["elbow"]?.rotate(...)`).

The skinning math then falls out of the shipped pose path with no new
code: a joint's scene-root world is skelWorld · jointSkelSpace, and each
binding's inverse-bind entry is authored as
`inv(bindTransform) · geomBindTransform`, so the shipped
`worlds[joint] · inverseBind · point` is exactly the UsdSkel skinning
equation (bind transforms are world-space at bind; the geometry bind
transform maps the mesh's authored points to world at bind), and the
skinned node's own chain stays ignored, the rule glTF already established.
The skel primvars expand per point: `elementSize` influences each
(`constant` interpolation is the rigid binding, one shared element applied to
every point), the `skel:joints` remap reordering mesh-local indices into
skeleton order when authored, influences past the pose path's four dropped
heaviest-first (the blend renormalizes over what's used).

SkelAnimation channels need no bake, because the schema is already
TRS-shaped (`translations`/`rotations`/`scales`, each an array over the
animation's own joint list per sample), so each joint's samplers slice
directly out of the channels: LINEAR per the interpolation rule stage 3
pinned, quats real-first from both containers per the parser rule, a
static (default-only) channel becoming a single held key. Blend shapes
pair `skel:blendShapes` names with `skel:blendShapeTargets` prims by
position; offsets land dense or through the sparse `pointIndices` form
(zeros elsewhere), `normalOffsets` carried along when they pair one-to-one. The
weights channel gathers the animation's `blendShapes` token order into the
mesh's own target order by name (an unnamed shape holds 0) and binds by
the mesh node's identity, joining the joint tracks and the stage-3 xform
tracks in the stage's one merged animation. The animation source resolves
like the spec binds it:
the `skel:animationSource` relationship on the skeleton or inherited down
the prim hierarchy, which is also what lets a blend-shape-only mesh with
no skeleton at all play its weights (the bundled lotus).

Verification: `USDSkinningTests` pins hand-derived skinned positions
through the full `Scene(contentsOf:)` → `apply` → pose path (a two-joint
arm bending 90°, the two-influence midpoint, the rigid binding, whose
expected position also pins morph-before-skin ordering, and the remap
reproducing the unremapped pose); the usdcat-converted crate must resolve
identically to the text authoring (matrix4d[] binds, primvar metadata, the
sparse shape, every channel); and the platform importer's one trustworthy
skeleton output serves as a second oracle: our inverse binds must invert
to its `jointBindTransforms`. The bundled pond
(`make-usd-skinned-scene.swift`: a sea serpent on a five-joint chain with
two blended influences per point and a geometry bind transform; a lotus
breathing on a dense-with-normals bloom and a sparse tip curl) is rendered in the
`usd-skinned-scene` snapshot at a fixed sample time, with the same
Metal-gated loop-wrap probe the mobile has.

### The native scene walk (stage 5)

The final stage moved structure, meshes, cameras, and materials off the
platform importer, so the whole USD `loadScene` path (and `loadMesh`'s USD
leg) now runs on the raw tree alone: `Scene.loadUSDScene` opens the file
once (`USDStage.open`, which for a package also keeps the archive and the
default layer's entry name), walks the prims depth-first in authored order,
and hands the light / animation / skinning legs everything they need. What
the swap fixed outright: children keep their authored order (the platform
importer alphabetizes; the system shaderball's `core, base, sss_bars` group
is the pin, at the parser level and the loaded-`Scene` level), preview
surface colors read spec-correctly (authored values are linear; they
re-encode to sRGB, the treatment every loader's colors get), textures
resolve (a `UsdUVTexture` connected to the diffuse input reads its file
through the package entries or the layer's folder, `USDAssetStore`, with a
sole-file-name fallback for exporters that flatten directory layouts), and
every track kind binds by real per-prim identity.

**Identity.** Each prim that becomes a node gets a `sourceIndex` assigned
depth-first in authored order; the walk returns the path → index map, the
animation bake's path-keyed entries and the skinning attach resolve through
it, and synthesized joints number from the walk's next free index, so one
scheme covers prims and joints. Prims that never become nodes: Material,
Shader, NodeGraph, Skeleton (the skinning pass synthesizes the real joint
subtree), SkelAnimation, BlendShape, GeomSubset, and abstract `class` prims;
everything else, Scope and light and camera prims included, keeps its place
as a (possibly bare) named node.

**The general mesh read.** `buildUSDMesh` generalizes the stage-4 rebuild to
every Mesh prim through one primvar model (`USDPrimvarSpec`): an authored
attribute resolves its interpolation (authored metadata; `varying` reads as
`vertex`; unauthored infers from the element count against the declared
default) and its optional `:indices` indirection (a blocked `None` indices
attribute reads as unauthored), and a mis-sized set drops whole rather than
mis-mapping. A mesh whose normals and texture coordinates are per-point (or
absent) stays **indexed on the authored points**; faceVarying or per-face
(`uniform`) data can't be stored on shared vertices, so the general form **expands
one vertex per face corner** and lands each corner's values exactly, with
missing normals smoothed (which over expanded corners is the honest
flat-facet look). A deforming mesh must stay indexed, the layout its skel
primvars and blend-shape offsets are authored against, so there a
faceVarying set drops instead (`keepIndexed`). `primvars:normals` wins over
`normals` when both are authored, the schema's precedence.

**Schema semantics honored, and the deliberate stances.** `visibility =
"invisible"` prunes rendering for its subtree, and a `guide`/`proxy`
`purpose` does the same (both inherit down; nodes stay in the tree, meshes
and lights skip, cameras skip only under invisibility). `upAxis` and
`metersPerUnit` are *not* applied: a scene arrives in its author's own units
and orientation, the `loadMesh` contract, and a probe confirmed the platform
importer ignored both too, so nothing regressed. A subdivision-surface mesh
draws its control cage (`mesh.subdivided(_:)` is the explicit refinement
path). A `!resetXformStack!` prim bakes the discard into its local matrix
(`parent⁻¹ · composed`) so the tree's composed world stays exact. Raw-value
normalization (the text-vs-crate `.token`/`.string` shapes, tuple arrays,
scalar widths) lives in one set of internal `usd*` accessors on `USDValue`
in `SceneLoaderUSD.swift`; consumers read through them, never re-switch.

**The merged form.** `Mesh.loadUSD` walks the same scene with node
transforms baked into positions and normals (normal matrix per node), first
authored material wins, UVs all-or-nothing, which fixed a real defect: the
platform merge read node-local vertex data with no transforms, so a
multi-part file's parts piled at the origin. Model I/O remains only for
STL/PLY/ABC (`loadViaModelIO`).

Verification kept the three-way oracle: the platform importer still reads
these files, so node worlds cross-check against its composition
(`USDXformTests`), inverse binds against its skeleton read
(`USDSkinningTests`), and camera projections land on the same numbers its
`MDLCamera` derivation produced; usdcat still converts the authored text
vectors to crate at test time; and the fixture scripts author their designed
display colors as linear literals, so the spec-correct read renders the
committed assets exactly as designed and the whole snapshot suite passed the
swap unrecorded.

---

## The 2D soft body

`World.addSoftBody(from:)` (`Sources/OllinPhysics/SoftBody.swift`) turns a `Shape` into a ring of ordinary `Particle`s per contour and holds them with four constraint families inside the world's own relaxation passes: an edge back to its rest length (`stiffness`), links from each particle to the ones two and three along its ring (`bend`), each ring's signed area back to its rest (`pressure`), and every particle toward a rigid fit of the rest shape (`shapeMemory`, shape matching after Müller, Heidelberger, Teschner and Gross 2005, the best turn in the plane read in closed form as `atan2` of the summed cross and dot products). Each is written in the compliant form of XPBD (Macklin, Müller and Chentanez 2016, read from the paper: Δλ = (−C − α̃λ) / (∇C M⁻¹ ∇Cᵀ + α̃), α̃ = α / h², λ cleared at the start of every step), which is what lets a setting mean one body at any step: a disc sagging under its own weight settles to heights within 2% at 30, 60, 120 and 240 Hz. Contacts are hard (no compliance, no multiplier, as the paper treats them).

**The settings are relative gives, measured against the body.** Each 0…1 dial passes through the 3D cloth's `softness` (`(1/s − 1)/10`), and each compliance is that softness times a length over the body's own weight: an edge's or a link's rest length over `mass · pull`, so a whole outline under its own weight gives that share of its length however finely it was cut; a ring's area constraint takes `rest · width / (pressure · weightShare)`, the pressure that holds its share of the weight across its width pressing out `1 / pressure` of the area; and shape memory takes the body's size times its particle count over its weight, divided by a further 100. That last factor is measured, not chosen: read against a load, as the others are, a landing from the body's own height squashes it by about √(2 · softness) of its size, so the useful dial lived between 0.9 and 0.99 (a letter A dropped from its own height slumped into a dome at 0.5 and only held at 0.99). Read against a drop, a disc squashes by a third at 0, a tenth at 0.3, and under 4% at 0.9. `pull` is the world's gravity when the body was made, or the default's 980 when the world had none, the 3D cloth's rule.

**Three shapes that were built and measured out.**
- *Bending as an angle constraint* (the turn at each particle back to its rest turn, the planar form of the dihedral bend). Its gradient is first order even where the outline runs straight, which is why it was the first choice. It came out effectively hard at every setting under the weight-relative scale, and a hard angle constraint not converged within the passes pumps energy into the ring: with one pass a step, the worst angle error grew from 0.25 to 1.4 radians and the kinetic energy climbed until the perimeter reached hundreds of times its length, and with 32 substeps it blew up at eight passes too. Links two and three along are distance constraints, stable at every setting, iteration count and substep count probed. Their cost is that they barely resist a straight side bowing (second order), which `shapeMemory` holds.
- *One area constraint an island* (an outer ring's area less its holes'). A ring with a counter met it by letting the counter collapse into a tangle on the floor while the outer ring shrank to match, so the island's area held and the letter became a solid blob. Each ring holds its own area now, which holds the island's and keeps a counter open; `keepRingsApart` then holds each hole inside its outer ring and outside the others (at shape memory 0 a counter falling onto the bottom of its ring goes 0.03 points outside with it, 13.6 without).
- *Reflecting a particle's velocity at a wall* the way a loose particle's is. The walls run once a pass and the body's constraints pull a particle back against them between passes, so each pass reflects a velocity already reflected; a soft body's particles are held inside `bounds` by position alone. A velocity pass after the solve, reflecting each wall particle's arriving speed once (the restitution pass of Müller, Macklin, Chentanez, Jeschke and Kim 2020), was built and measured: it moved a drop's rebound from 55 to 55 points and a weightless glide's from −2.48 to −2.49, since the particles behind a landing one carry it straight back against the wall on the next step. A soft body bounces by its own give, about half its speed off a wall at the defaults, and `restitution` does not reach it.

**Contacts are per island.** Every island of every body against every other island whose box overlaps, the same body's included, so a word built as one body does not let its letters overlap: each particle inside the other island's material (even-odd over its rings) is moved to the nearest point on its rings, and the edge's two ends take their share the other way by `(1 − t)` and `t` (the standard point-edge gradient). A loose particle with a radius, when `particlesCollide` is on, is pushed out to sit its radius clear. The walk is in the order bodies and particles were added, so a seeded pile settles the same every run (`aRunRepeatsExactly`). Nine piled blobs of about sixty particles each step in 4.3 ms a frame in a release build, most of it in these contact pairs, each of which is the product of the two outlines' particle counts.

A world with no soft body steps exactly as before: every new pass is guarded by the list being empty, and the walls take the old path for every particle that is not a soft body's.

## The 3D physics bridge

The 3D rigid-body world (`World3D` / `Body3D` / `Joint3D` in `OllinPhysics`)
is backed by vendored Jolt Physics (`External/CJolt`, MIT), reached through an
Ollin-authored C bridge rather than Swift C++ interop: `include/cjolt.h` is a
flat-POD `extern "C"` surface (opaque world/constraint pointers, a `uint32`
body handle, tagged-union shape and constraint descriptors carrying plain
float arrays), and `src/cjolt.cpp` is the only compilation unit in the repo
that includes a Jolt header. That containment is what makes Jolt's
define-consistency requirement (every unit including its headers must agree on
all `JPH_*` configuration macros, or structs silently change layout) hold by
construction: the macros live in one target, and Swift only ever imports the C
module. The vendored tree is the upstream `Jolt/` subtree verbatim; the GPU
compute backends, HLSL shaders, and debug renderer are on disk (so their
gated `#include`s resolve) but excluded from the SwiftPM build, since their
`JPH_USE_*` / `JPH_DEBUG_RENDERER` gates stay off.

Inside the bridge, each `CJoltWorld` owns the whole solver stack: a 16 MB temp
allocator, a `JobSystemThreadPool` (threads clamped to 1…8 from the core
count; Jolt's simulation is deterministic across thread counts, which the
test suite pins by stepping two identical worlds to byte-equal poses), the
three broad-phase/object-layer filter implementations, and the
`PhysicsSystem`. The layer scheme is the canonical two-layer setup
(NON_MOVING statics that only pair with MOVING) plus a GHOST layer that pairs
with nothing, reserved for any future collision-less helper body. Library
globals (allocator, RTTI factory, type registry) initialize once per process
behind `std::call_once`; there is no global *world* pool, which is why the 3D
tests run parallel where Box2D's need `@Suite(.serialized)`.

Two hard-won rules live here. First, **marshaling**: the C API's `float[3]` /
`float[4]` parameters map to Swift homogeneous tuples, and the one legal way
to hand a tuple's storage across is `withUnsafe(Mutable)Bytes(of:)` over the
whole tuple (the `CJoltInterop.swift` helpers). The original code took
`withUnsafeMutablePointer(to: &out.0)`, which is only valid for that single
element; the compiler materialized a temporary for it, the C side's writes to
elements 1 and 2 landed in dead stack memory, and positions read back with x
correct while y and z stayed 0, while the simulation itself ran perfectly (the
pendulum's x swung on schedule). The diagnostic that cracked it was printing
poses over time rather than staring at final asserts.

Second, **the grab is Jolt's own reference drag model, reproduced exactly**
(`Samples/SamplesApp.cpp` in the upstream tree): the anchor is a *static*
body that is created but never *added* to the world (nothing can collide with
it, it lives in no broad-phase), it is *teleported* to each new target with
`SetPositionAndRotation` so its velocity is always zero, and the dragged body
hangs on a `DistanceConstraint` with coincident points and a soft limit
spring (frequency 2 Hz, damping 1). Both deviations tried first were real
bugs: a rigid `PointConstraint` on a hand-driven anchor rings undamped, and a
*kinematic* anchor driven by `MoveKinematic` arrives carrying velocity that
the constraint solver dutifully matches, pumping the body into a widening
orbit (measured at ±40 units/s around the target). A zero-velocity anchor
plus a critically-damped spring can only ever bleed energy. The Swift-side
sugar (`grabBody(at:in:)` / `dragGrab(_:to:)`) adds the camera ray
(perspective and orthographic branches over the public `Camera3D` fields) and
remembers the picked point's depth along the view axis, so dragging moves the
body in the screen-parallel plane through the grab point.

Stepping clamps `dt` to `maxTimestep` (1/30 s) and runs
`⌈dt·60⌉` collision passes, so a hitch slows the world rather than
detonating a stack. Snapshot policy follows the artificial-life precedent: the
solver is deterministic *per binary* (pinned behaviorally), but a toolchain
rebuild may move ulps and a toppling stack amplifies them, so 3D physics
scenes are pinned by `RigidBody3DTests`' behavioral asserts (settling
heights, joint arm lengths, grab convergence, byte-equal replays) and not by
pixel references.

**Joint motors, limits, and springs** (stage 2 of the arc) surface the hinge
and slider constraints' powered side through four bridge calls that mutate a
live constraint: `cjolt_constraint_set_motor` (state + target + servo spring +
effort cap in one call), `_set_friction`, `_set_limit_spring`, and
`_current` (the angle/offset readback). Each switches on
`Constraint::GetSubType()` and no-ops on every other kind, so the Swift side
never needs a downcast or a kind check to stay safe. The design follows the
solver's own model rather than inventing one: a *velocity* motor is
`SetMotorState(Velocity)` + a target rate capped by the motor's torque/force
limits, and a *position* motor is `SetMotorState(Position)` + a target driven
by the `MotorSettings` spring (frequency in Hz, damping as a ratio, the
upstream defaults 2/1 kept as `drive(to:)`'s defaults), which is what
`Joint3D.drive(at:)` / `drive(to:)` map onto one-to-one. Three facts here are
load-bearing. Hinge limits must be authored min ∈ [-π, 0], max ∈ [0, π]
*relative to the connect pose* (the constraint defines angle 0 as the relative
orientation at creation because the bridge sets both bodies' constraint frames
identically in world space); `connect` clamps the user's range to that window
rather than letting the solver assert. `set_motor` ends by activating both
constraint bodies (`ActivateBody` skips statics internally): a settled body
sleeps, a sleeping pair never feels a motor change, and a door whose closer
engages after the scene has gone quiet would otherwise hang open forever with
no error anywhere. And the friction parameter (`SetMaxFrictionTorque` /
`SetMaxFrictionForce`) applies only while the motor is *off*, per the solver's
contract, which is exactly what lets one number serve as both the stiff-hinge
drag and the coast-down brake after `stopMotor()`. Units cross the bridge the
same way everything else does: angles and rad/s pass through untouched, slider
targets/offsets convert by `unitsPerMeter`, and the effort caps stay in the
solver's N·m / N (documented, defaulted to unlimited via a non-finite
sentinel the bridge maps to ±FLT_MAX). `JointMotor3DTests` pins each parameter
behaviorally, always against its counterfactual twin (a capped motor against
an unlimited one, a limited pendulum against a free one, soft limits against
hard), the same discipline as the rest of the suite.

**Compound bodies and world-geometry colliders** (stage 3) widen the shape
catalog without touching the API's shape: `CJoltShapeDesc` grew a recursive
child array (`CJoltShapeChild`, a desc pointer plus a local pose), a
height-field leg (samples plus offset/scale), and the tapered forms, and
`makeShape` recurses through `StaticCompoundShapeSettings::AddShape` (whose
`Create` collapses a single posed child to a rotated/translated shape and a
single unposed child to the child itself, which is why an offset single shape
costs nothing extra). Marshaling moved from the fixed two-deep
`withUnsafeBufferPointer` nesting to a `ShapeDescArena` (plain allocations
owned by an object held across the create call with `withExtendedLifetime`),
because a compound makes the number of pinned buffers data-dependent, which
static scope nesting cannot express. Two facts here are load-bearing. First,
`mAllowDynamicOrKinematic` must be `false` for any shape whose
`MustBeStatic()` is true (mesh, height field, a compound containing either):
allowing the switch makes body creation compute mass properties, which those
shapes cannot provide, and the library *traps*; this was a latent stage-1 bug
(nothing had ever actually created a `.mesh`-collider body) that the stage-3
tests exposed. The static pin now asks the created shape rather than
pattern-matching the descriptor type, `cjolt_body_set_motion` refuses the
switch for the same shapes, and the Swift side notes once when a dynamic body
gets pinned. Second, the height-field mapping: the solver wants an n×n sample
grid with n a multiple of its block size (a power of two stores best) and
defines the surface as `offset + scale · (x, h[z·n+x], z)`, so `Collider3D`
resamples the `Heightfield` bilinearly onto the smallest power of two
covering the source grid's cells (4…1024 per side; a 257-sample
diamond-square field lands on its natural 256) and derives offset/scale to
reproduce `mesh(width:depth:height:)`'s centered sizing exactly, storing 16
bits per sample so the collider tracks the drawn mesh to well under a visible
error. Per-part densities are carried in each child's own desc (the body's relative
density multiplies the part's). Scene colliders are a walk over the
package-visible `Scene.visitWorlds`: each mesh node's composed world
transform is baked into its triangles (general matrices, scale and shear
included, which a body pose could not carry) and added as one static mesh
body at identity, followed by an `OptimizeBroadPhase`. `Collider3DTests`
pins the tier: compound mass sums and balance against a counterfactual twin,
offset and rotated part poses, tapered-shape masses against their analytic
neighbors (a cone weighs a third of its cylinder), the flat and non-square
height-field mappings, the off-the-edge void, the scene-collider transform
composition, and a byte-identical compound-on-terrain replay.

**Contact events and sensors** (stage 4) surface the solver's collision
callbacks without letting a callback anywhere near Swift. Jolt's
`ContactListener` fires on the job system's worker threads *during* `Update`,
several at once, with every body locked, so the bridge's `ContactRecorder`
(owned by `CJoltWorld`, declared *before* the `PhysicsSystem` that holds a
pointer to it, so it outlives every step that could still be writing) buffers
flat-POD `CJoltContactEvent`s under a `std::mutex`, and
`cjolt_world_drain_contacts` empties that buffer on the main thread after
`cjolt_world_step` returns. This is the C cousin of the render-thread-closure
rule: the listener only ever reads what it is handed.

Three decisions inside the recorder are load-bearing. First, **the buffer
speaks in body pairs, not sub-shapes**: a compound's parts and a mesh's
triangles each report their own contact, so the listener reference-counts each
pair (keyed on the two `BodyID`s packed into a `uint64`) and emits one `began`
as the count leaves zero and one `ended` as it returns, which is the
granularity a sketch asks about; without it a crate landing on terrain would
report a landing per triangle. Second, **the drain sorts before it copies**
(by pair, then phase): worker threads record in whatever order they finish, so
an unsorted list would replay differently run to run and break the catalog's
determinism rule (the Swift-side `touching` lists are sorted arrays rather
than `Set`s for the same reason). Third, **`speed` is computed in
`OnContactAdded`, where the velocities are still pre-solve**: the manifold
normal moves body 2 out of collision, so it points from body 1 toward body 2,
and the solver's own measure of a pair's relative velocity is `v2 - v1` along
that normal, negative while they approach, which makes the closing speed
`(v1 - v2) · n` clamped at zero. `OnContactRemoved` receives only a
`SubShapeIDPair` and may not touch the bodies at all (one may already have
been destroyed), which is why an `ended` event carries no point or normal, and
why an event naming a body Swift can no longer resolve is dropped after its
bookkeeping is applied rather than handed over half-resolved.

**Soft bodies come in through a second listener**, and its shape is nothing
like the first. `SoftBodyContactListener::OnSoftBodyContactAdded` is called once
per soft body per collision pass, carrying a `SoftBodyManifold` of that body's
*whole* contact set, and there is no removal callback at all. So began and ended
are derived rather than reported: the same `ContactRecorder` (registered as both
listeners, so a sketch reads one list) accumulates the set as it arrives and
`finishSoftStep` diffs it against the previous step's once `Update` has
returned. Three details are load-bearing. The event's point and normal come
from the *first* particle to report a pair, which keeps the choice deterministic
(the particle array is in a fixed order); the pair is stored low id first and
the normal turned around when the soft body is the second of the two, so the
"normal runs from `a` toward `b`" rule holds either way. `speed` reads
`inSoftBody.GetLinearVelocity()` **during the callback**, which is the velocity
the body arrived with, because the update averages the new particle velocities
into it only afterwards, and reading the *other* body's would be a documented
race. And a sleeping soft body's touches are **held rather than ended**: the
solver stops asking it, which is not the same as it having let go. (That is a
deliberate difference from the rigid rule, where the solver genuinely reports
the removal; it is also what tells the buoyancy pass below that a sunk sheet is
lying on something rather than stalled in mid water.)

Sensors use `BodyCreationSettings::mIsSensor` plus a fourth object layer
(`SENSOR`) that pairs only with `MOVING`, so a trigger volume never spends a
step colliding against static scenery or another sensor. The DX decision here
came from the library's own note: a *static* sensor only detects **active**
bodies, and Jolt drops the contact the moment a body falls asleep, so a
pressure plate would report empty the instant its load settled. A sensor is
therefore created **kinematic and activated** whatever `kind` was asked for
(`Body::UpdateSleepState` exempts sensors from sleeping outright), which is
what makes `sensor.touching` answer the standing occupancy question that
`Body3D.touching` on ordinary solids cannot: a settled pile sleeps and stops
reporting, pinned deliberately by `aSleepingPileStopsReportingItsTouches`
against its sensor counterfactual. Ray casts take a `NonSensorBodyFilter` so
the cursor looks through a trigger to the solid scene, and `Body3D.kind`
refuses to change a sensor's motion (with a `noteOnce`) rather than silently
turning it solid. The `ground` slab became a registered-but-unlisted `Body3D`
(`world.groundBody`, in `bodyByID` so a contact can name it, out of `bodies`
so no drawing loop has to skip a 1000-unit box). `Contact3DTests` pins the
tier behaviorally, each against its twin: a sensor passes a ball through to
exactly the height an empty scene would while a solid one catches it, a
compound's simultaneous foot contacts report one event where two loose boxes
report two, impact speed tracks √(2gh) across drop heights, the list survives
being read twice and empties on a quiet step, a removed body leaves no stale
touches, and two identical worlds log identical event sequences.

### Characters

`Character3D` wraps Jolt's `CharacterVirtual`, which is a different kind of
object from everything above it: not a body the solver integrates, but a shape
the library sweeps through the world by hand on demand. It is not in the broad
phase, nothing collides with it by default, and the `PhysicsSystem` does not
know it exists. That shape drives every structural decision in the tier.

It needs its own bridge object and its own update call. `CJoltCharacter` holds
the `Ref<CharacterVirtual>` plus the two distances the combined update takes
(`stepHeight`, `stickToFloor`): those are *arguments* to `ExtendedUpdate`
rather than state on the character, so the wrapper is where they live.
`World3D.step(dt:)` advances every character **before** `cjolt_world_step`,
which is the order the library's own sample runs in: the character sweeps
against the world as it currently stands, then the solver integrates the
bodies. `ExtendedUpdate` is the update to call rather than `Update`, because
it is the one that also walks stairs and sticks to the floor; both distances
run along the character's own up axis, which must match the world's y-up.

Two geometry decisions make the API pleasant. The capsule is built
bottom-at-origin (a `RotatedTranslatedShape` lifting a `CapsuleShape` by
`halfHeight + radius`), so `position` is the character's **feet**: a figure
modeled standing at the origin stands on the ground in the world, and
`withCharacter` is a plain translate. And `mSupportingVolume` is
`Plane(up, -radius)`, so only contacts against the lower cap can hold the
character up; without it a hand brushing a wall counts as ground. `isOnGround`
reads `GetGroundState()` rather than testing a normal, because the library
already distinguishes *supported by ground you may walk on* from *supported by
a slope you may not* from *touching something that cannot hold you*.

**The velocity composition is the library's recipe, and it lives in Swift.**
`Character3D.advance(dt:)` reproduces the sample's `HandleInput`: on the
ground the character inherits `GetGroundVelocity()` rather than keeping its own
fall speed (which is what makes a moving platform carry it, and is the only
moment a jump may be added), in the air it keeps just its vertical velocity so
gravity accumulates, gravity is added, and the walking velocity is added last
and always. Ollin takes the no-inertia, air-control-on branch: the sketch owns
input feel, and Ollin already has easing and springs for anyone who wants
smoothing. Keeping the composition on the Swift side leaves the C bridge a
thin mechanical wrapper and the policy visible where Ollin's DX decisions live.

**`velocity` is intent; `actualVelocity` is outcome.** The library never
writes the achieved velocity back into `mLinearVelocity`, so a character pinned
against a wall still reads a full walking pace. The sample derives an
"effective velocity" from the position delta across the update, and
`Character3D` does the same, publishing it as `actualVelocity`. The pair is
worth having rather than tidying away: `velocity` is the right thing to read
for what the character is attempting, and `actualVelocity` is the only correct
input to a walk cycle, which otherwise skates on the spot against a wall.

**Every character always carries an inner rigid body.** `CharacterVirtual`
supports an optional `mInnerBodyShape`, and Ollin makes it mandatory rather
than a flag: it is what gives the character presence among the ordinary
bodies, so ray picking finds it, the stage-4 `ContactRecorder` reports it,
sensors see it walk in, and a fast body cannot pass through it in one step. It
is created kinematic in `Layers::MOVING` at 0.9× scale so it never collides
before the swept shape does, and the library filters it out of the character's
own queries. On the Swift side it surfaces as `character.body`, registered in
`bodyByID` (so a `Contact3D` can name it) but deliberately kept out of
`world.bodies`, the `groundBody` precedent: a drawing loop over the bodies
should not render a capsule where the sketch draws its own figure. Characters
also register in the world's `CharacterVsCharacterCollisionSimple`, since
otherwise they would pass through each other.

Three envelope facts, each measured rather than assumed. `stepHeight` is the
distance the stair walk probes upward, not a hard ceiling: the capsule's
rounded foot slides up an edge slightly before the probe runs, so a ledge roughly a
quarter taller than the setting may still be climbed (0.4 climbs 0.5, stops at
0.6). That is inherent to the algorithm, so it is documented rather than
compensated for, and the tests use a clear margin. Whether `pushStrength`
actually shifts something is a contest with ground friction, not just mass: a
4 kg crate slides under the default 100 N while a 43 kg one does not, because
friction alone asks for more. And a slope *just* past `maxSlope` reads
`.onSteepSlope` while a near-vertical face reads `.notSupported`, because at
that angle nothing supports the capsule at all.

`Character3DTests` pins the tier behaviorally, each parameter against a
counterfactual twin that isolates it: the same 40° ramp is climbed or refused
by `maxSlope` alone, the same 0.3 ledge is a step or a wall by `stepHeight`
alone, the same crate scatters or blocks by `pushStrength` alone. Around those
sit the standing/falling/steep readbacks, jump-only-from-the-ground, a platform
carrying the character exactly as far as it travels, a sensor reporting the
walk-through, teleporting re-reading the ground, and identical replays.

### Vehicles

`Vehicle3D` wraps Jolt's `VehicleConstraint` plus its `WheeledVehicleController`
(or the `MotorcycleController` subclass). The shape of the tier follows from
one fact: **a vehicle is a constraint on a body, not a body.** The chassis is
an ordinary `Body3D` created through the same path as everything else, so it
collides, takes impulses, reports contacts, is ray-pickable, and lives in
`world.bodies`; the constraint on top owns the wheels, the suspension springs,
and the drivetrain. Creating one therefore does three registrations, and
missing the third is the classic mistake (upstream's own header warns about
it): `AddConstraint`, then `AddStepListener`, because the wheels are collided
and driven inside `PhysicsStepListener::OnStep` and a vehicle without that
listener keeps its shape and simply never moves. Teardown reverses both, and
`World3D.remove(_:)` destroys the constraint *before* the chassis body, since a
constraint may not outlive a body it holds.

Two fields were added to `CJoltBodyDesc` for the chassis and are used only
there so far: an explicit `mass` (`EOverrideMassProperties::CalculateInertia`,
so a 1500 kg car is 1500 kg whatever its box's volume) and a `centerOfMass`
offset, applied by wrapping the finished shape in an
`OffsetCenterOfMassShape`. The wrapper is load-bearing for drivability (weight
at roof height levers a car over in the first corner) and free for drawing,
because Jolt's `GetPosition()`/`GetWorldTransform()` report the *shape origin*
rather than the center of mass, so `withBody` is unaffected. `addVehicle`
defaults the offset to the mean height of the wheel mounts, which reproduces
the reference sample's hand-picked value for a normal layout.

**Axles are derived from geometry, not from list order.** The public surface is
per-wheel (`steers`, `driven`), but Jolt wants differentials and anti-roll bars
addressed as left/right index pairs. `Vehicle3D.axles(of:)` sorts wheels by
`(z, x, index)` (deterministic, the catalog rule), clusters them where their
`z` agree within a quarter of the wheelbase, and pairs within a cluster; a lone
wheel becomes a single-wheel axle with the other index `-1`, which is how a
two-wheeler's front and back come out right where pairing by list order would
have made one nonsense "axle" out of them. An axle with any driven wheel gets a
differential (engine torque split evenly across driven axles), and every full
pair gets an anti-roll bar. **At least one axle must be driven**: with zero
differentials the controller's own debug assert on the torque split fires (the
ratios sum to 0 rather than 1), so with nothing marked the rear axle takes it.

**Gearing is solved from a speed rather than exposed as ratios.** The gearbox
keeps the library's default gear ratios, and the differential ratio is derived
in the bridge (where those ratios live) from `topSpeed`: top gear at the
engine's max RPM must turn a driven wheel of the measured radius that fast.
That turns an opaque number into one a sketch understands, and it stays live,
since `GetDifferentials()` is writable between steps. Ollin does **not** adopt
the sample's `SetTireMaxImpulseCallback` 10× longitudinal hack, which exists
only to preserve the feel of settings tuned against an old bug; the library's
physically-correct Coulomb limit is used, and the defaults (500 N·m against a
30 units/s top speed) were picked by measuring a torque sweep, not by copying
the sample's numbers. That sweep is also what settled `suspensionTravel`'s
0.3 default: at 1.5 Hz a 1500 kg car sags ~0.11 m, so 0.2 sat too close to the
bump stops.

**Everything on a wheel is live, `driven` included.** `applyWheelDesc` writes a
whole `CJoltWheelDesc` onto a wheel's settings, and the same function serves
the initial build and a retune through `cjolt_vehicle_set_wheel_settings`,
which `const_cast`s the wheel's settings handle. That is safe here and nowhere
else: the bridge news one settings object per wheel and shares it with nothing,
and the solver re-reads every field on each step. The friction curves are
rebuilt from a default-constructed tire before `grip` scales them, so repeated
pushes cannot compound. `driven` is the gearbox rather than the wheel, so it
goes through `cjolt_vehicle_set_drive`, which rebuilds the differentials (or a
tracked machine's two sprockets) and re-solves the gearing against the new
driven radius, so the vehicle keeps the `topSpeed` it was given. That is why
`CJoltVehicle` remembers that speed. **The rebuild waits for the step rather
than happening on the assignment**, and that is load-bearing rather than an
optimization: which wheels drive is set a list at a time, and each wheel's flag
is only half an answer while the loop is still running. Rebuilding eagerly
means the drivetrain's report-back (an axle the engine turns drives *both* its
wheels) lands in the middle of the sketch's loop and undoes the assignments
still to come, so `for wheel in car.wheels { wheel.driven = wheel.position.z > 0 }`
ends with all four driven. Deferring to `advance()` makes the whole list one
edit, resolved once.

Three smaller decisions. The wheels' collision tester gets a body filter that
rejects both the chassis and any sensor, because overriding the filter replaces
the default one that hides the vehicle from itself, and a detector volume is a
region to drive through rather than a surface to ride on. All three testers
(ray, sphere, cylinder) are built up front and switched by reference, so
`wheelContact` costs nothing. And the wheel pose comes back as a position plus
a quaternion posing a **+y-aligned cylinder** (`GetWheelWorldTransform(i,
Vec3::sAxisY(), Vec3::sAxisX())`), which is the axis convention `drawCylinder`
already uses, so `withWheel` is a translate and a rotate with no fixups.

**The tracked sibling is a third controller, and the wheel type goes with it.**
`tracked: true` builds a `TrackedVehicleControllerSettings` instead, whose
`ConstructWheel` asserts on the wheel settings type, so the bridge news
`WheelSettingsTV` rather than `WheelSettingsWV` for every wheel. That split is
the reason `applyWheelDesc` became a shared base over two overloads: the
suspension, the position, and the radius are the same on both, while a rolling
wheel takes steering, brakes, and friction *curves* read against slip, and a
road wheel takes a flat pair of coefficients (4.0 longitudinal, 2.0 lateral by
default, which is why a band keeps pulling while it slides where a tire's curve
falls away past its peak). `cjolt_vehicle_get_wheel` guards its `WheelWV` cast
on the kind for the same reason; a `WheelTV` has no slip to report.

Three decisions shape the tier, all of them on the Ollin side. **The bands come
from geometry, like axles do:** the driver's right is `-x`, so the left band
takes the wheels at positive `x`, and a machine whose wheels all sit on one side
is refused rather than half-built (the controller looks each wheel's track up by
an index it fills in from the two lists, and a wheel no track claims keeps the
`-1` it was born with). The axle list is still computed and still passed, but
for a tracked machine it is read only for the anti-roll bars, which live on the
constraint rather than on the controller. **Steering is mapped in the bridge**,
because the library takes a rotation-rate multiplier per band rather than an
angle: `steering` runs the inside band at `1 - 2·|steering|`, so half lock stops
it and full lock reverses it. Neither ratio may be exactly zero (the controller
divides by them, and asserts), so a stopped band is spelled as a very slow one.
**Track inertia is derived, not exposed.** `VehicleTrackSettings::mInertia` is
the band's moment at the sprocket, and the library's default is one number for
one reference machine; Ollin forms it as `share · mass · radius²` with the share
at 0.08, which is a run of track plus its road wheels swung at the sprocket's
radius. It was measured across a range of sizes rather than guessed, and the
range from 0.02 to 0.15 turned out to be within a few percent on straight-line
distance and turn rate, which is why it stays a constant instead of becoming a
parameter. The same derivation covers the brake: a band's `mMaxBrakeTorque` is the
sum of its wheels' `brakeTorque`, so the shipped per-wheel parameter keeps meaning
something and a tracked machine needs no new one.

The motorcycle sibling is the same call with `balances: true`. Getting it to
work took one thing the car does not need: `casterAngle`, which rakes both
`mSuspensionDirection` and `mSteeringAxis` back on the front wheel. Without
it the balance controller cannot hold a line and the machine goes over within a
second; with 30° of rake it holds a line, and leans into a corner. Note that a
two-wheeler running dead straight stays up even with the controller *off*,
because nothing perturbs it, so the counterfactual test starts it leaned over.

`Vehicle3DTests` pins the tier behaviorally, each parameter against a counterfactual
twin. The sharpest is the drive-routing one: two identical cars with slick
front tires, differing only in which axle is driven, travel 2× apart, which
pins where the torque goes rather than merely that there is some. Around it sit
the suspension sag (soft vs stiff), braking distance vs coasting, the hand
brake locking only the wheels that have one, reverse braking through a stop,
the top-speed gearing ceiling, wheels-in-the-air, the axle grouping as a pure
CPU test, a leaned two-wheeler righting itself where the unbalanced twin falls,
and identical replays. `TrackedVehicle3DTests` does the same for the tracked
machine, its own sharpest being the pivot: full lock turns it through more than
a full circle without leaving its own length, where the twin given the same
throttle and no steering covers ground and barely changes heading. Around it
sit the band split, one sprocket per band, the bands running opposite ways in a
pivot and the inside one stopping at half lock, steering needing throttle at
all, a road wheel neither steering nor slipping, the brake and the hand brake
pulling the same one, slick bands climbing less of the same bank, and the live
`driven` rebuild pinned through the wheeled routing test (a car switched to
front drive after it was built goes nowhere on slick front tires where its
rear-driven twin keeps its legs).

### Ragdolls

A `Ragdoll3D` (`Sources/OllinPhysics/Ragdoll3D.swift`, the fitting in
`RagdollFit.swift`) turns a skinned `Scene` into a tree of rigid bodies and
writes the simulated pose back onto the skin. It is built on the library's own
`RagdollSettings` / `Ragdoll` pair rather than on loose bodies and constraints,
which buys three things the loose form would have to reinvent: `Stabilize()`
(the Havok mass-ratio and inertia treatment, without which a light hand on a
heavy arm makes the solver fight itself), `CalculateConstraintPriorities()` (the
root solved before the leaves), and `DisableParentChildCollisions()` (the group
filter that stops a thigh from fighting the pelvis it sits inside).

**The layout decision everything else follows from: a limb's body stands at its
joint, not in the middle of its bone.** The shape is pushed out along the bone
inside the body instead, through the same `RotatedTranslatedShape` the character
capsule uses. That makes the body's world transform *identical* to the joint's
world transform, so reading the pose back is an assignment with nothing to
undo (the write-back test pins node positions equal to body positions to 1e-5),
and the local rotation a motor is aimed at is exactly the skeleton's own local
rotation. The alternative (bodies centered on the bones, as the library's own
sample authors them by hand) would need a per-joint offset carried through every
read and write.

**The skeleton seam lives in the core**, since satellites never depend on each
other: `Scene.skeleton()` flattens the first skin's joints (name, file identity,
parent within the skin, current world transform, inverse bind matrix),
`Scene.skinnedVertices()` hands over the mesh in bind space with its joint
weights, and `Scene.setJointWorlds(_:)` is the write-back, all `package` in
`Sources/Ollin/3D/SceneSkeleton.swift`. `setJointWorlds` walks top-down so a
joint it writes is what its children are placed against, preserves whatever
scale a node carries (a rigid pose has none, and dividing it out would shrink
the mesh), and re-syncs a node's TRS animation base to the posed transform so a
rotation-only track applied next frame does not drag the node back to its
authored translation. Joints it is not given keep their local transform and move with
their parent, which is what makes a partial ragdoll (one that skips the fingers)
carry the rest of the figure rigidly.

**Shapes are fitted to the mesh, not derived from bone lengths.** Each vertex is
assigned to the limb whose weight over it is largest (summed over the joints
that limb absorbed), transformed into that joint's frame by its inverse bind
matrix, and a capsule is fitted: the axis is the dominant eigenvector of the
covariance (power iteration seeded from the largest-variance world axis, so the
start can never sit at right angles to the answer), the radius the 85th
percentile of the perpendicular distances, and the ends the 2nd and 98th
percentiles along the axis. Percentiles rather than extremes, because one stray
vertex should not decide how thick an arm is; the axis is flipped to point away
from the parent joint so the twist convention is consistent. A limb with too
little mesh falls back to the bone to its children, and one with neither to a
small ball. The measured result on the bundled figure: a torso radius of 0.157
against a forearm's 0.051, from bones of similar length. Mass is split by fitted
volume and then rebalanced by `Stabilize()`, which preserves each chain's total,
so the figure weighs what the call asked for.

**Powered figures.** `drive(toward:)` reads the target scene's joint worlds,
converts each to the child's rotation relative to its ragdoll parent, and hands
the whole array to `cjolt_ragdoll_drive_to_pose`, which sets both motors of
every swing-twist to `Position` and calls `SetTargetOrientationBS`. The spring
(frequency, damping) and the torque cap are re-applied on every call, so
`strength` is live. The root has no constraint, so a powered figure holds its
*shape* and still falls as a whole; pinning the root limb kinematic is the
puppet-on-a-hook answer, and `ragdoll.kind = .kinematic` routes `drive(toward:)`
to `MoveKinematic` instead, giving the figure arrival velocities so it shoves
what it walks through.

**Bodies in, but not in `bodies`.** Each limb is wrapped in an ordinary `Body3D`
and registered in `world.bodyByID` (so contacts, sensors, and ray picking name
it) while staying out of `world.bodies`, the `groundBody` / `character.body`
precedent: a sketch draws the figure's mesh, not the capsules. Making that
useful needed one fix elsewhere: `body(under:in:)` looked its hit up in
`world.bodies`, so it could never find a limb; it now looks up `bodyByID`, which
also makes a character's stand-in and the ground slab pickable.

`Ragdoll3DTests` pins the tier behaviorally (no pixel snapshots, the physics
policy). The headline counterfactual is the same figure dropped twice, once
driven toward its rest pose and once limp: the shape error (per-limb offset from
the built pose, measured in the root's own frame so falling over does not count
as deforming) is 0.48 limp against 0.034 powered. Around it sit the fit
proportions, mass split, placement, the named-joint subset and what the skipped
joints do, the exact write-back, `pose(from:)` as a reset, a weaker motor
sagging further, the kinematic figure shoving a crate out of its way, a
cone-limited joint stopping where a `.ball` keeps going, a tighter cone holding
a figure straighter, per-joint retuning, limbs naming the floor they land on,
parent-child pairs never reporting a touch while two figures do, and identical
replays.

### Soft bodies

A `SoftBody3D` (`Sources/OllinPhysics/SoftBody3D.swift`) is the one member of
the tier whose state is not a pose. The solver's soft body is position-based
dynamics over a set of particles with springs between them; the bridge
(`cjolt_soft_body_*`) builds one `SoftBodySharedSettings` per body from a mesh,
lets the library derive stretch, shear, and fold constraints from the faces, and
hands the result to `CreateAndAddSoftBody`. It is a real body in the world (it
collides with the rigid bodies and a ray cast reports its id), so it needs no
step listener or hand update the way a character and a vehicle do.

**Welding is a precondition, not a nicety.** Ollin's generators emit flat-shaded
meshes whose triangles share no vertex index, and a soft body built from one has
no connectivity at all: it would fall apart into loose triangles on the first
step. `addSoftBody` runs the mesh through the core's `package` `Mesh.welded()`
seam (`Sources/Ollin/3D/MeshWeld.swift`, a thin public-to-the-package face over
the existing internal `WeldedMesh`, the same seam `MeshReactionDiffusion` needs
for the same reason), simulates on the merged particles, and republishes through
the `remap` so `positions` and `mesh` still line up with the source mesh index
for index. That is what keeps uvs, colors, and the material intact.

**Normals follow the source mesh, not the winding.** The read-back derives
normals from the simulated positions, and the obvious cross-product order is
right only if the mesh winds the way you assume. Ollin's own catalog does not
agree with itself here (the same trap the subdivision surfaces hit), so a
`Mesh.plane` came back lit from underneath. The fix is a one-time area-weighted
vote at build time: derive the normals for the *rest* shape, dot them against
the mesh's authored normals, and remember a flip if the sum is negative.

**Two parameters, both made scale-free, both by measurement.** The library's own
numbers are physical and therefore useless as a 0…1 dial:

- *Compliance* (the inverse stiffness of a spring, in m/N) has to be compared
  against the load, so one fixed value visibly softens a heavy cloth and does
  nothing at all to a light one. The parameter is normalized by the body's own
  hanging weight: `scale = meanEdge * sqrt(particleCount) / (mass * gravity)` is
  the compliance at which one loaded edge stretches by its own length, and
  `stiffness` maps onto a fraction of it. Measured: mean edge stretch runs
  1.0005 at `stiffness: 1` to 1.22 at `0.05`, and a 0.2 kg cloth and a 20 kg one
  agree to four decimals (`stiffnessMeansTheSameAtAnyWeight` pins that).
- A *fold* constraint measures an angle where a stretch constraint measures a
  length, so its compliance carries two fewer powers of length. `bend` therefore
  divides the same scale by the mean edge squared. Without that correction the
  whole 0.2…1.0 range of the parameter was already rigid, which the first probe found
  by sweeping.

`pressure` is the third: the solver's number is `n R T`, and working the force
through `ApplyPressure` gives an outward acceleration of `pressure * area /
(mass * volume)`. Expressing the parameter as that acceleration *in gravities* makes
`pressure: 1` mean "just holds its own weight up" at any size, and the rest
shape's area and volume are measured once at build time to convert. It is
refused on an open surface with a one-time note, the closed test being that
every edge belongs to exactly two faces.

**A soft body is a thing in the world**, which took three seams. Its touches
reach `world.contacts` through the soft contact listener described above, and
the same widening that made that possible (a contact naming
`any Colliding3D` rather than two `Body3D`s) let the query surface stop looking
through one. Buoyancy is its own loop (see *Water and buoyancy*). The shared
protocol is deliberately tiny (`group`, `isAwake`, `wake()`, `userData`, all of
which already existed on both types) because the *difference* between the two
kinds is the honest part: a cast to `Body3D` is what a sketch writes before
applying an impulse, and it fails for exactly the reason it should.

**What the tier does not do**, all of it the library's own envelope rather than
a shortcut: soft bodies collide with rigid bodies but not with each other or
themselves; impulses and constraints do not apply to one (hence `applyForce` for
wind, and a grip that *pins a particle* rather than adding a joint); and there
is no tearing, because a tear has to split a shared vertex and rebuild the
constraint set, which cannot be done to a body mid-simulation.

#### Why there is no tetrahedral solid

The solver has a sixth constraint family the bridge does not build: a **volume
constraint** over a tetrahedron, held at the volume it started with, which is
how a body resists being squashed through its *interior* rather than only at its
skin. Its cost is that nothing in the library builds one. `CreateConstraints`
derives edges and bends from *faces*, so the tetrahedra are the caller's to
supply, and the only tetrahedral body upstream ships is `sCreateCube`, whose
lattice *is* its surface. Any other shape has to be meshed, and the tractable
form of that is a lattice: fill the bounds with a grid, keep the cells a parity
test puts inside the surface, split each into six tetrahedra, and tie the skin
to the result.

Both models were built and measured against each other before the question was
answered, because the answer decides whether a whole meshing pass and two more
parameters earn their place. The shipped one wins or ties everywhere that matters, on
a 5 kg ball of radius 0.5 against a lattice six cells across:

| | `pressure: 20` | interior lattice + tetrahedra |
|---|---|---|
| a 25 kg weight on it | 99.2% of its height, 100.0% of its volume | 100.4%, 100.0% |
| a finger driven 0.4 in, then withdrawn | no dent left, 100.0% of its volume | no dent left, 100.0% |
| pressed to 45% of its height, released | **100% of its height, 100% of its volume** | 111%, 103% |
| the same on a cube | **99%, 97%** | 115%, 60% |
| holding an authored cube's volume at rest | 122 to 127% at `bend: 0`, 100.6 to 103.5% at `bend: 1` | 100.0% |
| particles | 162 | 243 |

Three readings settle it. The **load case is a tie**, so the tetrahedra are not
buying the thing they exist for at any load a sketch would apply. **Recovery
from a hard squash goes to pressure**, which is the case a jelly demo actually
is. And the one place the lattice plainly wins, holding an authored shape with
corners, is won by its interior **springs** rather than its tetrahedra: the same
lattice with the tetrahedra removed holds the same 100.0%, and `bend` closes
most of that gap on a pressurised body anyway, which is guidance worth having on
its own and is on the soft-body page.

The recovery gap is structural rather than tunable, and it is one line of the
vendored source: `ApplyVolumeConstraints` forms its residual as
`abs(signed volume) - restVolume`, so a tetrahedron that has been turned inside
out reads as *satisfied* and nothing pushes it back. A hard squash is exactly
what inverts one. A gas law has no such failure mode, since `nRT/V` grows
without bound as the enclosed volume shrinks, which is why the pressurised ball
comes back round every time.

Two things would reopen it: a vertex-follows-tetrahedron binding in the solver
(so a real surface could follow a lattice instead of hanging off springs, which is
what a proper embedded solid does and what Jolt's skinned constraints, which
bind to a *skeleton*, cannot express), or a signed volume constraint that can
drive an inverted tetrahedron back out.

Found while probing this, and fixed rather than worked around: a soft body built
from `Mesh.cylinder` detonated inside half a second, because the generator wound
both cap fans into the solid while its wall wound out of it, leaving the surface
with no consistent inside. `Mesh.pyramid`, `Mesh.roundedBox`, `Mesh.extrude`,
and `Mesh.cone` carried versions of the same defect, and `parametricSurface`
derived its normals as `du × dv` while winding its quads the other way, which
shaded the superellipsoid, the supershape, the Möbius band, and the Klein bottle
from the side the light is not on. Nothing culls back faces and every mesh
carries its own normals, so none of it showed in a snapshot; `MeshTests`
now pins consistent outward winding and normal/winding agreement across the
catalog.

`SoftBody3DTests` pins it behaviorally against counterfactual twins: a pinned
sheet hangs where a free one lands on the floor, a stiffer cloth stretches less
under the same load, a fold-resisting sheet held at its middle stays a plate
where a limp one falls around the pin, a pressurized ball keeps a height and
volume a limp one loses and shoves a crate further, pressure on a sheet changes
nothing at all, a cloth drapes over a sphere rather than through it, a wind
holds a banner out, and identical runs replay identically.

### Cloth a skeleton carries

The solver has a second way to hold a particle, beside pinning it: a *skinned
constraint* ties it to a set of joints and caps how far it may travel from where
they put it. That is what turns a hanging sheet into a cape. Ollin exposes it as
four parameters on the same `addSoftBody` call (`skinnedTo:` a scene,
`carriedBy:` a closure naming a joint per vertex, `sway:` a leash, `backStop:` a
clearance) plus `SoftBody3D.follow(_:)` and `snap(to:)`.

**The bind pose is whatever the figure is standing in when the cloth is built,
which is what makes it need no authored weights.** The solver's `InvBind` takes
a particle's rest position into a joint's own space; Ollin forms it as
`jointWorld_bind⁻¹ · placement`, where `placement` is where the rest shape was
put. Every later pose then reads as the motion since. This has a second payoff
that is not obvious: **the bind pose does not have to be remembered to be
recovered**, since `placement · invBind⁻¹` is that joint's bind transform back
exactly. That is what lets a surface restored from a snapshot, with no scene in
sight, open standing in the pose it was hung in rather than collapsed on its own
origin.

Scale is handled by dividing *only the translation* of every joint matrix by
`unitsPerMeter`. For an affine `[R|t]` that is exactly the conjugation
`S⁻¹ [R|t] S`, so a bind and a later pose converted the same way compose in
meters precisely as they did in world units.

**A particle held exactly on the skin is made kinematic, and that is the link
between the two halves of the slice.** The library treats `maxDistance == 0` as
"kinematic" for the skin constraint's own purposes, but leaves the particle's
inverse mass alone, so the springs still drag it about. The bridge zeroes the
inverse mass instead, which does three things at once: the particle really does
hold still, `CalculateClosestKinematic` can see it, and therefore the long-range
attachments (`maxStretch:`, the LRA constraints of Kim/Chentanez/Mueller-Fischer,
which Ollin drives at `GeodesicDistance` so a cloth that has to reach round a
corner is not over-constrained) work from anchors the *skin* holds rather than
only from ones pinned to the world. Measured: a 6 kg cape hangs 8 cm long on its
springs alone and exactly at its rest length with `maxStretch: 1`.

**`sway` and `backStop` are raw lengths, and that is a deliberate departure from
the stage-8 rule.** Stiffness, bend, and pressure all needed a derived
normalization because compliance is m/N and means different things on different
bodies. A distance does not: probed across the range, a sway of 0.02 held every
particle within 0.0202 of its skinned position, 0.05 within 0.0504, 0.1 within
0.1009, and a back stop of 0.02/0.1/0.4 held the cloth 0.0200/0.1000/0.4005 past
the skin. The rule is about *compliance*, not about every physical number the
library takes.

**`follow(_:)` records; `World3D.step` applies.** `SkinVertices` interpolates
from the previous skin pose to the current one across the step's iterations, so
it must be called exactly once per step; recording the pose and handing it over
beside the character and vehicle passes is what guarantees that, and it keeps the
"call it before `step`" contract meaningful. `snap(to:)` is the other half and
applies at once, since a teleport should be readable before the next step.

**A pose that changed is what wakes the body, and it had to be.** The skin
constraints are solved *during* the update, so a cape that has hung still long
enough to settle and fall asleep stops answering the figure entirely: stand
still until it settles, walk away, and it is left behind (the probe caught this
outright, with a fully hard-skinned cape reading 1.39 units adrift). Comparing
this frame's flattened pose against the last applied one costs a few hundred
float comparisons and wakes the body only when the figure has actually moved,
which is also what still lets a cape on a still figure sleep.

Build order inside `cjolt_soft_body_create` is load-bearing: the skinned
constraints and their inverse-mass zeroing go in *before* `CreateConstraints`
(which is where the LRA constraints are derived, and it can only see kinematic
particles that already are), and `CalculateSkinnedConstraintNormals` runs before
`Optimize`. Both are no-ops for a body nothing carries, so every existing soft
body is untouched.

The snapshot writes the skin down rather than naming a scene for it: the
closures that decided which joint carries what, and how long each leash is, are
a sketch's and cannot be carried, exactly as the pinned list already could not
be. That is one `simd_float4x4` per joint plus six numbers per carried particle
(measured 4.7 KB for a 169-particle cape), and it needs no new `PhysicsAsset`
case, because the mesh is already named. `SkinnedCloth3DTests` pins the tier
against counterfactual twins: a cape goes where its figure goes where an
unskinned twin stays put, a settled one still answers a figure that walks away,
each leash holds to within 5%, cutting the skin loose multiplies the reach
five-fold, the back stop holds the cloth off the figure a free one sinks into,
a snapshot round trip is exact to 1e-6 and still follows a figure it has never
seen, and identical runs replay identically.

### Ropes, and the frame a rod carries

A `Rope3D` (`Sources/OllinPhysics/Rope3D.swift`) is a `SoftBody3D` whose
particles are held by **Cosserat rods** rather than springs. The solver has
seven soft-body constraint families and the bridge built four; this is the
fifth, and it is the one that carries an *orientation*: a rod holds a rotation
of its own, integrated beside the particle positions, so geometry attached to it
turns as the rope bends and twists. A chain of springs cannot express that,
because a frame guessed from neighboring points has no roll.

**A rope is the first body with no surface at all**, which is why the slice
starts in the bridge. `cjolt_soft_body_create` refused a description with no
faces, on the reasonable ground that a triangle is the smallest simulable
surface; with rods it is not the smallest simulable *thing*. The guard now asks
for faces only when there are no rods. Everything downstream of that turns out
to cope: `CreateConstraints` derives its long-range attachments by walking every
vertex rather than every face, and `CalculateClosestKinematic` walks rods beside
edges, so `maxStretch:` works on a rope with no faces exactly as it does on
cloth (measured 1.80 uncapped against 1.00 capped on the same hanging rope) as
long as the rods go in *before* `CreateConstraints`, the same ordering rule the
skin already needed.

**Two things the solver does to the rod list have to be undone on the way out,
and both are the bridge's to know.** `Optimize()` reorders the rods so it can
solve them in parallel, and `CalculateRodProperties()` reverses any rod pointing
against its neighbor, so that neighboring rods agree on which way is forward.
The handle therefore records, per rod the caller handed over, where it ended up
and whether it was turned around; the read-back walks that map and multiplies a
reversed rod's rotation by a half turn about its own x axis, which sends its +z
back the way the caller asked for and leaves a right-handed frame. So `segments`
speaks in the caller's own order and direction, and the `RopeSegment3D.rotation` a
sketch reads is the same convention `withSegment(_:)` applies (local +y along
the rope, matching the axis Ollin's cylinders and capsules stand on; local +x
carries the twist).

**The bend parameter needed its own scale, and the scale had to be measured.** The
stretch constraint is a distance constraint with an orientation term bolted on,
so the shipped cloth normalization (`meanEdge * sqrt(particleCount) /
(mass * gravity)`) transfers to it unchanged and is exact across weight. The
bend-and-twist constraint is not: it holds a *rotation*, a pure number, against
a generalized mass in units of inverse kilograms, so there is no length in it to
compare against and no derivation to do. Hanging a rope out sideways and asking
what compliance leaves the tip drooping by a third of the rope's own length, at
several lengths and particle counts, gives

    compliance  ∝  meanRod² / (length³ · mass · gravity)

with a measured constant that puts "drooping about a third" in the middle of the
parameter. Before the correction the parameter's meaning ran away with length: `bend: 0.1`
left a half-unit rope nearly rigid (drop/span 0.13) and a six-unit one limp
(0.92). After it, `bend: 0.5` lands at 0.26 to 0.33 across twelve-fold in length,
double in particle count, and a hundred-fold in mass. The one case that stays
out of that band is a long rope divided finely (forty particles over six units),
which is not the normalization failing but PBD's own propagation limit, the same
thing the raft's folding deck runs into: stiffness travels one rod per solver
pass, so `iterations` is the lever, and raising it from 5 to 20 takes that rope
from 0.40 to 0.027.

Both sweeps were **non-monotone at 900 steps and monotone at 3000**, which is
the stage-8 lesson again: a cantilever that has not stopped swinging measures
its swing, not its stiffness.

**Putting a rope back needed the rod frames, and they turned out to be
writable.** `SoftBodyMotionProperties` exposes `GetRodRotation` and no setter,
and `mRodStates` is private, so a restored rope opened every rod in the frame
its *rest* polyline gives while its particles stood in the shape it had reached.
The stretch constraint then hauls each rod round to the direction it is actually
in and drags the particles with it: exact at the moment of restore and 0.36
units out one step later, oscillating for hundreds of frames. The way in is that
`RodStretchShear::mBishop` is read exactly once at runtime, by
`SoftBodyMotionProperties::Initialize`, to seed each rod's state, and everything
else it feeds (`mLength`, `mOmega0`, the rest shape the rope springs back
toward) is computed by `CalculateRodProperties` *before* that. So writing the
saved orientations over `mBishop` after that call and before `Optimize` stands
the rope's rods back up without touching the shape it wants to return to.
Measured on a settled rope: 0.0014 units of drift after one step against 0.361
without, which is what `aRestoredRopeDoesNotSpring` pins as a counterfactual
twin. It also means a rope needs **no `assetName`** to be saved, unlike a
surface: its whole rest shape is a handful of points, so it carries itself.

**The envelope is mostly the tier's, with one piece of its own.** No
self-collision, so a coil passes through its own turns. And `SoftBodyShape` is
built entirely from a body's faces, so a rope is invisible to `raycast`,
`sweep`, and `bodiesOverlapping` (pinned deliberately by
`aRopeIsInvisibleToQueries`) while still colliding perfectly well *with* the
rigid world, because that runs off the particles' own `vertexRadius` spheres
inside the soft body's update rather than off the shape. `grabSoftBody(at:in:)`
would have gone with the queries, so it falls back to the nearest particle
within a few thicknesses of the line of sight, which is what the cursor plainly
means on a rope anyway. Branching is out: a rope is one strand, and a plant with
several stems is several ropes.

#### Why there is no hair tier

Jolt v5.6.0 vendors a whole `Jolt/Physics/Hair/` module beside the soft-body
solver, with 49 shader files behind it. It simulates a groom of hair strands as
Cosserat rods, which is the same maths `Rope3D` already uses, plus a velocity
and density grid that lets strands push on each other, plus an interpolation step
that draws ten render strands around every simulated one. It is not exposed, and
the reason is a fit problem before it is a cost problem.

**It cannot be blown.** `Hair` takes no force of any kind: the whole external
input is the head's transform, the scalp's joint matrices, and the velocities of
the shapes it collides with, and the string `wind` does not occur anywhere in the
49 shaders (upstream's own missing-features list in `Hair.h` names wind forces
first). Hair moves because the head moved, and by nothing else. Every cloth and
rope thing Ollin ships is built the other way round: the Drape, Cape, and Rigging
examples all gust, through `applyForce`. A strand tier that cannot be blown is
not the tier this framework wants.

**It only collides with `ConvexHullShape`.** Every other shape sub-type is
skipped without a word (`Hair.cpp`, the `GetSubType() == EShapeSubType::ConvexHull`
tests). Ollin's figures are meshes and capsules: a ragdoll limb is a capsule and
so is a character, so hair would pass straight through everything the framework
actually builds.

**Its input is an authored groom, not a generated one.** `HairSettings` wants
simulation strands, render strands, a scalp mesh, that scalp's inverse bind pose
and skin weights, and per-frame joint matrices, and nothing in Jolt reads a groom
from a file. In Ollin the sketch is what makes the geometry, and once a sketch is
placing strands the whole scalp-and-bind-pose apparatus is overhead:
`addRope(through:)` already takes a polyline.

**And it is a secondary-motion system, by design.** Measured on 200 strands run
for 10 s at the shipped defaults: gravity moves the tip of a 175 mm strand by
2.15 mm, against 0.02 mm with the gravity factor zeroed. Long-range attachments
and the global-pose pull hold the hair in the groom it was authored in, which is
right for a game character whose hair should stay styled and is the opposite of
what a sketch asks for.

The measurement that made the decision cheap is that the rod family **already
exposed** carries more strands than the hair module does. Nothing requires a
soft body's rods to form one chain, so one body can hold a whole groom. On an M2
in release, 8 particles per strand, every root held and every body awake:

| strands (particles) | one soft body of rod chains | `Rope3D`, one body each | Jolt `Hair`, its own CPU backend |
|---|---|---|---|
| 1,000 (8,000) | **0.94 ms** | 1.30 ms at 800 | 5.93 ms |
| 4,000 (32,000) | **3.10 ms** | 8.58 ms | 20.28 ms |
| 10,000 (80,000) | **6.90 ms** | past the body budget | 25.74 ms at 4,000 simulated |
| 20,000 (160,000) | **12.45 ms** | past the body budget | past the frame |
| 40,000 (320,000) | 26.02 ms | past the body budget | past the frame |

So the shipped solver holds **20,000 fully simulated strands inside a 60 fps
frame** where the hair module holds about 2,500, and it does it with no new build
machinery at all. The honest caveat is that those hair figures are its **CPU**
compute backend, whose `Dispatch` is a serial loop over every thread and which
upstream describes as being for debugging rather than for speed, so a Metal
backend would be much faster than the table shows. That is exactly the work being
declined: the Metal backend loads a precompiled `Jolt.metallib`, so the 49
HLSL-dialect shaders would have to be cross-compiled to Metal and packaged by a
SwiftPM build step, the backend is Objective-C++ and would have to be admitted
into a target whose whole point is that `cjolt.cpp` is the only code that ever
includes a Jolt header, and the simulation's output is a Jolt-owned compute
buffer that Ollin's renderer would need a new path to draw, since the readback
the header offers is labeled slow and for debugging. All of that pays for a
system that still could not be blown by wind.

`Rope3D`'s own parameters already reach hair scale, which is what makes the
alternative real rather than theoretical: swept from rope scale down, a
cantilever of 8 points at 25 mm spacing droops 0.26 of its span at `bend: 0.5`
and 0.79 at `bend: 0.05`. Below about 10 mm of spacing the bend parameter loses its
authority (0.149 against 0.165 across its whole range), which is the floor.

What would reopen it: upstream growing **external forces** and **collision
against more than convex hulls**, and dropping the "currently still in
development" note the header carries, since those two are what make it fit at
all. If hair is wanted before then, the way in is a multi-strand `Rope3D` rather
than this module: one body, many rod chains, drawn with a few interpolated
strands around each simulated one, which is the trick that earns the hair module
most of its strand count and is pure sketch code. The cost to watch there is
build rather than step time, since `CreateConstraints` plus `Optimize` take 3.2 s
at 20,000 strands and 12.5 s at 40,000, which makes strand count a setup-shaped
artwork parameter like `maxVertices`.

### Water and buoyancy

Buoyancy is unlike everything above it in the bridge: the solver does not work
it out. It is an impulse the *caller* applies to each body, each step, before
the step that integrates it, so the whole tier is a loop Ollin owns rather than
a thing configured on the solver. `World3D.step` runs it after the characters
and vehicles have had their turn and immediately before `cjolt_world_step`,
which is where the library's own samples put it.

**Density in, factor out.** The library takes a dimensionless `inBuoyancy`
(the ratio of the fluid's density to the body's) rather than a fluid density,
on the grounds that a plain number is easier to configure. That is the wrong
trade for Ollin, because a body already carries a `density` and the collider
already maps relative density 1 onto 1000 kg/m³, which is water. So the bridge
takes a real density and forms the ratio itself:
`buoyancy = scale · density · totalVolume / mass`. The `totalVolume` is
deliberately the one `GetSubmergedVolume` just returned rather than the shape's
own reported volume, so the ratio is formed against exactly the volume the
submerged fraction was measured against and the waterline lands where the
displaced volume says. That is what makes the headline behavior derived rather
than tuned: a body of density *d* settles with fraction *d* of itself under,
pinned to within 0.08 across the range (the margin is what sleeping costs,
since the solver freezes the body wherever its last small oscillation had
reached rather than at the exact equilibrium).

**Waves are a per-body tangent plane.** The impulse takes a surface *point and
normal* per call, not a world-wide plane, so a swell is expressible: each body
is handed the plane tangent to the surface under its own center of mass. This
is the library's own boat sample's approach, not an invention, and it carries
the honest envelope that a body much larger than the wavelength it floats on is
approximated. The surface function lives in Swift (`Water.surface(at:phase:)`,
three crossed sines whose gradient is analytic rather than sampled either
side), which is what lets `waterMesh` and `waterHeight` read the very same
surface the bodies float on: one source of truth, pinned by a test that every mesh
vertex sits at `waterHeight` for its own x and z.

**The query.** `cjolt_world_bodies_in_box` is a broad-phase `CollideAABox` over
the water volume, filtered to dynamic rigid non-sensor bodies and sorted by
handle. The filter is load-bearing three times over: a kinematic body (a
sensor, a character's stand-in) has motion properties an impulse would nudge
off its driven path, a static one has none at all, and the library's buoyancy
is not implemented for soft bodies and asserts on one. Sorting is the
stage-4 rule again, so the per-body pass replays in one order.

**Sleeping is the interesting part, and the wake rule has two modes because
one is wrong.** A floating body settles and sleeps, and that is correct: it
holds its waterline exactly (measured: zero movement over thirty further
seconds) and costs nothing. It stops being right the moment the surface moves,
and the two ways that happens want different answers. A swell wakes only what
its surface passes through, tested against the body's own bounds, so a stone
sunk to the bottom (whose submerged volume no wave shape can change) sleeps on
instead of being stirred awake every step. A *changed setting* has to wake
everything afloat wherever it is, because the equilibrium itself moved. The
first draft had only the swell rule, and a probe caught it: raising the level
from 0 to 3 left a sleeping crate frozen at the old waterline, three units
under the new surface and outside its own bounds, so it never woke.
`CJoltBuoyancyWake` names the three cases rather than passing a bool.

**A soft body is floated by hand, particle by particle.** The library's own
`ApplyBuoyancyImpulse` asserts `IsRigidBody()`, and rightly: it works through one
inverse mass, one inertia tensor, and a submerged volume, and a bag of particles
has none of them (`SoftBodyShape::GetSubmergedVolume` returns a submerged volume
of exactly zero: it is a stub). So `cjolt_soft_body_apply_buoyancy` adds the
impulse to each particle's velocity itself, and three decisions make it work.

*What it takes is a ratio, not a density.* The lift a particle gets is
`(ρ_fluid / ρ_body) · g` upward, which needs neither the particle's mass nor its
share of a volume the surface may not even enclose, so the bridge takes that
dimensionless number and Swift forms it, since only Swift knows what a sheet
weighs per unit of displacement. Hence `SoftBody3D.density`: derived from mass
over enclosed volume when the surface is closed (the rigid tier's
derived-not-tuned rule), and `1` for an open sheet, which has no volume to
derive one from.

*Every particle gets its own surface height.* Where a rigid body is handed one
tangent plane sampled under its center, the call takes an array of per-particle
heights above the surface, computed in Swift from the same `Water.height(at:)`
the drawn mesh uses. This was not a refinement, it was the fix for a real
defect: with one plane, a 4-unit raft on a 9-unit swell reads its far edges
against a wave that is not under them, so every crest gave the edges extra lift
and the deck **curled into a bowl** over about ten seconds. Sampling per
particle made the same deck flat, and it is cheap (three sines per particle, on
a body whose positions the frame reads anyway).

*A particle's push ramps in over a band* about one particle spacing wide, rather
than switching on at the surface. A flat sheet's particles all cross the
waterline at the same instant, so a hard test leaves it with no waterline to
hold and it chatters; with the band, the deck settles where the mean submerged
fraction balances its weight.

Sleeping needed one more rule than the rigid path. A cloth's area for its weight
is enormous, so drag holds a *sinking* sheet below Jolt's own sleep threshold
(`mPointVelocitySleepThreshold`, compared against a squared speed, so an
effective 0.17 m/s) and it would stop dead in mid water and read as though it
floated. The test that fixes it is not a measurement but a fact about the two
densities: **a body heavier than the fluid has no depth at which the fluid could
hold it**, so if it is asleep, submerged, and touching nothing, it has stalled
rather than settled, and it is woken. "Touching nothing" is exactly why a
sleeping soft body keeps its touch list: a sheet lying on the sea bed has its
weight carried and is left alone. An earlier draft compared the *measured* lift
against 1 and failed, because a floating raft's balance sits a few percent under
a full gravity (measured 0.92: the band means it settles slightly high, and the
solver freezes it there).

`Buoyancy3DTests` (25) pins the tier against counterfactual twins: a cork
floats where a stone sinks and where the same cork with no water just falls,
the waterline tracks density across the range, denser water floats the same
body higher, drag settles a body that otherwise still bobs after a minute, a
sleeping floater holds its level, a tide reaches it, a swell carries what
floats and lets the bottom sleep, a current drifts a raft, `buoyancy`
multiplies both ways, sensors and soft bodies are left alone, the drawn surface
is the one the bodies float on, and identical runs replay identically.

### World queries

Ray casts, shape sweeps, and overlap tests (`Sources/OllinPhysics/Query3D.swift`
over four `cjolt_world_*` bridge calls) are the one part of the tier that is not
simulation at all: they run against the world as it stands, between steps, and
change nothing. The public surface is `raycast` / `raycastAll`, `sweep` /
`sweepAll`, `bodiesOverlapping`, and `bodiesContaining`, all returning the
shared `Hit3D` (body, point, outward normal, distance along the query) the way
`Contact3D` is the shared answer for what the solver noticed *during* a step.

Three decisions carry the design.

**The filter is a struct, not a parameter list.** `CJoltQueryFilter` (ignore
list, `includeSensors`, `includeSoftBodies`) travels by pointer into every query
call, so the next way to narrow one, collision layers and groups, is a field
there rather than another argument added to four functions and their Swift
wrappers. Its body-level half is `QueryBodyFilter`, which replaced the old
`NonSensorBodyFilter` the mouse pick used; the broad-phase and object-layer
halves are the same "filter as a moving body would" pair the pick has always
used, so statics stay visible and the ghost layer (grab anchors) never is.

**What a query is blind to was chosen, not inherited.** A sensor is a region to
be inside rather than a surface to hit, so it is transparent unless asked for,
which is the rule mouse picking already followed. Soft bodies started
transparent for a different reason (no single rigid pose meant no `Body3D` to
hand back), and that reason **went away** with the soft-contact work: once a
touch could name `any Colliding3D`, so could a hit, and a hanging sheet blocking
a sightline is plainly the right answer. So `Hit3D.body` is
`any Colliding3D` and every query sees cloth (`ignoring:` and collision groups
are how to look through one). Every public call site of `hit.body` was an
identity comparison, so the widening cost one cast, in the one place that
applies an impulse to what it found. Bodies the world keeps out of `bodies` (the
ground slab, a character's stand-in, a ragdoll's limbs) *are* visible too,
because hits resolve through `bodyByID` rather than by searching that array,
which is what makes a ground probe and a line-of-sight check on a walking figure
work at all.

**The mouse pick is the same call.** It used to be a separate internal ray
(`World3D.pick`) that differed only by seeing soft bodies; once the public
queries saw them too it had nothing left to be, so it went away and
`body(under:in:)` and `grabSoftBody(at:in:)` both call `raycast` and cast the
hit to the kind they can use. A cloth hanging in front of a crate therefore
answers for the cloth, which is what the cursor is on.

The rest is mechanical, with two details worth knowing. A ray's surface normal
can only come from the body itself (the sub-shape id names the face, and a
mesh's faces each have their own), so it needs a `BodyLockRead`; a sweep's
normal is the negated penetration axis, which needs no lock. And the count-out
convention is that the bridge returns how many hits it *found*, not how many it
wrote, so the Swift side sizes a stack buffer of 16, and grows exactly once when
a scene held more.

Sweeping or overlapping with a `mesh` or `heightfield` collider is refused with a
one-time note (`Shape::MustBeStatic()` asked of the created shape, the stage-3
rule): those describe scenery, and the narrow phase cannot carry one as a probe.
`Query3DTests` (24) pins the surface against counterfactual twins: the nearest
body versus the same scene with it removed, line of sight with and without the
wall, a swept sphere stopping exactly its radius short of where the ray reached,
a gap a ray threads and a sphere does not, a probe that fits turned edge-on and
not flat, a compound reported once however many parts are inside, sensors and
soft bodies invisible until asked for or never, distances in world units under
a changed `unitsPerMeter`, and identical worlds answering identically.

### Collision groups

"These two never touch" is a named `CollisionGroup` on each thing plus a
symmetric table on the world (`Sources/OllinPhysics/CollisionGroup.swift`,
`ignoreCollisions(between:and:)` / `allowCollisions` / `collides(_:with:)`).
The name is `ExpressibleByStringLiteral`, so a group costs one word at the site
where a body is made and the rule is one sentence somewhere else.

**The group is stored in the object layer, and that choice is the whole design.**
Jolt offers two filtering mechanisms and only one of them reaches everywhere a
pair can meet. A per-body `CollisionGroup`/`GroupFilterTable` is consulted in
the *narrow phase only*: it would never have reached a query, a character's own
sweep, or a wheel's collision tester, it hardcodes "same sub-group never
collides" (so two crates in one category could not have stacked), and it only
filters within one `GroupID`, which is the slot the ragdoll tier already owns
for keeping one figure's limbs from fighting each other. The **object layer**,
by contrast, is what the library consults when finding collision pairs (so the
contact listener never hears a filtered touch), in `DefaultObjectLayerFilter`
for every query, in the filters `CharacterVirtual::ExtendedUpdate` takes, in the
`ObjectLayer` a `VehicleCollisionTester` is constructed against, and in
`SoftBodyCreationSettings::mObjectLayer`. So the layer carries both halves:

```
layer = (group << 2) | kind        kind in {NON_MOVING 0, MOVING 1, GHOST 2, SENSOR 3}
```

Group 0 leaves every layer numerically identical to the four fixed kinds it had
before, so **a world that never names a group is byte-identical**; the whole
existing physics suite passed unchanged. `ObjectLayerPairFilterImpl` answers the
fixed kind rules against `layerKind` and then consults a `GroupTable` (64
`uint64` rows, symmetric, diagonal meaningful), written from the main thread
between steps and only read while one runs. The two mechanisms stay orthogonal:
a ragdoll takes an Ollin group *and* keeps its own per-figure `CollisionGroup`,
so two figures in one group still collide.

Four places needed the split honored by hand, each of which would otherwise have
filtered a pair in one place and not another:

- **Character against character.** Characters are not in the broad phase; they
  meet through `CharacterVsCharacterCollisionSimple`, which the layer never
  reaches. `GroupedCharacterCollision` wraps it and refills a scratch list per
  call with only the characters the caller's group can touch, then delegates to
  the library's own loop rather than reimplementing it. The group travels as the
  `CharacterVirtual`'s user data (the bridge owns that slot) so the filter can
  answer for a bare character pointer without searching for its wrapper.
- **Wheel collision testers** take their `ObjectLayer` at construction, so
  changing a live vehicle's group rebuilds all three and re-selects the active
  one (`CJoltVehicle` remembers `contactIndex` and `wheelWidth` for exactly
  this). `Body3D.group`'s setter routes through `World3D.bodyChangedGroup` so
  setting the group on a *chassis body* rebuilds them too, rather than leaving
  the wheels probing the old layer.
- **Buoyancy's sweep** asked for `SpecifiedObjectLayerFilter(Layers::MOVING)`,
  an exact layer match that was correct while there was one moving layer and
  silently stops floating a grouped body once there are many. It became a
  kind-based `MovingKindLayerFilter`. This was a real trap, caught by writing
  the "a grouped body still floats" twin before trusting the encoding.
- **Motion changes rewrite the layer**, so `cjolt_body_set_motion` and
  `cjolt_ragdoll_set_motion` re-derive it from the *current* layer's group: a
  body let go from static keeps the group it was in.

Queries narrow through a `group` field on `CJoltQueryFilter` rather than a new
parameter on the four C calls (the stage-10 rule paying off); `QueryFilters`
builds its layer filters from `layerFor(filter->group, MOVING)`, so `as:` sees
what a moving body of that group would see and the default group answers exactly
as before. A world holds 64 groups; naming more keeps the extras in `.default`
(never aliasing an in-use group, which would filter the wrong things) with a
one-time note, and `world.collisionGroups` lists the names so a typo, which is a
*new* group silently doing nothing, is findable. `CollisionFilter3DTests` (22)
pins each answer against its counterfactual twin, the sharpest being the four
that exist only to catch a half-applied filter: the contact listener, the
sensor, the second character, and the wheel testers.

**Per-body motion parameters** are three fields the body descriptor and the solver
already carried, surfaced as `Body3D.freedom` / `.gravityScale` / `.checksPath`
plus matching `addBody` parameters. Two of them are pass-throughs
(`SetGravityFactor`, `SetMotionQuality`, both null-guarded upstream so they are
safe on a body that is currently static). `freedom` is not, and the reason is
worth keeping: the library stores a restricted degree of freedom *inside the
body's mass properties* (a locked axis is one given infinite mass or inertia),
so `MotionProperties::SetMassProperties(dofs, properties)` is the only way in
and the whole mass set has to be re-derived from the shape and scaled back to
the body's mass. That mass cannot be read back from the body, because a body
whose translation is already locked reports an inverse mass of zero, so
`cjolt_body_set_freedom` takes it as an argument: Swift passes the explicit
mass a body was created with (the vehicle-chassis path) and otherwise zero,
which the bridge reads as "the shape's own". The same asymmetry made
`cjolt_body_get_mass` answer `0` for a pinned body, reading as weightless when
the truth is the opposite, so it now falls back to the shape's mass properties
and `Body3D.mass` prefers an overridden mass over that fallback. `Freedom3D` is
an `OptionSet` whose raw bits *are* `EAllowedDOFs`', so the bridge casts; two
sanity rules live in `allowedDOFs()`: a zeroed descriptor field means
unrestricted (which is what keeps every existing `CJoltBodyDesc()` byte-identical,
including the ground slab's), and a mask with no freedom at all, which the
library documents as invalid and would divide by a zero mass, reads as
unrestricted rather than trapping. Setting freedom activates the body, since
whatever was holding it still may not any more.

Continuous collision detection needed a decision rather than a translation. The
solver's own gate (`PhysicsSystem`, `mLinearCastThreshold` × the shape's inner
radius) means a `LinearCast` body that is moving slowly costs nothing beyond one
comparison, which is what makes an opt-in flag the right shape: turning it on
for a slow body is measurably free (`checkingThePathChangesNothingWhileTheBodyIsSlow`
pins the two runs byte-identical), so there is no reason to guess on the sketch's
behalf, and guessing would change results for every fast body in every existing
sketch. What a probe also settled is where tunnelling actually starts: speculative
contacts catch far more than the naive "moved further than the wall is thick"
estimate, so a 0.05-radius pellet against a 0.04-thick wall passes through at 40
units/s and bounces at 20, which is why the example's shot has to cross *thin*
plates to make the point. `Character3D` and `Vehicle3D` need nothing turned on:
a `CharacterVirtual` shape-casts its own path each update, and a vehicle's wheels
find the road by ray, leaving the chassis as the only part with an ordinary
body's tunnelling story. `Motion3DTests` (16) pins the tier against counterfactual
twins, the sharpest being a contact that throws a free body five units and cannot
move its flat twin at all.

Folded in here: the floor slab's collision group is remembered on the world
(`World3D.groundGroup`, recorded by `bodyChangedGroup`) rather than on the slab,
because `rebuildGround` builds a fresh descriptor whenever `ground` or
`unitsPerMeter` moves and the group would otherwise be silently lost.

### Tracks, ropes, freedoms, and joint-to-joint links

Five more constraint kinds, split by what each one links. Three connect two
bodies and use `JointKind3D` (`.path`, `.pulley`, `.allowing`); two connect two
*joints* and use a second enum, `JointLink3D` (`.gear`, `.rackAndPinion`),
through a `connect(_ a: Joint3D, _ b: Joint3D, _:)` overload. The split follows
what the solver actually constrains: a gear ties the rotation two hinges allow,
so naming the hinges gives the bodies, the axes, and the drift-correction
constraints in one go, where naming bodies would give none of them.

`.path` is `PathConstraint` over a `PathConstraintPathHermite`. Three pieces are
load-bearing. **The spline frame is built in Swift** (`PathSpline` in
`JointPath.swift`): tangents are cardinal (half the span between neighbors,
one-sided at an open path's ends) and normals are carried along the curve by
**parallel transport**, because re-deriving "world up minus its along-track
part" per point flips when the track goes vertical and the rider flips with it;
the holonomy a closed loop comes back with is a stated envelope, not a defect.
**Points arrive in world space and the bridge takes them into body 1's own
space** with the settings' path transform left at identity, which is exactly
what makes path space equal body-1 *body* space (the center-of-mass offset in
`mPathToBody1` cancels against the body's own COM transform), so a track hung
off a moving body moves with it. The rider joins at `GetClosestPoint` of where it
already is. **Progress is normalized in the bridge**, since a Hermite path's
fraction runs to its segment count and only the bridge knows that: `drive(to:)`
scales a 0…1 target up and `cjolt_constraint_current` scales the reading back
down, so `Joint3D.progress` means the same thing on any track. The four
`PathAlignment` cases map onto `EPathRotationConstraintType`.

`.pulley` is the one place a vendored-library defect had to be handled rather
than patched. `IndependentAxisConstraintPart::CalculateConstraintProperties`
guards on `IsStatic()` where every other constraint part guards on `IsDynamic()`,
so a **kinematic** end reaches `MultiplyWorldSpaceInverseInertiaByVector`, which
asserts on non-dynamic bodies (and in a release build would read a kinematic
body's inertia and shove it off its driven path). The bridge refuses that pair
and `World3D.connect` notes once; the vendored source stays pristine. The rope's
default range is `min 0, max -1`, which is the library's "measure the current
length" sentinel on the max only, and is exactly rope behavior: resists being
pulled longer, gives when let slack. `taut:` sets both to -1.

`.allowing` is `SixDOFConstraint` with `ESwingType::Pyramid` (the swing type
that takes limits which are not symmetric) and the world's own axes as the
constraint frame, so a freedom names the same direction here that it names on a
body. `Freedom3D`'s six bits are already in `EAxis` order, so the per-axis loop
is `MakeFixedAxis` / `MakeFreeAxis` / `SetLimitedAxis` off one mask plus the two
shared ranges.

The links resolve **which end of each joint actually moves** in the bridge
(`resolveLinkEnd`): the body that is not static, or the second body when both
can move, matching the order every `connect` call is written in. A hinge hands
back `GetLocalSpaceHingeAxis1/2`; a slider has no public accessor, so its axis
is column 0 of `GetConstraintToBody{1,2}Matrix()`, and both are already in the
body's center-of-mass space, which is why the settings use
`EConstraintSpace::LocalToBodyCOM`. Both links call `SetConstraints` with the
two source joints so the solver can measure and correct its own drift. Two
parameters are converted on Ollin's side: `travelPerTurn` becomes the library's
radians-per-meter (`2π / travel`), and a **gear ratio is forced positive** with
a note, because a negative one leaves the position correction pulling against
the velocity rule until the pair detonates (probe-confirmed), and a real gear
ratio is a tooth count with no sign anyway. A link records all four bodies it
reaches (`Joint3D.alsoTouches`) and its two source joints (`links(_:)`), so
removing any body or either joint takes it with them, which matters because the
gear holds raw `Body` pointers.

The swing-twist motor gap closed at the same time. `cjolt_constraint_set_motor`
now handles `SwingTwist` and `Path` beside Hinge and Slider. A scalar target on
a joint that bends in every direction can only mean the roll about its own axis,
so it runs the **twist motor only** (`SetSwingMotorState(Off)`, a pure-twist
target orientation): the solver reads the twist error from constraint-space x
alone, so the swing half of the target is unused and the bone keeps swinging
free (measured: twist lands within 0.001 of the target with swing at 0.000).
Pointing it somewhere is `cjolt_constraint_set_orientation_motor`, which takes a
world direction back through body 1's rotation and `GetConstraintToBody1()` into
constraint space, turns x onto it with `Quat::sFromTo`, and composes the twist;
`SetTargetOrientationCS` clamps that to the joint's own limits, so aiming past
the cone leans as far as it may. `cjolt_constraint_twist` reads the roll back
(`2·atan2(q.x, q.w)` of the twist half).

`JointKind3DTests` (24) pins the family against counterfactual twins, the
sharpest being a body on a track and a loose one thrown the same way (the railed
one holds its circle to 0.05 while its twin falls twenty units). Example
`3D/Physics/Contraption`.

### Saving a world

`World3D.snapshot()` captures the whole world as a `PhysicsSnapshot`, and
`restore(_:)` builds it back; `save(to:)` / `load(contentsOf:)` are the file
pair over them. The format is **Ollin's own**, not the solver's
`PhysicsScene`, and the reason is `Body3D.collider`: a snapshot has to hand back
the *Ollin* description of each body, because that is what a sketch draws from
and what `addBody`'s parameters are expressed in, and a restored Jolt shape cannot
be turned back into a `Collider3D` case (a compound or a hull has forgotten it
ever was one). Once the colliders have to be written down anyway, the solver's
serialization buys nothing and costs a second representation to keep in step.

The consequence is the design's best property: **restoring replays the ordinary
`addBody` and `connect` calls**, so a restored world is one a sketch could have
built by hand and cannot be in a state a built one cannot reach. What it costs
is a small binary encoder (`SnapshotWriter` / `SnapshotReader` in
`PhysicsSnapshot.swift`): a header, then a payload holding the world settings,
the group table, the bodies, the joints, and the joint-to-joint links, every
number a `Float64`. `PhysicsSnapshot` wraps the `Data` rather than the
decoded model, which makes it `Sendable` and `Equatable` for free (a `Collider3D`
is neither) and leaves exactly one representation, so a snapshot that went
through a file and one that did not are the same value.

The payload is **packed with LZFSE** (the system codec, so nothing is vendored
and the bytes are the same on every machine), and the header carries a flag, the
unpacked length, and an FNV-1a checksum beside the magic, version, and counts.
Two measurements settled that. On a scenery-heavy world (a 129² heightfield
collider, a 16,641-vertex terrain mesh, a 5,000-vertex knot, sixty boxes) the
file goes 1912 KB to 966 KB, and on the case the feature exists for, a settled
heap of sixty primitives, 10.7 KB to 1.2 KB. A world is arrays of `Double`s
whose exponent bytes repeat, which is exactly what a general compressor eats.
The second measurement is the one that **rejected narrowing mesh positions to
`Float32`**: it saves less (1912 KB to 1210 KB, or 575 KB packed as well), it is
lossy for the `Mesh` a sketch reads back out of `body.collider`, and where it
would help most it is dominated outright by naming the geometry instead of
holding it. Compression is conditional on being smaller, which the codec reports
by refusing a destination the source's size, so an incompressible payload simply
stays uncompressed and the flag says so.

The checksum is not decoration. **LZFSE's decoder returns the full requested
length from a truncated stream** rather than reporting the truncation (measured:
15 bytes of a 46-byte stream decoded to all 825 bytes, none of them right), so
the old "the reader runs out of bytes" defense silently became "rubbish parses
into some world" the moment the payload was packed. The checksum makes *refused
rather than half-read* true by construction, and it also catches a
corrupted-but-complete file, which the unpacked format never could.
`aDamagedSnapshotIsRefusedRatherThanHalfRead` pins it against its undamaged twin.

Four things are load-bearing:

- **A joint's zero is the pose its bodies were in when it was made**, and that
  is not where they are now. `connect` reads both bodies' current poses to build
  the constraint frames, so a hinge saved half open and re-connected where it
  stands would call *open* zero, and its limits would run a whole swing further.
  So `Joint3D` records `connectPoseA` / `connectPoseB` at creation, and restore
  stands the two bodies back there, makes the joint, and then puts them where
  the snapshot found them. Constraint frames are fixed at creation, so the
  result is exact for every kind at once (a weld's relative pose, a
  length-less rod's span, a track's attachment point) rather than per-kind
  arithmetic. Pinned by `aHingeKeepsItsZeroAcrossASnapshot` against the naive
  twin, which reads 0.
- **The desc grew three fields whose zero is the old behavior**, the encoding
  rule from stages 11 and 12: `CJoltBodyDesc.linearVelocity` /
  `angularVelocity` / `startAsleep`. Velocity through
  `BodyCreationSettings` rather than a post-create setter, because
  `BodyInterface::SetLinearVelocity` activates a body it moves; `startAsleep`
  adds `EActivation::DontActivate` to what was already the static case, which
  is what "asleep" is. A settled pile therefore comes back *settled*: measured
  0/12 awake after a restore against 12/12 for the same poses handed to
  `addBody`, and zero movement over the next sixty steps.
- **A vehicle's chassis is an ordinary body in `world.bodies`**, so capturing
  it with the loose ones would save a crate that restores without its wheels.
  It is written in the vehicle section instead, while still taking a body index
  after the loose ones so a joint saved against it (a trailer on a hitch) still
  names it.
- **The group table survives `removeAll()` in the solver but not in Swift.**
  Restore rebuilds `groupNames` in the saved order (which *is* the solver's
  indexing, so a body's saved index still names its group) and then sets every
  pair among the union of the old and new counts back to what the snapshot
  says, rather than trusting whatever the previous world left behind.

Two bridge getters came with it (`cjolt_body_get_friction` /
`_get_restitution`, surfaced as live `Body3D.friction` / `.restitution`), since
the two parameters `addBody` took had until now been write-only.

The round trip is exact end to end, which is stronger than the docs promise: a
restored pile's worst pose error is 0, a re-capture is byte-identical to the
first, and a ballistic body stepped on from a restore lands at the same
position as one never interrupted. The envelope is the solver's in-flight
bookkeeping (contact caches, island assignments), which is not carried, so a
scene captured mid-collision may drift where a settled one cannot. That is also
the whole point of the feature: determinism is per binary, so a heap made by
simulating is a different heap wherever the floating point rounds differently
(measured: releasing one stone 1e-7 higher moves a stone in the settled heap
0.45 units), where a saved one has nothing left to compute.
`PhysicsSnapshotTests` (23) pins it; example `3D/Physics/Yard`.

#### The tiers above a loose body

Characters, vehicles, and ragdolls are in the file too, and the reason they can
be is the taxonomy: **sorting the tiers by what is actually heavy in each**
turns "what may a snapshot reference" into a much narrower question. A
character is a capsule and a handful of numbers. A vehicle is colliders plus a
wheel list plus live drivetrain state. A ragdoll *splits*: what the solver holds
(a fitted shape per limb, the joint tree, the limits) is derived data that fits
by value, while the skinned `Scene` it was fitted from is the sketch's own
asset, already loaded and already handed back for `scene.apply(ragdoll)`. None
of the three needs to name anything outside the file, so all three are written
by value and only a soft body's source mesh is left.

That split is what shaped `Ragdoll3D`: its initializer was divided into a
`convenience init` that works a `Scene` down to a `RagdollPlan` and a designated
one that builds the solver objects **from the plan**, so a restored figure is
built by the same code a fitted one is, and `RagdollPlan` grew each limb's
`name` and `sourceIndex` (which a plan previously read back off the skeleton it
no longer has). The per-joint limits are held on the figure as
`jointLimits`, since the solver takes them and never hands them back.

Three findings came out of building it, each measured rather than reasoned:

- **A vehicle must carry its drivetrain, not just its chassis.** Restoring pose
  and velocity alone leaves the engine idling and the wheels stationary, so a
  machine at speed has to spin both up again: measured, a car under full
  throttle fell **6.4 units** behind over two seconds. Carrying engine RPM,
  gear, clutch, and each wheel's angular velocity and rolled angle brings that
  to 1.5. What is left is the solver's own in-flight bookkeeping, and the fair
  comparison says so: a *rigid* stack captured mid-collapse and restored
  diverges **2.13** units over the same 120 steps, worse than the vehicle, while
  a parked machine and a coasting one come back at 0 and 0.07. Wheel suspension
  length is deliberately not carried (there is no setter, and `PreCollide`
  re-measures it against the ground), which shows only as the wheels' contact
  cache being rebuilt on the first step.
- **Never write a pose as a position then a rotation.** The solver holds a body
  by its **center of mass**, so `SetPosition` computes it against the
  orientation the body still has; a following `SetRotation` then leaves the body
  origin a fraction out. It is invisible for a box or a sphere (center of mass
  at the origin) and real for a **ragdoll limb**, whose shape is pushed out
  along the bone by a `RotatedTranslatedShape`: three of sixteen limbs came back
  up to 4.8e-07 off. The new `cjolt_body_set_pose` (over
  `SetPositionAndRotation`) fixed all three.
- **The bytes settle after one restore rather than on the first capture, and
  that is the solver's doing.** Integrating a body lets its quaternion drift a
  hair off unit length; anything handed back to the solver is normalized on the
  way in, since it requires a unit quaternion. So capturing a *stepped* world
  twice around a restore differs in a few last bits, and every capture after
  that is identical (verified to five rounds). Reproducing the normalization in
  Swift was tried and reverted: it fixed fifteen limbs of sixteen, which is a
  half-measure dressed up as a guarantee. `aWorldOfEveryTierRoundTripsToTheSameBytes`
  states the property that is actually true.

`SnapshotTierTests` (13) pins the tier against counterfactual twins; example
`3D/Physics/Yard`.

#### Naming geometry rather than holding it

The one thing genuinely too heavy to write down every time is bulk geometry: a
`.mesh` or `.heightfield` collider, and a soft body's whole source mesh. The
shape that fits the house rules is **a name the sketch chooses plus a resolver
at restore** (`Body3D.assetName` / `SoftBody3D.assetName`, a serializable
sibling of `userData`; `restore(_:resolving:)` and `load(contentsOf:resolving:)`
over a `PhysicsAssetResolver`), which keeps the sketch the source of truth about
where its assets live, the way `resource:in:` refuses to default its bundle.
`PhysicsAsset` is deliberately just the two kinds worth naming. Naming stays
**opt-in**, because a self-contained file is what makes a settled arrangement
committable, and that is the right default. Measured: a world with a 65²
terrain, a 5,000-vertex mesh, and twenty crates is **105 KB held, 1.1 KB
named**.

Naming is also what lets **soft bodies into the snapshot at all**, which closes
the last tier: a soft body is nothing *but* its mesh, so an unnamed one is left
out with a note while a named one saves the numbers it was built with plus every
particle's position and velocity and the pinned set. That needed
`cjolt_soft_body_get_velocities` and `cjolt_soft_body_set_state` (the write goes
through the body's center-of-mass transform, mirroring the existing read, and
sets `mPreviousPosition` alongside `mPosition` so the first step does not read a
step's worth of phantom motion). Measured round trip: 289 particles back at
1.19e-07.

Three decisions are load-bearing:

- **A name is per body, at the top level only.** A mesh nested inside a compound
  is still written whole, because the name belongs to the body rather than to
  one of its parts, and a compound of meshes pins the body anyway.
- **An unresolved name costs one body, not the restore.** It is skipped with a
  note and the joints that named it are dropped with it, which meant the
  restored-body list has to keep **nils in the gaps** so the saved indices still
  line up (`restored(_:in:)` reads that list rather than searching `bodies`).
- **The fingerprint hashes the geometry; counts and bounds were not enough.**
  The first draft stored counts plus a bounding box, which is *blind* to the
  case that matters most: `Heightfield.diamondSquare` normalizes to 0…1, so two
  terrains grown from different seeds have identical counts and identical bounds
  and are not remotely the same ground (a probe caught it). FNV-1a over the
  samples costs the same walk and answers the question. A mismatch notes and
  restores anyway, since the saved poses are the best answer available.

One bug worth remembering came out of it: the reader's `count()` helper bounds a
length against the bytes remaining, which is right for an array *in* the stream
and wrong for a fingerprint's piece count, which counts geometry that is
deliberately **not** there. Reading a 4,225-sample terrain's count that way threw
on a 1 KB payload and the whole restore was refused. A bounds-checked reader
belongs only where the number really is a length.

`SnapshotAssetTests` (10) pins it; the `3D/Physics/Yard` example names its
heightfield floor and its cloth banner.

### Reading UsdPhysics

`World3D.addBodies(from: Scene)` picks up rigid-body, collider, and joint
annotations authored elsewhere. It is the interchange leg, and the direction is
the whole argument: **reading is lossy and that is fine** (a file's notion of a
body is a description, and what it omits has a sensible default), where writing
would not be, which is why the snapshot stays Ollin's own format.

The split follows the lights/cameras precedent: the core reads the annotations
into `package` value types (`ScenePhysics` / `ScenePhysicsBody` /
`ScenePhysicsShape` / `ScenePhysicsJoint`, in `3D/SceneLoaderUSDPhysics.swift`,
attached to the loaded `Scene`), and `OllinPhysics/UsdPhysics.swift` turns those
into ordinary `addBody` / `connect` calls. So the satellite never sees the USD
parser, and a restored or snapshotted imported world is one a sketch could have
built.

**Every attribute name was verified against `pxr/usd/usdPhysics/schema.usda`
rather than recalled**, which the continuation prompt specifically warned about,
and the schema's shape drove four decisions:

- **A collider's shape is the prim's own geometry.** There is no shape
  attribute: `PhysicsCollisionAPI` on a `Cube` means a box. So the reader maps
  gprim types, and the sizes are the gprim's own (`Cube.size` default **2**, not
  three extents; `Capsule` default radius 0.5 / height 1, the height being the
  span between cap centers, which is already Ollin's meaning).
- **USD stands a Capsule, Cylinder, and Cone on z** (`axis`, default `"Z"`)
  where Ollin's stand on y, so the difference is baked into the shape's own turn
  inside its body. A rod authored the default way arrives lying down, and
  `aCapsuleAuthoredOnZComesInLyingDown` pins it against a y-axis twin.
- **A rigid body owns its whole subtree**, so several collider prims under one
  body fuse into one `.compound`, which is exactly what the schema means and
  what makes a hammer one body.
- **A joint naming one body holds it to the world.** The bridge already
  supported that (`resolveBody` returns `Body::sFixedToWorld` for
  `CJOLT_BODY_INVALID`, which the header had documented all along), so the fix
  was a Swift seam rather than C: the new public **`connect(_:toWorld:)`**, with
  the two-body `connect` and it both forwarding to one optional-second-body
  form. A first draft added a `cjolt_body_create_anchor` before reading far
  enough to find the existing support; checking the header beat writing the
  code.

The bug worth remembering: **a collider prim that *is* its body has an identity
local transform**, so taking the shape's scale from the collider-inside-body
transform loses the prim's own scale entirely, which is the common case. Scale
must come from the collider's **world** transform always, with the
body-relative transform supplying only position and rotation (and that
measured against the body's rotation and place rather than any scale it
carries, since the scale is already in the sizes). It rendered as a scene of
unscaled cubes: caught by looking at it, not by a test.

`UsdPhysicsTests` (14) pins the tier; example `3D/Physics/Imported` with a
hand-authored `yard.usda`.

## The geometry and generator catalog

The CPU-side geometry types and generative-technique recipes live in
`Sources/Ollin/Geometry/`. CLAUDE.md's *Geometry & color* bullet is the
capability index and keeps the terse regression-preventing invariants; this
section carries the mechanism and the history behind them. Two contracts span
the whole catalog. First, determinism: every builder is a pure function of its
inputs and seed (or a stateful class that steps reproducibly), so figures,
export recipes, and snapshot tests reproduce. The recurring rule behind it:
Swift `Set`/`Dictionary` iteration order is randomized per process, so no rng
decision or position-mutating pass may walk one directly; use sorted candidate
lists and fixed iteration orders (WFC is the canonical case, and mazes, space
colonization, boids, and the physics disk-collision resolver follow it).
Second, fit-by-points: a figure whose stroke should stay constant-width is
fitted by scaling its *points*, never `scale()`, because the CTM scales the
stroke width too (found as the trochoid fat-blob snapshot bug; applies to
Lévy flights and anything else fitted to the canvas).

### The spatial index

`SpatialIndex` (`Geometry/SpatialIndex.swift`, with the tree in
`SpatialKDTree.swift`) is the neighbor-search substrate the catalog's own
recipes stand on, and the answer to a private uniform grid appearing in every
new file that needs one.

The grid backing keeps one index list per cell (`cells: [[Int32]]`) rather than
the counting-sorted flat pair `PointGrid3` uses. That is the whole reason
`insert` can be O(1): a counting sort is built once over a finished set, and
half of what a sketch does with a point set is grow it one point at a time
(dart throwing, aggregation, a mark dropped where the mouse goes). A point that
lands outside the box rebuilds it, padded by a quarter of its span, so a set
that spreads outward as it grows pays for the rebuild a shrinking share of the
time. Coordinates are held apart from the `Vector2` array in flat `xs`/`ys`,
for the same reason `PointGrid3` does it: this walk runs per point per frame in
sketches built in debug, where every non-transparent operation is a real call.

Two decisions are worth keeping. **The cell lattice is anchored to the origin,
not to the point set's own corner.** Anchoring it to the set means a rebuild
after the points moved cuts the plane in a slightly different place, sorts the
points into differently placed cells, and reports the same neighbors in a
different order; a force summed over them then differs in its last digits, and
a simulation walks away from itself over enough steps. Snapping the lower
corner down onto the lattice through the origin costs one extra cell each way
and makes a given cell size cut the plane the same way forever. **A radius
query measures its block from the query point's true cell**, clamping only the
block's ends into the grid, rather than clamping the point first. A clamped
home cell reads cells that are not within reach, in an order that has nothing
to do with where the query really is.

The conversions were verified rather than assumed. Four recipes moved onto the
index (`DifferentialGrowth`, `Boids`, `DiffusionLimitedAggregation`, and
`FlowField.streamlines`), and each was checked against its committed snapshot
reference at an **exact zero difference**, not the suite's mean-diff tolerance,
which is loose enough to hide a real change (the stroke-join work moved 79
Guide figures under it). That held because the shared walk visits cells in the
same order those private grids did (column outer, row inner, ascending index
within a cell), and because the arithmetic is identical: the recipes measured
`p - other` where the index measures `other - p`, and a negation is exact. The
move also fixed a latent `Boids` defect: cells were keyed from the origin but
queried from `bounds.origin`, so a flock in a box whose origin was not a
multiple of the cell size silently lost neighbors on one side. Every snapshot
uses a zero origin, which is why nothing moved.

`Packing` and `DielectricBreakdown` keep their private grids on purpose. Their
query is a block of cells rather than a radius, and a radius filter would drop
candidates they still need (a far center with a large radius can constrain a
point that a distance test would reject).


### Grid

`Grid` (`Geometry/Grid.swift`) is a regular `columns × rows` grid over a
`Rectangle`, the typed replacement for margin-then-nested-loop boilerplate.
The row-major `points` (`Grid.Point`) and `cells` (`Grid.Cell`) elements
carry their indices, so indexed cases (checkerboard, hue-by-position) are a
single loop; killing the double `for` was the point of the type.
`Grid.Distribution` is `.center` (a dot per cell, the default) or `.spanning`
(an edge-to-edge lattice, ignores `gutter`); `cells` always tile and a cell's
`.center` is always its true center; `padding` is the outer margin, `gutter`
the gap between cells, both over the typed `Insets` (per-edge,
literal-expressible: `padding: 20` equals `.all(20)`). The element type
churned during design (a `Cell`-wrapping `Collection` whose `center` shifted
meaning read wrong, then flat arrays, then the rich indexed elements without
the shape-shifting) and is settled. Most 2D grid examples adopt it;
warped/proportional/polar layouts stay hand-rolled. Example `Patterns/Grid`.

### Shape booleans, offsets, and stroke-as-shape (Clipper2)

`Shape.union` / `intersection` / `subtracting` / `symmetricDifference` and
`offset(by:join:)` run over vendored Clipper2 (BSL-1.0, `External/CClipper2`).
They operate on the filled region; each side is normalized under its own
`winding` first. Offset output must be run through Clipper2's `SimplifyPaths`
before returning; without it, cascaded insets balloon vertex counts
geometrically. `Contour.stroked(width:join:cap:)` / `Shape.stroked(...)`
convert a stroke to a filled `Shape` via the shim's `cc2_stroke` (Clipper2
`InflatePaths` at half-width): open contours take `StrokeCap` end types,
closed ones become bands, and self-crossings union clean under non-zero
winding. Unlike `cc2_offset`, the input is *not* normalized first;
normalization would erase open paths, which are the whole point of stroking.
Open and closed groups stroke in two shim calls, then union. Example
`Shapes/InkRibbon`; snapshot `stroke-shape`; `StrokeShapeTests`.
`convexHull(of:)` is the monotone chain: corners in boundary order, collinear
edge points dropped, duplicates deduped (`ConvexHullTests`; example
`Shapes/Hulls`). Both in `Docs/Drawing/Geometry.md`.

### SVG import

`Geometry/SVGImport.swift` reads `<path>` with the full grammar (packed
numbers, glued arc flags, S/T reflection; elliptical arcs become cubics of at
most 90° via the SVG F.6.5 center conversion), the basic shapes, `<g>` +
`transform` lists, presentation and inline styles, and both fill rules, into
per-element `Shape`s carrying fill/stroke/width/join/cap in document order
(`SVG.Element`; `id` maps to `name`, looked up via `element(named:)`).
Control points transform *before* flattening: flattening is affine-invariant,
so transforming first keeps sampling density matched to the final size (arcs
convert to cubics first for the same reason). The SVG initial fill is black
and the initial winding `.nonZero`, unlike Ollin's `.evenOdd` shape default;
the mismatch is per spec, so the two defaults stay different. An unresolvable
`url(#…)` paint falls back to mid-gray rather than dropping the element; open
contours never fill; unsupported containers (defs, clipPath, mask, symbol,
pattern, marker, style, text) skip their whole subtree via a depth counter.
Example `Shapes/SVGImport` (an original hand-authored rocket badge, drawn as
authored and mined as resampled dots); snapshot `svg-import`;
`SVGImportTests` covers the grammar edge cases including the post-`Z`
malformed-input stop.

### Fourier epicycles

`Epicycles` + `drawEpicycles(_:at:terms:)` (`Geometry/Epicycles.swift`): a
plain O(n²) DFT, built once at setup time, over a closed contour's even
arc-length resamples, into amplitude-sorted spinning-circle `Term`s (signed
integer `frequency`; k > n/2 folds negative). Read via `point(at:)` /
`joints(at:)` / `path(samples:terms:)`. The sort's tie-break (slower
|frequency| first, then positive) keeps symmetric inputs deterministic.
`terms:` is always a largest-first *prefix* of the one built chain, so a
detail parameter needs no rebuild. `point(at:)` is periodic, so negative phases
wrap and a fixed-length trail across the lap seam is a one-liner. With all
terms the reconstruction is exact at sample phases; `EpicyclesTests` pins
that plus the circle/ellipse closed forms. No rng anywhere. Example
`Motion/Epicycles` (traces an original whale SVG, declares `loopDuration`);
snapshot `epicycles`.

### Classic curves and the harmonograph

`Geometry/ClassicCurves.swift` + `Geometry/Harmonograph.swift`, all rng-free
and deterministic. `phyllotaxis(count:spacing:angle:)` is Vogel's model
(default `Double.goldenAngle`; index = seed age).
`lissajous(a:b:phase:width:height:)` reduces shared frequency factors and
samples one exact period into a closed `Contour`. `rose(n:d:radius:)` reduces
n/d and samples exactly the parity-dependent closure span (πd when n·d is
odd, 2πd otherwise) so no arc retraces doubled.
`hypotrochoid`/`epitrochoid(ring:wheel:pen:)` take integer gear radii so
closure is guaranteed, sampling exactly `wheel/gcd(ring, wheel)` laps with
auto sample counts scaling by the laps; figure symmetry is `ring/gcd` lobes,
and the examples spin one lobe or petal per loop for seamless laps.
`Harmonograph` sums per-axis damped `Pendulum`s,
`amplitude·sin(frequency·τ·t + phase)·e^(−damping·t)`, frequency in turns per
unit time; `point(at:)` reads live, `contour(duration:samples:)` bakes; the
default duration `settleTime` is the 1%-decay time of the *slowest* pendulum,
capped at 240 when undamped. Chaikin `smoothed(iterations:)` on
`Contour`/`Shape` is the quarter-point corner cut; open contours keep exact
endpoints; each pass doubles the point count, iterations capped at 10.
Examples `Patterns/Spirograph` / `Patterns/Roses` (both declare
`loopDuration`), `Motion/Lissajous` (the classic table over `Grid`),
`Motion/Harmonograph` (pendulums rolled from the seeded `random`, one figure
per `variation`), `Shapes/CornerCutting`; `Patterns/Phyllotaxis` uses the
helper. Snapshots `classic-curves` + `harmonograph`; `ClassicCurveTests`
(astroid/cardioid closed forms, petal counts, exact open-pass Chaikin).

### Shape morphing

`ShapeMorph` (`Geometry/ShapeMorph.swift`) builds correspondence once and
reads `shape(at:)`; `Shape`/`Contour: Tweenable` lets `Timeline` sequence
geometry (those conveniences rebuild correspondence per read);
`morphed(toward:_:spacing:)` is the sugar. Contours pair closed with closed
and open with open (largest by area/length first, then nearest centroid);
both sides densify by segment *insertion* so corners survive; spacing derives
per outline (1/128 of its own length, capped near 4096 points); closed rings
then take the min-travel cyclic rotation and open runs a direction flip. An
orientation mismatch reverses *all* target contours: a global flip preserves
both fill rules, per-pair flips do not. Spacing per outline, not per pair,
was a real fixed bug (a shared spacing bunches the equalizer's insertions and
warps the blend). Unmatched contours lerp to their own centroid (uniform
scale-away, so holes grow in and out without popping). `shape(at:)` returns
the originals verbatim at 0 and 1; the winding rule switches at t = 0.5; no
rng anywhere (`ShapeMorphTests`). Example `Motion/Morphing` (star to blob to
donut cycle, declares `loopDuration`); snapshot `shape-morph`.

### Scattering, sampling, and stippling

Voronoi & Delaunay (`Tessellation.swift`): Bowyer-Watson with a
Sutherland-Hodgman clip to `bounds` for seamless cells, plus `relaxed()`
Lloyd; sugar `voronoi` / `delaunay` / `drawVoronoi` / `drawDelaunay` /
`lloyd`. Blue noise (`Geometry/PoissonDisk.swift`): Bridson's algorithm,
`poissonDisk(in:radius:candidates:maxCount:)`, as a public generic free
function over any `RandomNumberGenerator` (`SplitMix64` is public) plus
seeded `Sketch` sugar; the even-but-organic scatter and the seed set the
tessellators and packing consume. Example `Patterns/BlueNoise`; snapshot
`blue-noise`.

Even spacing on a surface (`Geometry/BlueNoiseThinning.swift`, behind
`surfacePoints(..., scatter: .blueNoise)`) is weighted sample elimination:
five times the wanted count is drawn by area, each candidate is weighed by how
closely its neighbors press on it, and the heaviest is dropped until the count
is left. Nearly all of the time is the neighbor search, and its shape follows
from where the points lie. They lie on a surface, so a dense grid over the
bounding box (what the pass first walked, through `PointGrid3`) is nearly all
empty: 33 million cells for 400,000 points on the Guide's valley, 264 MB of
offsets, and every query a miss. The pass now sorts the candidates cell by
cell (three stable radix passes over the cell coordinates, x, then y, then z)
and finds each occupied cell's nine row runs once, through a hash used only
while they are found. A neighbor walk is then nine ranges of the sorted
points. **The walk visits exactly what the dense walk visited, in the same
order** (cells in (z, y, x) order, input order within a cell). That order
fixes how each weight's sum rounds, and the drop sequence follows the
rounding, so it is what lets `BlueNoiseThinningTests` hold the pass to the
dense one, kept there verbatim, point for point.

The queue keeps one entry per live candidate. A weight that falls is not
pushed again; a top entry whose weight is out of date goes back in at its true
weight. Weights only fall, so an entry is never lighter than its candidate,
and a top that is still true is the heaviest there is. The old heap pushed on
every change: 19.6 million pushes and a peak of 5.2 million entries at 400,000
points, where this one holds 2 million and re-inserts 6.9 million times.

Measured on the valley's 2 million candidates in a release build on the M2:
12.6 s before (3.3 s weighing, 9.2 s eliminating) and 3.6 s after (0.3 s
sorting and finding runs, 0.5 s weighing, about 1.3 s in the queue and 1.2 s
updating neighbors), with the same survivors. Inside the framework the whole
`surfacePoints` call went from 13.4 s to 4.7 s, a second of which is drawing
the candidates by area, and in a debug build the pass alone went from 181 s
to 20 s. Four alternatives were measured
and not kept: a radix heap over the weights' ordered bits (94 million bucket
moves, no faster), each candidate's coordinates and weight packed into one
record, the weights alone in a 16 MB array to stay in cache (neither faster),
and the indexed queue that sifts a lighter weight down in place, which runs
slower than a lazy one in a release build past 100,000 points. What is left is
memory latency. Drops come in weight order from all over the terrain, so each
drop's neighborhood is a fresh set of misses, where a weighing walk in cell
order finds its neighbors already in cache, and costs a third as much per
point.

Low-discrepancy sampling (`Geometry/Sampling.swift`): `halton(_:base:)` (the
radical inverse) plus `haltonPoints(count:in:bases:startIndex:)` /
`sobolPoints(count:in:startIndex:)` give even coverage as an ordered stream:
any prefix is itself even, and growing the count never moves a placed point,
the draft-then-refine property neither `random` nor `poissonDisk` has. They
are pure functions of the index (no rng, no seed, never touching the sketch's
`rng`); `startIndex` defaults to 1 because index 0 of both sequences is the
corner point; Halton bases must stay coprime (default (2, 3)); Sobol is
Gray-code stepped with dim-2 direction numbers from the degree-1 recurrence
`m_k = 2m_{k−1} ⊕ m_{k−1}`. `SamplingTests` pins the canonical first points
of both. Example `Patterns/LowDiscrepancy`; snapshot `low-discrepancy`.

Stippling (`Geometry/Stipple.swift`) is Secord's weighted Voronoi:
`stipple(_ image:count:in:iterations:)` plus a density-closure form. Density
rasterizes once to a working grid (~256 px per dot, capped); seeding is
rejection-sampled; weighted-Lloyd passes then assign every ink-bearing pixel
to its exact nearest dot over a site-bucket grid. The expanding-ring nearest
search may stop before ring r only once the best find is at most
(r−1)·bucketSide; the r·bucketSide bound shipped first and was a real bug,
locking dots onto the bucket lattice (rectilinear runs at bucket scale).
Image density is (1 − linear `luminance`) · alpha, so transparency carries no
ink (the same rule the dither pass uses); zero ink anywhere returns an empty
array, never a uniform scatter. Deterministic given (input, count, seed);
setup-time work, hold the points. Example `Patterns/Stippling` (paints its
sphere in `setup()`, no asset); snapshot `stipple`; `StippleTests`.

### Spanning-tree line art and isolines

`spanningTree(through:)` (`Geometry/SpanningTree.swift`) is the branching
sibling of the `singleLine` tour: the exact Euclidean minimum spanning tree,
built by Kruskal over the unique edges of the `Delaunay` triangulation
(which always contains the EMST), ties broken by (distance², index) so the
build is deterministic. Degenerate inputs take two fallbacks: fully
collinear points have no triangulation, so the tree is the lexicographic
chain (exactly the MST along a line); exactly-coincident duplicates never
enter the triangulation, so leftover components join by nearest-vertex
passes. The output is the minimal trail decomposition, one open `Contour`
per pair of odd-degree vertices: a walk may start only at a vertex whose
*unconsumed* degree is odd (each walk flips its two endpoints odd-to-even
and no vertex ever flips back, so an index-order sweep is minimal;
starting at *originally*-odd vertices strands edges into extra chains, a
real first-build bug caught by `decompositionIsMinimal`). The image sugar
`spanningTree(of:points:in:iterations:cutoff:)` mirrors
`singleLine(of:...)` over the same stipple + cutoff pipeline. Example
`Images/SpanningTree`; snapshot `spanning-tree`; `SpanningTreeTests`
(brute-force Prim match, minimality, degenerate inputs).

`isolines(at:in:resolution:field:)` (`Geometry/Isolines.swift`) is marching
squares over an on-demand sampling grid (`resolution` cells across the
longer side, square-ish cells). The case table emits *oriented* segments
(inside on a fixed side), so stitching walks directionally: forward along
to→from key matches until the chain closes or runs out, then backward from
the start, which is what lets a contour that exits the bounds come back as
an open `Contour` ending on the edge. Saddle cells (codes 5/10) are settled
by the cell-average rule. Two invariants: a contour passing exactly through
a grid corner (corner value exactly zero) makes its cell emit a null
segment, dropped before stitching (the real bug was two-point litter beside
an intact ring, seen with a radius-100 circle whose lattice hits are exact);
and the endpoint maps are only ever indexed, never iterated, so the walk
order is the segment order and the trace is deterministic. The levels form
samples the grid once and traces per level; the image form reduces pixels
to linear-luminance-on-white (transparency reads as paper), samples
bilinearly, and takes its levels in sRGB tone (converted once), matching
the stipple/`cutoff` convention. Examples `Patterns/ContourMap` (looping
fbm terrain, index contours); snapshot `isolines` (metaball merge/split +
image tone lines); `IsolineTests`.

### Hulls and the medial axis

`Geometry/ConcaveHull.swift` holds the two tighter scatter outlines, both
carved from the shared Bowyer-Watson `Delaunay`. `concaveHull(of:concavity:)`
is the characteristic shape (chi-shape) of Duckham, Kulik, Worboys, and
Galton: the starting boundary is the triangulation's outer edge, and border
triangles erode longest-boundary-edge-first through a max-heap, each pop
allowed only while the edge exceeds a length threshold and the triangle's
opposite vertex is not already on the boundary. That single stop rule is
what keeps the polygon simple and every input point inside, and it makes
each edge a one-shot decision (boundary vertices never leave the boundary,
so a blocked edge stays blocked). The public parameter inverts the JTS-style
edge-length ratio into `concavity` 0...1, interpolating the threshold
between the triangulation's longest and shortest edge, so it reads
scale-free.

One repair proved load-bearing: the shared `Delaunay` builds against a
finite super-triangle (20x the input span), and a nearly collinear hull
triple has a circumcircle that reaches that scale, so the sliver triangle
can drop out of the mesh and leave the boundary with a shallow notch (a
real case: a point 0.35 units inside a hull edge on a 400-unit scatter,
found the day the suite pinned "concavity 0 equals `convexHull`"). Rather
than touch the substrate (whose exact output existing snapshots pin),
`capHullNotches` compares the mesh boundary against `convexHull(of:)`,
walks each notch path, and caps it with a fan of sliver triangles before
erosion starts. Zero concavity then reproduces the convex hull exactly,
and a cap erodes away like any border triangle the moment the parameter changes,
so higher concavities are untouched. `HullTests` keeps the seed-7 scatter
that first exposed the notch as the regression.

`alphaShape(of:alpha:)` is Edelsbrunner's alpha complex in its standard
form: keep the triangles whose circumradius is at most `alpha`, orient
them counterclockwise, and link the edges owned by exactly one kept
triangle into loops (at a pinch vertex the walk takes the clockwise-most
departure relative to the reversed arrival, which keeps each loop on its
own face). Union-find over shared edges groups kept triangles into
islands; each island's loops sort largest-area-first, so a `Shape` comes
back outer contour first with its holes after.

`Geometry/MedialAxis.swift` approximates Blum's medial axis as the Voronoi
subcomplex of a boundary sampling (Brandt-Algazi): every contour resamples
at `spacing`, the samples triangulate, and the Voronoi edge between two
adjacent triangles survives when its dual Delaunay edge joins samples that
are not neighbors along their ring and both circumcenters pass the new
winding-honoring `Shape.contains(_:)` (tested against the resampled
domain, so the test and the triangulation agree). Each skeleton vertex is
a circumcenter whose circumradius is exactly its clearance, which is what
makes the carried radii honest inscribed-disk radii. The graph decomposes
into branches between degree-not-2 vertices plus leftover pure rings (a
hole's skeleton), and `prune` trims terminal twigs shorter than the parameter.
Pruning collects each round's twigs against the round-start graph and only
then removes them together: the first cut removes a twig, drops its
junction to degree 2, and a same-round sibling walk would run straight
past the vanished junction down the trunk (a real bug, caught by the
rectangle's roof-ridge test). A twig that is its own component never
prunes, so small regions keep their skeletons.

All three functions canonicalize their output by geometry rather than
construction order, because the substrate's triangle *order* is
process-varying (the Bowyer-Watson refan iterates a Dictionary) even
though the triangle *set* is stable: hull loops rotate to their
lexicographically smallest vertex, alpha islands sort by their outer
points, and skeleton branches orient and sort by their endpoints. Examples
`Shapes/Hulls` (the three-outline scatter) and `Shapes/MedialAxis`
(letterform skeletons with rolling inscribed disks); snapshots
`concave-hull` / `medial-axis`; `HullTests` / `MedialAxisTests`.

### The straight skeleton

`Geometry/StraightSkeleton.swift` computes the mitered-offset sibling of
the medial axis exactly (no boundary sampling): the shrinking-wavefront
simulation over circular lists of active wavefront vertices (one per ring,
holes included), an event heap ordered by inset distance, and one supporting
line per boundary edge that never changes while its wavefront segment
slides along it. Naive event-at-a-time processing is famously wrong on
real inputs, where right angles and symmetry land several events on one
point at one time, so the queue drains in *levels*: every event within
tolerance of the lowest distance loads at once, the level clusters events
that share a meeting point or a parent vertex, and each cluster processes
as one generalized event over *chains* (runs of consecutive collapsing
edges, split vertices, and the opposite edges splits land on, sorted by
angle around the meeting point; one new wavefront vertex is born into each
gap between consecutive chains). A split's opposite edge resolves against
the live wavefront at processing time (projecting the meeting point onto
the edge picks the right segment when earlier splits already divided it),
splits cut one LAV in two, cross-LAV splits merge a hole's wavefront into
the outer one, and a chain of collapsing edges inside a multi-split
retires all its vertices like a lone edge event would (the mixed-chain
case the studied reference leaves unhandled). Events computed during a
level that land back on it are stale echoes and are purged, which is also
why a LAV down to two vertices closes as a *bridge* (the ridge arc between
where its pair stopped) rather than through an event: the pair's mutual
event always lands on the current level.

Two hardening rules earned their place. First, degenerate multi-splits can
leave a *ghost corner*: two wavefront vertices at one point flanking a
zero-length front, whose mutual event lands on the current level forever
(purged each time), starving the LAV; a level-end pass fuses each such
pair into the single vertex it should have been (a real bug, caught by a
nine-lobed 400-vertex blob whose partition came back 8% short, pinned by
`facesPartitionSpikyBlobs`). Second, output assembly does not use the
reference implementations' face-queue pointer surgery at all: every event
creates one shared *node* (point + distance), each wavefront vertex
records its origin and death node, and a face reassembles at the end from
its edge plus the origin-to-death arcs of every vertex that carried one of
the edge's endpoint roles, stitched by node identity. Node sharing is what
makes arcs dedupe exactly across neighboring faces, and it gives the whole
system its test net: the faces must partition the shape, so their areas
sum to its area (held to 1e-7 relative by the fuzz tests).

`inset(by:)` leans on faces being roof planes: within a face, inset
distance is an affine function (distance to the edge's line), so the inset
at `d` is a straight chord through each face crossing level `d`, paired
even-odd along the edge direction. A chord endpoint lies on a face-boundary
arc shared with a neighbor face, and because inset distance restricted to
that shared segment is the same affine function from both sides, chords
stitch into rings by *arc identity* (canonical node-pair keys), no epsilon
matching. Rings start at their lexicographically smallest chord, outer
rings come out positive, holes negative, and `distance <= 0` returns the
normalized input rings. Determinism holds the house rule throughout: the
SLAV is an array in creation order, sets are membership-only, level lists
sort by (distance, point, sequence), and chain sorting tie-breaks by edge
id. Example `Shapes/StraightSkeleton` (the island contour ladder);
snapshot `straight-skeleton`; `StraightSkeletonTests` (closed-form squares,
ridges, spokes, hand-mitered L and plus insets, the notch pinch, hole
merges, the symmetric square ring whose wavefronts annihilate along whole
segments, and the partition fuzz).

### Painterly marks: marbling and watercolor

`Geometry/Marbling.swift` is mathematical paper marbling in Jaffer's
closed-form model (the Lu-Jaffer-Jin-Zhao-Mao formulation): a `Marbling`
bath is an ordered stack of `MarbledInk`s (a fillable `Shape` plus a
color; a value type, so a copied bath forks its history), and every
operation is one exact point transform run over every outline. `drop`
displaces each point at distance `d` from the center to `√(d² + r²)`
(the area-preserving displacement; the `d → 0` limit lands on the new
rim, direction picked deterministically) and then appends the new disk,
polygonized at the bath's `spacing`. The stylus family shares one falloff
law, displacement `strength · 2^(−d/falloff)` parallel to the tool's
motion: the straight tine takes `d` from the line, the comb from the
nearest tooth (`offset − round(offset/teeth)·teeth`), and the two
rotational tools (circular tine, `swirl`) convert the same decayed length
into an arc about the center, which is why both preserve each point's
radius exactly. The exponential form is Jaffer's `z·uᵈ` with the base
reparameterized so `falloff` reads as a half-distance in canvas units.

Refinement is what keeps stretched outlines smooth, and its direction
matters: segments subdivide at **pre-transform** midpoints while the
*transformed* span exceeds `spacing` (depth-capped, with a source-length
floor so a drop centered on an outline can't recurse forever), so every
inserted point lies exactly on the transformed curve; refining after the
fact could never recover a fold. Since the transforms are homeomorphisms
the outlines never self-cross, `add(_:color:)` floats arbitrary
shapes (holes intact, so glyph counters survive), and the whole bath is
rng-free and deterministic. Cost note: points accumulate per operation,
so a bath is setup-shaped work redrawn from the held value.

`Geometry/Watercolor.swift` is the generative-watercolor recipe (the
Hobbs formulation): recursive midpoint subdivision where each edge splits
at its midpoint, the midpoint jumps by an isotropic Gaussian scaled by
that edge's own variance, children inherit `0.4...0.8` of it, and the
starting edges draw jittered (`0.2...1.8`) shares of the base variance,
which is what gives one blob crisp stretches beside blooming ones. The
`Watercolor` value is the *base*: an irregular polygon pushed through the
shared rounds once, held immutable, with every `layer` deforming a copy a
few rounds further so all layers agree at the core and differ at the
fringe. Two decisions proved load-bearing: layers must fill through
`layerShape` / the `drawShape` route with **non-zero winding**, because
the deformed outline self-crosses and even-odd cuts white pinholes while
a triangle-fan `drawPolygon` fill shatters the concave outline into
radial spokes (both real first-render bugs); and the `detail` floor
(edges below it stop subdividing) bounds a layer's point count no matter
the round count, which took the first full-sheet render from ~40 s to
seconds with no visible change (sub-pixel splits are invisible). The
Gaussian is a stateless Box-Muller over the generic rng so the draw count
per call is fixed and a seed reproduces exactly. The `drawWatercolor`
sugar dilutes the current solid fill color to the per-layer opacity and
stacks layers via the sketch's own rng; painting is deliberately
setup-shaped (every layer is a full concave fill).

Examples `Patterns/Marbling` (bull's-eyes, nonpareil combs, a vortex;
click drops, drag pulls a stylus) and `Shapes/Watercolor` (three
interleaved pigment pools glazing where they overlap, a clipped speckle
for granulating texture, `noLoop`); snapshots `marbling` / `watercolor`;
`MarblingTests` (exact-displacement, falloff, radius-preservation, and
refinement-bound checks) / `WatercolorTests`. Docs
`Docs/Generators/Marbling.md` / `Watercolor.md`; technique credits in
`ATTRIBUTION.md` (Jaffer / Lu et al.; Hobbs).

### Random walks

`Geometry/Walks.swift`. `randomWalk(from:steps:stepLength:)` is isotropic.
`levyFlight(from:steps:minStep:maxStep:exponent:)` draws truncated power-law
step lengths via inverse CDF, with the log-uniform special case at μ = 1;
`minStep` must be > 0 because the power law diverges at zero.
`selfAvoidingWalk(in:cellSize:from:maxLength:)` is a lattice DFS with
backtracking: visited-forever marks make the search finite and the path
self-avoiding, and the best path reconstructs through parent links, valid
precisely because a visited cell's parent never changes; the lattice centers
in bounds like the tiling grids. All return `[Vector2]`, seeded and
deterministic (`WalkTests`); fit a flight by scaling its points (the
catalog-wide rule above). Examples `Patterns/LevyFlight` +
`Patterns/SelfAvoidingWalk` (both declare `loopDuration`); snapshots
`levy-flight` + `self-avoiding-walk`.

### Truchet, tiling, and layout

Truchet (`Geometry/Truchet.swift`): one tile per `Grid` cell at a
seed-chosen orientation, open `[Contour]` line-work; `Truchet.Tile` is
`.arcs` (after Smith) or `.diagonals` (the maze look). Arcs are centered on
cell *corners* through the two adjacent edge midpoints, so abutting cells
join regardless of flip; they go elliptical for non-square cells. Example
`Patterns/Truchet`; snapshot `truchet`.

`HexGrid`/`TriangleGrid` are `Grid` siblings whose blocks keep true aspect:
sized to the tighter fit and centered in the padded bounds, so more columns
means smaller cells, never stretch. Hex cells carry offset `column`/`row`
*and* axial `q`/`r` (odd-r/odd-q storage, math in axial:
`distance`/`neighbors`/`ring`); `cell(at:)` picks exactly via cube rounding
(round all three cube coordinates, recompute the worst offender; rounding
the axial pair independently drifts off-lattice near cell edges). Hex
`gutter` insets the corner radius by g/√3; triangle gutter shrinks vertices
toward the incenter; triangle parity: up iff `column + row` is even,
neighbors left/right/across. `Subdivision.cells`/`subdivide` split `.binary`
(cut across the longer side, fraction clamped so both halves keep `minSize`)
or `.quad`; the root always splits when it can, `chance` applies from depth
1; leaves carry `depth`. `Maze` has three carvers (`.backtracker`,
`.kruskal`, `.wilson`), all perfect mazes (n−1 passages, pinned by
`TilingTests`). Wilson's loop-erasure is implemented as the
last-exit-direction map: overwriting a revisited cell's exit *is* the
erasure, and the path replays from the walk start. `walls(in:)` merges
collinear segments into single runs (plotter-clean); `longestPath()` is the
double-BFS tree diameter. Determinism: fixed iteration orders and candidate
arrays only, no Set/Dictionary walks. `apollonianGasket` applies the
Descartes circle theorem with every child as the quadratic's *other root*
given its parent triple (Vieta), so the whole recursion is linear: no
complex square root, no tangency-validation epsilon; seeds are the closed
form (2√3−3)·R; BFS order doubles as generation age for tinting. Examples
`Patterns/HexGrid` / `TriangleGrid` / `Subdivision` / `Maze` / `Apollonian`;
snapshots `tiling-grids` / `subdivision` / `maze` / `apollonian`;
`TilingTests`.

### Aperiodic tilings

Penrose (`Geometry/Penrose.swift`): both tilings live as Robinson
half-triangles (a `Half` is an acute/obtuse flag plus apex, axis, and free
corners), deflated from a ten-half wheel at the chosen center and merged
into whole tiles at the end: P2 halves pair across the apex-to-axis leg,
P3 halves across the axis-to-free base, matched through a quantized edge
key. Halves on the wheel's outer rim have no partner and are dropped, which
is why the wheel over-covers the bounds by a few tile widths. The P3
subdivision is the standard published one; the P2 rules were derived from
the merge structure, and the derivation's load-bearing part is not the
split points (golden sections of the legs) but each child's *ordering*:
which corner is the axis corner decides how the next generation subdivides,
a wrong choice only shows up as T-junctions one level later, and the
crack-free test is what pins all six assignments.

The arcs are corner-centered circles whose radius is a fraction of the two
adjacent edges (equal in length at every chosen corner). P2's fractions
fall straight out of the continuity constraints (kite nose 1/phi^2, kite
144-corner 1/phi, dart both 1/phi^2). P3 hides a real subtlety: a rhomb's
outline is only canonical up to its 2-fold rotation, and under any
C2-symmetric arc placement the thick-thin meeting classes demand f = t and
f = 1 - t at once, so midpoint crossings are the *only* solution family.
The classic golden decoration needs each rhomb oriented beyond C2, and the
merge already holds the datum: the two halves are mirror twins, so exactly
one is positively wound, and leading the outline with that half's apex
orients every rhomb intrinsically. A brute-force continuity solve over
golden candidates (run at development time on a deflated patch) then
yields a small family of exact solutions; the shipped constants are thick
arcs at the apex pair at 1/phi^2 and thin arcs at the side pair at
(1/phi^2, 1/phi), and the continuity test re-verifies them on every run.

Wang tiles (`Geometry/WangTiles.swift`): the stochastic scanline of Cohen,
Shade, Hiller, and Deussen. Each cell picks weight-proportionally among the
tiles matching its west neighbor's right edge and north neighbor's bottom
edge; `WangTiling.completeSet(colors:)` is the full colors^4 product set,
which always has a candidate, while hand-built sets that dead-end get fresh
attempts and then return nil (the WFC-style contract).

Girih (`Geometry/Girih.swift`): Kaplan's inference algorithm from the
polygons-in-contact paper. Two rays leave each edge midpoint at the contact
angle, leaning into the polygon (interior side from the signed area);
every crossing pair is costed by the summed origin-to-crossing distances
(collinear facing pairs by their origin distance), sorted with a
(cost, i, j) tie-break, and taken greedily, each ray used once; unmatched
rays drop, which is the paper's behavior on awkward shapes. The five girih
tiles are turtle walks over exterior-angle tables with unit steps, and
`points(onEdge:_:)` re-runs the walk along a caller's segment, which is
the exact composition primitive: the decagon-and-ten-pentagons flower in
the example is each pentagon laid on a reversed decagon edge.

Spectre (`Geometry/Spectre.swift`): the substitution system of the chiral
aperiodic monotile paper. Tile(1,1) is a 14-step turtle on the 30-degree
grid (the direction table in `edgeDirections`; quad anchors at vertices
3, 5, 7, 11). Nine metatiles substitute through eight slot transforms per
generation, built by chaining anchor matches through the rotation schedule
(60, 0, 60, 60, 0, 60, -120) and then mirroring the entire generation:
layout handedness alternates per level, every leaf path picks up exactly
one mirror per level, so all tiles at a given depth share one handedness
(the cyclic turn-sequence test pins it). Gamma is the mystic, carrying a
second spectre translated to vertex 8 and rotated +30 degrees, surfaced as
`isOdd`. The child tables and slot data are the paper's, cross-checked
against the authors' reference app; the implementation (value types, the
level walk, exactness tests) is Ollin's own. Sizing grows levels until the
patch covers the bounds in tile units, then scales, recenters, and clips
per-tile. The curved outline replaces every edge with the same flattened
cubic bump, alternating sides edge to edge; 14 being even keeps the
alternation cyclically consistent, and abutting tiles nest because odd
edges always meet even edges in a spectre tiling, which is exactly the
paper's chirality-forcing edge modification.

`AperiodicTilingTests` covers all four: the crack-free scan (every edge
shared at most twice, no corner strictly inside another edge), P2/P3 edge
ratios and kind presence, the thick:thin ratio near phi, arc-endpoint
pairing across interior edges, Wang constraint satisfaction and seed
determinism, girih motif completeness and n-fold symmetry on regular
polygons plus cross-tile joining, and the spectre's unit edges, congruent
single-handedness, odd-tile sparsity, and determinism. Examples
`Patterns/Penrose` / `WangTiles` / `Girih` / `Spectre`; snapshots
`penrose` / `wang-tiles` / `girih` / `spectre`;
`Docs/Drawing/AperiodicTilings.md`.

### Packing

`Geometry/Packing.swift`. `packCircles(in:count:minRadius:maxRadius:padding:)`
is grow-to-touch greedy gap-filling, big-first. `packCircles(around:)` grows
each point's circle to half its nearest-neighbor distance, a closed form
with no iteration or rng. `relaxCircles(_:iterations:)` parts a coincident
pair on a fixed axis to stay reproducible. All seeded and deterministic,
output `[Circle]`. Shape packing is geometry-aware: it fits against each
shape's actual outline, not bounding circles, so small shapes nestle into
notches and concave gaps (built to fix the visible space around triangles
and stars). The engine is the stateful `ContinuousPacking` class:
grid-accelerated incremental `step()` with even-odd ray-cast rejection and
grow-to-nearest-outline, broad-phased by a bounding-circle prune over a
spatial hash; an empty shape bag packs plain circles on a fast path.
`packShapes(_:in:count:…)` runs it to completion; `packShapes(_:around:…)`
scatters one shape per point. An unbounded `maxRadius` clamps the grid cell
*and* the search reach to the region size, else the reach is infinite. The
example uses `noClear()` accumulation and draws only newly added shapes,
keeping per-frame cost flat as the field fills. `PackingTests`; examples
`Patterns/CirclePacking` + `Patterns/ShapePacking`; snapshots
`circle-packing` + `shape-packing`.

### Growth systems

L-systems (`Geometry/LSystem.swift`): axiom + rules (or stochastic
`choices`) rewrite to a string a turtle walks into open `[Contour]`
line-work; 13 presets verified against Wikipedia and Paul Bourke; seeded
`Sketch` sugar fits the form to the canvas and drives `&rng` so stochastic
presets vary by `seed`. A 2M-symbol expansion cap stops expansion and keeps
the last string under it. The turtle flushes one `Contour` per unbroken run,
so a branch shares its branch point with the trunk and no segment draws
twice. Example `Patterns/LSystem`; snapshot `l-system`.

Differential growth (`Geometry/DifferentialGrowth.swift`): a stateful
`final class` you hold and `step()`; attraction/alignment/repulsion over a
uniform spatial hash, long edges split at midpoints, `jitter` breaks a
symmetric ring so it buckles; `ring(...)` / `line(...)` seed factories.
CPU-only and deterministic given the seed (bucket lookups and summation are
order-stable), so it grows over frames yet a fixed-`frame` snapshot
reproduces. Example `Patterns/DifferentialGrowth`; snapshot
`differential-growth` (`frame: 130`).

Space colonization (`Geometry/SpaceColonization.swift`): branching growth
toward attraction points; each attractor pulls its single closest node
within `influenceRadius`; attractors within `killRadius` are consumed (keep
`killRadius` > `stepLength`). No rng: deterministic given input, with
Dictionary iteration sorted by node index, oscillation guards, and a stall
counter so unreachable attractors terminate (`isFinished`). Pipe-model
`thicknesses(leafWidth:exponent:)`; pairs with `poissonDisk`. Example
`Patterns/Venation`; snapshot `space-colonization` (`frame: 140`).

Diffusion-limited aggregation
(`Geometry/DiffusionLimitedAggregation.swift`): off-lattice seeded walkers
freeze at first touch; long strides while far, kill-radius respawn,
`stickiness` < 1 packs denser, optional `bounds` cage. Particles are
parent-linked and arrival order equals index, so tint-by-age is free.
Example `Patterns/Dendrite`; snapshot `dla` (`frame: 110`). `GrowthTests`
pins both growth systems.

Crack growth (`Geometry/CrackGrowth.swift`): straight cracks over an
angle-raster grid. A cell within 5° of the crack's angle reads as its own
line. Any other angle stops the crack, which restarts perpendicular at a
random claimed cell and recruits one more, up to `maxCracks`. Restarts
draw from a kept list of claimed cells. A bounded random probe went
dormant on a sparse grid, a real bug the tests caught. `step()` returns
per-tick `Mark`s (point, one-sided wash span, gain); `CrackGrowth.grains`
lays the sin-eased wash. The wash-side test was verified red by flipping
the sign. Example `Patterns/Cracks`; snapshot `crack-growth`;
`CrackGrowthTests` (8, CPU-only).

Meander (`Geometry/Meander.swift`): kinematic river migration, a stateful
`final class` you hold and `step()`. Per step: curvature by central
differences; each point's drift blends the local curvature (weight -1) with
a normalized, exponentially decaying average of upstream curvature (weight
2.5, e-folding over `memoryLength`), applied perpendicular to the line and
clamped to 0.9 x `spacing`; pinch pairs closer than `cutoffDistance` across
the land (skipping nearby indices along it) excise the loop into an
`Oxbow`; the centerline resamples through an interpolating Catmull-Rom at
even `spacing`. Two findings are load-bearing. The resample must be a
curvature-preserving spline: routing it through the straight-segment
`Contour.resampled(spacing:)` shaves curvature every step and visibly damps
the growth. And the seed perturbation must be long-wave: a white-noise
jitter of the start line carries almost no energy at the amplified
wavelengths (about ten channel widths) while its curvature spikes trip the
per-step clamp, and the measured sinuosity stalled at 1.19 by step 1600;
three tapered sine components in the 6-16 width band reach 1.84 by step
800, with the first natural cutoff before step 1600. Oxbows shrink toward
their centroid and are deleted under half a width; `scars` records the
centerline every `recordEvery` steps, trimmed to `maxScars` in one pass.
The migration itself is deterministic; all rng is in the seeded start
waves. Example `Patterns/Meander` (starts 700 units off-canvas, so the
canvas windows the middle of a longer river and the downstream translation
keeps feeding it); snapshot `meander`; `MeanderTests` (9, CPU-only).

### Wave Function Collapse

`Geometry/WaveFunctionCollapse.swift` is the simple-tiled model (Gumin):
`WFCTile` edge sockets + weights, min-entropy observe, weight-biased
collapse, propagate, restart-on-contradiction. Every rng decision runs over
a sorted candidate list with a fixed cell-iteration order (the catalog-wide
determinism rule originated here); anything else makes the seeded solve
irreproducible and the snapshot flaky. Sugar `wfc(...)` +
`drawWFC(_:in:padding:gutter:tile:)`; draw from `tiles[i].sockets` so one
draw block covers a tile and all its rotations. Example
`Patterns/WaveFunctionCollapse`; snapshot `wave-function-collapse`.

`Geometry/OverlappingWFC.swift` is the overlapping model, which learns its
constraints from an example `Image` instead of declared tiles. Colors are
indexed first-met in scan order; patches are cut at every position (the
sample read as wrapping) or only where a whole patch fits, augmented by the
dihedral variants `WFCSymmetry` keeps, and counted by content into weights.
Six pieces are load-bearing, and each is a way the algorithm goes wrong
rather than merely slow:

- **Propagation runs over the four unit offsets only**, not the `(2N-1)²`
  offsets the 2016 formulation used. It is sound because in a complete
  assignment the intervening cell holds *some* pattern and the two overlaps
  chain by transitivity, so the long-range arcs are redundant. This is the
  20x that made the model practical.
- **The support counters are the propagator, not a recompute.**
  `compatible[cell][pattern][d]` counts the patterns still standing in the
  neighbor *opposite* `d` that would allow `pattern` here, so it initializes
  to `propagatorCount[opposite[d]][p]`. Using `d` there compiles, runs, and
  is quietly wrong. A counter reaching zero means nothing over there can sit
  beside that pattern, which is what bans it in O(1). `ban` zeroes all four
  of a pattern's counters so later decrements go negative and it can never
  be banned twice.
- **Entropy is Shannon over the weights**, `log(S) - Σw·log w / S`, updated
  incrementally in `ban`. A plain count of survivors treats a cell holding
  two equally likely patterns the same as one holding two at 1000:1, which
  is nearly decided. The scan's sentinel is `.infinity`, never a magic
  constant a large pattern set could exceed.
- **A bounded output's last `N-1` columns and rows are outside the solve
  entirely**: never observed, and never propagated into. A ban spreading
  into one would empty a cell nobody asked about and read as a
  contradiction. The read-out then recovers those pixels from the last cell
  that covers them (the `dx`/`dy` offset in `render`), which is why the
  output needs `2N-2` pixels a side.
- **Patterns with no legal neighbor in a direction that exists are banned
  before anything is settled.** Propagation can never eliminate one (nothing
  ever withdraws support from it), so it survives to be chosen and the
  output is quietly illegal rather than contradictory. These appear when the
  sample is read as bounded, where its edge patches may have nothing that
  can follow them.
- **Determinism** follows the catalog rule: patterns are appended in scan
  order and the content dictionary is lookup-only, the noise that breaks
  entropy ties is drawn in a fixed cell order, and the weighted pick walks
  the survivors in index order.

Deliberate cuts: no seeding constraints (the reference's `ground` names a
pattern by index, which is an artifact of extraction order and silently
means something else when the sample or symmetry changes) and no
backtracking (a contradiction restarts the whole solve, `attempts` times,
as upstream does). Caps at 256 colors and 1024 patterns refuse a photograph
rather than grinding on it. Sugar `wfc(from:...)` + `wfc(_ model:...)`;
example `Patterns/TextureSynthesis`; snapshot `texture-synthesis`;
`OverlappingWFCTests` pins extraction against hand-derived counts, the
rotation closure, local legality in all three output modes, and
determinism.

### Cellular automata and turmites

`Geometry/CellularAutomata.swift` + `Geometry/Turmite.swift`.
`elementaryCA(rule:width:generations:from:wrap:)` covers the 256 rule-byte
automata; `totalisticCA(code:colors:width:generations:from:wrap:)` is the
base-k digit table over the neighborhood sum; both are pure functions
returning stacked generation rows, with `startDensity` Sketch sugar rolling
the start row on the seeded `random`. The Sketch facade mirrors the full
free-function signatures because any bare-named Sketch member blocks
unqualified global calls inside sketches (the shadowing rule). `Turmite` is
the stateful walker class (the `DifferentialGrowth` shape): `[state][color]`
`Rule(write:turn:state:)` tables over a wrapped grid, turn quarters from the
published 1/2/4/8 codes, multi-ant in array order, no rng anywhere; the
`Preset` catalog (`.langton`/`.spiral`/`.highway`/`.chaos`/`.frame`/
`.fibonacci`) decodes the published turmite catalog and conforms to
`ParamOption` for free menu parameters. `CellularAutomataTests` pins rule
30/90/110 known rows, the totalistic 777 opening, wrap semantics, and
Langton's flip-parity plus the exact 104-step highway period. Examples
`Patterns/ElementaryCA` + `Patterns/Turmites`; snapshots
`cellular-automata` + `turmites`. `Docs/Generators/CellularAutomata.md`.

### Flow fields, boids, and steering

`FlowField` (`Geometry/FlowField.swift`) is a value type around an
`angle: (Vector2) -> Double` closure. `streamline(from:)` traces both
directions through a seed; `streamlines(from:separation:)` traces evenly
spaced non-crossing lines (Jobard-Lefer): a batch commits its own points to
the occupancy grid only *after* tracing, so a line never blocks itself.
`advected(_:)` steps points along the field. The seeded sugar
`flowField(scale:turns:z:)` / `curlField(scale:z:)` captures the sketch's
seeded noise, so the field is transient: trace streamlines once and hold
those, don't store the field (it retains `self`); `z` is the noise slice.
Example `Patterns/Streamlines` (distinct from the older `Motion/FlowField`
field-arrow viz); snapshot `streamlines`.

`Boids` (`Geometry/Boids.swift`) is Reynolds' separation/alignment/cohesion
over spatial-hash neighbors, with edge avoidance within `margin` (no wrap,
so no toroidal-seam artifact) and an optional `field: FlowField?` join. The
grid buckets fill in boid-index order and the 3×3 cell scan is fixed-order,
so force sums are order-stable and a seeded flock at a fixed frame
reproduces. `drawBoids(_:size:)` sugar. Example `Patterns/Flocking`;
snapshot `flocking` (`frame: 120`). `Vehicle` (`Geometry/Steering.swift`)
is the single-creature side: composable forces (`seek`/`flee`/`arrive`/
`pursue`/`evade`/`wander`/`follow(field)`/`follow(path:)`/`separate`/
`contain`) over the public `steer(toward:)`; behaviors return forces you
weight and sum, `step()` integrates. Only `wander` rolls the rng, and it
rolls a *unit* then scales by jitter (the zero-width-range gotcha). Path
following seeks a point further along the arc length; `closed:` wraps
through the seam (`SteeringTests`). `drawVehicle(_:size:)` sugar. Example
`Motion/Steering`; snapshot `steering` (`frame: 150`).

### Force-directed graph layout

`ForceLayout` (`Geometry/ForceLayout.swift`) is the Fruchterman-Reingold
spring embedder, faithful to the 1991 pseudocode: repulsion `k²/d` between
all node pairs (accumulated once per pair, equal-and-opposite, in fixed
index order), attraction `d²/k` along edges (per-edge `weight` multiplies
the pull), each node's move capped at `temperature` and clamped to
`bounds`, the temperature cooling *linearly* to zero over `coolingSteps`
from `initialTemperature` (a tenth of the frame's larger side, the paper's
figure). `k` (`idealDistance`) defaults to `√(area/n)`. Two honest facts
worth keeping straight. First, `k` is a scale constant, not a promised edge
length: for a ring of n nodes the radial components of the crowd's
repulsion sum to exactly `(n−1)k²/2R` regardless of angle, giving the
equilibrium `R⁴ = (n−1)k³/16sin³(π/n)`, spacing well under `k`; the
measured settle sits on that equilibrium (6× longer cooling moves the mean
edge ~1.5%), so a fuller frame comes from raising `k` (FR's own `C`
multiplier), not from more iterations. Second, the freeze is real: at
`temperature` 0 `step()` early-returns, so a settled layout is free to
leave in `draw()`, and `reheat(_:)` (restore a fraction of
`initialTemperature`) is the one way anything moves again; it's the idiom
for growth (`addNode`/`connect`, then a small reheat) and for dragging
(pin the grabbed node, write its position, keep `reheat(0.15)` topped up
so neighbors follow). `gravity` (default 0, byte-identical off) is a
phantom edge from every node to the bounds center scaled by its value,
quadratic in distance, so small values (~0.01) center disconnected
components and big ones crush the web. Coincident nodes part along an
index-derived direction (deterministic tie-break). Everything is arrays in
index order: seeded `SplitMix64` placement, no Set/Dictionary walks, so a
run replays exactly (`sameSeedReplaysExactly`). Example
`Patterns/ForceGraph` (scale-free growth by degree-weighted stub picks,
drag-to-pull); snapshot `force-graph`; `ForceLayoutTests`.

### Strange attractors and chaotic maps

`Geometry/Attractors.swift`. `StrangeAttractor` integrates the 3D systems
(lorenz, rossler, aizawa, thomas, halvorsen, dadras, chen, fourWing) under
RK4; `ChaoticMap` iterates the 2D maps (clifford, deJong, henon). Both
carry a user-suppliable `@Sendable` closure and emit orbits via
`orbit(count:settle:)`; 3D plots as a `PointCloud` through the camera, 2D plots
as additive density. RK4 lives in `Math/Integration.swift` behind the
internal `Integrable` protocol, promoted from file-private when the double
pendulum became its second caller. Examples `3D/StrangeAttractor` +
`Patterns/ChaoticMaps`; snapshots `strange-attractor-3d` +
`clifford-attractor`.

`Geometry/Bifurcation.swift` adds the family's 1D member, `IteratedMap`:
where `ChaoticMap` is a fixed rule, this is a parameterized *family*
`x' = f(x, r)` (the closure takes `(x, r)`), because the bifurcation
diagram is a sweep over `r`. Factories logistic / sine / tent /
gauss(alpha:) carry an analytic `derivative` plus the canonical sweep
window (`parameterRange`) and vertical axis (`valueRange`); readings are
`orbit(at:)`, `graph(at:)` (the hump), `cobweb(at:steps:)` (the
graphical-iteration staircase, `2·steps + 1` points from `(x₀, floor)`),
`bifurcation(over:columns:perColumn:settle:)` as `(r, x)` points (the
plotter-friendly form `drawBifurcation` maps into a rect), and
`bifurcationImage` (per-column orbit binned into pixel rows, tone =
`log1p(count)` normalized by one global peak factor, the fractal-flame
one-log-factor rule, ink on white). `lyapunovExponent(at:)` averages
`ln |f′|` along the orbit, using the analytic derivative when present and
a central difference otherwise, flooring `|f′|` at 1e-12 so a superstable
visit doesn't emit −∞; `BifurcationTests` pins it to the exact known
values (`ln r` for the tent map termwise, `ln 2` for logistic r = 4 via
the tent conjugacy) and pins the cascade itself (fixed point below 3, the
2-cycle's closed form at 3.2, period 4 at 3.5, period 3 inside the
1 + √8 window). Two numerical decisions are load-bearing: sweeps sample
**column centers**, never the range endpoints, because some families are
degenerate exactly there (the μ = 2 tent orbit collapses to 0 in binary
floating point, one doubled bit per step, ~50 steps and it's gone); and
the factory `start` values avoid the critical point 0.5, whose logistic
orbit dies in two steps at exactly r = 4 (0.5 → 1 → 0). The gauss family
genuinely holds two coexisting attractors over part of its window, so its
diagram depends on `start`; that's the map, not a bug. Everything is
rng-free and pure, so the diagram is render-once `setup()` work and the
example holds it in a retained `Batch`. Example `Patterns/Bifurcation`;
snapshot `bifurcation`; `BifurcationTests`.

### IK chains, the double pendulum, and N-body

`Geometry/IKChain.swift` / `DoublePendulum.swift` / `NBody.swift`: three
stepped CPU motion systems, deterministic throughout (no rng; `NBody`
factories seeded). `IKChain` warm-starts per call. `reach(toward:)` is
fixed-base: `.fabrik` (default; even, smooth) or `.ccd` (the whippy
tip-heavy alternative, with `maxTurn` damping). `drag(to:)` is the
free-base single tip-to-base pass (the classic tentacle); `moveBase(to:)`
re-roots. `maxBend` is enforced per joint *inside* both FABRIK passes:
clamp against the pass-side neighbor, then re-place at exact segment
length; post-clamping would break the rigid lengths. An out-of-reach target
stretches the chain straight before the iterate loop. Keep the stall exit:
constraints can make in-range targets unattainable, and `reach` returns
false rather than spinning.

`DoublePendulum` is the standard point-mass Lagrangian EOM over the shared
RK4; `step(dt:)` splits into fixed substeps of at most 1/480 s, so default
stepping is a pure function of the start (energy drift pinned by
`DoublePendulumTests`); passing live `deltaTime` trades reproducibility for
wall-clock pacing. `bob1`/`bob2` are pivot-relative, y-down.

`NBody` is a Barnes-Hut quadtree (index-order insertion, incremental
mass/COM, s/d < θ opening) with Plummer softening and KDK leapfrog;
accelerations cache across steps and recompute on a body-count change.
Coincident bodies chain at the depth cap, else subdivision recurses
forever. The force walk runs on unsafe buffers on purpose (~2× debug frame
rate, measured). θ = 0 must stay the exact all-pairs sum, and vanilla
monopole BH at θ = 0.7 runs a few percent of field scale; `NBodyTests` pins
brute-force equality at θ = 0 plus the roughly quadratic error shrink as θ
tightens, so don't "tighten" that bound. Seeded factories: `disk` (circular
orbits from enclosed mass; `velocity:` drift stages collisions) and
`cluster`. Examples `Motion/InverseKinematics` / `DoublePendulum` /
`NBody`; snapshots `ik-chain` / `double-pendulum` / `n-body`;
`IKChainTests` / `DoublePendulumTests` / `NBodyTests`.
`Docs/Simulation/Motion.md`.

---

## Color: palettes, dithering, and print separations

`Color` itself (the CSS Color 4 named set, `Color(hex:)`, HSB with wrapping
hue, the gamut-mapped OKLab family written from Ottosson,
`Color.mix(_:_:t:in:)` over `ColorSpace`, blackbody `Color(kelvin:)`,
`Ramp` / `Palette` / `CosinePalette` / `Colormap`) is enumerated in
`Docs/Drawing/Color.md`. This section records the mechanism in the color
*pipelines* under `Sources/Ollin/Color/`: palette import and extraction,
image dithering, and print separations. All are CPU-side, setup-time, and
deterministic, so they are snapshot- and recipe-safe.

### Palette import

`Color/PaletteImport.swift`: `loadPalette` / `loadPalettes` (plus
`Palette(contentsOf:)` / `palettes(data:format:)` / `resource:in:`) over the
`PaletteFormat` set: hex-per-line, CSV, TSV, JSON (array-of-arrays, flat
array, or `{"colors":[…]}`), and ASE (Adobe Swatch Exchange, written from
the community spec; swatch groups become palettes in document order, loose
colors flush in place; RGB/Gray/CMYK/LAB, the last via CIELAB on D50, where
LAB lightness arrives 0…1, not 0…100). The three text formats share one
parser (they differ only in separator), so `.auto` decides palette-per-line
by the *file's* shape: every line yielding exactly one color means one
palette, not a stack of one-color palettes. An explicit format must override
that sniff; the parameter is `palettePerLine: Bool?` with nil meaning
"decide", and a plain `Bool` here was a real bug. A line parsing to zero
colors is skipped (CSV headers fall away), and no comment syntax is
promised: `#deface` is a color. Every loader returns `[]`/`nil` on malformed
bytes, never traps (bounds-checked `ByteReader`; the ASE block length is
authoritative, so an unknown block can't desync the stream). Ship the
loader, not the data (the `.fnt` rule): bundled palette *data* stays
license-gated. Example `Color/PaletteFile`; `PaletteImportTests`.

### Palette extraction

`Color/PaletteExtract.swift`: `Palette(extractedFrom:count:seed:)` /
`extractPalette(from:)` run weighted k-means++ then Lloyd over OKLab (sRGB
distance splits greens and merges blues). Input is grid-sampled to at most
16k pixels; identical pixels merge and are sorted before clustering
(Dictionary order is per-process; determinism is a promise); emptied
clusters reseed to the worst-explained sample. Output is most-used first, so
`p[0]` is the dominant color. Fewer distinct colors than `count` returns
only what's there; a texture-backed `Image` has no CPU pixels and yields an
empty palette (`snapshot()` first); setup-time work, not per-frame. Example
`Color/PaletteFromImage`; `PaletteExtractTests`.

### Image dithering

`Color/Dither.swift`: the `Dither` enum + `Image.dithered(_:to:)` /
`(_:levels:)`. Eight error-diffusion kernels (Floyd-Steinberg,
Jarvis-Judice-Ninke, Stucki, Atkinson, Burkes, the three Sierras), ordered
Bayer (`.ordered(size:)`, powers of two 2…16, recurrence-built), and
`.blueNoise` (a 64² void-and-cluster tile after Ulichney, generated once
from a fixed seed and cached: ~15 ms release, ~2 s debug; generated, not
bundled, so no asset and no license). CPU-only by nature: error diffusion is
serial and can never be a fragment shader. The GPU `Filter.dither` /
`.ditherDuo` remain the cheap real-time *layer* effect, a different tool.

The load-bearing rule is three color spaces, one per family, and mixing them
up *is* the bug. Tone always accumulates in linear light (diffusing in sRGB
brightens gradients). `.none` picks the OKLab-nearest color: it carries no
error, so it is free to be perceptual, and the bands land evenly. Error
diffusion must pick the *linear*-nearest: choosing in a space it doesn't
accumulate in leaves a consistently-signed residual that draws a thin false
contour of the wrong color along the tone where the two rules disagree. That
contour is 1-2 px wide, so it hides from tile-averaged error statistics;
don't try to pin it that way. `DitherTests` pins the rule on a 1×1 image,
where no neighbor exists and the choice rule is directly observable.

Threshold maps (Bayer and blue noise) run the Yliluoma pair search: every
palette pair (and each color alone) is scored by the OKLab distance of the
mix it can actually reach, plus a variance-weighted noisiness penalty
(`0.005 · f(1−f) · pairDist²`; Yliluoma's flat 0.1 suits dense game palettes
but bands a sparse one: on plain black+white it hands the whole top of the
ramp to solid white). The fraction along the winning pair is taken in linear
light (deciding by OKLab distance instead puts a 25% gray at ~46% white
instead of 5%), the pair is oriented dark-to-light so neighboring tones
share pattern phase, and plans are memoized per distinct color. The shipped
v1 anchored the search on the single nearest color, a real bug: gray on a
black/white/red palette dithered *pink*.

Details that earned their place: a fully transparent pixel diffuses no
residual (its black isn't light; it darkened cutout edges). Input walks the
premultiplied bytes through a 256-entry linear LUT (bit-identical to the
subscript path, ~2× release). Bayer thresholds are centered
(`(m+0.5)/n² − 0.5`) and added *before* quantizing, never compared against
raw (the naive `v > m/n²` form biases the image lighter). Serpentine
(default on) mirrors the kernel's `dx`. Void-and-cluster's phase-3 role
inversion collapses into phase 2: on the torus zeros-energy = G −
ones-energy, so the tightest cluster of zeros *is* the largest void (one
loop, provably). Alpha passes through; a texture-backed `Image` returns
unchanged (`snapshot()` first); an empty palette returns self. Example
`Color/Dithering`; snapshot `dither`; `DitherTests`.

### Print separations

`Color/Ink.swift` + `Color/PrintSeparation.swift` +
`Export/SeparationExport.swift`. `Image.separated(into:paper:)` splits a
frame into per-ink grayscale masters (byte = ink fraction, black = full ink)
under the translucent-spot-ink overprint model: each ink is a transmittance
filter (its color in linear light), coverage mixes in linear light, layers
multiply. The per-distinct-color coverage search (lattice seed, then
coordinate descent, memoized like the dither planner) is judged in OKLab:
the dither pass's two-space rule again. `preview()` reconstructs the
overprint *from the masters*, so screened masters preview their actual dots.
`dithered(_:)` reuses the `Dither` kernels over the coverage plane (coverage
*is* the linear quantity, no sRGB detour); `halftoned(pitch:angles:)` runs
rotated round-dot screens with an area-exact covered-area threshold (darkest
ink at 45°). Both drop coverage past the 2% minimum dot: the sub-1% residue
8-bit rounding leaves in "white" otherwise screens into stray specks (a real
fixed bug). `Ink` carries the 78-ink standard riso catalog (stencil.wiki
values, credited in `ATTRIBUTION.md`; the workflow references
p5.riso/Spectrolite are inspiration-only, since p5.riso's license is not
MIT-compatible). The sketch declares `printInks`, not `inks` (that name
collided with an example's own property). `--export-separations` writes one
PNG per ink plus `-preview.png`, each with registration targets and a label
in a white band *around* the untouched artwork, drawn in full ink on every
layer so the crosses stack into register; recipes carry the ink list
(`ExportMetadata.inks`). GPU-backed image yields an empty separation; the
model limits by physics (inks only darken; no white ink on dark stock).
Example `Color/PrintSeparation` (the canvas *is* the poster; a `@Param` view
parameter flips artwork/masters/preview so exports separate the artwork, not a
demo layout); snapshot `print-separation`; `PrintSeparationTests`.
`Docs/Output/PrintSeparations.md`.

### Print color management (soft proof, gamut check, process plates)

`Color/ICCProfile.swift` + `Color/SoftProof.swift` +
`Color/ProcessSeparation.swift` + `Export/ProcessSeparationExport.swift`, the
process-color sibling of the spot-ink path above. Nothing here is a color
engine: every transform is ColorSync's, which is the same engine the rest of
the machine color-manages through and reads the same `.icc` files a print
shop hands out. What Ollin owns is the value layer, the caching, and the
decision about which chain answers which question.

`ICCProfile` is bytes plus the few header fields worth reading (data color
space at offset 16, `prtr` at offset 12, the channel count spelled into an
`nCLR` tag), so it stays `Sendable`/`Hashable` and keys a cache by an FNV
digest rather than by tens of kilobytes. The built-ins come from
`CGColorSpace.copyICCData()`, so no filesystem lookup is involved;
`installed()` walks the three ColorSync directories for what a studio has
added.

`ProofTransform` builds one of four chains and caches it per printing
condition behind an `OSAllocatedUnfairLock`: the proof itself (canvas to
press to canvas, four steps), `toDevice` (the separation), `fromDevice` (the
plate preview), and the gamut check, which is ColorSync's own
`kColorSyncTransformGamutCheck` rather than a distance in a perceptual space.
Three facts about that API were established by probe and are easy to get
wrong:

- **The gamut check writes one 32-bit float per pixel** (1 means out of
  gamut) whatever depth is asked for. Requesting `kColorSync1BitGamut` with a
  packed bit buffer overruns it and traps.
- **Float conversions carry device-encoded values, never linear light.** The
  image path therefore unpremultiplies to encoded floats, and the baked
  lattice is indexed by encoded values while storing linear ones.
- **The module must be imported `@preconcurrency`.** The SDK spells its
  CFString keys as mutable globals, which Swift 6 rejects at every use.

Black point compensation stays off and `simulatesPaper` switches the *return*
leg's intent, which is what makes the proof honest in the two ways that
matter. Measured on the generic four-ink profile: relative in and relative
out leaves white at 1.005 and lifts black to 0.055 (ink black, always shown);
relative in and absolute out lands white at .934/.864/.726 (the stock's
tint, opt-in). Turning BPC on for the four-step chain misbehaves outright
(white drops to .862), which is why it is not exposed as a parameter.

The live filter exists because the full transform measures about 145 ms on a
1080 by 1080 canvas. `ProofLUTCache` bakes the condition into a 33 cubed
lattice instead (about 4.4 ms), rgb in linear light and the gamut flag stored
in alpha, and hands back **the same `ProofLUT` object** for the same
condition every frame. That identity is what the renderer's texture cache is
keyed on, so a steady sketch uploads its 574 KB once rather than per frame.
`ollin_fx_soft_proof` encodes with `linearToSrgb` before the lookup and
samples at `(i + 0.5)/n` through the clamped image sampler, whose linear
filtering across three axes is exactly the trilinear read a lookup table
wants. Dropping that encode is a real picture change rather than a rounding
one, which `theLiveProofMatchesTheImageProof` pins (verified red by
sabotage).

Plates come from the `toDevice` chain (1 is full ink), written out under the
spot path's own convention (`master byte = 255 - ink`) so both exports hand a
shop the same kind of file, screening included: `ProcessSeparation` reuses
`PrintSeparation`'s plane helpers at the conventional rosette angles (cyan
15, magenta 75, yellow 0, black 45). `preview()` runs the `fromDevice` chain
*from the plates* rather than proofing the source, which is what makes the
CPU separation and the GPU proof cross-check each other
(`thePlatePreviewMatchesTheDirectProof`). Total area coverage is reported
rather than enforced, and is meaningful only on unscreened plates, since a
screened set counts overlapping dots and reaches 400% at a rosette.
`Sketch.printProfile` + `--export-plates` mirror `printInks` +
`--export-separations`; the recipe carries the plate names and the printing
condition. `PrintColorTests`, snapshot `soft-proof`, example
`Color/SoftProof`. `Docs/Output/PrintColor.md`.

### Color-vision simulation (dichromat projections)

`Color/ColorVision.swift` + the `ollin_fx_color_vision` fragment. At severity 1 the simulation is a projection in the cone space along the missing cone's axis, Brettel, Viénot and Mollon's 1997 two half-planes for every kind, built once per kind at load from published measurements, never stored as numbers: Smith and Pokorny's 1975 cone fundamentals over the Judd-Vos corrected tristimulus space, the sRGB primaries and D65 moved into that space by Vos's 1978 chromaticity correction, and the CVRL Judd-Vos color matching functions at the anchors (475 nm and 575 nm for protan and deutan, 485 nm and 660 nm for tritan). Each half-plane holds the neutral axis and one anchor; the half is picked by the side of a separator through the neutral axis and the missing cone's axis. The neutral axis is the display's white rather than the equal-energy one, so white and every gray map to themselves. Because the separator contains the missing axis, a projection along it keeps a color's side, which is what makes the two-half model idempotent; on the separator both halves give the same gray, so there is no step at the seam. `DichromatProjection.of(kind)` holds the two linear-RGB transforms and the separator, and the GPU pass binds them as seven rows.

The measurements that set the model, 2026-10-08 and 09 (numpy, before any Swift; the derivation reproduced the reference implementation's printed matrices to the fifth place first). Machado, Oliveira and Fernandes's table, which this replaced, is not a projection at severity 1 (protan and deutan rank two but M squared off M by up to 0.06; tritan rank three), and its anomalous path does not lead to a projection either, so it cannot sit below one: its path carries pure blue 0.17 (protan) and 0.20 (deutan) OKLab from where a projection at severity 1 puts it, and a splice from the table's 0.9 overshoots by as much. The straight line in linear light from the color to its projection is the only candidate measured that holds the monotone dial for every kind, and it is what the reference implementations do; splitting it per side is exact, since the side does not depend on severity (the GPU transform at severity s is `(1 - s) I + s T` for each half). Viénot, Brettel and Mollon's 1999 single plane (through black, blue and yellow) was built first for the red-green kinds and refused after a look: it is exactly `r = g` in linear RGB, so every blue becomes the display's blue primary mixed with gray and reads lavender, 0.03 to 0.06 OKLab from the half-planes' steel blue. Its virtue is that it sits inside the display's range by design, which is the 1999 paper's purpose.

Out-of-range results are the other half of the decision, and the metric that settles them is what the simulated viewer sees: the two kept cone responses, normalized by white. Brettel's surfaces leave the cube for 28% to 41% of it (protan output spans -0.31 to 1.35). A per-channel clamp moves what a protanope sees by 0.011 on average (max 0.035; deutan 0.019, tritan 0.016), because its error runs mostly along the confusion line. The nearest in-range point on the surface itself, found by a 2D search in kept-cone space, moves it by 0.066 (max 0.20): sRGB's cube holds too little of the red-green surfaces to stay on them. A pull toward the gray of equal Rec. 709 luminance, built and ruled and then retracted in the same session, moves it by 0.26 (max 0.78; a protanope's green and yellow came out near-white and blue navy), since normal-observer luminance is not the dichromat's; it had been argued from staying on the surface without measuring what the viewer sees, and only the red-green half-planes exposed it (for tritan, fit and clamp are even). Iterating clamp and projection converges onto the surface but drifts further in what the viewer sees (0.075 after 32 rounds). So the clamp ships: the CPU call clamps both ends, the fragment only the low end (a layer may carry light brighter than white for the tone map), and idempotence is exact wherever the first result stays in range and bounded elsewhere (a second pass moves what the viewer sees by at most 0.044). The worked values in the tests come from the reference implementation's printed matrices; they differ from the derivation by up to 1.6e-5 because the reference rounded its anchors (660 nm's Z, 1.19e-5 in the table, became 1e-5) and built its printed cone matrix from primaries rounded to six places (2.5e-6 relative). `ColorScienceInvariantTests` (the projection's five statements), `ColorVisionTests`, `ColorVisionRenderProbes`, snapshot `color-vision`, the `Simulated` web page probe. `Docs/Helpers/Accessibility.md`.

---

## Wide gamut and HDR output (`colorOutput`)

The mechanism is small: `ColorOutput` picks the drawable's pixel format and the
present pass's encoding, and the renderer carries both for its lifetime.
`.standard` keeps the 8-bit sRGB drawable and the shipped
`ollin_present_fragment`; `.wide` and `.extended` present into `rgba16Float`
through `ollin_present_wide_fragment`, a twin kept beside the original rather
than a branch inside it, because that fragment's exact codegen is what every
dithered 8-bit frame reproduces and growing it re-contracts under fast math.

The whole difference between `.wide` and `.extended` at the pixel level is one
uniform: the ceiling the twin clamps to. `.wide` stops at 1.0, `.extended`
starts unbounded (so an off-screen render keeps the highlights an exported HDR
video is for) and a live host pulls it down to the display's reported headroom
every frame, since the system grants and withdraws that as brightness and
surrounding content change.

### Naming a color outside sRGB needed no shader change

`Color(displayP3:green:blue:)` converts to linear sRGB and re-encodes, which
puts some components outside 0…1. That value has to survive the ordinary vertex
path, where the fragment applies the shipped `srgbToLinear`. It does, exactly:
below the curve's knee that function is the straight line `c/12.92`, and the CPU
`linearToSrgb` is its exact inverse there, so the pipeline's decode of a
negative component is precisely the encode's inverse. Extending the shader's
transfer function to the mirrored form was the obvious alternative and would
have re-contracted every 2D fragment for no gain.

### The float drawable is not slower (measured)

The expectation going in was that `.wide` would cost bandwidth: eight bytes per
pixel against four. Measured, it is **cheaper**, because the standard present
fragment pays more arithmetic than the wider one saves in bytes. `finalizeColor`
runs `linearToSrgb`, two hash calls for the dither, and `srgbToLinear` per
pixel; the wide twin runs a 3x3 matrix and a clamp.

M2, release, a near-empty frame so the present pass dominates, ten paired runs
(the two settings measured back to back so thermal drift cancels within a pair,
the rule that a single A/B run can otherwise invert):

| canvas | `.standard` | `.wide` | difference |
| --- | --- | --- | --- |
| 540² | 0.17 ms | 0.12 ms | 0.05 ms cheaper |
| 1080² | 0.44 ms | 0.23 ms | 0.21 ms cheaper, ±0.01 over ten pairs |
| 2160² | 0.88 ms | 0.83 ms | noisy, break-even to slightly cheaper |

`.extended` measures identically to `.wide` (same shader, same format). The
saving shrinks at 2160² as bandwidth starts to matter against the fixed
per-pixel arithmetic.

What this does *not* measure: the harness renders off-screen, so the window
compositor's own cost for a float layer, and for an EDR layer in particular, is
outside it. The render cost is settled; the system cost is not.

The measurement is why the case against `.wide` as a future default is about
what leaves the window (a P3 file read by a consumer that ignores the tag reads
oversaturated; Syphon and the virtual camera feed such consumers) and about
re-recording every reference and figure, rather than about speed.

### A still keeps its highlights in a gain map

PNG stops at white, so `--export frame.png` throws away the one thing
`.extended` exists for. `--export frame.heic` writes HEIC instead, with an
ISO 21496-1 gain map beside the picture (`Export/GainMapExport.swift`). The
picture is the frame clamped at white, which is what the PNG already carries.
The map records, per pixel and per channel, the ratio between the two. Each
decision below was settled by measuring, and each one has an obvious wrong
answer.

**The map is computed here, not by the imaging framework.** Core Image will
derive one from an SDR/HDR pair (`kCIImageRepresentationHDRImage`), but its
ceiling comes from the frame's statistics rather than its peak. Measured on a
synthetic frame peaking at 4x white, varying only how much of it is bright:

| highlight area | peak read back |
| --- | --- |
| 0.1% | 0.99x |
| 1% | 1.75x |
| 5% | 3.63x |
| 25% and up | 4.00x |

A sketch's highlights are small by nature (a lamp core, a spark, a specular
hit), so that behavior deletes them. Declaring `CIImage.contentHeadroom` does
not help, though the header says it drives the calculation: the value is live
(the tone-map filter reads it) and the writer ignores it. Supplying a
ready-made map through `kCIImageRepresentationHDRGainMapImage` writes the
legacy Apple auxiliary type rather than the ISO one. So the map, its metadata,
and the file are built with ImageIO directly.

**The map carries a gain per channel.** Clamping happens per channel, so a
bright color clamps unevenly. An amber core of (4.0, 2.2, 0.72) becomes
(1, 1, 0.72), which needs three different multipliers to undo. Measured with
one channel for all three: green came back 81% high, blue 300% high. With
three channels: every channel within 0.6%.

**The base is written at ten bits.** It is handed to the writer at sixteen bits
under the Display P3 curve (`FrameBytes.sixteenBit`), and the writer stores a
sixteen-bit picture as a 10-bit HEVC image. At
`kCGImageDestinationLossyCompressionQuality` 1.0 the coding is near-lossless:
an 8-bit picture came back within one level of its bytes. The two 8-bit forms
both fell short of ten bits, on the survey's gradient with an amber core:

| Base | Worst against the frame | Gradient | File |
| --- | --- | --- | --- |
| 8-bit, undithered (the old base) | 1.3% | 4 runs: bands | 1.7 KB |
| 8-bit, dithered | 3.6% on a dark pixel, 6% beside the highlight | 475 runs | 4.5 KB |
| 10-bit | 0.3% | 15 ten-bit codes, no visible steps | 1.9 KB |

The dithered picture is two levels off at worst (the dither's one and the
codec's one), and two levels is 3.6% of a dark pixel. Ten bits won on all
three columns, and the ruling of 2026-10-17 took it.

The metadata is the `HDRToneMap` namespace ImageIO reads back: `Version`,
`BaseHeadroom`, `AlternateHeadroom`, `BaseColorIsWorkingColor`, and one
`ChannelMetadata` entry per channel carrying `GainMapMin`, `GainMapMax`,
`Gamma`, `BaseOffset`, and `AlternateOffset`. Reconstruction is
`(base + offset) * 2^(e * GainMapMax) - offset`, where `e` is the stored byte
over 255.

**The map is written at four times the frame's size.** ImageIO re-encodes the
three-channel map as 4:2:0 (`'420f'`), so the *differences between* channels,
which are what a per-channel map is, are stored at half resolution. And a
colored highlight's edge is exactly where those differences change. Measured on
an amber core with a one-pixel antialiased rim, each gain repeated over a block:

| Map | Highlight | Rim | Elsewhere | 1080² write |
| --- | --- | --- | --- | --- |
| frame size | 1.7% | 40% | 1.5% | 0.23 s |
| twice | 0.4% | 4.0% | 2.7% | 0.20 s |
| four times | 0.4% | 1.7% | 0.3% | 0.44 s |

At twice the size the half-resolution color is the frame's resolution, but the
codec sits each color sample at the left of its two-by-two block. A frame
pixel's center therefore reads a quarter of its right neighbor's color, which
is harmless inside a flat highlight and 4% on a rim one pixel wide. At four
times, every sample a pixel's center reads is its own. The map is held in
memory while the file is written, sixteen times the frame's pixels at four
bytes each (75 MB for a 1080 square), so `GainMap.mapScale` drops to twice
once the map would pass 24 million pixels, about a 1.5-megapixel frame.

The system's own gain-map writer (`kCGImageDestinationEncodeToISOGainmap` with
`kCGImageDestinationEncodeGenerateGainMapWithBaseImage` and a subsample factor
of 1) refused to write this frame at all, in every combination tried, so it
was not pursued.

A frame whose peak is at or below white carries no map at all, and
`OllinApp.StillExport.keepsHighlights` reports which happened.
`GainMapExportTests` pins the peak, the color, the base, the ISO type, and the
declared ceiling; the per-channel map and the true-peak ceiling were both
verified red by sabotage.

---

## Live reload and the live-coding hosts

`swift run OllinLive <path/to/Sketch.swift>` opens a window, watches the file,
and on save recompiles just that sketch into a `.dylib` and hot-swaps it into
the running loop; the window never closes. The rules live in CLAUDE.md's
*Live reload* section; this is the machinery.

### The host and the swappable dylib

OllinLive is a SwiftUI `App`: its `WindowGroup` hosts a `SketchView`, which
hands back the `SketchRunner` through its `onRunner` callback; the file
watcher then drives `SketchRunner.reload(to:)`. `SketchRunner.sketch` is a
`var`, and the loop already calls `sketch.performDraw()` through the instance,
so swapping the var means the next frame runs new code. The reusable loader
(`SketchLoader`) lives in the `OllinRuntime` library target, shared with the
gallery and kept out of the shipping `Ollin` framework.

The sketch dylib compiles with `-I` pointing at the dirs that hold
`Ollin.swiftmodule` and the C targets' module maps (to type-check
`import Ollin`) and `-undefined dynamic_lookup` with *no* `-lOllin`; the host
links `-Xlinker -export_dynamic`, so the dylib's Ollin symbols resolve against
the host at `dlopen`. Those `-I` dirs are **discovered, not assumed**
(`SketchLoader.moduleSearchPaths`): the classic SwiftPM build puts them next
to the executable (`<bin>/Modules`, `<bin>/<C>.build`), but the Xcode/Swift
build system puts `Ollin.swiftmodule` directly in `<bin>` and the C maps under
`.build/index-build/<triple>/debug`, so a `<bin>/Modules`-only assumption
fails with "no such module 'Ollin'" (it did; the `--selftest` harness
exercises exactly this). A third source sits outside the build tree: a
vendored C target with a checked-in module map
(`External/<T>/include/module.modulemap`, e.g. CBox2D, CSyphon) never lands
under a `*.build` dir, so the loader also collects those `include/` dirs from
the package root; without them a loose sketch cannot `import OllinPhysics`
(its swiftmodule needs the clang module even though the import is
`internal import`).

There is one copy of `Sketch`, so the loaded object casts as `Ollin.Sketch`.
**A `.dynamic` Ollin product does not fix this**: SwiftPM still links the
target statically into the executable, giving two copies and a failed cast
(verified the hard way). Each load uses a unique `-module-name` so repeated
reloads of the same class do not collide in the objc runtime. The loader
regexes the `class ...: Sketch` name and compiles a sibling
`@_cdecl("ollin_make_sketch")` factory file alongside the user's source, so
the user file is untouched and its `@main` is harmless under `-emit-library`.

### A sketch that is a package's target

A loader set to `.target` (the three hosts that run what a person names; the
gallery, the Guide figures and the generator preview keep `.file`) asks
whether the sketch file sits in a target's folder, and if so compiles the
target's other `.swift` files into the same dylib under the target's Swift
settings. `SketchTarget` is that answer: the nearest `Package.swift` above the
file, read through `swift package dump-package`, and the regular or
executable target whose folder holds the file, the deepest when folders nest.
Four decisions carry it:

- **The manifest read has its own scratch folder.** SwiftPM locks a package's
  `.build` for the length of a build, and a `dump-package` sharing it waited
  out a whole `swift build` (32 s, measured); under
  `$TMPDIR/OllinRuntime-manifest` it answers in 0.25 s whatever runs in the
  package, and nothing is written into the person's project. `dump-package`
  evaluates the manifest only, so no dependency is fetched. The answer is
  cached per root until `Package.swift`'s modification date moves, behind a
  lock held across the read so two compiles asking at once read once; the
  hosts' first compile pays the read and every later one a `stat`.
- **The file list is walked on every compile** rather than cached, so a file
  added since the last save joins the next. The walk mirrors SwiftPM's:
  hidden entries, `exclude:`, and resource paths skipped, `sources:` honored.
  `main.swift` is left out, because a library compile refuses its top-level
  code and the factory shim is the entry point there.
- **Paths are compared in one spelling, `realpath`'s.** Foundation's
  `resolvingSymlinksInPath` strips a leading `/private`, while the folder walk
  keeps it, so the sketch file came back from the walk under a second name
  and compiled twice ("filename used twice"); a path that does not exist
  keeps its missing tail on the resolved part that does.
- **The bundle is the package's.** A target that declares resources gets
  `Bundle.module` pointed at `<work>/Resources`, where each resource is
  linked as the build lays it out (a processed folder flattened, `.lproj` and
  copied folders whole, the first name winning): `url(forResource:)` looks at
  a bundle's top level only, so the sketch's folder, the loose file's bundle,
  never found a picture in a processed `Images/`. Links rather than copies,
  so an edited picture reaches `setup()` when an asset save re-runs it.

The settings become flags the way SwiftPM passes them (`-swift-version`, `-D`,
the feature flags, `-default-isolation`, `-strict-memory-safety`, the Swift
tool's unsafe flags), with platform conditions other than macOS dropped and a
configuration condition following the compile's own level. Compiling a
package's code in its own Swift 6 mode exposed the old factory shim, which
returned a pointer out of `MainActor.assumeIsolated`; the shim now hands the
address across as a `UInt` and declares both members `nonisolated`, so it
compiles in every mode and under main-actor default isolation.

What a folder offers (`SketchChoice.resolve`) is read with comments and
strings blanked (`SourceRegions.code(in:)`): a target is a sketch's when a
`Sketch` subclass in it carries `@main`, or when one exists and no other
`@main` does (a library target an app beside it runs, a target a `main.swift`
starts). A target whose `@main` is something else, such as this repository's
own hosts with their test sketches, is never offered; naming its sketch file
still runs it.

### The loose file on a wall (`OllinRun`)

`swift run OllinRun <file>` uses the same loader for the opposite purpose. It
compiles the file once and hands the sketch to `OllinApp.run`, the standalone
path a packaged `@main` sketch takes. That path is the only one that reads a
sketch's declared `Installation`, so it is how a loose file goes on a wall.
`Scripts/ollin` routes there when the arguments carry `--installation` or
`--calibrate`, and the live host prints that command rather than saying only
that the flag does nothing. There is no watcher here, by design: a wall
runs the code it was started with.

Two details are load-bearing, and both fall out of the unique `-module-name`
above. First, the checkpoint file is named from
`String(describing: type(of: sketch))`, which drops the module. So
`OllinRuntimeSketch_<token>.Piece` files as `Piece.json`, and a relaunch finds
yesterday's run rather than starting the piece over every morning.
`OllinRun --selftest` compiles one file twice and compares the two names,
printing both qualified ones so the check cannot pass for the wrong reason.
Second, the compile happens before `OllinApp.run` rather than inside it. `run`
is where a piece asking for `restarts` becomes its own supervisor. A file that
does not compile therefore fails once here, with swiftc's own message, instead
of failing in every child until the watch gives up.

### Watching, threading, and reload state

Editors save atomically (temp file plus rename), which breaks fd-based
watches, so `FileWatcher` watches *directories* with
`kFSEventStreamCreateFlagUseCFTypes | ...FileEvents` (the UseCFTypes flag is
required or the path-array cast crashes), debounced ~150ms, dispatching by
extension: `.swift` recompiles and swaps; `.metal` routes to the shader
reload (a user-resource `.metal` invalidates just the user-shader caches, a
framework segment rebuilds the whole library; the dispatch tells them apart by
path); image assets re-run `setup()`.

Recompiles run off the main thread (the window keeps drawing the old sketch);
the swap is marshaled to the main queue. A compile error is printed and the
running sketch is left alone, so a typo never closes the window; likewise
`MetalRenderer.reloadLibrary` builds the new pipelines before committing, so a
bad shader edit cannot blank the renderer. `reload(to:keepClock:)`
re-instantiates and re-runs `setup()`; by default it resets
`time`/`frameCount`, and `--keep-clock` carries them forward (offsets
`startTime` so `time` continues, copies `frameCount`) so an animation's phase
does not jump. `Sketch.reloaded()` fires once after the post-reload setup,
never on first launch. `SketchRunner.reload(to:)` honors the *new* sketch's
declared `canvasSize` for non-`.resizable` modes, so an edited resolution
takes effect on the swap.

### Carrying a run across a swap

`reload(to:keepClock:keepRun:)`'s third argument is the performance host's
one, and it exists because "evaluate this block" cannot mean in Swift what it
means in a language that interprets. The code lives in a freshly compiled
dylib, so there is no object whose method could be replaced: the buffer is
compiled whole and the instance is replaced whole, every time. What *can* be
kept is everything the running instance had become by the moment of the swap,
and the decision of whether keeping it is safe is a question about the text.

`keepRun` skips `renderer.resetAccumulation()` and leaves `didSetup` alone, so
`setup()` never runs on the fresh instance; `didReload` is then consumed by a
branch of its own in the draw loop, which is where `reloaded()` fires for a
carried swap. `Sketch.carryRun(from:)` (Core/RunCarry.swift) does the rest.
Its one design decision worth stating: it **adopts the running `Drawer`
whole** rather than copying fields out of it. A `Drawer` holds no reference
back to a `Sketch`, so handing the object over brings the accumulated canvas,
the fill and stroke, the blend mode, the text settings, the loaded fonts and
images and the retained batches across in one assignment, including whatever
is added to the drawer later; enumerating them would drift the day a field is
added, and a missed one reads as a subtle difference between a run that
carried and one that restarted. Beside it come the four generators and their
recorded seeds (carrying the seed alone would restart the sequence, which a
`random()`-driven piece shows as a jump), `view2D`, `isLooping`, and the
`@Saved` properties matched by name through their own JSON, so one property
that will not travel is named and skipped rather than costing the rest. That
`@Saved` is also what a checkpoint carries is the point: a sketch marks what
it would hate to lose once, and the mark covers a relaunch and an edit both.

Two of the things it carries are not state at all but registrations, and they
are the ones a first cut loses in silence: the extensions a sketch installed
for itself and the automation it wrote, both put there by an `extend` or an
`automate` call inside a `setup()` that is not going to run again.
`adoptExtensions(from:)` lives on `Sketch` rather than in `RunCarry.swift`
because the list is private, and it skips anything the fresh instance already
holds so the runner re-attaching the stats observer and a running recorder
never doubles them; `extensionsDidSetup` is left false, so every carried
extension hears `setup` again against the instance now drawing, which is what
the carried session recorder already relied on. The third of the kind is the
pointer: the `else if didReload` branch seeds it the way the setup branch
does, or a cursor-following sketch reads (0, 0) until the mouse next moves.

A still sketch is why the camera snap's holdover flag is now named
`pauseHoldover`: the `noLoop()`
that paused it stands in a `setup()` that is not going to run again, so the
swap un-pauses the view for the frame that shows the edit and hands the pause
back once it settles, which is the same shape the camera snap already needed.

`SourceRegions` (in `OllinRuntime`, so both hosts could use it) is the reading
half. It is a bracket-counting line scan rather than a parser, on the same
reasoning as `SourceReorder`: declarations are all the decision needs, a
grammar is far more to keep right, and every way the scan can be wrong falls
back to a restart. A comment or a bare attribute or modifier line joins the
declaration under it, strings (raw literals included) and comments are
skipped, and headers are compared with whitespace collapsed and comments
dropped, so a reworded note or a rewrapped signature is the same declaration.
The refusals are what make it safe rather than merely clever: a changed
declaration, a changed `setup()` or anything reachable from it by a
fixed-point name walk, and a `setup()` that names a stored property which is
not `@Saved`. That last one is the `Examples/Patterns/Cracks` shape, where the
field is built in `setup()` and an implicitly-unwrapped optional: skipping
`setup()` there would crash the piece on stage, while being wrong the other
way costs one unnecessary restart.

### `@Param` parameters and the inspector

The generic `@Param` wrapper/registry in the core (`Param.swift`) drives the
shared inspector (`Inspector.swift`) in all three hosts through the
type-erased `AnyParam`/`ParamControl` surface (typed get/set closures per
control kind), so hosts never touch `Param<Value>` directly. The control
follows the property's type: `Double` slider (optional `step:` snaps every
write; `style: .field` drops the track), `Int` stepper, `Bool` toggle, a
`ParamOption` enum menu (keyed on case *names* for persistence; the
CaseIterable mode enums conform out of the box), `Color` well, `Vector2`
paired x/y fields (`style: .pad` adds a drag pad mapped top-left = both lower
bounds, the canvas origin), `Vector3` and `Rectangle` and `Insets` field
lines, `ClosedRange<Double>` a two-thumb slider (a min dragged past the max
pushes it along; the grabbed thumb is held for the whole gesture), `String` a
text field, `style: .segmented` on the option kinds, and the `ParamChoices`
named-catalog menu (which needs `Equatable` to find the current selection;
that is why `Easing`, a closure wrapper, and the parameterized `Material`
finishes stay out, while `LightingPreset` conforms). `ParamValue` is public,
so a user type can conform by mapping onto an existing control kind. `icon:` /
`group:` put an SF Symbol on the row and split the list into titled group
cards (declaration order; ungrouped first).

Numeric value boxes scrub (drag to change, Option fine, Shift on round marks,
click to type, and the arrows step a box being typed in). How far each goes is
one pure value, `ParamStepping`, so every box (slider pill, stepper, vector,
rectangle, insets, range ends) moves by the same rules and the tests read them
without a window. Shift used to multiply a rate that already covered the range
in a track's length, so it ran to an end in about 25 points; it now keeps that
rate and lands on marks instead. The arrows are an `onKeyPress` on the text
field, which a real key event through the panel reaches (the box test sends one
through `NSWindow.sendEvent`, and a missing handler turns it red). Two SwiftUI gotchas were real bugs: any Text sharing a scrub pill's
HStack must be `.fixedSize()`, or the paired-pill (Vector2) row compresses it
to zero width and it silently vanishes; and `.segmented`'s ViewThatFits needs
the label `.fixedSize()`-pinned (an un-pinned truncatable label never wraps)
plus `maxWidth` with `alignment: .leading` on the wrapped block (ViewThatFits
centers a narrower child). Parameter values persist across reloads as `ParamStored`
payloads (`SketchSession.recordParam`/`syncParams`; a property that changed
*type* in the edit drops its stale value so the new default wins). Headless
gates: `OllinLive --paramtest` plus `ParamTests`.

### Frame stats and the host chrome

The runner fills one shared `FrameStats` (`@Observable`: fps, CPU frame time,
vertex/SDF draw counts, clock, canvas size) a few times a second via the
built-in `StatsExtension` on the extend seam. Three surfaces read it: the
OllinLive sidebar inspector, the gallery's right-sidebar inspector, and (for a
standalone `swift run`) a detached frosted `NSPanel` (`StatsPanel.swift`;
View > Show Inspector, cmd-/ via `OllinHUDCommands`, bound to
`@AppStorage(OllinHUD.showStatsKey)`). A host with its own inspector sidebar
opts the panel off (`SketchView(showsInspectorPanel: false)`) and omits
`OllinHUDCommands`, so its cmd-/ toggles the sidebar instead. Stats are debug
chrome, SwiftUI siblings of the Metal view, so they never land in exports.

Two gotchas the build proved: the CPU-ms must time `performDraw()` *alone*
(timing across `renderer.render()` includes the triple-buffer semaphore wait,
pinning the number to ~1/fps and telling you nothing); and an on-canvas
element's corner inset must be `.padding` *before* the canvas-filling
`.frame`, or the padded box overflows and clips. `SketchView` is a SwiftUI
`View` that `ZStack`s host chrome (the axis widget, the keyboard-focus hint)
over a private `MetalCanvas`, the `NSViewRepresentable` MTKView that remains
the AppKit/UIKit portability seam.

### The shared session engine (`SketchSession`)

`OllinRuntime.SketchSession` holds the compile/reload orchestration both
OllinLive and OllinLiveCoding wrap (supersede-cancel compile scheduling,
`syncParams` re-applied *before* the swap so parameters never snap, and the
two-channel Swift-versus-shader error model where neither clears the other);
`LiveSession` and `PerformanceSession` are thin wrappers. Orchestration
changes go in `SketchSession`, never re-forked per host. Buffer compiles
(`SketchLoader.Input.source`) write the editor text into the per-compile work
dir *under the sketch's own file name*, so diagnostics carry exact buffer
lines and the same file name (`CompileDiagnostic.parse` matches by `fileName`,
never by path, which is the temp copy), while the factory shim's
`Bundle.module` keeps pointing at the sketch's real folder so co-located
assets resolve. The rest of the live-coding host's invariants (stage overlays,
editor rules, recovery scoping, the headless gates) are enumerated in
CLAUDE.md's *Live coding* block.

**Two-speed evaluation** (`landsPlainBuildFirst`, the performance host's
default) is the engine's, not a host's. `evaluate` starts the optimized
compile and, beside it, a plain one, in the one `Task` that owns both, so a
newer evaluation's `cancel()` refuses both of the older one's builds and the
older optimized build can never land over newer code. The plain build is
*the* landing as far as the host knows (`onSuccess` once, the reload count
and the build time), and the optimized build behind it swaps in through the
same `land` path with the clock always carried and no hook, `isOptimizing`
up in between.

The measurement that shaped it: a sketch compile is mostly fixed cost. On the
loader's own `swiftc` line the driver ran one frontend job per source, the
sketch and the factory shim, each loading the framework's module, so `-wmo`
(one job) took a 20-line sketch from 1.00 s to 0.57 s plain and 0.95 s to
0.61 s optimized, and a 470-line one from 0.96 to 0.74 and 1.48 to 1.09.
What is left for the second speed to buy is the optimizer's share, which
grows with the sketch. With the two compiles started together, the plain
build is on stage at 0.63 s on 20 lines (the single optimized build alone:
0.60 s), 0.68 s on 150 lines (0.76 s), and 0.78 s on 470 lines (1.09 s); the
optimized build follows at 0.68 / 0.84 / 1.16 s, about 0.07 s later than it
would alone, because the two share the machine.

Two gotchas from the gates. The two builds of a small sketch land tens of
milliseconds apart, so a poll never sees the plain one; the gates read the
first landing through `onSuccess`, and start the superseding evaluation from
inside it. And the pixel pin compares decoded pixels, because an exported PNG
carries the recipe with the `@Param` values and the gate's sketch reads its
build level into one on purpose, so the two files differ while the pictures
do not (the optimizer does not fuse multiply-adds; probed, 0 of 100,000
disagreeing).

---

## Live recording (the real-time recorder)

`SessionRecorder` (`Sources/Ollin/Export/SessionRecorder.swift`) records a live
run as it happens, where the offline exporters re-render on a fixed clock. It
is a `SketchExtension`: while a take runs it arms `wantsRenderedFrame` and
receives each frame as a `CGImage` from the runner's frame-grab hook.

The hook reads the frame the window shows. It used to draw the frame a second
time off-screen through the export path and read it back synchronously, which
cost a lit 3D scene more than half its frame rate while a take ran (46 to 19
fps on the scene explorer's file camera at 1080², a light 2D sketch 940 to 195
frames a second in the headless probe), and the texture hook had an
off-screen render of its own that skipped the 3D pass list, so Syphon and the
virtual camera shared a degraded frame of a 3D sketch. Now the runner asks
every extension once, before the frame is drawn, and hands the renderer a
`FrameGrabRequest` carrying the canvas size. The renderer tone-maps the
finished linear-float picture once more into a display texture of that size
inside the frame's own command buffer (a picture larger than the canvas is
copied into a mipmapped texture first, so the present sampler minifies
through the chain; one the same size presents byte-identically to the
headless render), blits it into a shared buffer when an image is wanted, and
hands both to a completed handler that puts the delivery on the main run
loop in every common mode. So a frame lands as soon as the GPU is done, in
frame order, during a drag, and inside a nested run loop, where a main-queue
block could not run (one cannot start while another is spinning the loop,
which is what a test's wait does). Textures and buffers come from a small
pool a delivery returns to after the hook ran, so nothing is written while a
consumer still reads it, and a frame nobody asks for lets the pool go.

Three rules follow. A frame being handed out is drawn no smaller than the
canvas, so a window smaller than it shows the frame scaled rather than
deciding how sharp the take is; an accumulating pile keeps its size, since
resizing it would wipe the drawing, and is brought to the canvas size on the
way out. The ground grid, host chrome injected into the geometry pass, leaves
the window while a hook is armed, because the window's frame is now the
take. And the take is the live picture rather than the export's: temporal
anti-aliasing converges over frames and reflections and global illumination
accumulate, where `--export-video` resolves each frame on its own. Measured
after the change, the lit scene records at its unarmed rate (45.7 against 46
fps) and so does the light sketch. `FrameGrabTests` pins the pass count (one
more present, never a render), the byte-identity at equal size, the floor,
the minification, the pile, the frame order, and the grid gate; the floor and
the gate were verified red by sabotage.

Everything that touches the `AVAssetWriter` lives in a queue-confined
`RecorderWriter` on one serial queue: the frame appends, the audio drain
timer, and the finish. Both writer inputs run `expectsMediaDataInRealTime`,
and the session's timeline is the host clock: a video frame's presentation
time is the wall time it rendered at, so a slow frame simply lasts longer in
the file. A frame the encoder cannot take is dropped and counted, never
awaited; real time does not block on a codec.

Sound has no master bus to tap. Every sound object owns a private engine, so
the recorder discovers the sketch's sources by Mirror walk (the
`ExportAudioSource` trick) through the core `CaptureAudioSource` seam, and
each source gets an `AudioCaptureSink` lane. A node allows one tap per bus
and the analyzer already owns it, so capture uses the same tap through a
`CaptureTapRelay` slot added to `installAnalyzerTap`'s fan-out. A lane places
each buffer on the master timeline by its host-time stamp with a running
counter smoothing the jitter: the counter wins while the stamp disagrees by
under 50 ms, the stamp wins past that, and the hole a device drop leaves is
filled with silence rather than closed up. Closing it up is the classic
drift bug: the audio track comes out shorter than the video and the two part
ways a few seconds per headphone switch. The writer queue mixes all lanes
every 100 ms, 150 ms behind the clock (late buffers still land), applies the
offline mixer's `tanh` knee, and appends interleaved float stereo; the AAC
track is declared whenever the mode carries sound at all, so an instrument
that first plays mid-take lands mid-file, in sync, after plain silence.

Two lifecycle rules carry the live hosts. A recording survives a hot swap
because `SketchRunner.reload(to:)` moves `sketch.sessionRecorder` to the new
instance and re-extends it; the recorder's `setup` hook then rescans that
instance's instruments, which is how the sound keeps flowing after an
evaluation replaced every `Synth`. Hosts must address the *current* sketch
through `SketchSession.currentSketch` (the runner's), because
`SketchSession.sketch` deliberately stays the first mount and a recorder
wired to a stale instance films nothing. And a take must end as a playable
file however the process ends: `stop` finishes asynchronously, app
termination and the recorder's own SIGINT/SIGTERM dispatch sources go
through `stopAndWait()`, and a canvas-size change (the one thing a movie
cannot absorb) finishes the file cleanly and says so.

---

## Record and replay (the take transport)

`Take` (`Sources/Ollin/Core/Take.swift`) writes a run down as data: the `variation` seed, one clock sample per frame exactly as the display drove it (jitter included), every input event, and every `@Param` change, the last two stamped with the frame they precede. Playing the same file into a fresh instance walks it through the same frames, and the pixel test pins that to the byte. Where the real-time recorder above keeps a run's *pixels*, a take keeps the *performance*, so it is small (roughly 90 bytes a frame as JSON, about 20 MB an hour) and it can re-render through any export path afterwards, path tracer included.

The design lives on two choke points rather than a parallel driver. Every driver Ollin has, the live window, each offline export loop, the benchmark, funnels its clock through `Sketch.advance`; the player overrides the caller's values there, after applying the frame's recorded events and parameter changes, and the recorder writes down whichever clock is about to apply. And every input enters `Sketch` through a small set of internal handlers (`setMouse`, `handleMouseButton`, `handleKey`, `handleScroll`, and friends); each one logs to the recorder and then refuses the call while a player is attached, and the player re-ingests recorded events through the same private halves, which is what makes the hooks (`mousePressed()` et al.) fire again on replay. Because both seams sit below every host, `--replay` composes with the whole export flag surface through nothing but the one `make()` wrapper in `handleCommandLine`.

This is deliberately *not* a `SketchExtension`. The seam's hooks see the frame boundary but never the input events between frames, and they run after `advance` has already applied the clock, too late to override it. The recorder and player are two small internal collaborators on `Sketch` (`takeRecorder` / `takePlayer`, at most one attached), wired by the runner or by `Take.install(on:)`.

The stamp contract is the one non-obvious invariant. An event stamped `k` arrived after frame `k` drew; it applies at the advance whose pre-increment `frameCount` is `k`, and `frames[k]` is that advance's clock. Recording and playback must agree on the pre-increment stamp, or every replay shears its input by one frame. `Take.install(on:)` must run before `setup()` (seed first, then the starting parameter values, then the player), because `setup()` builds from both; `--seed` beside `--replay` re-seeds deliberately *after* install, which replays the same gestures onto a different variation.

Recording starts at the run's own frame 0 (the runner attaches the recorder in its first-frame block, ahead of `setup()`, or `beginTake` restarts in place), because a take that begins mid-state could never reproduce. The file autosaves on a doubling cadence capped at a minute of frames: a growing take re-encodes whole, so a fixed short cadence would cost more the longer the run gets, and the write is a value-copy handed to a utility queue so the frame loop never pays it. A live-reload swap or a seed restart ends the take (writing it out) rather than corrupting it.

Scrubbing backward is re-simulation: rewind to frame 0 (reseed, restore the starting parameters, `setup()` again) and step forward to the target. The intermediate frames go through `stepReplayFrame`, which mirrors the headless drive in `renderImage(of:)`: an accumulating or feedback frame must actually render off-screen for its persistent surface to evolve, anything else only needs its compute stepped. Generic state snapshots are not possible (only the sketch knows its state), so the honest cost of a deep backward scrub is the frames in between; determinism is what makes the landing exact.

## Keyframed parameters (the automation track)

`Automation` (`Sources/Ollin/Core/Automation.swift`) is the other half of the take transport's idea, pointed the other way. A take writes down what a run *did*; an automation writes down what a run *should do*: one track per `@Param`, each a sorted list of keys carrying a `ParamStored` value and the curve that leaves it. Both are versioned Codable JSON keyed by the property name, which is what lets a future timeline view read and write either.

The tracks are applied inside `Sketch.advance`, and the position in that function is the whole design. It runs *after* the take player has resolved the frame's clock, because a track is a function of that clock and a replay overrides it; and *before* `takeRecorder.recordFrame`, because the recorder diffs the parameter values to find its changes. Put the apply after the recorder and a run recorded under an automation writes down the previous frame's values, so replaying that take without the automation shears the parameters by a frame. `AutomationTests` measures the take's own changes against the ramp for exactly this reason, and the sabotage (moving the apply below the recorder) turns it red.

Two smaller decisions carry their own reasons. A track *sets* the parameter rather than assigning it, so a `@Param` carrying `smoothing:` does not glide toward the curve on top of the curve's own glide; the curve is the glide, and a parameter that snapped to each frame's target twice would lag behind its own automation. And the player caches the parameter handles on first use, the same way `TakeRecorder` does, because the `@Param` set of an instance is fixed and walking the mirror every frame costs real time; that cache is why the `automation` property mutates the player in place instead of building a new one for each `automate` call.

Blending is by kind, which is where the semantics live rather than the math. Numbers, colors, points, rects, insets, and ranges interpolate; a switch, a menu choice, and a piece of text have nothing between two settings, so they hold what they left until the next key, and so does any pair of different kinds (which is what a parameter that changed type leaves behind). Both ends short-circuit, so a key always reads back as exactly itself, whatever curve leaves it.

The curve set is the shipped `Easing` catalog plus one editable curve, a unit cubic Bezier through two handle points, solved by Newton with a bisection fallback. The non-obvious part is that its handles bend the *clock* as well as the value: reading it means finding the parameter whose x is the progress and only then taking that parameter's y. A test that only checked symmetry could not see the difference, and passed while the solver was skipped entirely; the test that catches it measures against an independent bisection written in the test itself.

## Spatial video (the stereo pair and its file)

`--export-spatial` writes stereo MV-HEVC, the format Apple's platforms play with
real depth. Two things carry the weight: how the pair is made, and what the file
has to say about itself before the system will call it spatial.

**One draw, two renders.** The pair comes out of a single `performDraw()`, with
`Drawer.aimStereoEye(_:previous:)` swapping the camera between the two
`renderer.image(of:)` calls (`renderStereoFrames` in
`Sources/Ollin/Export/SpatialVideoExport.swift`). Drawing twice was the obvious
alternative and is wrong: it would roll the sketch's randomness twice and step
every simulation twice, and the artificial-life tier documents itself as not
reproducible frame for frame even at one seed, so the two eyes would get
genuinely different worlds. Rendering twice is already a supported shape: the
renderer's `statefulEncodeIsRepeat` stamp (keyed on drawer identity plus frame
count) exists for the live frame grab, so the second eye advances no feedback
slot, no sim, and no GI history. Two consequences fall out and are documented
rather than fixed: anything the sketch flattened during `draw()` (a `project()`,
a `depth(at:)` placement, a billboard) keeps the center camera's answer in both
eyes and therefore lands on the screen plane, and an accumulating sketch has one
persistent surface, so both eyes are handed the same picture with a note. The
`previous:` half of the seam is not decoration: motion blur measures against the
last frame's camera, and left against right would read the eye separation itself
as the whole world lurching sideways every frame.

**The lean is one matrix entry, for two different reasons.** `stereoEye` steps
the eye and its target sideways along the camera's own right axis (parallel axes,
the film rig; toeing in tilts the two frames against each other and leaves
vertical misalignment at the corners). The convergence correction then lands
entirely in `[2][0]`, the term mixing view z into clip x, and that one entry is
right for all three projections by two separate routes that happen to meet. For a
perspective or intrinsic frustum, clip w *is* the view distance, so premultiplying
a constant shift of the flattened image collapses into `[2][0]`, which is exactly
the asymmetric frustum a parallel rig is defined by (the same identity
`MetalRenderer.jittered` uses for TAA). An orthographic camera has no such w, and
sliding it sideways moves the whole picture with no parallax at all, so what it
needs is a shear proportional to depth; working the convergence condition through
gives the same entry the same value. `stereoLateral` defaults to 0, so an
ordinary camera's projection is untouched by construction.

**The derivation is a stated criterion, not a constant.** Unset, the eyes sit 1%
of the frame width apart measured at the convergence plane. For a camera with a
vanishing point that is *also* the classic comfort rule, because the two work out
to the same number: far-field separation equals `p00 · e / convergence`, and
`e = frameWidth/100` at that distance makes it 1% of the frame, under the figure
a viewer's own eyes span. The frame width is read off the built projection's
`[0][0]` rather than off the field of view, so an intrinsic lens and an angled one
are measured the same way and an intrinsic camera's letterboxing is already in
the number. Orthographic reuses the expression with the distance factor dropped,
since its frame is the same width wherever you measure it.

**The file's five load-bearing numbers.** MV-HEVC alone gives a two-layer video
that plays in stereo; the system calls it *spatial* only with
`HasLeftStereoEyeView`, `HasRightStereoEyeView`, `HorizontalFieldOfView`
(millidegrees), `StereoCameraBaseline` (micrometers), and
`HorizontalDisparityAdjustment` all present. Drop any one and
`AVAssetPlaybackAssistant` reports `.stereoMultiviewVideo` but not
`.spatialVideo`, which is why the near-neighbor options are not the test. The
disparity adjustment is 0 because convergence is already in the pictures: the
eyes were aimed when the frame was drawn rather than left parallel for a player
to slide together. The layer/view keys are written as all three of
`MVHEVCVideoLayerIDs`, `MVHEVCViewIDs`, and `MVHEVCLeftAndRightViewIDs`; naming
the middle one without the last is the one combination that *hangs* the encoder
rather than refusing. Buffers come from the receiver's own pool and must be
IOSurface-backed. The write path uses the macOS 26 `TaggedPixelBufferGroupReceiver`
with its synchronous `appendImmediately`, not the adaptor whose
`appendTaggedBuffers` is already deprecated at the deployment floor. Settings are
built at frame 0 rather than up front, because the field of view and the eye
spacing are only known once the sketch has set its camera in `draw()`, and a
writer takes its settings before it starts (the same deferral the ordinary video
export makes for the seed in its recipe).

**The video input must be `expectsMediaDataInRealTime = true`**, which the
ordinary video export does not need and which is not about the data arriving in
real time. A file-paced input is interleaved in chunks of about a second, and a
multi-layer input under that pacing stops accepting frames at the end of the
first chunk and never becomes ready again: measured at exactly frame 36 of a
30 fps export, with `AVAssetWriter.status` still `.writing`, `error` nil,
`isReadyForMoreMediaData` false forever and `appendImmediately` returning false
forever. A realtime input is exempt from the pacing. This is the same escape,
for the same class of reason, that the soundtrack input already takes in the
ordinary video export, and it costs only a less tidily interleaved file, which
for one video track is nothing. A sound-carrying spatial export is therefore two
realtime inputs and interleaves without either gating the other (verified: a
6 second clip and a 3 second sound-carrying one, both complete and both read as
spatial).

---

## The GIF writer

`OllinApp.exportGIF` used to hand every frame to `CGImageDestination`, which keeps what it is given and lays the file down at finalize: the peak was ten bytes a pixel a frame (measured within 4% across two sizes and four lengths), 1.6 GB for a 150-frame 1080 square loop, and the export printed that figure past an eighth of the machine. `GIFWriter` (`Sources/Ollin/Export/GIFWriter.swift`) writes the format itself, a frame at a time into an open file handle. Its buffers are sized once at init (the drawn frame, two key planes, the index plane, the histogram, the lookup, the coder's dictionary), and nothing is kept between frames but the previous frame's keys.

**What a frame goes through.** The `CGImage` is drawn into the writer's own sRGB 8-bit context at the writer's size, which is also how `--gif-width` is applied (the export has no scaling path of its own) and how a Display P3 or float frame becomes sRGB bytes. Each pixel becomes a key, `0x00RRGGBB` with the premultiplication undone, or a clear key under half coverage. The keys are diffed against the previous frame's to find the changed box; unchanged pixels inside it take the transparent index and the frame is written at that box with disposal 1, a frame with no change as one transparent pixel that keeps its delay. A see-through picture (the first frame carries alpha and has a clear pixel) is written whole with disposal 2 every frame, since a pixel that goes from opaque to clear cannot be expressed by leaving it alone; the choice is made once, from the first frame. The system writer diffs after quantizing, so its crops run a pixel tighter where a source pixel moved a level and its index did not; that refinement is open.

**The table.** Colors are binned at six bits a channel (262,144 bins) with exact sums under each bin, and a median cut splits the box with the most pixels over the widest volume along its widest axis at the median pixel until 255 boxes, skipping a box whose bins sit within three levels RMS of its mean (an antialiased ramp does not need an entry per shade). Each box lands on the exact mean of its pixels, and entries within six levels of each other are then merged: a median split parts a dominant bin from its dithered neighbors first and isolates a neighbor later, which the split floor cannot see, and the present pass dithers, so before the merge the garden figure's flat sky flipped between two entries a level apart and its first frame cost 9157 bytes against the system's 6696; after it, one sky color and 4952. The table is sized to the smallest power of two above its colors plus the transparent entry, so a picture of under 64 colors codes in six bits, and the transparent index is the last entry. Mapping a pixel goes through a lookup indexed by its bin, filled on demand with the nearest entry to the bin center, so a table costs at most one nearest search per populated bin.

`.shared` chooses the table from the first frame, writes it as the global table, and measures its training distance (RMS over the frame). Every later frame's mapped pixels are read two ways. Their squared distance spread over the whole frame's pixel count against `max(8, 2 × training)` catches a whole picture drifting off its table: a fade from black reads 18 to 23 there. Their RMS over the moved pixels alone, when at least eight moved, against `max(12, 3 × training)` catches a thin thing of a new color that arrives late and stays: a pale tree growing eighteen pixels a frame over dark soil is almost no energy over the frame and the whole picture where it is, and without this trigger the Guide's garden drew its cream tree green. The strip a moving disc uncovers, colors that were under it when the table was chosen and that no streaming table can see, reads 10 to 15 over its pixels against a training of 6.5 and 2.5 over the frame, under both bars, which is the case the bars are set by. When a frame has left its table, its colors farther than eight levels from every entry join the global table: into its free entries by a seek and a write, never touching an entry a written frame points at; when there is no room, by growing the table to the next size that holds them, one streamed copy of the file so far behind a wider head (a written frame keeps its narrower codes and its own transparent index, both valid under the wider table), at most once per doubling; and only past the format's 255 colors does the frame, and every frame after it, carry a local table. `.perFrame` chooses per frame, the first as the global table and every later one local.

**The coder** is the format's LZW: codes from the table's width plus one up to twelve bits, a clear code emitted when the dictionary fills (a deferred clear is not used), codes packed least significant bit first into 255-byte sub-blocks, the dictionary an open-addressing hash of 8192 slots keyed by prefix and byte that never passes half full. The code width steps when the next free code reaches the current width, one step later than a decoder that adds an entry per code read, and the end-of-information code honors the same lag. It is byte-identical to ImageIO's on the same index image: the system writer's own decoded first frame re-coded through it lands on the system's byte count.

**Measured** (`GIFWriterTests`, `GIFMemoryTests`, 2026-09-17), against the system writer on the same frames: on a flat ground with antialiased rings and a crossing disc the whole file is 12% smaller, 5500 against 6226 bytes, with a 64-entry table; on a two-axis gradient with a crossing disc, every pixel its own color, the first frame is the system's plus 5% at a lower error (2.56 against 2.63 levels mean absolute, since the two tilings differ) and every sub-frame lands on the same rectangle within a few bytes; every frame of every probe decodes within 1.25 times the system's error. On the Guide's garden figure (83 frames at 420 square, a cream tree and lichen arriving over dark soil) the file is 63.7 KB against the system's 67.0, with no local table and no pixel more than 32 levels from the system's. A 480² writer holds the same bytes after one frame and after sixty, the process grows under 40 MB over 190 more frames where holding them would add 175, and a real export's sampled peak at 120 frames is within 40 MB of the same sketch at 20.

## Camera rig: showcase orbit, view snaps, and scene chrome

`cameraShowcase(_:)` (the default the 3D examples use) is an auto-orbit the viewer can
take over, easing back to the sketch's framing when left alone. It adds no new camera
math: it is a small state machine in `CameraRig` (`Sources/Ollin/3D/CameraRig.swift`)
that orchestrates the two halves that already exist: the cinematic-move driver
(`updateMove`/`startMove`/`apply`) and the damped interactive controller
(`updateControl`).

**Three phases.** `updateInteractiveMove(move:input:dt:viewportHeight:idleTimeout:returnDuration:)`
runs one of:

- `driving`: calls `updateMove` verbatim (the move plays). A drag, dolly, or pan
  this frame switches to `manual` and resets the idle clock. The interaction check
  runs *before* the branch, so the input that starts the takeover (a single scroll
  tick included) lands the same frame.
- `manual`: calls `updateControl`. Each input frame zeroes the idle clock; each
  idle frame accumulates it. At `idleTimeout` (10s default) it begins the return.
- `returning`: eases the displayed pose from where the viewer left it toward the
  *home* orbit, then hands back to `driving`.

**Reset to the sketch's shot, not resume-in-place.** The framing the last camera
call passed (target, radius, azimuth, elevation, field of view) is the rig's
`home`, a `CameraFraming` kept apart from the live pose the controller mutates.
When the return begins, `beginReturn` snapshots the viewer's pose (`returnFrom*`),
restarts the move at the home framing (`startMove` with the pose temporarily set to
it, so the move's base is the sketch's shot), then restores the displayed pose to
the viewer's so the blend starts there with no jump. `advanceReturn` advances the
move (a *live* orbit at the home framing) and blends `returnFrom → movePose` by a Hermite
`easeInOut(returnClock/returnDuration)` (4s default). Hermite is flat at both ends,
so the orbit's angular velocity ramps from rest to full, the "progressive" return.
Azimuth blends along the shortest arc (`lerpAngle`) so a viewer who spun several
turns returns the short way instead of unwinding every turn. At `t=1` the blend
equals `movePose`, and `driving`'s next `updateMove` reads the same base/clock, so
the handoff is seamless; a fresh interaction during the return cancels straight back
to `manual`.

**Byte-identical when uninteracted (load-bearing).** With no input the machine never
leaves `driving` and runs the unchanged `updateMove`, so the headless/export and
snapshot paths are pixel-identical to a plain cinematic move, and `cameraMove` /
`camera` themselves are untouched, so every existing 3D snapshot is unaffected
(`cameraShowcase` is a *new* opt-in call, not a behavior change to the old ones). The
state machine is exercised by CPU units in `CameraRigTests` (interrupt, idle return
to the home framing, seam continuity, mid-return cancel), since a snapshot cannot
drive synthetic input events.

**The framing follows the call.** Every camera call hands the rig its framing through
`frame(_:orthographic:)`. The first call opens the shot on it; after that it is the
`home`, and an argument that changed moves the home there. What else moves depends on
who owns the pose. A running move (or the showcase's auto-orbit) modulates a base pose,
and each base part whose argument changed glides to it (`baseGoal*`), so a push-in
retuned mid-move ends at the new radius times its factor and a move that departed from
a hand-framed angle keeps that angle when only the distance changes. The controller
glides after the framing only while it still rests at home (`restsAtHome`: true from
the first call and after a reset, false once the viewer orbits, pans, dollies, or snaps
to another view); after that only the home moves, for the next reset. The lens has no
gesture, so a changed `fieldOfView` glides under every driver. The glide is an
exponential approach at `homeGlideRate` (8/s, under a percent off in about 0.6 s, the
`cameraView` glide), not an eased one, because a parameter dragged in the inspector
changes the framing every frame and an eased glide restarted every frame never leaves
its flat start; each part lands exactly once it is within 1e-9, so a settled window
holds the very value an export frames with. Unchanged arguments do nothing at all,
which keeps a sketch passing constants byte-identical (`aConstantFramingMovesNothing`).
`startMove` clears the base goals: a new move departs from the pose as it stands.

**A move's clock reads the frame being posed.** `updateMove` poses at `moveClock` and
advances it after, the order `time` keeps, so a move's first frame is its start and
export frame N shows the move at N/fps at any frame rate (`CameraClockTests`, 30 and
60 fps). Advancing first put every exported frame one step ahead, and two exports of
one frame at different `--fps` differed. The eased part of a finite move is computed
from the base at the clock each frame (`eased`), not held in a `Timeline` built at
`startMove`, so a gliding base carries it; `easedPartHeld` is what a snap after a
finished move sets so the snapped pose stays the move's resting state.

`activeCamera` (on `Sketch`) returns the resolved `Camera3D` for the frame, so a
sketch that places geometry relative to the camera (e.g. `DepthCompositing` seats
its pins at the camera `eye`) keeps working when the interactive rig owns the pose.

### The controller model and input surface

`cameraControl()` is the canonical orbit-control model, reimplemented from the
references (studied, not ported): spherical azimuth/elevation with a
`2 * pi * delta / height` rotate, a multiplicative `pow(0.97)` dolly, a
`2 * radius * tan(fov/2) / height` pan, all `deltaTime`-corrected for
frame-rate independence, with exponential damping toward an input-driven goal
plus a capped release-momentum flick. Both halves take their framing as the
home (see *The framing follows the call*) and compose over the current pose (`CameraRig.lastMode` re-syncs the
controller's goal after a move), so "frame by hand, then drift" is one call
after another, and the rig clamps elevation off the poles.

**Seeding from an authored camera (the `from:` forms).** `cameraControl(from:)`
/ `cameraMove(_:from:)` / `cameraShowcase(_:from:)` seed the rig from any
`Camera3D` (a loaded scene's authored camera being the point), via the internal
`Camera3D.orbitPose` decomposition: `target` is the pivot, the eye offset
splits into radius / azimuth (`atan2(x, z)`, inverting `Camera3D.orbiting`
exactly, round-trip-pinned) / elevation (`asin(y/r)`), and the projection maps
to a field of view. An orthographic camera converts its frame height to the
equivalent fov at the target distance (`2 * atan(h / 2r)`) and seeds
`isOrthographic` through a `frame(orthographic:)` parameter that lands only
on the first call, so the shot shows the authored extent, the axis widget's
projection toggle still owns the flag afterward, and flipping projections
holds the scale (the existing `makeCamera` convention). `near`/`far` default
to the camera's own clip range, read every frame rather than seeded. What
doesn't carry, by design: the rig is y-up, so authored roll drops, and a
straight-down camera clamps just off the pole. The overloads hand the rig the
authored pose as their framing and run the same driver the plain forms do, so
every downstream behavior (the home, the idle return, view snaps) sees the
authored framing as the sketch's shot.

The rig is a plain `let` on `Sketch` advanced explicitly, *not*
`FrameAdvancing`: the Mirror-collection cache behind `FrameAdvancing` only sees
stored properties that exist before the first frame, which the rig sidesteps.
The input surface feeding it is platform-neutral and plumbed once through
`OllinMTKView` (so it reaches standalone, gallery, and OllinLive):
`scrollDeltaY` is the per-frame scroll total, **double-buffered** in
`advance()` so a wheel event landing between frames is never lost; `modifiers`
is a `ModifierKeys` option set mapped from `NSEvent.ModifierFlags` behind the
AppKit seam (like `KeyCode`); plus `rightMouseIsPressed` and the `mouseScrolled()`
hook.

### Trackpad pressure delivery

`Sketch.pressure` / `pressureIsAvailable` are the platform surface (documented
in `Docs/Drawing/Marks.md`); how the values actually arrive was rebuilt once
the first hand test on a Force Touch trackpad showed a stroke that never varied
(the feature had shipped verified by CPU tests only, and two masked defects hid
each other). The working delivery, each piece load-bearing:

- **Delivery is a view-owned local event monitor, not the `pressureChange`
  responder override.** Inside the SwiftUI hosts, `.pressure` events reach the
  app but the hosting layer consumes them before responder dispatch: verified
  by hand with an app-level monitor (every event visible, 239/239) against the
  view override (zero delivered). `OllinMTKView` installs
  `NSEvent.addLocalMonitorForEvents(matching: .pressure)` in
  `viewDidMoveToWindow`, guarded to its own window and an active canvas press,
  and uninstalls in `viewWillMove(toWindow: nil)` (a nonisolated `deinit`
  cannot touch main-actor state). The responder override stays for plain
  AppKit embeddings, where dispatch works and the monitor's double report of
  the same value is harmless.
- **`mouseDown` re-claims the stream with `pressureConfiguration?.set()`.**
  The hosting layer's gesture recognizers install their own deep-click
  configuration, which takes precedence over the view's `.primaryGeneric`
  property for the press (observed effective behavior 5, deep click, where the
  whole normal force range reads about 0). `set()` during `mouseDown` is the
  documented way to re-claim the active stream; after it the events arrive as
  behavior 2, one stage, smooth 0...1, and no force click fires mid-stroke.
- **Never read `associatedEventsMask` on a `.pressure` event.** The access
  raises, and AppKit's event dispatch swallows the raise, silently abandoning
  the rest of the handler: the report dies with no crash and no log line
  (pinned by step-tracing: 118 monitor-fed calls entered the reporter, every
  one vanished at the mask read). A pressure event is its own capability proof,
  so that path reports `canVary: true` unconditionally; the mask is read only
  on mouse events, where it separates a pressure-capable device from a plain
  mouse.
- **Mouse events from a pressure-capable device seed zero, and drag events go
  quiet once the stream is live.** Their `pressure` field is the legacy
  constant 1; the true curve arrives only on the pressure stream and starts
  near zero. Without the seed a stroke opens on a one-frame full-force blip;
  without the drag gate (`pressureStreamLive`, reset each `mouseDown`) the
  value flaps between the true press and 1.0 on alternating events. A plain
  mouse keeps its honest flat 1 while a button is down.
- A feel note for anyone testing by hand: the generic curve saturates at
  moderate force, so the analog range lives in the light-touch zone. A firm
  press reads 1.0 throughout, which is easy to misread as "pressure is stuck".

### Scene inspection views (`cameraView` / `resetCamera`)

`cameraView(_:)` snaps the rig to canonical angles: `.reset` restores the
opening framing (target, radius, and angle); the six axis views swing only the
orbit angle, keeping the current target and radius; `.isometric` (`.corner` is
the deprecated alias) is true isometric, `asin(1/sqrt(3))` up at 45 degrees
around. Snaps glide by default (`animated: false` cuts), and on completion hand
the pose back to the active driver via `CameraRig.driver` (control resyncs its
goal, a move re-bases, showcase holds then idle-returns), so motion resumes
from the snapped pose with no jump. `applyViewSnap` early-returns when no snap
is active and the `.driver` set is inert, so the path is byte-identical when
unused (`camera-move` was not re-recorded). The viewer-facing surface is the
host Camera menu (`OllinCameraCommands`, cmd-0 through cmd-7) in all three
hosts, reaching the running sketch through the `OllinActiveSketch` weak holder
to `SketchRunner.requestCameraView` (applied next frame) to `cameraView`; a 2D
or hand-set-`camera()` sketch ignores it. Snap math and hand-back are pinned by
`CameraRigTests`.

### Scene chrome: the axis widget and the ground grid

`cameraAxis()` and `groundGrid()` are live-only host chrome, like the FPS
overlay: never exported, a no-op in 2D, read live each frame or toggled from
the Camera menu. The axis widget (`AxisWidget`) is a SwiftUI sibling reading
the per-frame `CameraOrientationState` the runner publishes: click an axis to
snap via `requestCameraView`, plus reset, isometric, and an
ortho/perspective toggle driving `CameraRig.isOrthographic`. The ground grid is
a `.grid` `PipelineKey` pass drawn straight from `SketchView.draw`,
depth-tested but not depth-writing, *outside* the deferred render graph.

**The grid LOD invariant (load-bearing).** The infinite-grid LOD (the Golus
technique, reimplemented) divides the once-computed base world-uv derivative by
each decade-spaced scale; never take `dfdx()` of a pre-scaled uv. The pre-scaled
uv (`cellA`) jumps a decade across adjacent pixels at a LOD boundary, and the
derivative spike shatters the lines into a crawling dotted band (a real fixed
bug). "Major" (every-10th) lines are distinguished *by width in the one line
color*, not by a brighter color and not by a separate per-class fade, so every
scale fades by the same rule (its line thinning below a pixel,
`covA + max(covB, covC)`) and the levels converge and fall off together toward
the horizon under a single uniform radial fade. The old form (a brighter,
separately-faded major) made the levels vanish at staggered depths and read as
stacked planes at different heights; do not reintroduce it.

---

## Example media: the pictures, the clips, and the grids

Every example sketch has a picture, most have a clip, and every listing opens
on a grid of them; so do the guide and the README. None of it is in the
repository. The files are served from one host, and `Media/media.json` is
the only place that address is written down, so moving the host is one edit.

Four commands keep it current, and they run in that order because each reads
what the one before it wrote:

| When | Run |
|---|---|
| A sketch changed, or a new one landed | `Scripts/media.sh --all` |
| A group's four-up picture needs redrawing | `Scripts/example-covers.py` |
| A listing's grid needs rewriting | `Scripts/example-grids.py` |
| The guide's or the README's opening grid | `Scripts/example-heroes.sh both` |

All four are safe to run again. An example whose folder digest is unchanged is
skipped, and a grid that is already right is left alone, so a sweep over
everything costs almost nothing the second time.

**What decides what.** A sketch earns a clip if it moves or makes a sound.
Movement is measured rather than judged: the master is sampled once a second
and the largest change between samples must clear `MOTION_FLOOR`. A static
print scores about 0.2 there and a sketch that merely grows slowly scores
about 3, so the floor separates them with room to spare. Below it the sketch
keeps its still and loses only the clip, and a sketch with its own music earns
a clip whatever its picture does. A sketch that needs something plugged in is
held back instead of drawn, read off its own imports, and the manifest records
what it is waiting for. One that imports a device library but plays the far
end itself (a loopback on the same Mac, a stand-in tracker, a dancer of its
own) is staged instead: `RUNS_ALONE` in `Scripts/example-devices.zsh` names
what stands in, it renders like any other, and its row says so as `staged`.
A loopback that still draws nothing alone (it waits for a hand, or for play,
or its far end serves only a live window) is held under `HELD_BECAUSE` with
that reason rather than the device it imports. Nothing is decided in silence:
every example is under one heading or the other with its reason.

**One seed per example.** The clip and the still are separate renders, and a
sketch that rolls its variation deals a new piece for each, so every render
passes the same `--seed` and the row records it as `seed`. A row without one
predates the rule. To find a page whose poster and clip disagree, compare the
published still against every frame of its own small clip. A page that shows
one piece mostly scores a mean difference of one to three levels at its best
frame, but a busy or fast one (a field of noise, grass in the wind) can score
near ten, and so can two different pieces; so a pair past three is looked at
side by side before it is re-rendered.

**A clear canvas and an HDR film.** A canvas left partly clear by a
translucent `background(...)` keeps that alpha in its PNG. A JPEG drops it
and shows the colors unweighted, paler than the clip, which shows the canvas
over black, so a still with any alpha below full is laid over black first. A
sketch whose `colorOutput` is `.extended` films in HDR (PQ, Rec. 2020), which
the page's standard-range clip would play dim without a tone map, so it keeps
its still and no clip. Both clips are capped, the page clip at 6 Mbit/s and
the small one at 2, since a field of noise does not compress.

**What is a choice rather than a rule** is written down in the manifest, not
computed: `covers` names the four sketches that stand for a group,
`heroes.readme.sketches` names the cells of the README's grid, and
`heroes.guide.leads` gives a chapter's sketch an age of its own where the
default reads badly. A row's own `seconds` or `frame` overrides how long that
example records or which frame its still comes from, and survives every later
run. The guide's cells are *not* a choice: each chapter figure marks its own
payoff sketch in source, so the list is the book's and there are exactly as
many cells as chapters.

**Two things that bite.** An address carries a token taken from the file's own
bytes, because the files are served as immutable for a year: right for a
picture nobody edits, wrong the moment one is redrawn, and a hand-written
address will not do it. And a tool that rewrites the manifest must keep what it
does not understand; one of them dropped an unknown key and deleted both page
heroes, which turned the guide's playing grid back into a still.

Uploading reads `~/.config/ollin/r2.env`, outside the repository. Without it,
`--no-upload` writes the files locally.

## Status of this document

The sections above are the current contents.
The *Systems map* near the top is the migration checklist: when a system marked
*pending* there accrues depth that would otherwise swell `CAPABILITIES.md` (or
that a contributor needs and that currently survives only as the memory of past
work), it moves here under the same convention. `CAPABILITIES.md` keeps the
terse invariant and a pointer; the mechanism, rationale, and measurements live
here; `CAPABILITIES.md` remains the authoritative capability index. The
migration is deliberately incremental: a system earns a writeup here when its
`CAPABILITIES.md` bullet is carrying mechanism it should not, or when someone is
about to work in that area, not as a one-time backfill.
