mirror of
https://github.com/samvallad33/vestige.git
synced 2026-07-04 22:02:14 +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
|
|
@ -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