music-video-gen/flow-state/EPIC-5-3D.md
Dejvino 7aa60d7336 Epic 5 Phase 0 — actors have bodies; the stage has depth
ActorGenerator is the cast with bodies: generateActor{mpl} takes
{summary,rng,archetype,personality,identity} and returns a serialisable
ActorSpec — same audio-tilts-centre / seed-picks-within rule as
Personality/Identity, forked rng so adding an actor never shifts later
decisions. Five archetypes (monolith/swarm/walker/vehicle/structure),
per-track actor set on look.actors, HUD helper included. Stage C will
grow as a library on this without infra changes.

Mesh twin of Identity.form: actors/meshes.js builds BufferGeometry from
the same assembly (cast SDF → Shape → ExtrudeGeometry, box/capsule/
torus/sphere primitives, symmetry folding radial/mirror/stack). Shared
with the shader impostor path — one character, two projectors.

Renderer depth targets: createDepthTarget / createTarget{depthTexture}
for WebGL DepthTexture plumbing.

Compositor shared rig: one PerspectiveCamera + DepthTexture so a ground
mesh can occlude a subject mesh from another layer. 4/scale dolly,
Personality.camera drift/sway/spin, framing shift — matches particles.js
and shader epilogue behaviour. ModelLayer (kind:model) with
build/update(actorSpec) and sharedCamera injection; createLayer dispatches
on model. Shader contract gains MODEL_PREAMBLE.

LookGenerator now derives actors before scenes; ArcDriver._actorFor +
_layerFor wires ActorSpec into ModelLayer; schema validates kind:model
and actor archetype; lint determinism gate covers actors/.

Gate: lint 107 files clean, 70 shader literals, 68 scenes green; vite
build 294 modules; ActorGenerator determinism + mesh smoke tests pass.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-20 16:26:15 +02:00

