fix(cinema): guarantee the storm stays centered — never flies off-screen

Root cause: layoutPositions grew per beat (radius 22 + i*6), so each beat sat
farther out; the camera + storm marched off into space as the tour progressed.

Fix (centered-by-construction):
- layoutPositions: tight BOUNDED golden-angle shell (SHELL_RADIUS 14), no growth.
- sandbox: storm pinned to the WORLD ORIGIN permanently; camera hard-clamped to
  an 18-46 unit distance band and always lookAt(origin); containment sphere
  sized to the FOV at origin. A runaway move is corrected every frame.
- director: new centerOnOrigin mode (enabled when WebGPU active) — frames/orbits
  the origin instead of flying to scattered nodes; variety from angle/standoff.

No path remains for the subject to leave frame. 937 tests + build green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sam Valladares 2026-06-22 01:09:07 -05:00
parent 5e8a22a427
commit b3f02ebc2f
3 changed files with 66 additions and 24 deletions

View file

@ -78,18 +78,29 @@
// Deterministic layout: spread path nodes on a gentle spiral so the camera
// has distinct world positions to fly between (independent of the WebGL
// graph's internal coordinates — keeps the sandbox isolated).
// Lay the beat nodes out on a TIGHT, BOUNDED shell centered on the origin —
// fixed radius, no per-beat growth. Earlier this grew (22 + i*6) so each beat
// sat farther out and the camera+storm marched off into space ("flying off").
// A bounded shell keeps the whole composition centered; cinematic variety
// comes from the camera angle/move/standoff, not from translating across a
// huge volume. The focused node is always re-centered by recenterOn() below.
const SHELL_RADIUS = 14;
function layoutPositions(p: CinemaPath): Map<string, THREE.Vector3> {
const pos = new Map<string, THREE.Vector3>();
const n = p.beats.length;
for (let i = 0; i < n; i++) {
const angle = (i / Math.max(1, n)) * Math.PI * 2 * 1.4;
const radius = 22 + i * 6;
// Distribute beats evenly on a sphere (golden-angle spiral) so they
// never clump and never exceed SHELL_RADIUS from center.
const t = n > 1 ? i / (n - 1) : 0.5;
const y = 1 - t * 2; // 1..-1
const r = Math.sqrt(Math.max(0, 1 - y * y));
const theta = i * 2.399963; // golden angle
pos.set(
p.beats[i].nodeId,
new THREE.Vector3(
Math.cos(angle) * radius,
(i % 2 === 0 ? 1 : -1) * (4 + i * 2),
Math.sin(angle) * radius
Math.cos(theta) * r * SHELL_RADIUS,
y * SHELL_RADIUS * 0.5,
Math.sin(theta) * r * SHELL_RADIUS
)
);
}
@ -235,7 +246,7 @@
stage = 'done';
statusLine = 'End of tour.';
},
}, { reducedMotion, shots });
}, { reducedMotion, shots, centerOnOrigin: webgpuActive });
stage = 'playing';
statusLine = webgpuActive

View file

