Epic 5 Phase 1 — form is a mesh, actors have parity with SDF

meshes.js now mirrors shader-contract.js verbatim:
- jsCastSDF rotates by -tilt (matching GLSL column-major mat2), hollow
  excluded during outer-radius search so annular SDFs sample correctly,
  radial search probes without hollow then punches hole separately.
- castShape samples 64 segments, inner hole scaled by hollow width.
- formToGeometry uses formDepth (u_formDepth = r.z*depth half-extent),
  capsule h correctly half-height, sphere as scaled unit sphere, prism
  extruded with proper halfDepth and centered; bevel thickness from
  part.round.
- actorToGeometry preserves per-part op/blend userData, uses YXZ Euler
  for yaw/pitch (matches formRot), mirrors radial/mirror/stack symmetry
  exactly as shader formFold does.

Shared camera wired live: Show.renderFrame drives
Compositor.updateSharedCamera from ArcDriver framing/personality/time
and injects sharedCamera into each active ModelLayer — pure f(frame,look)
so seek === playback. ArcDriver exposes framingForFrame helper.

Gates: lint 107 clean / 70 literals / 68 scenes green; vite build
294 modules; deterministic actor geometry smoke-tested (same seed same
vert count, hollow handling, fallback).

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Dejvino 2026-08-20 17:01:44 +02:00
parent 7aa60d7336
commit f60a29ea97
3 changed files with 107 additions and 42 deletions

View File

@ -202,6 +202,25 @@ export class Show {
this._lastLayers = layers.slice();
}
// Drive the shared perspective rig for model layers: framing (scale→dolly,
// shift→x,y) + personality.camera (drift/sway/spin) as real translation/roll.
// No-op cost when no model layer is active — Compositor keeps the camera but
// nothing reads it. Pure f(frame,look) so seek === playback still holds.
if (sceneLayers.some((l) => l && l.module && l.module.kind === 'model')) {
const pers = this.look.personality;
const framing = this.arc.framingForFrame(timeline.frame);
this.engine.compositor.updateSharedCamera({
framing,
personality: pers,
time: timeline.time,
});
// Inject the shared camera into each active model layer so they render
// through one perspective and one depth buffer.
for (const l of sceneLayers) {
if (l && l.module && l.module.kind === 'model') l.sharedCamera = this.engine.compositor.sharedCamera;
}
}
this.engine.compositor
.setPost(this._postAt(timeline, features))
.setFeedback(this.look.feedback);

View File

