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 { drosteFeedback } from './shader/droste-feedback.js';
|
||||||
import { analogWow } from './shader/analog-wow.js';
|
import { analogWow } from './shader/analog-wow.js';
|
||||||
import { mountainFlight } from './shader/mountain-flight.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
|
* The scene library. Families exist so the arc driver can choose by section
|
||||||
@ -167,6 +168,7 @@ const MODULES = [
|
|||||||
swarm,
|
swarm,
|
||||||
effigy,
|
effigy,
|
||||||
mountainFlight,
|
mountainFlight,
|
||||||
|
assembly,
|
||||||
];
|
];
|
||||||
|
|
||||||
const errors = [];
|
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