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>
29 KiB
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.buildFragmentShadercompilesVERTEX_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. RealTHREE.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}). Analyticz = fract(depthSeed + t*rise*...),framingapplied as dolly4/scale,personality.cameraassin/cospan/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/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
// 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 asrng.fork('form')inIdentity.js:311).archetype— optional; when absent the generator picks one weighted by audio.personality+identity— so the actor IS the song's cast (samesides/round/elong/tilt/notch/hollow, sameSOLIDS/SYMMETRIES/FORM_OPS, sameink/latticefamily). The geometry is the signature form made concrete — same sides/rounding/tilt asPersonality.shape, plus notches/hollows that turn a shape into a character.identityUniforms(identity, shape)already does theshape→castreconciliation; 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)— oneform.parts[i]+castprofile →BufferGeometry. V1 isExtrudeGeometryof thecastSDFprofile (castMain(q/rr)*min(rr)→ 2-D outline → extrude byu_formDepth), orLatheGeometryfor round forms. No marching cubes in V1.actorToGeometry(actorSpec, palette)— the mesh path of §2.3. Handles both primitive assembly andkitRefdeformation.paletteMaterial(index, {roughness, metalness})—MeshStandardMaterialwired topalette[index]viacolor.set(palette[i]);inkValuegrade still runs inCOMPOSITE_FRAGso 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 ascastLit).
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→ dollyz = baseZ / scale(centraliseparticles.js:149's4/scale).shift→ camerax,y(orlookAtoffset).Personality.camera(driftAngle/driftRate/sway/swayRate/spin/horizon) → same sinusoids assigCamerabut as translation/roll:pan = 20*sin(t*0.05),x = cos(driftAngle)*driftRate*pan + sin(t*swayRate)*sway + shift[0],zrollspin*t,lookAt(x, y, -depth*0.4)(mirrorsparticles.js:152-166, now shared).u_sigHorizon→ groundy = sigHorizonY()so shader ground and mesh ground agree (already shared bypylon-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 samplesu_sceneDepth; impliescanGround()==false.actor: 'monolith'|'swarm'|...→ stage requests anActorSpecof 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], 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 byArcDriver.layerCachekey, prewarmed inShow.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/hollowvia 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
PCFSoftShadowMapdirectional light,shadowMap.enabledonly when amodellayer is active. - SSAO —
three/examples/jsm/postprocessing/SAObetweenaccumandfeedback, disabled at 720p preview, enabled at 1080p export. - DOF —
COMPOSITE_FRAGswitch reading shared depth, driven byframing.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.
-
Pylon Field 3D — Replace
pylon-grid.js:83-169SDF imposter loop (castSolid/castChorusSolidper pylon withcastTurn+ bounding-spheremember*1.3+paintedfirst-wins) withInstancedMeshfor crowns + stackedInstancedMeshfor legs. Ground is a plane athy = 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). UsesActorSpec(archetype:'structure'). -
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), vergeroadHalf+psize*0.92+gap,bound=dot(p-passPos,p-passPos)/psize²) but passers (castChorusverges) becomeInstancedMeshofActorSpec(archetype:'vehicle')streaming on verges; herocastSolidatheroPos = (sin(t*0.08)*0.18, -0.68+bob)becomesActorSpec(archetype:'monolith')mesh with real shadow ellipse (currently faked atsynthwave-run.js:179). -
Assembly Stage — One protagonist
Mesh(fullformassembly viaactorToGeometry+SYMMETRIESasInstancedMeshfolds, cf.formFold) on astageNodechorus field. Camera orbits viagaze(shift + dolly, not just screen shift). This is the "solid's outline changes as it turns" promise fromIdentity.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/renderScenewith depth,Compositor.sharedCamera/sharedDepth,ModelLayerskeleton,formToGeometrystub (extrudedcastprofile),Show.prewarmpreloads kit manifest,ActorGeneratorpure module with monolith archetype.- Gate:
npm run lint:scenes+checks.html?phase=4(first-render determinism) green;grepstill clean (Math.random/performance.now/Date.nowonly in allowed spots); dual-resolution diff still passes.
Phase 1 — form → mesh (2-3 days)
Identity.form → actorToGeometry(extrude ofcastSDFprofile +u_formDepth;box/capsule/torusas primitives;sminForm/formFoldmirrored in JS for assembly). Pylon 3D variant behind?modelPylon=1for A/B screenshots.- Gate:
checks.html?variety=1— pylon bottom-third crop variety must rise (replicatespylon-grid.js:41measurement) without whole-frame collapsing;phase12seed 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 insrc/scenes/registry.jsas new modules, familystructural).LookGeneratorcasts them like anystructuralscene (DIRECTORSalready weight that family).ActorGeneratornow servesmonolith/structure/vehicle/swarm.- Gate:
filmstrip.html30s probes not interchangeable stills;phase12(seed variety) not regressed;checks.html?scene=Pylon%20Field%203D— trait/ink/staging gates green;decomposeidentity component rises.
Phase 3 — kit + materials (when Phase 2 measures well, 2-3 days)
src/assets/kit/(15 pieces),paletteMaterial, kit deformation bysides/notch/hollow(vertex displacement driven by identity uniforms). One new stage uses kit pieces as chorus.- Gate:
tools/build-song-bank.jsstill builds,src/scenes/metadata.jsonregenerated viagallery.html → refresh metadata,surfaceOfre-derived for kit stages (ASSUMED_COVERAGE=0.15no longer needed for them);phase6canGroundstill holds.
Phase 4 — depth polish, opt-in (1-2 days, flag-guarded)
- Shadows, SSAO, DOF behind
look.postflags (post.shadows/post.ssao/post.dof). Enabled byStorytension (climax gets DOF, resolution gets haze), never by default. Fallback:ModelLayerrenderscastSolidimpostor ifcapabilities.isWebGL2===falseormaxTextureSize<2048. - Gate: dual-resolution diff (
uv/pvs pixels) exact — anyu_resolution-dependent shadow bias fails it;phase5feedback stability still 10k frames; export stillmp4-muxerA/V sync within one frame.
Phase 5 — library growth (ongoing, Stage C)
src/actors/library/grows by adding files —walker/vehicle/structurearchetypes, articulated rigs (analyticsin/cosgait, no physics). MIGRATION.md gains Stage C appendix:castSolidloop →actorInstancedMeshloop.tools/new-scene.js --modelscaffoldsModelLayer;tools/new-actor.js --archetypescaffoldsActorSpec.- 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 --modelscaffoldsModelLayer(kind:'model',consumes,traits,actor,slowAxis,build/updatestubs that already mapstageNode+castTurnas quaternions +instanceMatrix.needsUpdate).tools/new-actor.js --archetype=walkerscaffoldsActorSpec+src/actors/library/<name>.js+ registry entry; bakes name-derived constants so two fresh actors are not twins.tools/lint-scenes.jsadds:kind:'model'must havebuild+update, must not declareshader;readsDepthscenes can't becanGround;actormust be a known archetype;rate:trueexclusion still enforced (fororbitRateetc.); 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 invarietyvia WebGL readback.src/scenes/metadata.json— addkind+actorto rows sosurface.jsdoes not pessimistically assumeASSUMED_COVERAGE=0.15. Measure kit stages like shader stages (gallery.html → refresh metadata).
7. What to decide at planning review
- Depth scope: depth between layers (ground mesh behind shader subject) vs within a layer (one
ModelLayerowns 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 vialumakey. Cheaper than a global scene graph, preservesstack.js/groundPersonalityFrom/groundCoverageOflogic. - 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.
- 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.