clone

an endless marble-race generator in the browser; the url is the seed, so every track can be shared

main
marbles / src / client / camera.ts
camera.ts 20.7K
1/**2 * Two camera modes over one PerspectiveCamera:3 * - orbit: damped OrbitControls, auto-framed to the track's bounds.4 * - follow: chases a point 4 units behind the marble along the centerline.5 */6import * as THREE from "three";7import { OrbitControls } from "three/addons/controls/OrbitControls.js";8import type { SceneCtx } from "./scene.ts";9import type { Marble } from "./marble.ts";1011export type CamMode = "orbit" | "follow";12type CameraSubject = Pick<Marble, "obj" | "s" | "speed" | "radius" | "update">;1314export interface Cameras {15 readonly mode: CamMode;16 update(dt: number): void;17 toggle(): CamMode;18 setMode(mode: CamMode): void;19 /** Point at a new curve/marble pair after a regeneration. */20 retarget(21 curve: THREE.Curve<THREE.Vector3> | null,22 marble: CameraSubject | null,23 ): void;24 /**25 * Endless mode: follow via an arc-length sampler instead of one closed26 * curve (the path grows without bound). `getPointAtS` writes the world27 * point at global s into `out` and returns false outside the built window.28 */29 retargetPath(30 getPointAtS: ((s: number, out: THREE.Vector3) => boolean) | null,31 marble: CameraSubject | null,32 ): void;33 /** Re-aim orbit at a new bounding box. */34 frame(box: THREE.Box3): void;35 onModeChange?: (mode: CamMode) => void;36 dispose(): void;37}3839/** How far behind the marble (in curve units) the follow camera rides. */40const TRAIL = 4;41const HEIGHT = 1.6;42const LERP = 0.08;43const LOOK_UP = 0.5;44/** Follow-mode drag angles ease back after quiet seconds. Wheel zoom45 * stays at the user's chosen distance until they change it again. */46const ADJUST_RETURN_AFTER = 3;47const ADJUST_RETURN_RATE = 2.2; // exp decay per second48const ZOOM_MIN = 0.35;49const ZOOM_MAX = 3;50/** Occlusion pull-in (follow mode). CLIP_MARGIN is how far in front of a51 * hit surface the boom settles; NEAR_CLEARANCE is the hard floor between52 * lens and surface — it must exceed the camera near plane (0.1 in53 * scene.ts) at the frustum CORNERS (near/cos of the diagonal half-fov),54 * or the near plane slices the wall even though the camera origin is55 * outside it. MIN_BOOM keeps the shot from collapsing onto the marble56 * when it hugs a wall — but never wins over staying outside the mass. */57const CLIP_MARGIN = 0.35;58const NEAR_CLEARANCE = 0.2;59const MIN_BOOM = 0.8;60/** Boom release: snap IN when a mass intrudes (clipping is worse than a61 * pop), ease back OUT at this exp rate per second once it clears. */62const BOOM_RELEASE_RATE = 3;63/** Look-target discontinuity that reads as a TELEPORT (respawns move the64 * subject tens of units): the camera cuts to the fresh framing. */65const TELEPORT_CUT = 5;66/** Thin lips, arches and isolated track walls may briefly cross the view67 * without pulling the camera all the way back to the marble. */68const THIN_SPAN = 1.6;6970/** Find a solid obstruction, measuring entry/exit on the SAME mesh and71 * instance. Pairing hits from different pieces counts the empty space72 * between them as solid and causes large, unnecessary camera dives.73 * Wide views use lensOnly: passing scenery may cover the subject briefly,74 * but only a solid containing the lens endpoint should shorten the boom. */75export function cameraObstructionHit(76 raycaster: THREE.Raycaster,77 origin: THREE.Vector3,78 dir: THREE.Vector3,79 far: number,80 objects: readonly THREE.Object3D[],81 lensOnly = false,82): number {83 raycaster.set(origin, dir);84 raycaster.far = far;85 const entries = raycaster.intersectObjects(objects as THREE.Object3D[], true);86 const checked = new Set<string>();87 for (const entry of entries) {88 const key = `${entry.object.id}:${entry.instanceId ?? "mesh"}`;89 if (checked.has(key)) continue;90 checked.add(key);91 raycaster.ray.origin.copy(origin).addScaledVector(dir, far);92 raycaster.ray.direction.copy(dir).negate();93 raycaster.far = far - entry.distance;94 const exit = raycaster.intersectObject(entry.object, false).find(95 (hit: { instanceId?: number }) => hit.instanceId === entry.instanceId,96 );97 if (lensOnly && !exit) {98 // Open track shells have no back face. A missing exit alone must99 // not claim that a distant lens is inside their solid volume.100 const mesh = entry.object as THREE.Mesh;101 const geometry = mesh.geometry;102 if (!geometry) continue;103 if (!geometry.boundingBox) geometry.computeBoundingBox();104 const world = mesh.matrixWorld.clone();105 if (entry.instanceId !== undefined) {106 const instance = new THREE.Matrix4();107 (mesh as THREE.InstancedMesh).getMatrixAt(entry.instanceId, instance);108 world.multiply(instance);109 }110 const localLens = raycaster.ray.origin.clone().applyMatrix4(111 world.invert(),112 );113 if (!geometry.boundingBox?.containsPoint(localLens)) continue;114 }115 // A missing exit in a containing volume protects the lens; close116 // shots also pull in for substantial foreground obstructions.117 if (118 !exit ||119 (!lensOnly && far - entry.distance - exit.distance >= THIN_SPAN)120 ) {121 return entry.distance;122 }123 }124 return Infinity;125}126127/**128 * Occlusion clamp for the follow boom (pure — tests/camera_test.ts).129 * Given the unobstructed boom length and the nearest occluder hit along130 * the look→camera ray (Infinity when clear), returns how long the boom131 * may be. Invariants: never longer than desired, never past the hit132 * (strictly closer than hitDist), and MIN_BOOM only applies when it133 * doesn't violate either.134 */135export function clampBoom(desiredDist: number, hitDist: number): number {136 const target = Math.min(137 desiredDist,138 Math.max(139 hitDist - CLIP_MARGIN,140 Math.min(MIN_BOOM, hitDist - NEAR_CLEARANCE),141 ),142 );143 // Floor: hold the minimum boom, but cap it at half the hit distance so144 // the camera NEVER crosses to the far side of a wall, and at the145 // desired distance so a deliberate user zoom-in is respected.146 return Math.max(target, Math.min(MIN_BOOM, hitDist * 0.5, desiredDist));147}148149/**150 * Boom length with asymmetric smoothing (pure — tests/camera_test.ts):151 * snap in to the clamped length when it shrinks, ease back out when it152 * grows. `prevBoom < 0` means uninitialized (first frame / retarget).153 */154export function resolveBoom(155 desiredDist: number,156 hitDist: number,157 prevBoom: number,158 dt: number,159): number {160 const target = clampBoom(desiredDist, hitDist);161 if (!(prevBoom >= 0) || target <= prevBoom) return target; // snap in162 const k = Math.exp(-BOOM_RELEASE_RATE * Math.max(dt, 1e-4));163 return target + (prevBoom - target) * k; // ease out164}165166export interface CameraOptions {167 /** Follow-cam trail distance/height — endless mode rides higher and168 * farther back (slalom-scale track pieces would swallow the default). */169 trail?: number;170 height?: number;171 /** Solids for camera clearance. Close shots also avoid foreground172 * occlusion; wide shots preserve framing unless the lens would enter173 * a solid. Resolved per frame — pass a closure over the live scene. */174 occluders?: () => readonly THREE.Object3D[];175 /** Local up (gravity reorientation, M16): the follow cam's up vector,176 * boom height and look offset follow the subject's LOCAL gravity177 * frame — riding a wall the camera rolls with the world. Resolved per178 * frame; smoothed internally. Default: world +y. */179 up?: () => readonly [number, number, number];180}181182export function createCameras(183 ctx: SceneCtx,184 curve: THREE.Curve<THREE.Vector3> | null,185 marble: CameraSubject | null,186 opts: CameraOptions = {},187): Cameras {188 const trail = opts.trail ?? TRAIL;189 const height = opts.height ?? HEIGHT;190 const { camera, renderer } = ctx;191192 const controls = new OrbitControls(camera, renderer.domElement);193 controls.enableDamping = true;194 controls.dampingFactor = 0.08;195 controls.maxPolarAngle = Math.PI * 0.495;196 controls.minDistance = 2;197 controls.maxDistance = 300;198199 let mode: CamMode = "orbit";200 let activeCurve = curve;201 let activeMarble = marble;202 let pathSampler: ((s: number, out: THREE.Vector3) => boolean) | null = null;203 /** Last box passed to `frame`, replayed when we return to orbit. */204 let framedBox: THREE.Box3 | null = null;205206 const desired = new THREE.Vector3();207 const lookTarget = new THREE.Vector3();208 const orbitTarget = new THREE.Vector3();209 const orbitShift = new THREE.Vector3();210 // Follow-mode user adjust state (wheel zoom, drag orbit).211 let zoomUser = 1;212 let yawUser = 0;213 let pitchUser = 0;214 let lastAdjustT = -1e9;215 const rel = new THREE.Vector3();216 const sph = new THREE.Spherical();217 let dragging = false;218 let dragX = 0;219 let dragY = 0;220 const dom = renderer.domElement;221 dom.addEventListener(222 "wheel",223 (e: WheelEvent) => {224 if (mode !== "follow") return;225 e.preventDefault();226 zoomUser = Math.min(227 ZOOM_MAX,228 Math.max(ZOOM_MIN, zoomUser * Math.exp(e.deltaY * 0.0012)),229 );230 lastAdjustT = performance.now();231 },232 { passive: false },233 );234 dom.addEventListener("pointerdown", (e: PointerEvent) => {235 if (mode !== "follow") return;236 dragging = true;237 dragX = e.clientX;238 dragY = e.clientY;239 });240 addEventListener("pointermove", (e: PointerEvent) => {241 if (!dragging || mode !== "follow") return;242 yawUser -= (e.clientX - dragX) * 0.006;243 pitchUser += (e.clientY - dragY) * 0.004;244 pitchUser = Math.min(1.1, Math.max(-1.1, pitchUser));245 dragX = e.clientX;246 dragY = e.clientY;247 lastAdjustT = performance.now();248 });249 addEventListener("pointerup", () => {250 dragging = false;251 });252 const lookNow = new THREE.Vector3();253 const subjectPrev = new THREE.Vector3();254 const raycaster = new THREE.Raycaster();255 const rayDir = new THREE.Vector3();256 /** Smoothed boom length (occlusion pull-in state); <0 = uninitialized. */257 let boomNow = -1;258 const bankA = new THREE.Vector3();259 const bankB = new THREE.Vector3();260 const bankC = new THREE.Vector3();261 let bankNow = 0;262 let lookInit = false;263 /** Smoothed local-up (gravity reorientation) — follows opts.up. */264 const upNow = new THREE.Vector3(0, 1, 0);265 const upTarget = new THREE.Vector3(0, 1, 0);266267 const api: Cameras = {268 get mode() {269 return mode;270 },271272 frame(box: THREE.Box3) {273 if (box.isEmpty()) return;274 framedBox = box.clone();275 const center = box.getCenter(new THREE.Vector3());276 const size = box.getSize(new THREE.Vector3());277 const radius = Math.max(size.x, size.y, size.z) * 0.5 || 8;278 const verticalFov = THREE.MathUtils.degToRad(camera.fov);279 const horizontalFov = 2 * Math.atan(280 Math.tan(verticalFov / 2) * camera.aspect,281 );282 // Portrait windows have a much narrower horizontal frustum. Framing283 // against vertical FOV alone clips wide pieces and their labels.284 const fov = Math.min(verticalFov, horizontalFov);285 const dist = (radius / Math.sin(fov / 2)) * 1.15;286 camera.far = Math.max(500, dist * 4);287 camera.updateProjectionMatrix();288 // Orbit-side effects ONLY in orbit mode. OrbitControls.enabled289 // gates input, not update(): an update() here in follow mode SNAPS290 // the camera to the orbit spherical around the new box center —291 // endless re-frames on every segment build, so every stream load292 // yanked the follow cam to the "default angle" and let the lerp293 // float it back (the user-reported jump; measured as a ~60-unit294 // teleport with a 0.39/100ms decay tail at each build event).295 if (mode === "orbit" && !pathSampler) {296 controls.target.copy(center);297 camera.position.copy(center).add(298 new THREE.Vector3(0.75, 0.55, 0.75).normalize().multiplyScalar(dist),299 );300 camera.lookAt(center);301 controls.update();302 }303 },304305 retarget(nextCurve, nextMarble) {306 activeCurve = nextCurve;307 activeMarble = nextMarble;308 pathSampler = null;309 lookInit = false;310 boomNow = -1;311 },312313 retargetPath(getPointAtS, nextMarble) {314 pathSampler = getPointAtS;315 activeCurve = null;316 activeMarble = nextMarble;317 lookInit = false;318 boomNow = -1;319 },320321 setMode(next: CamMode) {322 if (next === mode) return;323 mode = next;324 controls.enabled = mode === "orbit";325 if (mode === "orbit") {326 // Endless orbit is a chase-orbit: preserve the current boom, but327 // move its pivot with the streamed subject. A fixed world pivot is328 // eventually culled with the old segments, leaving only starfield.329 if (pathSampler && activeMarble) {330 controls.target.copy(activeMarble.obj.position);331 controls.update();332 } else if (framedBox) {333 api.frame(framedBox);334 } else {335 // No box yet — anchor the orbit pivot ahead of the current view.336 camera.getWorldDirection(desired);337 controls.target.copy(camera.position).add(desired.multiplyScalar(12));338 }339 }340 lookInit = false;341 boomNow = -1;342 api.onModeChange?.(mode);343 },344345 toggle() {346 api.setMode(mode === "orbit" ? "follow" : "orbit");347 return mode;348 },349350 update(dt: number) {351 if (mode === "orbit") {352 if (pathSampler && activeMarble) {353 orbitTarget.copy(activeMarble.obj.position);354 orbitShift.copy(orbitTarget).sub(controls.target);355 camera.position.add(orbitShift);356 controls.target.copy(orbitTarget);357 }358 controls.update();359 return;360 }361 if (!activeMarble) return;362 // Local up: eased toward the supplier (a reorient twist rolls the363 // shot over ~a second, never snaps) except on retarget. The boom364 // height and look offset ride the same axis, so the standard365 // framing survives walls and ceilings unchanged.366 if (opts.up) {367 const u = opts.up();368 upTarget.set(u[0], u[1], u[2]);369 if (!lookInit) upNow.copy(upTarget);370 else {371 upNow.lerp(372 upTarget,373 1 - Math.exp(-2.5 * Math.max(dt, 1e-4)),374 ).normalize();375 }376 }377 camera.up.copy(upNow);378 if (pathSampler) {379 if (!pathSampler(activeMarble.s - trail, desired)) return;380 desired.addScaledVector(upNow, height);381 } else if (activeCurve) {382 const total = activeCurve.getLength();383 if (total <= 0) return;384 const behind = ((activeMarble.s - trail) % total + total) % total;385 activeCurve.getPointAt(behind / total, desired);386 desired.addScaledVector(upNow, height);387 } else {388 return;389 }390391 lookTarget.copy(activeMarble.obj.position);392 lookTarget.addScaledVector(upNow, LOOK_UP);393394 // Subject teleported (respawn, checkpoint skip): CUT to the fresh395 // framing instead of flying the old camera across the world — the396 // lerp flight dragged the boom through island masses and the397 // anti-clip clamp snapped it mid-route, reading as "camera jumps398 // then floats back" (measured: a 60-unit snap with a decay tail).399 if (lookInit && lookTarget.distanceTo(subjectPrev) > TELEPORT_CUT) {400 lookInit = false;401 boomNow = -1;402 yawUser = 0;403 pitchUser = 0;404 }405 subjectPrev.copy(lookTarget);406407 // Drag angles return to the path-facing shot after a quiet spell;408 // zoom is a persistent preference, including across teleport cuts.409 if (410 performance.now() - lastAdjustT > ADJUST_RETURN_AFTER * 1000 &&411 (yawUser !== 0 || pitchUser !== 0)412 ) {413 const k = Math.exp(-ADJUST_RETURN_RATE * Math.max(dt, 1e-4));414 yawUser *= k;415 pitchUser *= k;416 if (Math.abs(yawUser) < 1e-3) yawUser = 0;417 if (Math.abs(pitchUser) < 1e-3) pitchUser = 0;418 }419 if (zoomUser !== 1 || yawUser !== 0 || pitchUser !== 0) {420 rel.copy(desired).sub(lookTarget);421 sph.setFromVector3(rel);422 sph.radius = Math.max(0.6, sph.radius * zoomUser);423 sph.theta += yawUser;424 sph.phi = Math.min(2.6, Math.max(0.25, sph.phi - pitchUser));425 rel.setFromSpherical(sph);426 desired.copy(lookTarget).add(rel);427 }428429 // Close shots keep the subject unobstructed. A deliberately wide430 // shot prioritizes the user's framing: scenery crossing the view431 // does not send the camera diving under the track. Still pull clear432 // if the lens itself would enter a solid, including its near plane.433 const wideView = zoomUser > 1.4;434 const obs = opts.occluders ? opts.occluders() : [];435 rayDir.copy(desired).sub(lookTarget);436 const dist = rayDir.length();437 if (obs.length > 0 && dist > 1e-4) {438 rayDir.divideScalar(dist);439 const hit = cameraObstructionHit(440 raycaster,441 lookTarget,442 rayDir,443 dist + CLIP_MARGIN + NEAR_CLEARANCE,444 obs,445 wideView,446 );447 boomNow = resolveBoom(dist, hit, boomNow, dt);448 desired.copy(lookTarget).addScaledVector(rayDir, boomNow);449 } else {450 boomNow = dist;451 }452453 // Wide views ease through bends more gently than a close chase shot.454 // Exponential smoothing stays consistent across frame rates.455 const followRate = -60 * Math.log(1 - LERP);456 const wideBlend = Math.min(1, Math.max(0, zoomUser - 1));457 const a = 1 - Math.exp(458 -(followRate * (1 - wideBlend) + 3 * wideBlend) * Math.max(dt, 1e-4),459 );460 camera.position.lerp(desired, a);461462 // The lerp above moves the REAL camera on its own path — while it463 // eases toward a freshly pulled-in `desired` (or the boom swings464 // around a corner the desired-ray missed) it can still be inside a465 // mass. Clamp the actual position the same way, along its own ray:466 // snap-in only, the ease-out happens through the lerp next frames.467 if (obs.length > 0) {468 rayDir.copy(camera.position).sub(lookTarget);469 const camDist = rayDir.length();470 if (camDist > 1e-4) {471 rayDir.divideScalar(camDist);472 const hit = cameraObstructionHit(473 raycaster,474 lookTarget,475 rayDir,476 camDist + NEAR_CLEARANCE,477 obs,478 wideView,479 );480 const safe = clampBoom(camDist, hit);481 if (safe < camDist - 1e-6) {482 camera.position.copy(lookTarget).addScaledVector(rayDir, safe);483 }484 }485 }486 if (!lookInit) {487 // Fresh framing (retarget / teleport cut): place the camera at488 // the resolved desired position outright — no cross-world lerp.489 lookNow.copy(lookTarget);490 camera.position.copy(desired);491 lookInit = true;492 } else {493 lookNow.lerp(lookTarget, a);494 }495 camera.lookAt(lookNow);496497 // Banking lean (M13): roll into the upcoming curve — signed turn498 // from three path samples around the marble; rotateZ(+φ) banks499 // right, a right turn has (d1×d2).y < 0, so φ = −θ·gain.500 let bankTarget = 0;501 const SPAN = 1.5;502 const s = activeMarble.s;503 const sampled = pathSampler504 ? pathSampler(s - SPAN, bankA) && pathSampler(s, bankB) &&505 pathSampler(s + SPAN, bankC)506 : activeCurve507 ? (() => {508 const total = activeCurve.getLength();509 if (total <= SPAN * 2) return false;510 const at = (ss: number, out: THREE.Vector3) =>511 activeCurve!.getPointAt(512 (((ss % total) + total) % total) / total,513 out,514 );515 at(s - SPAN, bankA);516 at(s, bankB);517 at(s + SPAN, bankC);518 return true;519 })()520 : false;521 if (sampled) {522 const d1x = bankB.x - bankA.x;523 const d1z = bankB.z - bankA.z;524 const d2x = bankC.x - bankB.x;525 const d2z = bankC.z - bankB.z;526 const crossY = d1z * d2x - d1x * d2z;527 const dot = d1x * d2x + d1z * d2z;528 const turn = Math.atan2(crossY, dot);529 bankTarget = Math.max(530 -0.16,531 Math.min(532 0.16,533 -turn * (0.05 + activeMarble.speed * 0.018),534 ),535 );536 }537 bankNow += (bankTarget - bankNow) *538 (1 - Math.pow(1 - 0.05, Math.max(dt, 1e-4) * 60));539 camera.rotateZ(bankNow);540 },541542 dispose() {543 controls.dispose();544 removeEventListener("resize", reframe);545 },546 };547548 // The old portrait framing was carried into a wide window, leaving549 // the circuit far away after rotation/full screen. Endless orbit is550 // subject-relative and keeps its boom instead of resetting its view.551 const reframe = () => {552 if (mode === "orbit" && framedBox && !pathSampler) api.frame(framedBox);553 };554 addEventListener("resize", reframe);555556 addEventListener("keydown", (ev) => {557 const target = ev.target as HTMLElement | null;558 if (559 target?.isContentEditable || target?.closest("input, textarea, select") ||560 ev.repeat || ev.metaKey || ev.ctrlKey || ev.altKey561 ) return;562 if (ev.key === "c" || ev.key === "C") api.toggle();563 });564565 return api;566}