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:
Dejvino
2026-08-20 16:26:15 +02:00
co-authored by Claude
parent ef1d24e692
commit 7aa60d7336
11 changed files with 981 additions and 3 deletions
+83
View File
@@ -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 });
}