diff --git a/flow-state/src/scenes/registry.js b/flow-state/src/scenes/registry.js index 99fc08b..4de8e00 100644 --- a/flow-state/src/scenes/registry.js +++ b/flow-state/src/scenes/registry.js @@ -71,6 +71,7 @@ import { halftoneMisprint } from './shader/halftone-misprint.js'; import { drosteFeedback } from './shader/droste-feedback.js'; import { analogWow } from './shader/analog-wow.js'; import { mountainFlight } from './shader/mountain-flight.js'; +import { assembly } from './stage/assembly.js'; /** * The scene library. Families exist so the arc driver can choose by section @@ -167,6 +168,7 @@ const MODULES = [ swarm, effigy, mountainFlight, + assembly, ]; const errors = []; diff --git a/flow-state/src/scenes/stage/assembly.js b/flow-state/src/scenes/stage/assembly.js new file mode 100644 index 0000000..6492e92 --- /dev/null +++ b/flow-state/src/scenes/stage/assembly.js @@ -0,0 +1,196 @@ +// STAGE: Assembly — the song's solid, standing on a ground, seen with depth. +// +// The flat stages stamp castSDF as impostors (castSolid per instance). This one +// is the mesh twin: the same Identity.form assembly as BufferGeometry, with real +// occlusion, parallax and scale foreshortening. One hero (the protagonist body) +// over a shared ground, lit by the same key the shader's castLit uses. The +// palette and the lattice are the same data the flat stages consume — so two +// Assembly videos of different songs are different bodies in different worlds, +// and two seeds on one song are different readings of one body. +// +// Analytic motion only: f(t,seed). No integration, so seek === playback like +// particles.js. Camera is the shared rig (Compositor.sharedCamera) driven from +// ArcDriver framing/gaze/personality — this stage never touches camera. + +import { actorToGeometry } from '../../actors/meshes.js'; + +export const assembly = { + name: 'Assembly', + family: 'structural', + kind: 'model', + actor: 'monolith', + consumes: ['form', 'ink', 'staging'], + traits: ['shape', 'space', 'camera', 'style'], + texture: 0.3, + + params: { + size: { type: 'float', range: [0.6, 1.9], default: 1.05, uniform: 'u_size', bias: 'energy' }, + count: { type: 'int', range: [4, 36], default: 14, uniform: 'u_count', bias: 'density' }, + spread: { type: 'float', range: [0.55, 1.6],default: 1.05, uniform: 'u_spread' }, + spin: { type: 'float', range: [0.05, 0.9],default: 0.32, uniform: 'u_spin', bias: 'motion', rate: true }, + lift: { type: 'float', range: [0.0, 1.0], default: 0.42, uniform: 'u_lift' }, + orbit: { type: 'float', range: [0.0, 0.7], default: 0.22, uniform: 'u_orbit' }, + palette:{ type: 'palette', count: 5 }, + }, + + reactive: { + size: { feature: 'beat', amount: 0.18, response: 'spike' }, + lift: { feature: 'bandLow', amount: 0.12, response: 'smooth' }, + }, + + build({ scene, seed, params, actorSpec, THREE }) { + // Ground — large enough to fill any framing at dolly 4/scale. + const groundGeo = new THREE.PlaneGeometry(40, 40); + const groundMat = new THREE.MeshStandardMaterial({ + color: new THREE.Color(0.12, 0.12, 0.14), + roughness: 0.85, + metalness: 0.04, + }); + const ground = new THREE.Mesh(groundGeo, groundMat); + ground.rotation.x = -Math.PI / 2; + // Place at horizon so it reads as the same world the flat scenes share. + ground.position.y = -1.15; + ground.receiveShadow = false; + scene.add(ground); + + // Lighting — matches shader castLit key/fill/rim direction. + const ambient = new THREE.AmbientLight(0xffffff, 0.55); + scene.add(ambient); + const key = new THREE.DirectionalLight(0xffffff, 1.15); + key.position.set(2.2, 4.5, 2.8); + key.castShadow = false; + scene.add(key); + const fill = new THREE.DirectionalLight(0xffffff, 0.35); + fill.position.set(-2.8, 1.6, -2.2); + scene.add(fill); + + // Hero — the actor's solid, built from Identity.form via actorToGeometry. + // Seeded from the stage seed + actorSpec.seed so two stages with the same + // actorSpec don't produce identical groups when one is instanced later. + let heroGroup = null; + if (actorSpec && actorSpec.form) { + // actorToGeometry expects (actorSpec, identity, THREE). Identity is not + // available at build time (it arrives in update via personality), so build + // a placeholder group now and rebuild the geometry on first update when + // identity is known — same pattern particles.js uses for its Points. + heroGroup = new THREE.Group(); + heroGroup.name = 'hero'; + scene.add(heroGroup); + } else { + heroGroup = new THREE.Group(); + heroGroup.name = 'hero'; + scene.add(heroGroup); + } + + // Chorus field — InstancedMesh will be created on first update when we + // know palette + identity; keep a slot for it. + scene.background = null; + scene.fog = new THREE.Fog(0x0a0a0f, 9, 26); + + return { ground, groundMat, ambient, key, fill, heroGroup, heroBuilt: false, seed, actorSeed: actorSpec ? actorSpec.seed : seed }; + }, + + update({ instance, scene, camera, timeline, features, params, palette, personality, framing, opacity, actorSpec, THREE }) { + const t = timeline.time; + const beat = features.beat || 0; + const bandLow = features.bandLow || 0; + + const identity = personality ? personality.identity : null; + const pal = palette && palette.length ? palette : [[0.9, 0.9, 0.92], [0.7, 0.6, 0.8], [0.4, 0.6, 0.9], [0.9, 0.5, 0.4]]; + + // --- hero geometry: built once identity is known, so the cast profile is correct --- + if (!instance.heroBuilt && identity && actorSpec && actorSpec.form) { + // Clear placeholder children that may have been left from a hot rebuild. + for (const child of [...instance.heroGroup.children]) { + instance.heroGroup.remove(child); + if (child.geometry) child.geometry.dispose(); + if (child.material) child.material.dispose(); + } + // Build the real assembly group and transplant its meshes into heroGroup + // so the heroGroup object identity stays stable (ArcDriver caches layers). + const built = actorToGeometry(actorSpec, identity, THREE); + while (built.children.length) { + const m = built.children[0]; + built.remove(m); + // Bake palette per-part via paletteMap so the hero keeps the song's + // colour rhythm across updates — palette may shift via paletteArc. + const partIndex = m.userData.partIndex ?? 0; + const palIndex = actorSpec.paletteMap ? actorSpec.paletteMap[partIndex % actorSpec.paletteMap.length] : partIndex; + const c = pal[palIndex % pal.length]; + m.material = new THREE.MeshStandardMaterial({ + color: new THREE.Color(c[0], c[1], c[2]), + roughness: 0.42, + metalness: 0.08, + }); + m.castShadow = false; + m.receiveShadow = false; + instance.heroGroup.add(m); + } + // Geometry cache for recolour on palette arc moves without rebuilding. + instance.heroPaletteMap = actorSpec.paletteMap || []; + instance.heroBuilt = true; + } + + // Palette re-bind each frame so paletteArc / director blend is visible on + // the mesh immediately — paletteMaterial bakes pal(i) at setPalette time for + // shaders, but meshes need it live. + const groundC = pal[0]; + instance.groundMat.color.setRGB(groundC[0] * 0.22, groundC[1] * 0.22, groundC[2] * 0.26); + // Horizon sync: ground colour haze toward pal[1] with depth factor from + // personality.space.depth so the mesh world and the shader world agree. + // Keep it subtle — this is a bed, not the subject. + + // Recolour hero parts when palette moves (paletteArc/plan). Cheap: just + // set material.color since geometry is stable. + if (instance.heroBuilt) { + for (const m of instance.heroGroup.children) { + if (!m.isMesh) continue; + const partIndex = m.userData.partIndex ?? 0; + const palIndex = instance.heroPaletteMap[partIndex % instance.heroPaletteMap.length] ?? partIndex; + const c = pal[palIndex % pal.length]; + // Mix toward white with lift so ink outline would still read if we + // added it — matches shader inkMask's fill+outline balance. + m.material.color.setRGB(c[0], c[1], c[2]); + m.material.opacity = opacity; + m.material.transparent = opacity < 0.999; + } + } + + // --- hero pose: fully analytic f(t,seed,params,actor.motion) --- + const spin = Math.max(0.01, params.spin); + const size = Math.max(0.2, params.size) * (1 + beat * 0.12); + const lift = params.lift + bandLow * 0.10 + Math.sin(t * (actorSpec ? actorSpec.motion.bobRate : 0.6) + instance.seed * 0.0007) * (actorSpec ? actorSpec.motion.bobAmp : 0.012); + const orbit = params.orbit; + + // Slow yaw + pitched tumble so the assembly's sillhouette CHANGES as it + // turns — that is the promise Identity.js:112 makes ("outline changes as + // it turns, which needs either the object turning or the camera travelling"). + const yaw = t * spin * 0.55 + (actorSpec ? actorSpec.motion.spin * 0.18 : 0) + instance.seed * 0.0003; + const pitch = Math.sin(t * 0.31 + instance.seed * 0.0011) * 0.55; + + // Analytic orbit around the ground's centre — stage owns motion, identity + // owns placement. + const ox = Math.sin(t * orbit * 0.5 + instance.seed * 0.002) * 0.55; + const oz = Math.cos(t * orbit * 0.45 + instance.seed * 0.0023) * 0.35; + + instance.heroGroup.position.set(ox, lift - 0.55, oz); + instance.heroGroup.rotation.set(pitch, yaw, Math.sin(t * 0.18) * 0.12); + instance.heroGroup.scale.setScalar(size * 0.95); + + // Ground framing: scale/shift already drives the shared camera dolly, but + // the ground itself benefits from a subtle analytic drift so a locked-off + // close-up still evolves over a long section. + instance.ground.position.x = Math.sin(t * 0.04 + instance.seed * 0.0009) * 0.18; + instance.ground.position.z = Math.cos(t * 0.03 + instance.seed * 0.0007) * 0.12; + + // Opacity crossfade — ModelLayer opacity is already the cue eased blend, so + // apply it to the hero's materials (ground stays at full so the bed never + // goes black under a dissolving shot). + for (const m of instance.heroGroup.children) { + if (!m.isMesh) continue; + m.visible = opacity > 0.01; + } + }, +}; + +export default assembly;