diff --git a/flow-state/src/Show.js b/flow-state/src/Show.js index df17211..6013365 100644 --- a/flow-state/src/Show.js +++ b/flow-state/src/Show.js @@ -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); diff --git a/flow-state/src/actors/meshes.js b/flow-state/src/actors/meshes.js index bb0176a..f78bfba 100644 --- a/flow-state/src/actors/meshes.js +++ b/flow-state/src/actors/meshes.js @@ -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); }; diff --git a/flow-state/src/look/ArcDriver.js b/flow-state/src/look/ArcDriver.js index 145c528..2a154fa 100644 --- a/flow-state/src/look/ArcDriver.js +++ b/flow-state/src/look/ArcDriver.js @@ -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;