Epic 5 Phase 2.1 — Assembly stage (model)
First kind:'model' stage: Assembly. The song's solid as a mesh. Hero is the ActorSpec(monolith) assembly via actorToGeometry, built from Identity.form with paletteMap recolour — same character as the shader impostor, now with real occlusion/parallax/foreshortening. Analytic motion only (spin/bob/orbit as f(t,seed,params)), ground plane + standard-material lighting that matches castLit key/fill, fog for depth haze, shared camera rig (framing→dolly, personality.camera→ drift/sway/spin). Palette arc visible live via per-frame material recolour; opacity crossfade via material.opacity. Registered in structural family so castingPool / director blends see it like any structural shot. Gates: lint 108 clean / 70 literals / 69 scenes green; vite 294 modules built. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
f60a29ea97
commit
fcbde602de
@ -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 = [];
|
||||
|
||||
196
flow-state/src/scenes/stage/assembly.js
Normal file
196
flow-state/src/scenes/stage/assembly.js
Normal file
@ -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;
|
||||
Loading…
Reference in New Issue
Block a user