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>
217 lines
8.8 KiB
JavaScript
217 lines
8.8 KiB
JavaScript
// 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;
|
|
}
|