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>
337 lines
29 KiB
Markdown
337 lines
29 KiB
Markdown
# 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.*
|