@ -37,12 +37,18 @@ export interface DirectorOptions {
* director every value falls back to the constants above. When present,
* each shot's move/angle/dutch/standoff/flight/dwell/cut directs that beat. */
shots?: ResolvedShot[];
/** When true, the camera frames the WORLD ORIGIN every shot (the WebGPU storm
* is pinned there) instead of flying out to scattered node positions so the
* subject is ALWAYS centered and can never fly off-screen. Camera variety
* comes purely from angle/standoff/orbit. Used by the WebGPU sandbox path. */
centerOnOrigin?: boolean;
}
type Phase = 'idle' | 'flying' | 'dwelling' | 'done';
const _tmpDir = new THREE.Vector3();
const _tmpUp = new THREE.Vector3(0, 1, 0);
const _origin = new THREE.Vector3(0, 0, 0);
export class CinemaDirector {
private camera: THREE.PerspectiveCamera;
@ -81,6 +87,7 @@ export class CinemaDirector {
standoff: opts.standoff ?? 26,
reducedMotion: opts.reducedMotion ?? false,
shots: opts.shots ?? [],
centerOnOrigin: opts.centerOnOrigin ?? false,
};
}
@ -122,11 +129,18 @@ export class CinemaDirector {
this.phase = 'done';
}
/** Compute the camera stand-off position for a beat's node, directed by its
* shot (move / angle / standoff). With no shot, reproduces the original
/** The focal point a beat frames: the world ORIGIN in centered mode (storm is
* pinned there), else the node's laid-out position. */
private focalPoint(beat: CinemaBeat): THREE.Vector3 | null {
if (this.opts.centerOnOrigin) return _origin;
return this.positions.get(beat.nodeId) ?? null;
}
/** Compute the camera stand-off position for a beat's focal point, directed by
* its shot (move / angle / standoff). With no shot, reproduces the original
* framing exactly: standoff = opts.standoff, +0.35 up-bias (filmic tilt). */
private framePosition(beat: CinemaBeat, index: number, out: THREE.Vector3): THREE.Vector3 {
const nodePos = this.positions.get(beat.nodeId);
const nodePos = this.focalPoint(beat);
if (!nodePos) {
// Node has no resolved position yet — keep current framing.
return out.copy(this.camera.position);
@ -159,7 +173,7 @@ export class CinemaDirector {
private beginFlightTo(index: number): void {
const beat = this.path.beats[index];
const nodePos = this.positions.get(beat.nodeId);
const nodePos = this.focalPoint(beat);
const shot = this.shotAt(index);
this.fromPos.copy(this.camera.position);
@ -205,7 +219,7 @@ export class CinemaDirector {
}
} else if (this.phase === 'dwelling') {
if (!this.opts.reducedMotion) {
const nodePos = this.positions.get(this.path.beats[this.beatIndex].nodeId);
const nodePos = this.focalPoint(this.path.beats[this.beatIndex]);
if (nodePos) {
this.target.lerp(nodePos, 0.02); // gentle settle keeps the shot alive
// An orbit shot slowly revolves the camera around the node

View file

@ -13,6 +13,12 @@
import * as THREE from 'three';
import type { SemanticRole, SemanticComputeStorm } from './storm';
// The storm lives at the world origin, permanently. The camera always looks here
// and is clamped to a safe distance band so the subject can never leave frame.
const ORIGIN = new THREE.Vector3(0, 0, 0);
const MIN_CAM_DIST = 18;
const MAX_CAM_DIST = 46;
export function isWebGPUSupported(): boolean {
return typeof navigator !== 'undefined' && 'gpu' in navigator;
}
@ -153,26 +159,37 @@ export class CinemaSandbox {
this.booted = true;
}
/** Retarget the storm + look the camera at the beat (called by the director). */
transitionTo(role: SemanticRole, worldPos: THREE.Vector3): void {
/** Retarget the storm's MODE/ignition for a beat. The storm is permanently
* centered at the WORLD ORIGIN (see render) so it is always dead-center in
* frame worldPos here only conveys which node, not where the storm sits. */
transitionTo(role: SemanticRole, _worldPos: THREE.Vector3): void {
if (!this.booted) return;
this.storm.transitionTo(role, worldPos);
this.storm.transitionTo(role, ORIGIN);
}
/** Render one frame. Camera is driven externally (director mutates position/target).
* A single frame's failure must not crash the tour — it's caught and surfaced
* via a thrown error the caller already handles (drops to camera-only). */
/** Render one frame. The storm is pinned to the origin and the camera always
* looks at the origin, so the storm CANNOT leave the frame. The director
* varies only the camera's orbital position/angle (set via cameraRef), and we
* clamp that to a safe distance band here as a final guarantee. */
async render(deltaSeconds: number): Promise<void> {
if (!this.booted) return;
this.camera.lookAt(this.target);
// Keep the storm inside the frame: derive the largest world radius that
// fully fits the camera's vertical FOV at the current distance to target,
// minus a margin so the glow halo stays on-screen too. The storm clamps
// itself to this each frame, so it reframes as the camera flies.
const dist = this.camera.position.distanceTo(this.target);
// Hard guarantee: clamp the camera into a distance band from origin so a
// runaway director move can never push the subject out of view, then look
// dead at the origin where the storm lives.
const distToOrigin = this.camera.position.length();
if (distToOrigin < MIN_CAM_DIST || distToOrigin > MAX_CAM_DIST || !Number.isFinite(distToOrigin)) {
const d = Math.min(MAX_CAM_DIST, Math.max(MIN_CAM_DIST, distToOrigin || MAX_CAM_DIST));
if (distToOrigin > 1e-3) this.camera.position.setLength(d);
else this.camera.position.set(0, 12, d);
}
this.camera.lookAt(ORIGIN);
// Size the containment sphere to the camera's FOV at the origin so the
// storm always fully fits the frame with margin.
const dist = this.camera.position.length();
const vfov = (this.camera.fov * Math.PI) / 180;
const fitRadius = Math.tan(vfov / 2) * dist * 0.62; // 0.62 = on-screen margin
const fitRadius = Math.tan(vfov / 2) * dist * 0.55;
this.storm.setContainRadius(fitRadius);
await this.storm.update(deltaSeconds);