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

29 KiB
Raw Blame History

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 ShaderLayers 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/stratastageNode(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

// 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):

{
  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

// 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:

// 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)

// 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:

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

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], inkMaskpaletteMaterial, 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#verifylint: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).
DeterminismInstancedMesh + 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 coherenceMeshStandardMaterial 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.