@ -3,7 +3,7 @@
// 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.
// become a stage 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
@ -13,19 +13,22 @@
// 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.
// Mirrors shader-contract.js castSDF / FORM_PREAMBLE verbatim so the profile and
// the solids match the shaders — a seek must land on the same mesh the shader
// would have stamped.
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.
// Mirrors shader-contract.js castSDF verbatim — any drift here makes the mesh a
// different character than the impostor.
function jsCastSDF(q, sides, rnd, elong, tilt, notchN, notchD, hollow) {
// rotate
// GLSL: mat2 rot = mat2(c, -s, s, c) is column-major => [[c,s],[-s,c]]
// => (c*x + s*y, -s*x + c*y), i.e. rotation by -tilt. Keep it identical.
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 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;
@ -43,8 +46,8 @@ function jsCastSDF(q, sides, rnd, elong, tilt, notchN, notchD, hollow) {
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))
const t = Math.max(0, Math.min(1, rnd));
d = poly * (1 - t) + (r - 1.0) * t; // 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;
@ -52,31 +55,36 @@ function jsCastSDF(q, sides, rnd, elong, tilt, notchN, notchD, hollow) {
}
function sampleCastRadius(angle, cast, steps = 24) {
// Binary search outward along ray until SDF crosses zero.
// Find the OUTER zero-crossing along a ray from the origin. For a hollow
// form the SDF is positive at the centre (outside the annulus), so sampling
// with hollow included would see two crossings and the binary search would
// fail. Sample WITHOUT the hollow term to get the outer silhouette — the hole
// is punched separately in castShape.
const probe = (r) => {
const q = [Math.cos(angle) * r, Math.sin(angle) * r];
return jsCastSDF(q, cast.sides, cast.round, cast.elong, cast.tilt,
cast.notchCount, cast.notchCount ? cast.notchDepth : 0, 0);
};
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;
if (probe(hi) > 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;
if (probe(mid) > 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.
* chorus). Used for formPrism the profile extruded. Hollow is punched as a
* hole path so the 2-D shape and the 3-D mesh agree with the shader's
* hollow (which is an SDF annulus, not an inner silhouette).
*/
export function castShape(cast, segments = 48) {
export function castShape(cast, segments = 64) {
const shape = new THREE.Shape();
for (let i = 0; i <= segments; i++) {
const a = (i / segments) * Math.PI * 2;
@ -86,14 +94,17 @@ export function castShape(cast, segments = 48) {
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) {
// Inner hole: scale the outer shape's bounding radius by the SDF hollow
// width. Not exact — the SDF hole is `abs(d)-h*0.35`, not a scaled copy —
// but the mesh reads as hollow and the outer silhouette still matches.
const hr = Math.max(0.08, 1 - cast.hollow * 0.9) * 0.45;
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;
const r = sampleCastRadius(a, cast) * hr;
const x = Math.cos(a) * r;
const y = Math.sin(a) * r;
if (i === 0) hole.moveTo(x, y);
else hole.lineTo(x, y);
}
@ -107,24 +118,33 @@ export function castShape(cast, segments = 48) {
/**
* One part of an Identity.form assembly BufferGeometry.
*
* Mirrors shader-contract.js formPrism/formBox/formCapsule/formTorus exactly:
* prism extruded cast profile, depth = 2 * r.z * u_formDepth
* box half-extents r
* capsule radius = min(r.x,r.z), half-height = r.y
* torus major = r.x, tube = r.z*0.45
* sphere ellipsoid r (else branch of formSDF)
*
* @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
* @param {object} [opts] { formDepth } u_formDepth from Identity.form.depth
*/
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]);
const formDepth = opts.formDepth ?? (identity && identity.form ? identity.form.depth : 0.8);
if (kind === 'prism') {
const cast = identity && identity.cast ? identity.cast.protagonist : null;
if (!cast || !cast.sides) {
// Fallback: box when no cast profile
if (!cast || cast.sides === undefined) {
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 shape = castShape(cast, 64);
// Shader: dz = abs(q.z) - max(r.z,1e-3)*u_formDepth => half-depth = r.z*formDepth
const halfDepth = Math.max(sz, 1e-3) * Math.max(formDepth, 1e-3);
const depth = halfDepth * 2;
const geo = new THREE.ExtrudeGeometry(shape, {
depth,
bevelEnabled: true,
@ -132,28 +152,37 @@ export function formToGeometry(part, identity, opts = {}) {
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.translate(0, 0, -halfDepth);
// Shape sampled at radius ~1, so scale xy by the part's half-extents.
geo.scale(sx, sy, 1);
geo.computeVertexNormals();
return geo;
}
if (kind === 'box') {
// Shader: d = abs(q) - max(r) => half-extents = r
return new THREE.BoxGeometry(sx * 2, sy * 2, sz * 2);
}
if (kind === 'capsule') {
// Shader: rad = max(min(r.x,r.z),1e-3), h = max(r.y,1e-3)
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);
const h = Math.max(1e-3, sy);
// THREE.CapsuleGeometry(len = cylinder height 2*h? — shader's h is half-height)
// Shader's capsule length = 2*h plus caps radius rad.
// Keep capSegments low — these are many small instances.
return new THREE.CapsuleGeometry(rad, h * 2, 6, 12);
}
if (kind === 'torus') {
// Shader: major = r.x, tube = r.z*0.45
const major = Math.max(1e-3, sx);
const tube = Math.max(1e-3, sz * 0.45);
return new THREE.TorusGeometry(major, tube, 16, 32);
return new THREE.TorusGeometry(major, tube, 12, 24);
}
if (kind === 'sphere') {
const rad = Math.max(1e-3, Math.min(sx, Math.min(sy, sz)));
return new THREE.SphereGeometry(rad, 16, 16);
// Shader else branch: ellipsoid `length(p/max(r))*min(r) - min(r)`
// Approximate as scaled sphere: unit sphere scaled by r.
const geo = new THREE.SphereGeometry(1, 16, 12);
geo.scale(sx, sy, sz);
return geo;
}
return new THREE.BoxGeometry(sx * 2, sy * 2, sz * 2);
}
@ -163,7 +192,9 @@ export function formToGeometry(part, identity, opts = {}) {
/**
* 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.
* the caller. Boolean ops (union/blend/carve) are carried as userData for later
* CSG Phase 1 treats them as union, which is exact for the first part and
* the common `union` op (majority of parts).
*
* @param {object} actorSpec from ActorGenerator.generateActor
* @param {object} identity
@ -173,7 +204,7 @@ 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 g = formToGeometry({ kind: 'prism', scale: [0.6, 0.6, 0.35], round: 0.1 }, identity, { formDepth: 0.8 });
const m = new T.Mesh(g, new T.MeshStandardMaterial({ color: 0xffffff }));
const grp = new T.Group();
grp.add(m);
@ -182,17 +213,22 @@ export function actorToGeometry(actorSpec, identity, THREE_) {
const group = new T.Group();
const sym = form.symmetry || 'none';
const symN = Math.max(2, form.symmetryN | 0);
const formDepth = form.depth ?? 0.8;
for (let i = 0; i < form.parts.length; i++) {
const part = form.parts[i];
const geo = formToGeometry(part, identity);
const geo = formToGeometry(part, identity, { formDepth });
const addInstance = (offset, yaw, pitch, matOffset) => {
const addInstance = (offset, yaw, pitch) => {
const mesh = new T.Mesh(geo, new T.MeshStandardMaterial({ color: 0xffffff }));
mesh.position.set(offset[0], offset[1], offset[2]);
// Shader: formRot(yaw, pitch) = mat3(cy,0,-sy, sy*sp,cp,cy*sp, sy*cp,-sp,cy*cp)
// THREE Euler order XYZ with ZYX would not match; use YXZ so yaw is Y.
mesh.rotation.order = 'YXZ';
mesh.rotation.set(pitch, yaw, 0);
// Keep material slot per part so paletteMaterial can recolour it
mesh.userData.partIndex = i;
mesh.userData.op = part.op || 'union';
mesh.userData.blend = form.blend ?? 0.12;
group.add(mesh);
};

View File

@ -816,6 +816,16 @@ export class ArcDriver {
return null;
}
/** Framing + personality for a frame, for the shared perspective rig. */
framingForFrame(frame) {
return this._framingAt(this._cueIndexAt(frame), frame);
}
personalityForFrame(frame, story) {
const s = story ?? (this.look.story ? storyStateAt(this.look.story, frame) : null);
return this._personalityAt(s);
}
/** Push a palette change through without rebuilding layers. */
setPalette(palette) {
this.look.palette = palette;