/** * The stage: one renderer, one loop, one canvas, and a scene you can swap. * * Stage owns *only* the WebGL renderer, the RAF loop and resize. It has no * camera, no lights and no picking โ€” those are per-scene and live in * `SceneKit`, because a city and an office cannot share a `THREE.Scene` at all: * SF's `latScale` puts one scene unit at ~94 m with 3.6x vertical * exaggeration, and an office renders at 1 unit = 1 m. * * The swap **retains and pauses** the outgoing scene rather than disposing it. * That is a measured choice, not a preference: SF's heightfield is 484 x 696 * lattice points and `world.ts` records a ~1.0 s build, so throwing the city * away every time somebody steps into an office means paying a second of * rebuild on the way back out. Stage therefore disposes nothing it did not * create โ€” whoever built a `StageScene` disposes it, when they actually mean * to be rid of it. See CONTRACT.md ยง1. */ import * as THREE from "three"; import type { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js"; export interface StageScene { scene: THREE.Scene; camera: THREE.PerspectiveCamera; controls: OrbitControls; onEnter?(): void; onExit?(): void; tick(dt: number, elapsed: number): void; dispose(): void; } export interface Stage { renderer: THREE.WebGLRenderer; setScene(s: StageScene): void; current(): StageScene | null; dispose(): void; } export interface StageOptions { antialias?: boolean; /** Device pixel ratio ceiling. Above 2 the cost is real and the gain is not. */ maxPixelRatio?: number; shadows?: boolean; } export function createStage(canvas: HTMLCanvasElement, options: StageOptions = {}): Stage { const renderer = new THREE.WebGLRenderer({ canvas, antialias: options.antialias ?? true }); renderer.setPixelRatio(Math.min(window.devicePixelRatio, options.maxPixelRatio ?? 2)); renderer.setSize(canvas.clientWidth, canvas.clientHeight, false); if (options.shadows ?? true) { renderer.shadowMap.enabled = true; renderer.shadowMap.type = THREE.PCFSoftShadowMap; } let currentScene: StageScene | null = null; // Elapsed time is tracked per scene rather than per stage. A scene that sat // paused for forty seconds should come back where it left off, not jump // forty seconds into whatever it animates. const elapsedByScene = new WeakMap(); // Compared against CSS pixels, because `canvas.width` is in device pixels and // differs from `clientWidth` on every retina display โ€” checking it would call // `setSize` on every single frame. let lastWidth = 0; let lastHeight = 0; function applyViewport(target: StageScene) { const w = canvas.clientWidth; const h = canvas.clientHeight; if (w === 0 || h === 0) return; target.camera.aspect = w / h; target.camera.updateProjectionMatrix(); } function resize() { const w = canvas.clientWidth; const h = canvas.clientHeight; if (w === 0 || h === 0) return; if (w === lastWidth && h === lastHeight) return; lastWidth = w; lastHeight = h; renderer.setSize(w, h, false); if (currentScene) applyViewport(currentScene); } const clock = new THREE.Clock(); let raf = 0; function tick() { raf = requestAnimationFrame(tick); // Clamped, so a backgrounded tab returning does not advance every animation // by however long it was gone. const dt = Math.min(clock.getDelta(), 0.05); resize(); const active = currentScene; if (!active) return; const elapsed = (elapsedByScene.get(active) ?? 0) + dt; elapsedByScene.set(active, elapsed); active.tick(dt, elapsed); renderer.render(active.scene, active.camera); } tick(); const onWindowResize = () => resize(); window.addEventListener("resize", onWindowResize); return { renderer, setScene(s) { if (s === currentScene) return; currentScene?.onExit?.(); currentScene = s; // The incoming camera may never have seen this canvas, and the canvas may // have been resized while the scene was paused. applyViewport(s); s.onEnter?.(); }, current: () => currentScene, dispose() { cancelAnimationFrame(raf); window.removeEventListener("resize", onWindowResize); currentScene = null; renderer.dispose(); }, }; }