337 lines
29 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Epic 5 — a stage with depth, a cast with bodies
> Every scene so far is an abstract flat image held full-frame. The song's cast is a shared silhouette, inked on a shared lattice. It is recognisably this track's, and it is still a sticker.
This epic puts the cast *in* a space, on a ground, seen by a camera that can be close or far, with occlusion and parallax that a fragment shader can only fake. And it gives the cast bodies — a **3D actor/model generator** that takes parameters and returns a mesh unique to the song and seed, so a later library of actors (Stage C) has something to be a library *of*.
---
## 0. Where the project is
**Stack:** `three@0.181.1` + WebGL2 via `vite`, no React. Deterministic core — `Rng` + analytic `f(t,index,seed)`, no `Math.random`, no wall-clock, `Timeline` injects `{frame,time,dt,progress}`. `Renderer` owns a fullscreen-quad rig + `createTarget`/`blit`/`renderScene`. `Compositor` owns `layerTarget`/`accumA/B`/`historyA/B`/`bloomA/B`/`outputTarget`, plus `BLEND_FRAG`/`FEEDBACK_FRAG`/`BRIGHT_FRAG`/`BLUR_FRAG`/`COMPOSITE_FRAG`.
**Two layer kinds exist today** (`src/engine/Layer.js`):
- `ShaderLayer` — 68 of 69 modules. `buildFragmentShader` compiles `VERTEX_SHADER + PREAMBLE (+ FORM_PREAMBLE when consumes.includes('form')) + param uniforms + shader body + EPILOGUE`. Depth is faked with SDF raymarch (`castSDF3`/`castSolid`/`castMarch`/`castLit`), perspective grids (`1/(horizon - p.y)`), `sigHorizonY()`, `sigAir()`.
- `SceneLayer` — 1 module: `src/scenes/layers3d/particles.js`. Real `THREE.Scene + PerspectiveCamera(60,16/9,0.1,200)`, `build({scene,camera,seed,params,THREE})` / `update({instance,scene,camera,timeline,features,params,palette,personality,framing,opacity,THREE})`. Analytic `z = fract(depthSeed + t*rise*...)`, `framing` applied as dolly `4/scale`, `personality.camera` as `sin/cos` pan/sway/roll. Proof the compositor is hybrid.
**The compositor is still 2.5-D.** Each layer renders to `layerTarget` (RGBA, `depth:true` allocated but never shared), then `BLEND_FRAG` composites into `accumA/B` with `normal/add/screen/multiply/overlay/softlight/lumakey`. Depth from one layer never occludes another. Two `ShaderLayer`s over a `SceneLayer` are stacked pictures, not a scene with depth.
**"3D" already has a contract** (`src/engine/shader-contract.js`): `FORM_PREAMBLE` (opt-in, only when `consumes.includes('form')`) gives `castSDF3`/`castChorus3`/`castSolid(local,turn)`/`castChorusSolid`/`castMarch`/`castLit`/`castNormal3`. `src/look/Identity.js:generateForm` builds a 6-part assembly (`SOLIDS: prism/box/capsule/torus/sphere` + `SYMMETRIES: none/mirror/radial/stack` + `FORM_OPS: union/blend/carve`, `MAX_FORM_PARTS=6`, `formRot`/`formFold`/`sminForm`/`formPrism`/`formBox`/`formCapsule`/`formTorus`, bounding-sphere chord `CAST_SPHERE_R2=1.3`, 24/12-step march, under-relaxed `0.82`). `pylon-grid.js` and `synthwave-run.js` already march dozens of *orthographic impostors* (`castSolid` per pylon/passer, `castTurn(yaw,pitch)` per instance). The ceiling they document is the reason for this epic: *an outline is the same picture from every angle; a solid's outline changes as it turns.*
**Camera today is a 2-D transform** (`src/look/Camera.js` + `src/look/framing.js`): `Camera.planGaze` (axial reach, `jumpFor`/`targetFor`/`timingFor`/`relocatesAt`, `reachFor(scale,camera)`, `gazeAt(move,frames)`, curves `snap/glide/drift/settle`, directors name a camera `contemplative/kinetic/deliberate/roaming/precise`) + `ArcDriver._framingAt` (size constant per shot, shift live via gaze, `p / scale + shift` in `EPILOGUE`). `particles.js` maps `scale→dolly` as `4/scale`.
**Identity already decides content** (`src/look/Identity.js` + `src/look/Personality.js`): `castMember` (sides/round/elong/tilt/notchCount/notchDepth/hollow), `generateForm` (parts under symmetry, ops, `chorus` as `count/symmetry/symmetryN/flat/thin`, `blend`/`depth`), `LATTICES` (`grid/radial/spiral/scatter/strata` → `stageNode(i,n)` with `xy` + `z` scale, `stageScale()` = `u_latScale/0.35`), `FILLS`/`IMPACTS` (`shift/warp/punch/morph/overlay`), `FOCUS`, ink (`weight/edge/fill/hatchAngle/hatchScale/outline/posterize`). `src/scenes/surface.js` derives `surfaceOf`/`canGround` from `src/scenes/metadata.json` (`GROUND_MIN=0.5`, `GROUND_BIAS`, `groundTemperamentFrom`, `groundPersonalityFrom` — hollow→flat for grounds). `ArcDriver` already owns drift/slow-axis/gaze/palette-plan/story.
**Diagnosis in one line:** we have a production-design system (`Identity + Personality + Camera + ArcDriver + Story`) rendering mostly through a flat projector. The fastest path to depth is to keep the design system and change the projector — and to give the cast bodies so the projector has something to film.
---
## 1. What this epic is and is not
**Is:** depth that is visible (parallax, occlusion, contact, scale-foreshortening, DOF), a camera that is a place in a space rather than a coordinate transform, and actors that are this song's — generated from parameters, reproducible from the seed, different between seeds/songs, audibly tilted but not determined.
**Is not (yet):** a model viewer, a glTF asset pipeline, WebGPU, physics, or a bespoke hero per track. Those are Stage C. This epic builds the Stage A+B rig that Stage C will be a library *on top of*.
Three levels, in the order they matter:
| Level | Viewer sees | Reuses |
|---|---|---|
| **A — real depth from existing content** | Same protagonist/chorus forms as meshes on a real ground, perspective camera, shared depth, contact shadows | `Identity.form → mesh`, `stageNode → world position`, `Camera/framing → camera rig` |
| **B — kit of small authored bases** | Same as A but parts are not only `prism/box/capsule/torus/sphere` — 15-20 curated low-poly bases deformed by `sides/round/elong/tilt/notch/hollow` | Kit + Identity deformers + palette materials |
| **C — actor library (explicitly deferred but designed for)** | Named actors (characters/vehicles/structures) assembled from B, with skeletons/poses, one per track as the protagonist body | `actors/` library, `ActorGenerator` (this epic), kit |
**This plan designs and builds A, prototypes B, and leaves C as a growing library that requires no infra change.**
---
## 2. The actor / model generator — the centre of the epic
Everything else in the plan is scaffolding for this.
### 2.1 Contract
```js
// src/actors/ActorGenerator.js (new, pure, no three.js import at generation time)
import { Rng } from '../engine/rng.js';
export const ACTOR_ARCHETYPES = [
'monolith', // one large solid — the protagonist body, Stage A
'swarm', // many small chorus instances — already exists as chorus, now as meshes
'walker', // articulated: two or three hinged parts, analytic gait ← C
'vehicle', // chassis + orientation axis, verges/streaming motion ← C
'structure', // ground-anchored, heightfield-aware ← C
];
export function generateActor({ summary, rng, archetype, personality, identity }) {
// returns ActorSpec — data, not scene graph
}
```
**Inputs** (mirrors `generatePersonality`/`generateIdentity`):
- `summary` (`FeatureTrack.summary`: `meanCentroid`, `meanFlatness`, `bpm`, `dynamicRange`, `meanLoudness`, `sections`) — tilts centres, never decides.
- `rng` — a fork (`rng.fork('actor:'+archetype)`), so adding an actor does not shift any decision made after it (same rule as `rng.fork('form')` in `Identity.js:311`).
- `archetype` — optional; when absent the generator picks one weighted by audio.
- `personality` + `identity` — so the actor IS the song's cast (same `sides/round/elong/tilt/notch/hollow`, same `SOLIDS`/`SYMMETRIES`/`FORM_OPS`, same `ink`/`lattice` family). The geometry is the signature form made concrete — same sides/rounding/tilt as `Personality.shape`, plus notches/hollows that turn a shape into a character. `identityUniforms(identity, shape)` already does the `shape→cast` reconciliation; the actor does it at the mesh level.
**Output — `ActorSpec` (serialisable, hashable, no live objects):**
```js
{
archetype, seed, // for HUD + determinism proof
form: { parts, symmetry, symmetryN, blend, depth, chorus }, // from generateForm, or a kit variant
kitRef: null | { id, deform: { sides, notchN, hollow } }, // Stage B
rig: null | { joints: [{ parent, axis, range, phase, ratio }], gait: 'walk'|'sway'|'roll' },
paletteMap: [0,1,2,3], // which ActorSpec part reads which palette entry
scale: { base: number, spread: number }, // maps to stageScale() / stageNode.z
placement: { latticeKind, spread, jitter }, // reconciled with Identity.lattice
motion: { orbitRate, spin, bobAmp, bobRate }, // analytic, f(t,seed) — no integration
}
```
An `ActorSpec` is data. The stage that consumes it decides *where* to put it (`stageNode`) and *when* it moves (`timeline.time`), but it never invents *what* it is.
### 2.2 How songs get different actors
Same arrangement as `Personality`: **audio sets the centre, seed picks within it.**
- `angular = clamp01(noisy*0.6 + fast*0.3 + rng.range(-0.25,0.25))`
- `intricate = clamp01(busy*0.5 + bright*0.3 + rng.range(-0.3,0.3))`
- `solid = clamp01(0.5 - dynamic*0.4 + rng.range(-0.25,0.25))`
These already drive `castMember`/`generateForm`. The actor inherits them — so a bright, intricate track gets a notched, multi-part actor and a dark, sparse one gets a monolithic round one — but two seeds on one song still land in different places inside that region. **Two different songs are different actors; two seeds on one song are different readings of the same actor family.**
Applied to kit deformation (Stage B): the kit base mesh is chosen from `assets/kit/` (see §7), then its vertices are displaced by the same `sides/notchD/hollow` that `castSDF` uses — so the mesh keeps the song's silhouette exactly as the shader does.
### 2.3 Relation to `Identity.form`
Not a replacement. `Identity.form` is the artifact the shaders already consume via `FORM_PREAMBLE` (`castSDF3`/`castSolid`/`castMarch`/`castLit`). The actor generator is the mesh-side twin that produces a `BufferGeometry` from the *same* `form`:
```
Identity.generateForm ─┬─► shader: FORM_PREAMBLE (SDF, imposter)
└─► mesh: actorToGeometry(form, kitRef) (this epic)
```
A track that brought no assembly (`u_formCount==0`) still renders correctly: `formSDF` falls back to `formPrism` extruded, `actorToGeometry` falls back to `extrudeCastProfile(identity.cast.protagonist)`.
The actor's `parts` array has exactly the same layout as `Identity.form.parts` (`offset xyz | kind`, `scale xyz | op`, `yaw/pitch/round`) so `formPartRows` and `actorToGeometry` consume the same rows. Adding an actor archetype never changes `formPartRows` width (`MAX_FORM_PARTS × 3 vec4`).
### 2.4 Library growth (Stage C) without infra change
```js
// src/actors/library/monolith.js, walker.js, vehicle.js, ...
export const monolith = {
archetype: 'monolith',
traits: ['shape','space'], // which personality traits it can express
consumes: ['form','ink','staging'],
// a function that maps an ActorSpec → THREE.Group, analytic f(t)
instantiate: ({ spec, THREE, palette, identity }) => Group,
};
```
A new actor is a file plus a registry entry, exactly like a new shader scene. The look generator casts actors the way it casts scenes (`signatureAffinity`/`signatureWeight`), and a stage declares which archetype it wants (`actor: 'walker'`). The migration recipe (cf. `MIGRATION.md`) gains a Stage C appendix: replace `castSolid` imposter loop with `actorInstancedMesh` loop.
No new uniform type is needed. An actor that needs to vary per instance beyond what `form` already varies (e.g., walker gait phase) gets it via `InstancedBufferAttribute` seeded from `stageNode` + `ActorSpec.seed`, still analytic.
---
## 3. Architecture
### 3.1 `Renderer` — depth-aware targets
Today `createTarget(w,h,{depth, float})` allocates depth only for `layerTarget` and discards it on `blit`.
New:
```js
// src/engine/Renderer.js
createDepthTarget(w,h) // RGBAFormat + DepthTexture (UnsignedInt24)
renderScene(scene,camera,target,{clear, withDepth:true})
getDepthTexture() // shared depth of last model pass
```
`Compositor` keeps one depth texture per slot if any active layer is `kind:'model'|'layer3d'`. Pure shader stacks keep the current `Copy/Blend` path unchanged.
### 3.2 `Layer` — introduce `ModelLayer` (do not overload `SceneLayer`)
```js
// src/engine/Layer.js
class ModelLayer extends Layer {
// module: { kind:'model', build({scene,camera,seed,params,THREE,actorSpec,formMesh})
// update({instance,scene,camera,timeline,features,params,palette,
// personality,framing,opacity,actorSpec,THREE}) }
// - scene is a THREE.Group owned by the layer
// - camera is borrowed from Compositor.sharedCamera (see 3.3), not per-layer
// - actorSpec is the ActorSpec for this stage (or null for kit-free Stages A)
// - formMesh(kind, opts) -> BufferGeometry from Identity form
}
```
Keep `ShaderLayer` and `SceneLayer` as-is. `particles.js` stays `layer3d`. `ModelLayer` is for meshes. `createLayer` dispatches on `kind`.
`build()` runs once, seeded. `update()` is analytic per frame: no `position += velocity*dt` (same rule as `particles.js:3` header). `dispose()` disposes geometries/materials.
Injected helpers:
- `formToGeometry(part, identity)` — one `form.parts[i]` + `cast` profile → `BufferGeometry`. V1 is `ExtrudeGeometry` of the `castSDF` profile (`castMain(q/rr)*min(rr)` → 2-D outline → extrude by `u_formDepth`), or `LatheGeometry` for round forms. No marching cubes in V1.
- `actorToGeometry(actorSpec, palette)` — the mesh path of §2.3. Handles both primitive assembly and `kitRef` deformation.
- `paletteMaterial(index, {roughness, metalness})``MeshStandardMaterial` wired to `palette[index]` via `color.set(palette[i])`; `inkValue` grade still runs in `COMPOSITE_FRAG` so posterize/hollow still affect meshes via the grade pass. Kept separate from lighting: two stages that both march the protagonist must agree which way the key light points (same rationale as `castLit`).
### 3.3 Camera — one shared rig, not N cameras
Today each `SceneLayer` owns its `PerspectiveCamera`. That breaks shared depth and makes `gaze` diverge per layer.
New: `Compositor` owns `sharedCamera: PerspectiveCamera(60, aspect, 0.1, 200)` + `sharedScene` root for the depth prepass. `ArcDriver._framingAt` + `gazeAt` drive it centrally:
- `scale` → dolly `z = baseZ / scale` (centralise `particles.js:149`'s `4/scale`).
- `shift` → camera `x,y` (or `lookAt` offset).
- `Personality.camera` (`driftAngle/driftRate/sway/swayRate/spin/horizon`) → same sinusoids as `sigCamera` but as translation/roll: `pan = 20*sin(t*0.05)`, `x = cos(driftAngle)*driftRate*pan + sin(t*swayRate)*sway + shift[0]`, `z` roll `spin*t`, `lookAt(x, y, -depth*0.4)` (mirrors `particles.js:152-166`, now shared).
- `u_sigHorizon` → ground `y = sigHorizonY()` so shader ground and mesh ground agree (already shared by `pylon-grid`, `synthwave-run`).
Shader layers that need depth-aware occlusion sample shared depth via opt-in uniform `u_sceneDepth` (module field `readsDepth:true`, which excludes it from `canGround` — a `prev()`-like entanglement, same as `readsHistory`).
### 3.4 `shader-contract.js` — keep, add sibling
`FORM_PREAMBLE` stays for shader impostors — it is the fallback when `u_formCount==0` and the opt-in that keeps `checks.html` distinctness sweep cheap (only `consumes.includes('form')` scenes pay the compile — measured as minutes saved). Add:
```js
export const MODEL_PREAMBLE = `...` // JS-side helpers only; NOT appended to fragment shaders
```
Shader scenes unchanged. Model scenes do not include fragment preamble.
New module fields (handled by `params/schema.js` + `scenes/surface.js`):
- `readsDepth: true` → shader samples `u_sceneDepth`; implies `canGround()==false`.
- `actor: 'monolith'|'swarm'|...` → stage requests an `ActorSpec` of that archetype.
### 3.5 Staging / lattice → world space
Reuse `stageNode(i,n)` directly: `xy ∈ [-1,1] → worldXZ`, `z (scale) → instance scale`, `+ sigHorizonY()*0.3 → ground offset`. `procession.js:47`'s loop
```glsl
vec3 node = stageNode(fi, total);
float scale = mix(1.0, 0.35, back*u_recede) * node.z;
```
becomes ~10 lines of `InstancedMesh` setup in `ModelLayer.build()` with the same `palette[i%N]`, `inkMask`→`paletteMaterial`, `stageScale()` mapping. Near/far LOD swaps `InstancedMesh` count, not geometry cost.
`look/stack.js` stays: slot 0 = ground (canvas) — now a plane/heightfield mesh when the subject is `model`; slot ≥1 = instanced subjects on it. Keep `blend:'normal'|'lumakey'` for shader+mesh composite; add `blend:'depth'` (depth-tested, no blend) when two model layers share `sharedCamera`.
### 3.6 Assets
- `src/assets/kit/` — 15-20 glTFs, <50KB gzipped each, Draco-compressed, single material slot. `THREE.GLTFLoader` + `DRACOLoader`, cached by `ArcDriver.layerCache` key, prewarmed in `Show.prewarm()` (fetch + `renderer.compileScene`). Budget: one stage loads 3 kit pieces. Growth path for Stage C is just adding files here.
- `src/assets/models/` reserved for bespoke heroes (C), not V1.
- Deformation: kit vertices displaced by `sides/notchD/hollow` via a small vertex shader driven by the same uniforms shaders already read, so a seek is still exact.
### 3.7 Post
Keep bloom/feedback/grade. Add opt-in behind flags (off by default):
- Contact shadows one `PCFSoftShadowMap` directional light, `shadowMap.enabled` only when a `model` layer is active.
- SSAO `three/examples/jsm/postprocessing/SAO` between `accum` and `feedback`, disabled at 720p preview, enabled at 1080p export.
- DOF `COMPOSITE_FRAG` switch reading shared depth, driven by `framing.scale` (close-up = shallow DOF).
---
## 4. What the first 3D stages are
Not "a 3D scene" rebuilds of existing scenes measured A/B, so the variety instrument can see what moved.
1. **Pylon Field 3D** Replace `pylon-grid.js:83-169` SDF imposter loop (`castSolid`/`castChorusSolid` per pylon with `castTurn` + bounding-sphere `member*1.3` + `painted` first-wins) with `InstancedMesh` for crowns + stacked `InstancedMesh` for legs. Ground is a plane at `hy = sigHorizonY()`. Proves chorus-stacked legs are more legible as meshes (current bottom-third crop 22.2/255 vs 13.4 with strut; whole-frame currently 0.132 vs flat 0.141 expect the crop to finally move the frame score once depth is real). Uses `ActorSpec(archetype:'structure')`.
2. **Synthwave Corridor (ground + passers as meshes)** Grid stays shader (`perspective = 1/(horizon - p.y)`, `abs(p.x*perspective)<0.8 → roadHalf = 0.8*(horizon-py+0.05)`, verge `roadHalf+psize*0.92+gap`, `bound=dot(p-passPos,p-passPos)/psize²`) but passers (`castChorus` verges) become `InstancedMesh` of `ActorSpec(archetype:'vehicle')` streaming on verges; hero `castSolid` at `heroPos = (sin(t*0.08)*0.18, -0.68+bob)` becomes `ActorSpec(archetype:'monolith')` mesh with real shadow ellipse (currently faked at `synthwave-run.js:179`).
3. **Assembly Stage** One protagonist `Mesh` (full `form` assembly via `actorToGeometry` + `SYMMETRIES` as `InstancedMesh` folds, cf. `formFold`) on a `stageNode` chorus field. Camera orbits via `gaze` (shift + dolly, not just screen shift). This is the "solid's outline changes as it turns" promise from `Identity.js:112`.
All three: `consumes:['form','ink','staging']`, `traits:['shape','space','camera']`, `actor:'…'` and declare `slowAxis` on `count/spread/columns` so the slow-axis journey is measurable. Kit variants of (1) and (2) are the first B prototypes (swap `formToGeometry` primitive for `kitRef`).
---
## 5. Phased rollout
### Phase 0 — infra, no visual change (1-2 days)
- `Renderer.createDepthTarget`/`renderScene` with depth, `Compositor.sharedCamera/sharedDepth`, `ModelLayer` skeleton, `formToGeometry` stub (extruded `cast` profile), `Show.prewarm` preloads kit manifest, `ActorGenerator` pure module with monolith archetype.
- **Gate:** `npm run lint:scenes` + `checks.html?phase=4` (first-render determinism) green; `grep` still clean (`Math.random`/`performance.now`/`Date.now` only in allowed spots); dual-resolution diff still passes.
### Phase 1 — form → mesh (2-3 days)
- `Identity.form → actorToGeometry` (extrude of `castSDF` profile + `u_formDepth`; `box/capsule/torus` as primitives; `sminForm`/`formFold` mirrored in JS for assembly). Pylon 3D variant behind `?modelPylon=1` for A/B screenshots.
- **Gate:** `checks.html?variety=1` pylon bottom-third crop variety must rise (replicates `pylon-grid.js:41` measurement) without whole-frame collapsing; `phase12` seed variety still computable.
### Phase 2 — two stages ship (3-4 days)
- `src/scenes/stage/pylon-field-3d.js` + `src/scenes/stage/synthwave-corridor.js` (registered in `src/scenes/registry.js` as new modules, family `structural`). `LookGenerator` casts them like any `structural` scene (`DIRECTORS` already weight that family). `ActorGenerator` now serves `monolith`/`structure`/`vehicle`/`swarm`.
- **Gate:** `filmstrip.html` 30s probes not interchangeable stills; `phase12` (seed variety) not regressed; `checks.html?scene=Pylon%20Field%203D` trait/ink/staging gates green; `decompose` identity component rises.
### Phase 3 — kit + materials (when Phase 2 measures well, 2-3 days)
- `src/assets/kit/` (15 pieces), `paletteMaterial`, kit deformation by `sides/notch/hollow` (vertex displacement driven by identity uniforms). One new stage uses kit pieces as chorus.
- **Gate:** `tools/build-song-bank.js` still builds, `src/scenes/metadata.json` regenerated via `gallery.html → refresh metadata`, `surfaceOf` re-derived for kit stages (`ASSUMED_COVERAGE=0.15` no longer needed for them); `phase6` `canGround` still holds.
### Phase 4 — depth polish, opt-in (1-2 days, flag-guarded)
- Shadows, SSAO, DOF behind `look.post` flags (`post.shadows`/`post.ssao`/`post.dof`). Enabled by `Story` tension (climax gets DOF, resolution gets haze), never by default. Fallback: `ModelLayer` renders `castSolid` impostor if `capabilities.isWebGL2===false` or `maxTextureSize<2048`.
- **Gate:** dual-resolution diff (`uv/p` vs pixels) exact any `u_resolution`-dependent shadow bias fails it; `phase5` feedback stability still 10k frames; export still `mp4-muxer` A/V sync within one frame.
### Phase 5 — library growth (ongoing, Stage C)
- `src/actors/library/` grows by adding files `walker`/`vehicle`/`structure` archetypes, articulated rigs (analytic `sin/cos` gait, no physics). MIGRATION.md gains Stage C appendix: `castSolid` loop `actorInstancedMesh` loop. `tools/new-scene.js --model` scaffolds `ModelLayer`; `tools/new-actor.js --archetype` scaffolds `ActorSpec`.
- **Gate per actor:** same as `HOWTO-visualizers.md#verify` `lint:scenes`, `checks.html?scene=`, then full suite before batch commit.
---
## 6. Tooling & checks
- `tools/new-scene.js --model` scaffolds `ModelLayer` ( `kind:'model'`, `consumes`, `traits`, `actor`, `slowAxis`, `build`/`update` stubs that already map `stageNode` + `castTurn` as quaternions + `instanceMatrix.needsUpdate`).
- `tools/new-actor.js --archetype=walker` scaffolds `ActorSpec` + `src/actors/library/<name>.js` + registry entry; bakes name-derived constants so two fresh actors are not twins.
- `tools/lint-scenes.js` adds: `kind:'model'` must have `build`+`update`, must not declare `shader`; `readsDepth` scenes can't be `canGround`; `actor` must be a known archetype; `rate:true` exclusion still enforced (for `orbitRate` etc.); trait evidence checked for model scenes too.
- `checks/scene-gate.js` model scenes excluded from fragment-only distinctness compile sweep (they have no fragment shader), included in `variety` via WebGL readback.
- `src/scenes/metadata.json` add `kind` + `actor` to rows so `surface.js` does not pessimistically assume `ASSUMED_COVERAGE=0.15`. Measure kit stages like shader stages (`gallery.html refresh metadata`).
---
## 7. What to decide at planning review
1. **Depth scope:** depth *between* layers (ground mesh behind shader subject) vs *within* a layer (one `ModelLayer` owns the whole 3D world). This plan picks **within a layer, composited at 2.5-D** one 3D stage is one world, still stacked over a shader ground if needed via `lumakey`. Cheaper than a global scene graph, preserves `stack.js`/`groundPersonalityFrom`/`groundCoverageOf` logic.
2. **Kit on day one?** Recommendation: **no** Stage A proves the pipeline with primitives; B adds kit once A measures well. Tentative kit shortlist: extruded profile, rounded box, capsule, torus, cone, low-poly teapot/monkey/icosphere as deformation targets.
3. **First stage to convert:** **Pylon Field** (proven variety story, clean ground plane, already documents the silhouette-vs-solid trade and has a crop measurement to beat).
If approved, this becomes `PLAN.md §15` + `HOWTO-visualizers.md` Appendix C + `MIGRATION.md` §C and the first commit is Phase 0.
---
## 8. Risks & mitigations
| Risk | Mitigation |
|---|---|
| **Compile/link hitches** each `ModelLayer` brings programs for shadow/SSAO/palette materials | `ArcDriver.prewarm` + `Compositor.prime` already exist; add `renderer.compileScene(sharedScene,sharedCamera)` there. Measure on `checks.html?phase=4`. |
| **Mobile / low-end GPU** instancing helps but shadows/SSAO hurt | Shadows/SSAO off by default; `ModelLayer` falls back to `castSolid` impostor when `isWebGL2===false` or `maxTextureSize<2048`. |
| **Variety regression** mesh fills silhouette uniformly vs stamped `inkMask`'s `flat/hatch/stipple/halftone/hollow` swing (already measured: pylon whole-frame 0.132 vs flat 0.141, `pylon-grid.js:41`) | Keep far rows as impostors or add `inkPattern` to `castLit` only for distant instances; keep imposter LOD for far field (already documented as the deliberate trade). |
| **Determinism** `InstancedMesh` + `lookAt` per frame can introduce order-dependent float error | Matrices set analytically from `timeline.time` + `seed`, `instanceMatrix.needsUpdate=true`, no `updateMatrixWorld` accumulation. Same rule as `particles.js:116` `fract(depthSeed + t*...)`. |
| **Palette coherence** `MeshStandardMaterial` doesn't read `u_colors` | `paletteMaterial` bakes `pal(i)` at `setPalette` time; `inkValue` posterize still runs in `COMPOSITE_FRAG` outline pass. |
| **Actor homogenisation** one `ActorGenerator` style becomes the house actor | Five archetypes + audio-tilted weights + per-actor `rng.fork`, same defense as `DIRECTORS`/`SIGNATURE_WEIGHTS`. Measure actor census (`tools/cast-census.js` extended) alongside scene census. |
| **Stage C scope creep** library wants rigs/physics before the rig is proven | Gate Stage C behind Phase 2 numbers; articulated walkers are Phase 5, gated per-actor like scenes. No physics analytic `sin/cos` gaits only. |
---
## 9. Validation — how we know it worked
Carries forward the gates from `PLAN.md §11`, `EPIC-2 §4`, `EPIC-3 §9a`, `EPIC-4 §6`.
| What | Gate |
|---|---|
| **Determinism** | `phase4` still green: fresh-engine frame vs later render 1 LSB (`max channel delta 1`), `prime()` still required (measured 10 bad frames on heaviest scene without it). |
| **Framing** | Resolution independence survives (`dual-resolution diff`), `framing.shift/scale` visible on model stages (render delta > floor), determinism + gaze still pure `f(frame)` (`ArcDriver` memoised on rounded `reveal`/`shift`). |
| **Variety** | `decompose` identity component non-zero and ≥ container component (cf. `MIGRATION.md:257` `identity 158% of container` after 18 scenes); `floor` still bounded by `GROUND_FLOOR_MIN`; `direction` still >0 over `arcless ref`. |
| **Composition** | `composition — a rendered section is neither black nor blown out` still two-ended (painted ≥88% mean / darkest ≥43%, clipped median 0% / worst ≤21%) — ground mesh must not reintroduce the `hollow` 2% failure or the `screen`-as-default 24% clipping. |
| **Per-scene** | `checks.html?scene=` 10-line battery for each new model stage (renders/animates/deterministic/distinct/param-sweep/flash-rate/trait evidence/consumes). |
| **Per-actor** | `ActorGenerator` census: 6-12 songs × 2 seeds, each archetype appears, no actor within 0.03 of another in identity distance; two seeds on one song produce different `ActorSpec.parts` order but same family (measured like scene census). |
| **Performance** | Full stack 60fps at preview; per-layer GPU cost budgeted; 10k-frame feedback stability; export A/V sync within one frame. Model layers report `InstancedMesh` count and `attribute.needsUpdate` churn. |
---
*Forked from `party-stage` by copying what was useful, then detached — no imports across the boundary. This epic keeps that rule: every new file lives under `flow-state/`, every new concept is data (`ActorSpec`/`paletteMap`/`rig`) before it is code, and every gate that exists today still runs unchanged.*