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