diff --git a/flow-state/src/Show.js b/flow-state/src/Show.js index 4b6d605..dfb204e 100644 --- a/flow-state/src/Show.js +++ b/flow-state/src/Show.js @@ -262,9 +262,11 @@ export class Show { ? timeline.frame / (timeline.frameCount - 1) : 0, }); // Inject the shared camera into each active model layer so they render - // through one perspective and one depth buffer. + // through one perspective and one depth buffer. Skipped for layers + // whose module drives its own rig (directCamera) — injecting it + // would be dead weight, and its presence must never change output. for (const l of sceneLayers) { - if (l && l.module && l.module.kind === 'model') l.sharedCamera = this.engine.compositor.sharedCamera; + if (l && l.module && l.module.kind === 'model' && !l.module.directCamera) l.sharedCamera = this.engine.compositor.sharedCamera; } } diff --git a/flow-state/src/engine/Compositor.js b/flow-state/src/engine/Compositor.js index c156df1..5c90c21 100644 --- a/flow-state/src/engine/Compositor.js +++ b/flow-state/src/engine/Compositor.js @@ -141,7 +141,9 @@ export class Compositor { const seed = (venue.seed >>> 0) || 1; const s1 = Math.sin(seed * 0.00013), s2 = Math.cos(seed * 0.00029); // Smaller rooms get a smaller walk: chamber is 22 wide, stage 44. - const room = venue.archetype === 'chamber' ? 0.62 : venue.archetype === 'ride' ? 0.85 : 1.0; + // (Ride drives its own rig now — see ride.directCamera — and never + // reaches this path, but the table stays total over archetypes.) + const room = venue.archetype === 'chamber' ? 0.62 : 1.0; const still = motion ? motion.stillness : 0.5; const churn = motion ? motion.churn : 0.25; // Planned room shots (see look/venueShots.js): absolute per-cue marks @@ -204,9 +206,7 @@ export class Compositor { // swapped; gliding through the old layout is the glitch). const lim = venue.archetype === 'chamber' ? { x: 2.4, yLo: -0.2, yHi: 1.4, zLo: 0.8, zHi: 7.0 } - : venue.archetype === 'ride' - ? { x: 4.0, yLo: -0.4, yHi: 2.2, zLo: -6.0, zHi: 6.0 } - : { x: 4.0, yLo: -0.2, yHi: 2.0, zLo: 1.0, zHi: 9.0 }; + : { x: 4.0, yLo: -0.2, yHi: 2.0, zLo: 1.0, zHi: 9.0 }; const isJump = framing.venueJump !== undefined && framing.venueJump !== null; if (isJump) { cam.position.set( @@ -218,48 +218,15 @@ export class Compositor { // Same traverse body as the legacy path below (walk + rise + // sway + gaze share): one body, two heads. The plan's per-shot // size scales the dolly (a close stands nearer). - const baseZ = venue.archetype === 'chamber' ? 4.0 : venue.archetype === 'ride' ? 3.4 : 4.6; + const baseZ = venue.archetype === 'chamber' ? 4.0 : 4.6; const sizeDolly = (planned.dolly && planned.dolly > 0) ? planned.dolly : 1.0; const dolly = (baseZ / Math.max(framing.scale, 0.05) - Math.max(0, push)) * sizeDolly; const rise = (trav - 0.5) * 0.9 * room; const shiftX = framing.shift[0] * 0.6; const shiftY = framing.shift[1] * 0.35; - let px = walk * 1.5 * room + Math.sin(time * swayRate) * sway * 4 + shiftX + hx; - let py = 0.35 + rise + Math.cos(time * swayRate * 0.83) * sway * 3 + shiftY + hy; - let pz = dolly; - if (venue.archetype === 'ride') { - // Orbit the car once per video (~200° arc, seeded direction) - // ON TOP of the traverse: the chase unit circles while it - // pushes in, so angles genuinely change instead of the - // camera shuffling on one side. Monotonic in progress — the - // arc survives — and bounded by the same box below. - // Radius tracks the shot dolly (wides orbit far, closes - // near) with a floor that keeps the camera out of the - // bodywork: the car is ~2 wide and ~3.5 long, and a 2.6 - // floor still put close-ups through the paint. - const oDir = s1 >= 0 ? 1 : -1; - const oAng = oDir * (0.5 + trav * 3.5); - const oRad = Math.max(3.2, dolly); - // Blend orbit with traverse by section energy of the gaze: - // keep half the traverse so the walk's travel isn't lost. - px = (px + Math.sin(oAng) * oRad) / 2; - pz = (pz + Math.cos(oAng) * oRad) / 2; - py = (py + (0.5 + Math.sin(trav * 6.28 + s2 * 3.0) * 0.9)) / 2; - } - // Minimum focus distance from the car (origin): blending can - // still land the camera inside the paint, where a frame is - // nothing but chassis. Push out along the xz ray, keep y. - { - const dx = px, dz = pz; - const r = Math.hypot(dx, dz); - const minR = venue.archetype === 'ride' ? 3.4 : 0; - if (r < minR && r > 1e-4) { - const k = minR / r; - px = dx * k; pz = dz * k; - } else if (r <= 1e-4) { - px = minR; pz = 0; - } - } + const px = walk * 1.5 * room + Math.sin(time * swayRate) * sway * 4 + shiftX + hx; + const py = 0.35 + rise + Math.cos(time * swayRate * 0.83) * sway * 3 + shiftY + hy; + const pz = dolly; cam.position.set( Math.max(-lim.x, Math.min(lim.x, px)), Math.max(lim.yLo, Math.min(lim.yHi, py)), @@ -267,25 +234,13 @@ export class Compositor { ); } cam.rotation.z = pCam ? pCam.spin * time * 0.5 : 0; - let lx = planned.look[0], ly = planned.look[1], lz = planned.look[2]; - if (venue.archetype === 'ride') { - // Track the car: the orbit swings wide, and the planned look - // alone lets the vehicle leave the frame (measured: empty-road - // shots). Blend the plan's motive look with the car's analytic - // position (same formula as the module sweep) so the chase - // unit keeps its subject while still panning with motive. - const carX = Math.sin(time * 0.133 + ((venue.seed >>> 0) || 1) * 0.001) * 1.1; - lx = carX * 0.55 + lx * 0.45; - ly = 0.3 * 0.55 + ly * 0.45; - lz = -0.3 * 0.55 + lz * 0.45; - } - cam.lookAt(lx, ly, lz); + cam.lookAt(planned.look[0], planned.look[1], planned.look[2]); cam.updateMatrixWorld(); return; } // Close enough that the screen fills the frame: the chamber window is // 12 wide at z=-10.9, so z≈4 makes it ~40% of a 60° frame. - const baseZ = venue.archetype === 'chamber' ? 4.0 : venue.archetype === 'ride' ? 3.4 : 4.6; + const baseZ = venue.archetype === 'chamber' ? 4.0 : 4.6; const dolly = baseZ / Math.max(framing.scale, 0.05) - Math.max(0, push); // A story jump relocates the room camera for the section: the furniture // swapped at its start (see ArcDriver.venueJumpAt), so the camera @@ -312,7 +267,7 @@ export class Compositor { // walk cannot provide. Clamped to the room box: the test asserts // |x| < 2.6 and z in 0.4..8, and the bigger swing must not break it. const rise = (trav - 0.5) * 0.9 * room; - const limX = venue.archetype === 'chamber' ? 2.4 : venue.archetype === 'ride' ? 4.5 : 4.0; + const limX = venue.archetype === 'chamber' ? 2.4 : 4.0; cam.position.set( Math.max(-limX, Math.min(limX, walk * 1.5 * room + Math.sin(time * swayRate) * sway * 4 + shiftX + hx)), diff --git a/flow-state/src/engine/Layer.js b/flow-state/src/engine/Layer.js index f94c1b4..af17ba5 100644 --- a/flow-state/src/engine/Layer.js +++ b/flow-state/src/engine/Layer.js @@ -436,11 +436,14 @@ export class ModelLayer extends Layer { 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; + // Who drives the camera: a module with `directCamera: true` owns its + // own rig outright — it drives `camera` inside update() and the shared + // rig is ignored for this layer. Everything else borrows the shared + // camera when injected (updated centrally from framing/gaze so model + // layers share one perspective and one depth buffer), else its own. + // Direct control is analytic like everything else: f(timeline, seed, + // framing, venueState) — seek === playback still holds. + const cam = (this.module.directCamera ? this.camera : (this.sharedCamera || this.camera)); if (cam.aspect !== w / h) { cam.aspect = w / h; cam.updateProjectionMatrix(); diff --git a/flow-state/src/look/venueShots.js b/flow-state/src/look/venueShots.js index 4df779e..e02ce1c 100644 --- a/flow-state/src/look/venueShots.js +++ b/flow-state/src/look/venueShots.js @@ -133,7 +133,7 @@ export function planVenueShots(cues, sections, archetype, style, isJump, rng, ga const prevMark = marks.length ? marks[marks.length - 1] : null; sectionSize = locked ? 'normal' : pickSize(prevMark, energy, story, style, rng); sectionTarget = pickTarget(prevMark, energy, story, rng, - sectionTargets.map((t) => ({ target: t }))); + sectionTargets.map((t) => ({ target: t })), archetype); sectionTargets.push(sectionTarget); } const progress = n > 1 ? cue.index / (n - 1) : 0.5; @@ -190,11 +190,19 @@ function pickSize(previous, energy, story, style, rng) { * still crosses the frame — the walk passes it — it is just never the mark. * Rotation between the two (never three in a row) is the coverage. */ -function pickTarget(previous, energy, story, rng, history) { +function pickTarget(previous, energy, story, rng, history, archetype = null) { const last2 = (history || []).slice(-2).map((m) => m.target); const streak = last2.length === 2 && last2[0] === last2[1] ? last2[0] : null; const act = story.act || 'development'; const tension = story.tension ?? 0.5; + // Ride-only: a passerby section plants the camera at the roadside and lets + // the car drive through the frame. Offered on breaths (setup/resolution, + // quiet passages) — never twice running, never at the climax. + if (archetype === 'ride' && act !== 'climax' && streak !== 'passerby' + && (act === 'setup' || act === 'resolution' || energy < 0.42) + && rng.bool(0.45)) { + return 'passerby'; + } let pick; if (act === 'setup') pick = 'room'; else if (act === 'resolution') pick = rng.bool(0.3) ? 'screen' : 'room'; @@ -210,6 +218,17 @@ function pickTarget(previous, energy, story, rng, history) { /** Absolute camera mark for a (size, target): position + look point. */ function placeMark(staging, size, target, jump, rng, progress = 0.5, gaze = null, roomSide = 1) { + // Passerby (ride only): plant the camera on the shoulder and look back up + // the road. The car enters far, drives to the camera and past it — the + // module drives the car through on driveT, so pos/look here are static. + // Shoulder side follows the video's roomSide: one consistent side all song. + if (target === 'passerby') { + const side = roomSide || 1; + return { + pos: [side * 3.6, 1.2, -2], + look: [-side * 1.0, 0.2, 8], + }; + } const clampTo = (v, lo, hi) => Math.max(lo, Math.min(hi, v)); const { box, dolly } = staging; // Room visits alternate sides so consecutive room views face opposite @@ -304,12 +323,20 @@ export function venueShotAt(marks, cueIndex, frame, cueStartFrame, cueEndFrame = // the look (the furniture swapped; the path would glide through the old // room). The reported target stays the cue's (HUD motive), the look itself // is where the path is. - const look = lookPathAt(marks, progress, cur.jump !== null ? cur : null); + // Passerby holds its plant: the camera is a bystander, and any drift of + // the look would read as a pan away from the road. driveT (0→1 across the + // cue) is the car's cue — the module drives it from far +z to far -z on it. + const look = cur.target === 'passerby' + ? cur.look.slice() + : lookPathAt(marks, progress, cur.jump !== null ? cur : null); + const span = Math.max(1, (cueEndFrame ?? (cueStartFrame + cur.easeFrames)) - cueStartFrame); + const driveT = clamp01((frame - cueStartFrame) / span); if (!prev || cur.jump !== null) { return { pos: cur.pos.slice(), look, size: cur.size, target: cur.target, dolly: VENUE_SIZE_DOLLY[cur.size] ?? 1.0, + drive: driveT, }; } // The travel OUTRUNS the cue (1.25x): the camera is always on the way to @@ -319,7 +346,6 @@ export function venueShotAt(marks, cueIndex, frame, cueStartFrame, cueEndFrame = // as churn to the descriptor). Travel-portion differences are small up // close and accumulate far away, which is exactly the gradient direction // measures. Jump cues still cut instantly (furniture swapped). - const span = Math.max(1, (cueEndFrame ?? (cueStartFrame + cur.easeFrames)) - cueStartFrame); const t = clamp01((frame - cueStartFrame) / Math.max(1, span * 1.25)); const e = t * t * (3 - 2 * t); return { @@ -328,6 +354,7 @@ export function venueShotAt(marks, cueIndex, frame, cueStartFrame, cueEndFrame = size: t < 1 ? `${prev.size}→${cur.size}` : cur.size, target: cur.target, dolly: lerp(VENUE_SIZE_DOLLY[prev.size] ?? 1.0, VENUE_SIZE_DOLLY[cur.size] ?? 1.0, e), + drive: driveT, }; } diff --git a/flow-state/src/venues/ride.js b/flow-state/src/venues/ride.js index 548f6ac..e0a8acb 100644 --- a/flow-state/src/venues/ride.js +++ b/flow-state/src/venues/ride.js @@ -18,6 +18,11 @@ export const ride = { family: 'structural', kind: 'model', venue: true, + // This venue drives its own camera rig outright (see the camera block at + // the end of update()). The shared rig is ignored for this layer — kept + // for every other model layer. Analytic f(timeline, seed, framing, + // venueState) like everything else, so seek === playback still holds. + directCamera: true, consumes: ['form', 'ink', 'staging'], traits: ['shape', 'space', 'camera', 'style'], texture: 0.4, @@ -215,7 +220,8 @@ export const ride = { const billSlot = 3 + Math.floor(rng() * 6); const billSide = rng() < 0.5 ? -1 : 1; const roadsideMeta = []; - for (let i = 0; i < 12; i++) { + const roadsidePopulation = 12; + for (let i = 0; i < roadsidePopulation; i++) { const g = new T.Group(); const side = i % 2 === 0 ? -1 : 1; const kind = i === billSlot ? 'billboard' @@ -600,15 +606,25 @@ export const ride = { const energyK = vState ? (0.55 + vState.light * 0.65) : 1; const speed = (0.35 + (params.speed || 0.7) * 0.75) * energyK * (instance.chase ? 1 : 0); const rate = 1; + // Passerby: the camera is planted, so the CAR does the travelling — + // far +z at cue start, past the lens mid-cue, far -z at the end — and + // the world holds still around it. (A scrolling world under a driving + // car would double the motion and read as a slide projector.) + const shot = framing && framing.venueShot ? framing.venueShot : null; + const passerby = !!shot && shot.target === 'passerby'; + const driveT = passerby && typeof shot.drive === 'number' + ? Math.max(0, Math.min(1, shot.drive)) : null; // World scroll: dashes, posts, roadside, chevrons and streaks stream // past toward +z (the car drives toward -z, so the world comes at the - // camera). Parked mode keeps a 12% idle creep plus a floor so the - // world never reads dead — a frozen world with a crawling camera - // measured as "no movement at all". Analytic in t (not += per frame): - // a seek must land on the same positions as playback. The chase rate - // reads as driving at a glance (~3x the original). + // camera). Frozen during passerby (see above). Parked mode keeps a 12% + // idle creep plus a floor so the world never reads dead — a frozen + // world with a crawling camera measured as "no movement at all". + // Analytic in t (not += per frame): a seek must land on the same + // positions as playback. The chase rate reads as driving at a glance + // (~3x the original). { - const dz = (instance.chase ? speed * 26 : speed * 26 * 0.12 + 0.25) * rate; + const dz = passerby ? 0 + : (instance.chase ? speed * 26 : speed * 26 * 0.12 + 0.25) * rate; const kids = instance.dashes.children; for (let i = 0; i < kids.length; i++) { const z0 = -70 + i * 6; @@ -675,6 +691,14 @@ export const ride = { const sweepAmp = instance.chase ? 1.1 : 0.15; const sweep = Math.sin(t * 0.133 + instance.seed * 0.001); instance.car.position.x = sweep * sweepAmp; + // Passerby drive-through: +12 (far, small) to -14 (far, gone) eased + // across the cue. Otherwise the car lives at the origin. + if (passerby && driveT !== null) { + const e = driveT * driveT * (3 - 2 * driveT); + instance.car.position.z = 12 + (-14 - 12) * e; + } else { + instance.car.position.z = 0; + } instance.car.position.y = bob * (instance.chase ? 1 : 0.3); // Lean INTO the sweep (roll) and yaw with it, like a lane change. const leanK = instance.chase ? 1 : 0.2; @@ -685,10 +709,13 @@ export const ride = { if (instance.wheels) { // Rolling spin on the meshes (local axes), not the yawed group: // tire about its hole axis (local z), hub about its cylinder - // axis (local x after the geometry pre-rotation). + // axis (local x after the geometry pre-rotation). During a + // passerby the car covers ~26 units a cue, so the wheels spin at + // drive speed rather than cruise speed. + const spin = passerby ? t * 14 : t * speed * 6 * rate; for (const w of instance.wheels) { - if (w.userData.tire) w.userData.tire.rotation.z = t * speed * 6 * rate; - if (w.userData.hub) w.userData.hub.rotation.z = t * speed * 6 * rate; + if (w.userData.tire) w.userData.tire.rotation.z = spin; + if (w.userData.hub) w.userData.hub.rotation.z = spin; } } // Passengers bob gently, out of phase — analytic f(t), seek-stable. @@ -725,7 +752,7 @@ export const ride = { const dense = (arrangement % 2 === 1) || (vState && vState.popMul > 0.8); const showUpTo = dense ? n : Math.max(3, Math.floor(n * 0.45)); for (let i = 0; i < n; i++) { - instance.roadside.children[i].visible = opacity > 0.01 && (i < showUpTo); + instance.roadside.children[i].visible = opacity > 0.001 && (i < showUpTo); } } // Dressing takes the palette tint so houses/trees belong to the song; @@ -748,10 +775,156 @@ export const ride = { // Ground follows horizon like the stage field. instance.road.position.y = -1.15 + (spaceHorizon - 0.5) * 0.6; - void camera; void framing; void params; + // --- camera: this venue's own rig (directCamera). The chase unit + // orbits the car once per video while pushing in, tracking the car so + // the orbit never loses it. Moved here from Compositor so the venue + // owns its whole grammar; the shared rig keeps serving every other + // model layer. All analytic f(time, progress, seed) — seek stable. + driveRideCamera({ instance, camera, timeline, framing, personality, venueState: vState, THREE: T }); + + void framing; void params; }, }; +/** + * The ride's own camera rig. Same grammar the shared rig used to serve for + * this archetype, owned here now: traverse body (continuous, monotonic — the + * thing that arcs) + planned-look head (screen / room) + one orbit per video + * on top + car tracking so wide swings keep their subject. Jump sections cut + * the body to the arrangement side. Box-clamped, focus-floored, handheld. + */ +function driveRideCamera({ instance, camera, timeline, framing, personality, venueState }) { + const t = timeline.time; + const trav = typeof timeline.progress === 'number' ? timeline.progress : 0.5; + const frame = framing || { scale: 1, shift: [0, 0] }; + const shift = frame.shift || [0, 0]; + const scale = frame.scale || 1; + const pCam = personality ? personality.camera : null; + const motion = personality ? personality.motion : null; + const seed = ((instance.seed >>> 0) || 1); + const s1 = Math.sin(seed * 0.00013), s2 = Math.cos(seed * 0.00029); + const room = 1.1; + const still = motion ? motion.stillness : 0.5; + const churn = motion ? motion.churn : 0.25; + const planned = frame && frame.venueShot ? frame.venueShot : null; + + const walkDir = s1 >= 0 ? 1 : -1; + const walkSpan = (1.1 + still * 0.9) * walkDir; + const walk = (trav - 0.5) * 2 * walkSpan + + Math.cos(t * 0.043 + s2 * 4.2) * walkSpan * 0.55 + + Math.sin(t * 0.23 + s1 * 3.1) * 0.08 + + Math.sin(t * 0.11 + s2 * 5.0) * 0.06; + // No journey on this path — drive the whole push off monotonic progress + // instead (smoother than the old staged+progress split, same travel). + const push = (trav - 0.5) * 3.8 * room; + const hx = Math.sin(t * 0.9 + s1 * 7.0) * 0.5 * (0.4 + churn) + + Math.sin(t * 0.23 + s2 * 5.0) * 0.5 * (0.4 + churn); + const hy = Math.cos(t * 0.77 + s2 * 9.0) * 0.5 * (0.4 + churn) + + Math.sin(t * 0.31 + s1 * 4.0) * 0.5 * (0.4 + churn); + const sway = pCam ? pCam.sway : 0.01; + const swayRate = pCam ? pCam.swayRate : 0.09; + const lim = { x: 6.0, yLo: -0.2, yHi: 3.2, zLo: -10.0, zHi: 10.0 }; + const isJump = frame.venueJump !== undefined && frame.venueJump !== null; + + if (planned && !isJump) { + // Wide base: the chase unit works FAR from the car (wides pull back + // to ~7) and HIGH (up to ~3). The old 3.4 base parked every shot in + // the car's lap. + const baseZ = 5.2; + const sizeDolly = (planned.dolly && planned.dolly > 0) ? planned.dolly : 1.0; + const dolly = (baseZ / Math.max(scale, 0.05) - Math.max(0, push)) * sizeDolly; + const rise = (trav - 0.5) * 1.4 * room; + const shiftX = shift[0] * 0.6; + const shiftY = shift[1] * 0.35; + let px = walk * 1.5 * room + Math.sin(t * swayRate) * sway * 4 + shiftX + hx; + let py = 0.8 + rise + Math.cos(t * swayRate * 0.83) * sway * 3 + shiftY + hy; + let pz = dolly; + // Orbit the car once per video (~200° arc, seeded direction). + // Radius tracks the shot dolly (wides orbit far, closes near) with a + // floor that keeps the camera out of the bodywork. Orbit-weighted + // 65/35 over the traverse so the chase unit actually leaves the car's + // lap — 50/50 parked it near the paint. + const oDir = s1 >= 0 ? 1 : -1; + const oAng = oDir * (0.5 + trav * 3.5); + const oRad = Math.max(4.5, dolly * 1.15); + px = px * 0.35 + Math.sin(oAng) * oRad * 0.65; + pz = pz * 0.35 + Math.cos(oAng) * oRad * 0.65; + py = py * 0.35 + (1.2 + Math.sin(trav * 6.28 + s2 * 3.0) * 1.6) * 0.65; + // Minimum focus distance from the car (origin): blending can still + // land the camera inside the paint. Push out along the xz ray. + { + const r = Math.hypot(px, pz); + const minR = 4.2; + if (r < minR && r > 1e-4) { + const k = minR / r; + px *= k; pz *= k; + } else if (r <= 1e-4) { + px = minR; pz = 0; + } + } + camera.position.set( + Math.max(-lim.x, Math.min(lim.x, px)), + Math.max(lim.yLo, Math.min(lim.yHi, py)), + Math.max(lim.zLo, Math.min(lim.zHi, pz)), + ); + camera.rotation.z = pCam ? pCam.spin * t * 0.5 : 0; + // Track the car: blend the plan's motive look with the car's analytic + // position (same formula as the module sweep) so the chase unit keeps + // its subject while still panning with motive. + const carX = Math.sin(t * 0.133 + ((instance.seed >>> 0) || 1) * 0.001) * 1.1; + camera.lookAt( + carX * 0.55 + planned.look[0] * 0.45, + 0.3 * 0.55 + planned.look[1] * 0.45, + -0.3 * 0.55 + planned.look[2] * 0.45, + ); + camera.updateMatrixWorld(); + return; + } + + if (planned && isJump) { + camera.position.set( + Math.max(-lim.x, Math.min(lim.x, planned.pos[0] + hx)), + Math.max(lim.yLo, Math.min(lim.yHi, planned.pos[1] + hy)), + Math.max(lim.zLo, Math.min(lim.zHi, planned.pos[2])), + ); + camera.rotation.z = pCam ? pCam.spin * t * 0.5 : 0; + camera.lookAt(planned.look[0], planned.look[1], planned.look[2]); + camera.updateMatrixWorld(); + return; + } + + // Passerby: the camera is a planted bystander — static plant, fixed road + // point, handheld micro only. The car drives through on driveT (see the + // car block below): far +z at cue start, past the lens mid-cue, far -z at + // the end. No orbit, no traverse, no tracking corrections — a static + // camera is the whole point of the shot. + if (planned && !isJump && planned.target === 'passerby') { + camera.position.set( + Math.max(-lim.x, Math.min(lim.x, planned.pos[0] + hx * 0.5)), + Math.max(lim.yLo, Math.min(lim.yHi, planned.pos[1] + hy * 0.5)), + Math.max(lim.zLo, Math.min(lim.zHi, planned.pos[2])), + ); + camera.rotation.z = pCam ? pCam.spin * t * 0.5 : 0; + camera.lookAt(planned.look[0], planned.look[1], planned.look[2]); + camera.updateMatrixWorld(); + return; + } + + // No plan (hand-built looks, tests): legacy traverse, forward look. + const baseZ = 3.4; + const dolly = baseZ / Math.max(scale, 0.05) - Math.max(0, push); + camera.position.set( + Math.max(-lim.x, Math.min(lim.x, + walk * 1.5 * room + Math.sin(t * swayRate) * sway * 4 + shift[0] * 0.6 + hx)), + 0.35 + (trav - 0.5) * 0.9 * room + + Math.cos(t * swayRate * 0.83) * sway * 3 + shift[1] * 0.35 + hy, + Math.max(0.6, Math.min(7.5, dolly)), + ); + camera.rotation.z = pCam ? pCam.spin * t * 0.5 : 0; + camera.lookAt(camera.position.x * 0.15, camera.position.y * 0.3 - 0.25, camera.position.z - 5); + camera.updateMatrixWorld(); +} + function mulberry(seed) { let a = seed >>> 0; return function () { diff --git a/flow-state/test/venue.test.js b/flow-state/test/venue.test.js index 82a915c..e75eb03 100644 --- a/flow-state/test/venue.test.js +++ b/flow-state/test/venue.test.js @@ -190,6 +190,73 @@ function countMeshes(root) { // scenes/metadata.json, which Node 20 cannot import without an attribute. // See tools/venue-check.js --filmstrip for the carryover measurement. +test('ride declares directCamera and drives its own rig deterministically', () => { + const THREE = awaitImportThree(); + return THREE.then((T) => { + const rideMod = venues.find((v) => v.name === 'Ride'); + assert.ok(rideMod, 'Ride venue not registered'); + assert.equal(rideMod.directCamera, true, 'Ride must own its camera rig'); + const personality = personalityAt(11); + const identity = generateIdentity(SUMMARY, new Rng(11).fork('identity'), 4); + personality.identity = identity; + const actors = generateActorSet(SUMMARY, new Rng(11).fork('actors'), personality, identity); + const scene = new T.Scene(); + const instance = rideMod.build({ + scene, seed: 4242, params: {}, actorSpec: actors.monolith, + venueSpec: { + seed: 5, population: { count: 3, spread: 0.6, jitter: 0.2 }, + paletteMap: [0, 1, 2, 3], + perSection: [{ light: 0.7, fog: 0.35, popMul: 0.8, reachMul: 0.9 }], + }, + THREE: T, + }); + const stubCam = () => ({ + position: { set(x, y, z) { this.x = x; this.y = y; this.z = z; } }, + rotation: {}, + look: null, + lookAt(x, y, z) { this.look = [x, y, z]; }, + updateMatrixWorld() {}, + }); + const drive = (time, framing) => { + const cam = stubCam(); + rideMod.update({ + instance, scene, camera: cam, + timeline: { time, frame: Math.round(time * 60), progress: time / 200 }, + features: {}, params: { light: 0.85, speed: 0.7 }, + palette: [[1, 0.9, 0.8], [0.5, 0.5, 0.7], [0.3, 0.4, 0.9], [0.9, 0.5, 0.4]], + personality, framing, opacity: 1, + actorSpec: actors.monolith, + venueSpec: instance.venueSpec, + venueState: { light: 0.7, fog: 0.35, popMul: 0.8, reachMul: 0.9 }, + THREE: T, + }); + const { x, y, z } = cam.position; + return { x, y, z, look: cam.look }; + }; + const framing = { scale: 1, shift: [0, 0] }; + const a = drive(10, framing), b = drive(10, framing); + assert.deepEqual(a, b, 'same inputs must place the camera identically'); + const c = drive(40, framing); + const travelled = Math.hypot(c.x - a.x, c.y - a.y, c.z - a.z); + assert.ok(travelled > 0.15, `ride camera barely moves: ${travelled.toFixed(3)} units in 30 s`); + // Stays inside its own box (wider + behind the car, unlike rooms — + // must match the clamp in driveRideCamera). + for (const t of [0, 30, 90, 200, 400]) { + const p = drive(t, framing); + assert.ok(Math.abs(p.x) <= 6.0, `x=${p.x.toFixed(2)} outside the ride box at t=${t}`); + assert.ok(p.z >= -6.0 && p.z <= 6.0, `z=${p.z.toFixed(2)} outside the ride box at t=${t}`); + assert.ok(p.look && p.look.length === 3, 'camera must look somewhere'); + } + // A planned mark steers the head: screen vs room framings look apart. + const shot = { pos: [1, 0.5, 4], look: [0, 0.8, -0.8], size: 'close', target: 'screen', dolly: 0.72 }; + const roomShot = { pos: [-1, 0.5, 5], look: [0, 0.3, -6], size: 'wide', target: 'room', dolly: 1.3 }; + const s1 = drive(60, { scale: 1, shift: [0, 0], venueShot: shot }); + const s2 = drive(60, { scale: 1, shift: [0, 0], venueShot: roomShot }); + assert.ok(Math.hypot(s1.look[0] - s2.look[0], s1.look[1] - s2.look[1]) > 0.2, + 'screen vs room marks must aim the camera apart'); + }); +}); + // Dynamic import indirection so a missing/broken three surfaces as a test // failure with a name on it rather than a top-level load error for the file. function awaitImportThree() {