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:
Dejvino 2026-08-20 18:22:52 +02:00
parent f60a29ea97
commit fcbde602de
2 changed files with 198 additions and 0 deletions

View File

@ -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 = [];

View 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;