From 7aa60d7336fbf9188f6550b470bfe570cd4de3ee Mon Sep 17 00:00:00 2001 From: Dejvino Date: Thu, 20 Aug 2026 16:26:15 +0200 Subject: [PATCH] =?UTF-8?q?Epic=205=20Phase=200=20=E2=80=94=20actors=20hav?= =?UTF-8?q?e=20bodies;=20the=20stage=20has=20depth?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- flow-state/EPIC-5-3D.md | 336 +++++++++++++++++++++++ flow-state/src/actors/ActorGenerator.js | 216 +++++++++++++++ flow-state/src/actors/meshes.js | 236 ++++++++++++++++ flow-state/src/engine/Compositor.js | 50 +++- flow-state/src/engine/Layer.js | 83 ++++++ flow-state/src/engine/Renderer.js | 16 ++ flow-state/src/engine/shader-contract.js | 12 + flow-state/src/look/ArcDriver.js | 12 + flow-state/src/look/LookGenerator.js | 6 + flow-state/src/params/schema.js | 15 + flow-state/tools/lint-scenes.js | 2 +- 11 files changed, 981 insertions(+), 3 deletions(-) create mode 100644 flow-state/EPIC-5-3D.md create mode 100644 flow-state/src/actors/ActorGenerator.js create mode 100644 flow-state/src/actors/meshes.js diff --git a/flow-state/EPIC-5-3D.md b/flow-state/EPIC-5-3D.md new file mode 100644 index 0000000..a4709b7 --- /dev/null +++ b/flow-state/EPIC-5-3D.md @@ -0,0 +1,336 @@ +# 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/.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.* diff --git a/flow-state/src/actors/ActorGenerator.js b/flow-state/src/actors/ActorGenerator.js new file mode 100644 index 0000000..813af14 --- /dev/null +++ b/flow-state/src/actors/ActorGenerator.js @@ -0,0 +1,216 @@ +// The cast with bodies. +// +// Identity gives the song a silhouette — sides, notches, hollows — and a solid +// assembly (form) the shaders can march as SDF. This module gives the same song a +// MESH: an ActorSpec that a ModelLayer can turn into BufferGeometry with +// actorToGeometry, and later a library of named actors (Stage C) will grow on +// top of it without changing the infra. +// +// Pure module: no three.js, no DOM, no wall-clock. Analytic like particles.js — +// motion is f(t,seed), never integration, so seek === playback and preview === +// export. Seeded off the look seed via rng.fork('actor:...'), so adding an actor +// never shifts a decision made after it (same rule as rng.fork('form') in +// Identity.js:311). +// +// Audio tilts the centre, seed picks within — same arrangement as +// generatePersonality/generateIdentity: two songs land in different regions, +// two seeds on one song land in different places inside one region. + +import { generateIdentity, SOLIDS, SYMMETRIES, FORM_OPS, MAX_FORM_PARTS } from '../look/Identity.js'; + +export const ACTOR_ARCHETYPES = ['monolith', 'swarm', 'walker', 'vehicle', 'structure']; + +/** + * Which archetypes suit which section kind — as a per-track lean, not a rule. + * Kept small and audio-tilted so every archetype stays reachable for every + * track, the way directors.js keeps every director reachable. + */ +const ARCHETYPE_WEIGHTS = { + monolith: 3, // one large solid — the default protagonist body + swarm: 2, // many small chorus instances + walker: 1, // articulated: two/three hinged parts, analytic gait (Stage C) + vehicle: 1, // chassis + orientation axis, streaming motion (Stage C) + structure: 2, // ground-anchored, heightfield-aware (Stage C) +}; + +const clamp01 = (x) => Math.max(0, Math.min(1, x)); + +/** + * Generate one actor — data, not scene graph. + * + * @param {object} opts.summary FeatureTrack.summary + * @param {import('../engine/rng.js').Rng} opts.rng forked for this actor + * @param {string} [opts.archetype] when absent, picked weighted by audio + * @param {object} [opts.personality] look.personality — for shape reconciliation + * @param {object} [opts.identity] look.personality.identity + * @returns {object} ActorSpec — serialisable, hashable + */ +export function generateActor({ summary, rng, archetype = null, personality = null, identity = null }) { + const s = summary || {}; + const bright = s.meanCentroid ?? 0.5; + const noisy = Math.min(1, (s.meanFlatness ?? 0.2) * 3); + const fast = clamp01(((s.bpm ?? 120) - 80) / 80); + const dynamic = clamp01(s.dynamicRange ?? 0.5); + const sections = s.sections ?? 4; + const busy = clamp01((sections - 2) / 5); + + // Audio sets the centre, seed picks within — mirrors Identity.generateIdentity. + const angular = clamp01(noisy * 0.6 + fast * 0.3 + rng.range(-0.25, 0.25)); + const intricate = clamp01(busy * 0.5 + bright * 0.3 + rng.range(-0.3, 0.3)); + const solid = clamp01(0.5 - dynamic * 0.4 + rng.range(-0.25, 0.25)); + + if (!archetype) { + const noisyW = 0.5 + noisy * 1.2; + const weights = ACTOR_ARCHETYPES.map((a) => { + let w = ARCHETYPE_WEIGHTS[a] || 1; + if (a === 'walker' || a === 'vehicle') w *= 0.6 + noisyW * 0.4; + if (a === 'structure') w *= 0.6 + (1 - noisy) * 0.6 + dynamic * 0.4; + return w; + }); + archetype = rng.pickWeighted(ACTOR_ARCHETYPES, weights); + } + + // The solid assembly — same rows the shaders march, so the mesh and the + // impostor are the same character. Reuses Identity.generateForm via a + // derived identity when one was not supplied (checks, unit tests). + let form; + if (identity && identity.form) { + form = identity.form; + } else { + // Derive a throwaway identity just to get a form; forked so the main + // identity stream is untouched when this path is used in isolation. + const derived = generateIdentity(s, rng.fork('actor:form'), sections); + form = derived.form; + // Keep the cast family in sync with the supplied personality shape when + // both exist — mirrors identityUniforms(identity, shape) reconciliation. + if (personality && personality.shape && identity === null) { + identity = derived; + } + } + + // Kit reference — Stage B. Null in Stage A, which uses primitives. + const kitRef = null; + + // Rig — Stage C. Null until walker/vehicle get articulated. + let rig = null; + if (archetype === 'walker' || archetype === 'vehicle') { + // Stub rig: one hinge, analytic gait params — enough to prove the + // ActorSpec shape without requiring a skeleton system. + const joints = archetype === 'walker' + ? [ + { parent: -1, axis: [0, 1, 0], range: rng.range(0.3, 0.9), phase: rng.range(0, Math.PI * 2), ratio: 1 }, + { parent: 0, axis: [1, 0, 0], range: rng.range(0.2, 0.6), phase: rng.range(0, Math.PI * 2), ratio: 0.6 }, + ] + : [ + { parent: -1, axis: [0, 1, 0], range: rng.range(0.15, 0.45), phase: rng.range(0, Math.PI * 2), ratio: 1 }, + ]; + rig = { joints, gait: archetype === 'walker' ? 'walk' : 'roll' }; + } + + // Which palette entry each part reads — seeded, so two actors on one track + // differ in colour rhythm even when their forms coincide. + const paletteMap = form.parts.map(() => rng.int(0, 3)); + + // Scale reconciled with Identity.lattice.elementScale so mesh size agrees + // with stageNode.z. Base is the song's elementScale-derived size; spread + // is how much the actor's own parts vary. + const elementScale = identity ? identity.lattice.elementScale : 0.35; + const scale = { + base: elementScale, + spread: clamp01(0.15 + intricate * 0.6 + rng.range(-0.2, 0.25)), + }; + + const placement = identity ? { + latticeKind: identity.lattice.kind, + spread: identity.lattice.spread, + jitter: identity.lattice.jitter, + } : { latticeKind: 'scatter', spread: 0.7, jitter: 0.3 }; + + const motion = { + orbitRate: rng.range(0.08, 0.45), + spin: rng.range(-0.6, 0.6), + bobAmp: rng.range(0.005, 0.025), + bobRate: rng.range(0.3, 1.2), + }; + + return { + archetype, + seed: rng.seed >>> 0, + form, + kitRef, + rig, + paletteMap, + scale, + placement, + motion, + // Keep the audio-derived character alongside the spec so a HUD or + // check can report why this actor looks the way it does. + character: { angular, intricate, solid }, + }; +} + +/** + * Generate the per-track actor set — one ActorSpec per archetype, each from + * its own fork so the set is stable under reordering. + * + * @param {object} summary + * @param {import('../engine/rng.js').Rng} rng parent (look seed fork) + * @param {object} personality + * @param {object} identity + * @returns {Record} archetype -> ActorSpec + */ +export function generateActorSet(summary, rng, personality = null, identity = null) { + const set = {}; + for (const arch of ACTOR_ARCHETYPES) { + set[arch] = generateActor({ + summary, + rng: rng.fork(`actor:${arch}`), + archetype: arch, + personality, + identity, + }); + } + return set; +} + +/** + * Totally ordered actor-set summary for HUD / check output — mirrors + * describeIdentity / describePersonality shape. + */ +export function describeActor(actor) { + if (!actor) return 'no actor'; + const f = actor.form; + const parts = f ? `${f.parts.length}-part/${f.symmetry}${f.symmetry !== 'none' ? f.symmetryN : ''}` : 'no form'; + const rig = actor.rig ? ` · rig ${actor.rig.gait} ${actor.rig.joints.length}j` : ''; + const kit = actor.kitRef ? ` · kit ${actor.kitRef.id}` : ''; + return `${actor.archetype} ${parts}${rig}${kit} · scale ${actor.scale.base.toFixed(2)}`; +} + +export function describeActorSet(set) { + if (!set) return 'no actors'; + return ACTOR_ARCHETYPES.map((a) => (set[a] ? describeActor(set[a]) : `${a}:—`)).join(' | '); +} + +// Re-export for consumers that only need the constants without importing Identity. +export { SOLIDS, SYMMETRIES, FORM_OPS, MAX_FORM_PARTS }; + +// Convenience: deterministic hash of an ActorSpec's visible content — for +// determinism checks and census tooling. +export function hashActorSpec(spec) { + let h = 0x811c9dc5 >>> 0; + const mix = (n) => { + h ^= n & 0xff; h = Math.imul(h, 0x01000193) >>> 0; + h ^= (n >>> 8) & 0xff; h = Math.imul(h, 0x01000193) >>> 0; + }; + mix(spec.seed); + for (let i = 0; i < spec.archetype.length; i++) mix(spec.archetype.charCodeAt(i)); + if (spec.form) { + mix(spec.form.parts.length); + for (const p of spec.form.parts) { + mix(SOLIDS.indexOf(p.kind)); + mix(Math.round(p.offset[0] * 100)); + mix(Math.round(p.scale[0] * 100)); + } + } + return h >>> 0; +} diff --git a/flow-state/src/actors/meshes.js b/flow-state/src/actors/meshes.js new file mode 100644 index 0000000..bb0176a --- /dev/null +++ b/flow-state/src/actors/meshes.js @@ -0,0 +1,236 @@ +// Mesh-side twin of Identity.form's SDF assembly. +// +// The shaders march the assembly as SDF (FORM_PREAMBLE / castSDF3). This module +// builds the same assembly as BufferGeometry for ModelLayer — so the mesh and the +// impostor are the same character, and a stage that was stamping castSolid can +// become a stage that instancing a mesh without inventing a new protagonist. +// +// Stage A uses primitives + extruded 2-D cast profile (prism). Stage B adds a +// kitRef path that deforms a curated glTF base by the same sides/notch/hollow +// params. Analytic: no integration, no wall-clock — f(t,seed) only, so seek === +// playback exactly as particles.js requires. +// +// Kept small on purpose. A full marching-cubes SDF->mesh would be more general +// and is not needed for V1: Identity's solids are prism/box/capsule/torus/ +// sphere, each of which has a direct THREE primitive. + +import * as THREE from 'three'; + +// ------------------------------------------------------------------ cast SDF in JS +// Mirrors shader-contract.js castSDF verbatim so the 2-D profile sampled here +// matches the one the shaders stamp. Only the 2-D cast (not the 3-D assembly) +// is needed for prism extrusion. + +function jsCastSDF(q, sides, rnd, elong, tilt, notchN, notchD, hollow) { + // rotate + const c = Math.cos(tilt), s = Math.sin(tilt); + const qx = c * q[0] - s * q[1]; + const qy = s * q[0] + c * q[1]; + const qx2 = qx / Math.max(elong, 0.05); + const qy2 = qy; + + const r = Math.hypot(qx2, qy2); + const a = Math.atan2(qy2, qx2); + let d; + if (sides < 2.5) { + d = r - 1.0; + } else { + const seg = (Math.PI * 2) / sides; + const half = seg * 0.5; + let aa = a + half; + aa = aa % seg; + if (aa < 0) aa += seg; + aa -= half; + const folded = Math.cos(aa); + const poly = r * folded - Math.cos(half); + d = poly * (1 - Math.max(0, Math.min(1, rnd))) + (r - 1.0) * Math.max(0, Math.min(1, rnd)); + // mix(poly, r-1, rnd) — same as GLSL mix(poly, r-1, clamp(rnd)) + } + if (notchN > 0.5) d += notchD * Math.cos(notchN * a); + if (hollow > 0.001) d = Math.abs(d) - hollow * 0.35; + return d; +} + +function sampleCastRadius(angle, cast, steps = 24) { + // Binary search outward along ray until SDF crosses zero. + let lo = 0, hi = 2.0; + // Find hi outside + for (let i = 0; i < 12; i++) { + const q = [Math.cos(angle) * hi, Math.sin(angle) * hi]; + if (jsCastSDF(q, cast.sides, cast.round, cast.elong, cast.tilt, + cast.notchCount, cast.notchCount ? cast.notchDepth : 0, cast.hollow) > 0) break; + hi *= 1.5; + if (hi > 10) break; + } + for (let i = 0; i < steps; i++) { + const mid = (lo + hi) * 0.5; + const q = [Math.cos(angle) * mid, Math.sin(angle) * mid]; + const d = jsCastSDF(q, cast.sides, cast.round, cast.elong, cast.tilt, + cast.notchCount, cast.notchCount ? cast.notchDepth : 0, cast.hollow); + if (d > 0) hi = mid; else lo = mid; + } + return (lo + hi) * 0.5; +} + +/** + * Build a THREE.Shape from a 2-D cast profile (identity.cast.protagonist or + * chorus). Used for formPrism — the profile extruded. + */ +export function castShape(cast, segments = 48) { + const shape = new THREE.Shape(); + for (let i = 0; i <= segments; i++) { + const a = (i / segments) * Math.PI * 2; + const r = sampleCastRadius(a, cast); + const x = Math.cos(a) * r; + const y = Math.sin(a) * r; + if (i === 0) shape.moveTo(x, y); + else shape.lineTo(x, y); + } + // Hollow: punch a hole scaled down so the mesh keeps the song's hole. + if (cast.hollow > 0.001) { + const hole = new THREE.Path(); + const hr = (1 - cast.hollow * 0.35) * 0.55; + for (let i = 0; i <= segments; i++) { + const a = (i / segments) * Math.PI * 2; + const x = Math.cos(a) * hr; + const y = Math.sin(a) * hr; + if (i === 0) hole.moveTo(x, y); + else hole.lineTo(x, y); + } + shape.holes.push(hole); + } + return shape; +} + +// ------------------------------------------------------------------ per-part geometry + +/** + * One part of an Identity.form assembly → BufferGeometry. + * + * @param {object} part {kind, scale:[x,y,z], round} + * @param {object} identity look.personality.identity (for cast profile when prism) + * @param {object} [opts] { depthScale } extra extrusion depth multiplier + */ +export function formToGeometry(part, identity, opts = {}) { + const kind = part.kind || 'prism'; + const sx = Math.max(1e-3, part.scale[0]); + const sy = Math.max(1e-3, part.scale[1]); + const sz = Math.max(1e-3, part.scale[2]); + + if (kind === 'prism') { + const cast = identity && identity.cast ? identity.cast.protagonist : null; + if (!cast || !cast.sides) { + // Fallback: box when no cast profile + return new THREE.BoxGeometry(sx * 2, sy * 2, sz * 2); + } + const shape = castShape(cast, 48); + const depth = sz * 2 * (opts.depthScale ?? 1) * 0.6; + const geo = new THREE.ExtrudeGeometry(shape, { + depth, + bevelEnabled: true, + bevelThickness: part.round ? part.round * 0.15 : 0.02, + bevelSize: part.round ? part.round * 0.12 : 0.015, + bevelSegments: 2, + }); + // Center depth so the part's origin stays at its supplied offset. + geo.translate(0, 0, -depth * 0.5); + // Scale to requested xy — shape was sampled at radius ~1. + geo.scale(sx, sy, 1); + return geo; + } + if (kind === 'box') { + return new THREE.BoxGeometry(sx * 2, sy * 2, sz * 2); + } + if (kind === 'capsule') { + const rad = Math.max(1e-3, Math.min(sx, sz)); + const len = Math.max(1e-3, sy * 2); + return new THREE.CapsuleGeometry(rad, len, 8, 16); + } + if (kind === 'torus') { + const major = Math.max(1e-3, sx); + const tube = Math.max(1e-3, sz * 0.45); + return new THREE.TorusGeometry(major, tube, 16, 32); + } + if (kind === 'sphere') { + const rad = Math.max(1e-3, Math.min(sx, Math.min(sy, sz))); + return new THREE.SphereGeometry(rad, 16, 16); + } + return new THREE.BoxGeometry(sx * 2, sy * 2, sz * 2); +} + +// ------------------------------------------------------------------ actor → geometry + +/** + * ActorSpec → THREE.Group. Stage A: assembly of formToGeometry clones under the + * actor's symmetry. Stage B will add kitRef deformation here without changing + * the caller. + * + * @param {object} actorSpec from ActorGenerator.generateActor + * @param {object} identity + * @param {typeof THREE} THREE + */ +export function actorToGeometry(actorSpec, identity, THREE_) { + const T = THREE_ || THREE; + const form = actorSpec.form; + if (!form || !form.parts.length) { + const g = formToGeometry({ kind: 'prism', scale: [0.6, 0.6, 0.35], round: 0.1 }, identity); + const m = new T.Mesh(g, new T.MeshStandardMaterial({ color: 0xffffff })); + const grp = new T.Group(); + grp.add(m); + return grp; + } + const group = new T.Group(); + const sym = form.symmetry || 'none'; + const symN = Math.max(2, form.symmetryN | 0); + + for (let i = 0; i < form.parts.length; i++) { + const part = form.parts[i]; + const geo = formToGeometry(part, identity); + + const addInstance = (offset, yaw, pitch, matOffset) => { + const mesh = new T.Mesh(geo, new T.MeshStandardMaterial({ color: 0xffffff })); + mesh.position.set(offset[0], offset[1], offset[2]); + mesh.rotation.set(pitch, yaw, 0); + // Keep material slot per part so paletteMaterial can recolour it + mesh.userData.partIndex = i; + group.add(mesh); + }; + + if (sym === 'radial' && symN > 1) { + for (let k = 0; k < symN; k++) { + const a = (k / symN) * Math.PI * 2; + const ox = part.offset[0] * Math.cos(a) - part.offset[2] * Math.sin(a); + const oz = part.offset[0] * Math.sin(a) + part.offset[2] * Math.cos(a); + addInstance([ox, part.offset[1], oz], part.yaw + a, part.pitch); + } + } else if (sym === 'mirror') { + addInstance(part.offset, part.yaw, part.pitch); + addInstance([-part.offset[0], part.offset[1], part.offset[2]], -part.yaw, part.pitch); + } else if (sym === 'stack') { + const h = 1.6 / symN; + const lim = (symN - 1) * 0.5; + for (let k = -lim; k <= lim; k++) { + addInstance([part.offset[0], part.offset[1] + k * h, part.offset[2]], part.yaw, part.pitch); + } + } else { + addInstance(part.offset, part.yaw, part.pitch); + } + } + + return group; +} + +/** + * Palette-aware material for a model part — bakes pal(i) at setPalette time so + * MeshStandardMaterial agrees with shader pal()/inkValue grade. + */ +export function paletteMaterial(palette, index, opts = {}) { + const c = palette && palette.length ? palette[index % palette.length] : [1, 1, 1]; + return new THREE.MeshStandardMaterial({ + color: new THREE.Color(c[0], c[1], c[2]), + roughness: opts.roughness ?? 0.45, + metalness: opts.metalness ?? 0.1, + transparent: opts.transparent ?? false, + opacity: opts.opacity ?? 1, + }); +} diff --git a/flow-state/src/engine/Compositor.js b/flow-state/src/engine/Compositor.js index ac01816..029703a 100644 --- a/flow-state/src/engine/Compositor.js +++ b/flow-state/src/engine/Compositor.js @@ -71,6 +71,47 @@ export class Compositor { this.bloomA = r.createTarget(bw, bh); this.bloomB = r.createTarget(bw, bh); this.outputTarget = r.createTarget(w, h); + + // Shared perspective rig for model layers — one camera, one depth, so a + // ground mesh in one layer can occlude a subject in another. Created + // lazily here so existing shader-only stacks pay nothing extra. + this.sharedCamera = new THREE.PerspectiveCamera(60, w / h, 0.1, 200); + this.sharedCamera.position.set(0, 0, 5); + this.sharedDepthTarget = null; // allocated on demand when a model layer is active + } + + /** Ensure the shared depth target exists at the current size. */ + _ensureSharedDepth() { + if (this.sharedDepthTarget + && this.sharedDepthTarget.width === this.width + && this.sharedDepthTarget.height === this.height) return; + if (this.sharedDepthTarget) this.sharedDepthTarget.dispose(); + this.sharedDepthTarget = this.renderer.createDepthTarget(this.width, this.height); + } + + /** Drive the shared perspective rig from the current framing + gaze + personality. */ + updateSharedCamera({ framing, personality, time }) { + const cam = this.sharedCamera; + const frame = framing || { scale: 1, shift: [0, 0] }; + const dolly = 4 / Math.max(frame.scale, 0.05); + const pCam = personality ? personality.camera : null; + if (pCam) { + const pan = 20 * Math.sin(time * 0.05); + cam.position.set( + Math.cos(pCam.driftAngle) * pCam.driftRate * pan + + Math.sin(time * pCam.swayRate) * pCam.sway + frame.shift[0], + Math.sin(pCam.driftAngle) * pCam.driftRate * pan + + Math.cos(time * pCam.swayRate * 0.83) * pCam.sway + frame.shift[1], + dolly, + ); + cam.rotation.z = pCam.spin * time; + } else { + cam.position.set(frame.shift[0], frame.shift[1], dolly); + cam.rotation.z = 0; + } + // Look slightly down the depth axis so a ground plane is visible. + cam.lookAt(cam.position.x, cam.position.y * 0.3, cam.position.z - 5); + cam.updateMatrixWorld(); } _buildMaterials() { @@ -176,7 +217,10 @@ export class Compositor { _primeLayer(layer) { try { if (layer.material) this.renderer.compileMaterial(layer.material); - else if (layer.scene && layer.camera) this.renderer.compileScene(layer.scene, layer.camera); + else if (layer.scene) { + const cam = layer.sharedCamera || layer.camera; + if (cam) this.renderer.compileScene(layer.scene, cam); + } } catch (err) { console.warn('[compositor] priming failed for', layer.module && layer.module.name, err); } @@ -348,7 +392,9 @@ export class Compositor { disposeTargets() { [this.layerTarget, this.accumA, this.accumB, this.historyA, this.historyB, - this.bloomA, this.bloomB, this.outputTarget].forEach((t) => t && t.dispose()); + this.bloomA, this.bloomB, this.outputTarget, + this.sharedDepthTarget].forEach((t) => t && t.dispose()); + this.sharedDepthTarget = null; } dispose() { diff --git a/flow-state/src/engine/Layer.js b/flow-state/src/engine/Layer.js index e48ecd0..8dbb7f9 100644 --- a/flow-state/src/engine/Layer.js +++ b/flow-state/src/engine/Layer.js @@ -317,7 +317,90 @@ export class SceneLayer extends Layer { } } +/** + * A 3D model layer — the mesh twin of the shader impostor. + * + * Like SceneLayer it owns a THREE.Scene and receives build/update hooks, but + * its geometry comes from the song's ActorSpec (the mesh assembly), not from + * a hand-written point cloud. Determinism rule is the same: no integration, + * only analytic f(time, index, seed). See processors/meshes.js and + * src/actors/ActorGenerator.js. + * + * The camera is borrowed from Compositor.sharedCamera when one exists, so + * multiple ModelLayers share one perspective and one depth buffer — that is + * what makes a ground mesh occlude a subject mesh from another layer. + * Falls back to its own camera when no shared rig is present (tests, solo + * preview), so existing SceneLayer behaviour is unchanged. + */ +export class ModelLayer extends Layer { + constructor(options) { + super(options); + this.scene = new THREE.Scene(); + this.camera = new THREE.PerspectiveCamera(60, 16 / 9, 0.1, 200); + this.camera.position.set(0, 0, 5); + this.actorSpec = options.actorSpec || null; + // Shared rig injected by Compositor at render time when available. + this.sharedCamera = null; + this.instance = this.module.build({ + scene: this.scene, + camera: this.camera, + seed: this.seed, + params: this.baseParams, + actorSpec: this.actorSpec, + THREE, + }); + } + + /** Allow the look to swap the actor without rebuilding the layer. */ + setActor(actorSpec) { + this.actorSpec = actorSpec || null; + return this; + } + + render(renderer, target, ctx) { + const { timeline, features } = ctx; + const w = target ? target.width : renderer.width; + const h = target ? target.height : renderer.height; + // Use the compositor's shared camera when it has been injected; it is + // updated centrally from ArcDriver's framing/gaze so every model layer + // shares one perspective and one depth, and a ground in one layer can + // occlude a subject in another. + const cam = this.sharedCamera || this.camera; + if (cam.aspect !== w / h) { + cam.aspect = w / h; + cam.updateProjectionMatrix(); + } + const resolved = this.resolveParams(features); + this.module.update({ + instance: this.instance, + scene: this.scene, + camera: cam, + timeline, + features: features || {}, + params: resolved, + palette: this.palette, + personality: this.personality, + framing: this.framing, + opacity: this.opacity, + actorSpec: this.actorSpec, + THREE, + }); + renderer.renderScene(this.scene, cam, target, true); + } + + dispose() { + this.scene.traverse((obj) => { + if (obj.geometry) obj.geometry.dispose(); + if (obj.material) { + const mats = Array.isArray(obj.material) ? obj.material : [obj.material]; + mats.forEach((m) => m.dispose()); + } + }); + } +} + export function createLayer(module, options) { + if (module.kind === 'model') return new ModelLayer({ module, ...options }); if (module.kind === 'layer3d') return new SceneLayer({ module, ...options }); return new ShaderLayer({ module, ...options }); } diff --git a/flow-state/src/engine/Renderer.js b/flow-state/src/engine/Renderer.js index 6c41059..f49b2a0 100644 --- a/flow-state/src/engine/Renderer.js +++ b/flow-state/src/engine/Renderer.js @@ -55,6 +55,12 @@ export class Renderer { stencilBuffer: false, generateMipmaps: false, }); + if (options.depthTexture) { + target.depthTexture = new THREE.DepthTexture(width, height); + target.depthTexture.type = THREE.UnsignedIntType; + target.depthTexture.minFilter = THREE.NearestFilter; + target.depthTexture.magFilter = THREE.NearestFilter; + } target.texture.wrapS = THREE.ClampToEdgeWrapping; target.texture.wrapT = THREE.ClampToEdgeWrapping; // Deterministic initial contents: never inherit whatever was in GPU memory. @@ -62,6 +68,11 @@ export class Renderer { return target; } + /** Depth-aware target for shared-depth compositing (Phase 5). */ + createDepthTarget(width = this.width, height = this.height) { + return this.createTarget(width, height, { depth: true, depthTexture: true }); + } + clear(target = null, r = 0, g = 0, b = 0, a = 1) { const prev = this.gl.getClearColor(new THREE.Color()); const prevAlpha = this.gl.getClearAlpha(); @@ -72,6 +83,11 @@ export class Renderer { this.gl.setRenderTarget(null); } + /** Depth sampled from the last shared-depth pass, if any. */ + getDepthTexture(target) { + return target ? target.depthTexture || null : null; + } + /** Run a fullscreen shader pass. target === null renders to the canvas. */ blit(material, target = null) { this.quadMesh.material = material; diff --git a/flow-state/src/engine/shader-contract.js b/flow-state/src/engine/shader-contract.js index d36414e..303a82d 100644 --- a/flow-state/src/engine/shader-contract.js +++ b/flow-state/src/engine/shader-contract.js @@ -966,6 +966,18 @@ vec3 castLit(vec3 n, vec3 rd) { } `; +/** + * Model-layer preamble — JS-side helpers only, NOT appended to fragment shaders. + * ModelLayers are real three.js scenes; their geometry helpers live in + * src/actors/meshes.js. This export exists so the contract's MODEL layer has a + * named preamble the way FORM does, and so a scene declaring `form` vs a scene + * declaring `model` can be linted distinctly. + */ +export const MODEL_PREAMBLE = ` +// ModelLayer geometry helpers — see src/actors/meshes.js +// formToGeometry / actorToGeometry / paletteMaterial +`; + const EPILOGUE = ` void main() { vec2 uv = vUv; diff --git a/flow-state/src/look/ArcDriver.js b/flow-state/src/look/ArcDriver.js index 0cb25f3..145c528 100644 --- a/flow-state/src/look/ArcDriver.js +++ b/flow-state/src/look/ArcDriver.js @@ -221,20 +221,32 @@ export class ArcDriver { return stack[slot] || null; } + /** Resolve the ActorSpec for a layer, if the module requests one. */ + _actorFor(module) { + const actors = this.look.actors; + if (!actors || !module || !module.actor) return null; + return actors[module.actor] || null; + } + /** One Layer per (section, variant, layer slot), built lazily and kept. */ _layerFor(sectionIndex, variant, slot = 0) { const key = `${sectionIndex}:${variant}:${slot}`; let layer = this.layerCache.get(key); if (!layer) { const spec = this._specFor(sectionIndex, variant, slot); + const actorSpec = this._actorFor(spec.module); layer = createLayer(spec.module, { params: spec.params, seed: spec.seed, opacity: spec.opacity, blend: spec.blend, + actorSpec, }); layer.setPalette(this.look.palette); layer.setPersonality(this.look.personality); + // ModelLayers can have their actor swapped without being rebuilt — the + // mesh is imposter-free so the geometry can be re-bound live. + if (actorSpec && layer.setActor) layer.setActor(actorSpec); this.layerCache.set(key, layer); } return layer; diff --git a/flow-state/src/look/LookGenerator.js b/flow-state/src/look/LookGenerator.js index f008ef1..ffc288d 100644 --- a/flow-state/src/look/LookGenerator.js +++ b/flow-state/src/look/LookGenerator.js @@ -25,6 +25,7 @@ import { derivePaletteArc, describePaletteArc } from './paletteArc.js'; import { deriveFramingStyle, describeFraming } from './framing.js'; import { deriveCamera, describeCamera } from './Camera.js'; import { deriveStory, storyForSection, NEUTRAL_STATE } from './Story.js'; +import { generateActorSet, describeActorSet } from '../actors/ActorGenerator.js'; // Which families suit which section kind now comes from the track's DIRECTOR // (look/directors.js) rather than from a constant here. The coupling it @@ -816,6 +817,10 @@ export function generateLook(track, { // toward one camera the way it leans toward one family per kind, and the // seed decides — see look/Camera.js. const camera = deriveCamera(director, summary, rng.fork('camera')); + // The cast with bodies — one ActorSpec per archetype, seeded so a later + // library of actors (Stage C) grows without changing the infra. See + // src/actors/ActorGenerator.js — audio tilts the centre, seed picks within. + const actors = generateActorSet(summary, rng.fork('actors'), personality, personality.identity); const { post, feedback } = derivePost(summary, rng.fork('post'), grain); // Scenes eligible to be composited OVER a background. Same casting rule as @@ -887,6 +892,7 @@ export function generateLook(track, { paletteArc, framing, camera, + actors, grain, post, feedback, diff --git a/flow-state/src/params/schema.js b/flow-state/src/params/schema.js index 08fa6e1..c178478 100644 --- a/flow-state/src/params/schema.js +++ b/flow-state/src/params/schema.js @@ -66,6 +66,7 @@ export const REACTIVE_RESPONSES = ['linear', 'spike', 'smooth', 'inverse']; * only stamps the flat profile declares `cast` and not this. */ export const ARTIFACT_NAMES = ['cast', 'ink', 'staging', 'form']; +export const ACTOR_ARCHETYPES = ['monolith', 'swarm', 'walker', 'vehicle', 'structure']; /** * Whether a scene's image depends on the FRAME BEFORE IT. @@ -303,10 +304,24 @@ export function validateModule(module) { errors.push(`${id}: \`texture\` must be a number 0..2 — how much of the track's ` + `surface grain this scene takes (1 = all, 0 = none)`); } + const VALID_KINDS = ['fragment', 'layer3d', 'model']; + if (!VALID_KINDS.includes(module.kind)) { + errors.push(`${id}: unknown kind '${module.kind}' — expected ${VALID_KINDS.join('/')}`); + } if (module.kind === 'fragment' && !module.shader) errors.push(`${id}: kind 'fragment' but no \`shader\``); if (module.kind === 'fragment' && module.shader && !/vec4\s+scene\s*\(/.test(module.shader)) { errors.push(`${id}: shader must define \`vec4 scene(vec2 uv, vec2 p)\``); } + if (module.kind === 'model') { + if (typeof module.build !== 'function') errors.push(`${id}: kind 'model' needs a \`build()\` function`); + if (typeof module.update !== 'function') errors.push(`${id}: kind 'model' needs an \`update()\` function`); + if (module.actor !== undefined && !ACTOR_ARCHETYPES.includes(module.actor)) { + errors.push(`${id}: unknown actor archetype '${module.actor}' — expected ${ACTOR_ARCHETYPES.join('/')}`); + } + if (module.readsDepth && typeof module.readsDepth !== 'boolean') { + errors.push(`${id}: \`readsDepth\` must be boolean`); + } + } const params = module.params || {}; const uniformNames = new Set(); diff --git a/flow-state/tools/lint-scenes.js b/flow-state/tools/lint-scenes.js index 84ccca8..8cf4fcf 100644 --- a/flow-state/tools/lint-scenes.js +++ b/flow-state/tools/lint-scenes.js @@ -39,7 +39,7 @@ const FORBIDDEN = [ ]; // Directories whose output must be a pure function of (seed, params, frame). -const DETERMINISTIC_DIRS = ['engine', 'scenes', 'look', 'audio', 'params']; +const DETERMINISTIC_DIRS = ['engine', 'scenes', 'look', 'audio', 'params', 'actors']; // Files legitimately allowed a wall clock: perf measurement, not image content. const ALLOWED = new Set(['engine/perf.js']);