mirror of
https://github.com/samvallad33/vestige.git
synced 2026-07-02 22:01:01 +02:00
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:
parent
5e8a22a427
commit
b3f02ebc2f
3 changed files with 66 additions and 24 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue