Epic 5 Phase 0 — actors have bodies; the stage has depth
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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<string, object>} 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;
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user