clone

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

fix: preserve zoom and smooth wide follow-camera views

Dami committed cfae470
Keep chosen wheel zoom through idle time, respawns and retargeting. Pair
obstruction hits on the same mesh/instance and distinguish open foreground
shells from volumes containing the lens. Wide shots tolerate foreground
occlusion and ease through bends while retaining lens clearance.

Detect teleports from consecutive subject positions rather than camera lag.
Add eight geometry regressions; verify continuous motion at 30/60/120 fps.
On e0b75o4y7s, an 80-second wide-camera comparison reduced moves over two
units per frame from 21 to zero.
commit
cfae4700bcf09cb8c7fd5928ce2cf8a37b5a5611
parent
bb28b82cc536eda960692c8eef76c9c6943f34e8
author
Dami <dami.etoile@gmail.com>
2 files changed, +244 -64
Msrc/client/camera.ts165+++++++++++++++++++-----------
Mtests/camera_test.ts143++++++++++++++++++++++++++-
M src/client/camera.ts +103 -62
@@ -41,8 +41,8 @@ const TRAIL = 4;
4141 const HEIGHT = 1.6;4242 const LERP = 0.08;4343 const LOOK_UP = 0.5;44-/** User camera adjust (follow mode): wheel zoom + drag orbit, easing45- * back to the standard follow shot after this many quiet seconds. */44+/** Follow-mode drag angles ease back after quiet seconds. Wheel zoom45+ * stays at the user's chosen distance until they change it again. */4646 const ADJUST_RETURN_AFTER = 3;4747 const ADJUST_RETURN_RATE = 2.2; // exp decay per second4848 const ZOOM_MIN = 0.35;
@@ -63,6 +63,66 @@ const BOOM_RELEASE_RATE = 3;
6363 /** Look-target discontinuity that reads as a TELEPORT (respawns move the6464 * subject tens of units): the camera cuts to the fresh framing. */6565 const 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. */68+const THIN_SPAN = 1.6;69+70+/** 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. */75+export 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+}66126 67127 /**68128 * Occlusion clamp for the follow boom (pure — tests/camera_test.ts).
@@ -108,10 +168,9 @@ export interface CameraOptions {
108168 * farther back (slalom-scale track pieces would swallow the default). */109169 trail?: number;110170 height?: number;111- /** Objects the follow cam must not clip through (island masses): the112- * desired position is pulled in front of the first hit along the113- * marble → camera ray. Resolved per frame — pass a closure over the114- * live scene. */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. */115174 occluders?: () => readonly THREE.Object3D[];116175 /** Local up (gravity reorientation, M16): the follow cam's up vector,117176 * boom height and look offset follow the subject's LOCAL gravity
@@ -191,48 +250,11 @@ export function createCameras(
191250 dragging = false;192251 });193252 const lookNow = new THREE.Vector3();253+ const subjectPrev = new THREE.Vector3();194254 const raycaster = new THREE.Raycaster();195255 const rayDir = new THREE.Vector3();196- const backDir = new THREE.Vector3();197- const backOrigin = new THREE.Vector3();198256 /** Smoothed boom length (occlusion pull-in state); <0 = uninitialized. */199257 let boomNow = -1;200- /** Obstructions thinner than this never pull the boom in: a tunnel201- * arch, a rail lip or a grazed cliff corner crossing the sightline202- * for a moment reads far better as a brief clip-through than as a203- * boom teleport (user report: "camera jumps then floats back"). */204- const THIN_SPAN = 1.6;205- /** Nearest occluder hit distance from `origin` along `dir` (unit),206- * looking no farther than `far`; Infinity when clear. */207- const nearestHit = (208- origin: THREE.Vector3,209- dir: THREE.Vector3,210- far: number,211- obs: readonly THREE.Object3D[],212- ): number => {213- raycaster.set(origin, dir);214- raycaster.far = far;215- const hits = raycaster.intersectObjects(obs as THREE.Object3D[], true);216- return hits.length > 0 ? hits[0].distance : Infinity;217- };218- /** nearestHit, but THIN obstructions count as clear: the blocked span219- * is measured with a reversed cast from the far end (front faces only220- * — a far end sitting INSIDE a mass sees no exit and stays thick,221- * which is exactly when the clamp must fire). */222- const thickHit = (223- origin: THREE.Vector3,224- dir: THREE.Vector3,225- far: number,226- obs: readonly THREE.Object3D[],227- ): number => {228- const entry = nearestHit(origin, dir, far, obs);229- if (entry === Infinity) return Infinity;230- backDir.copy(dir).negate();231- backOrigin.copy(origin).addScaledVector(dir, far);232- const exit = nearestHit(backOrigin, backDir, far - entry, obs);233- if (exit === Infinity) return entry; // far end inside the mass234- return far - entry - exit >= THIN_SPAN ? entry : Infinity;235- };236258 const bankA = new THREE.Vector3();237259 const bankB = new THREE.Vector3();238260 const bankC = new THREE.Vector3();
@@ -374,26 +396,23 @@ export function createCameras(
374396 // lerp flight dragged the boom through island masses and the375397 // anti-clip clamp snapped it mid-route, reading as "camera jumps376398 // then floats back" (measured: a 60-unit snap with a decay tail).377- if (lookInit && lookTarget.distanceTo(lookNow) > TELEPORT_CUT) {399+ if (lookInit && lookTarget.distanceTo(subjectPrev) > TELEPORT_CUT) {378400 lookInit = false;379401 boomNow = -1;380- zoomUser = 1;381402 yawUser = 0;382403 pitchUser = 0;383404 }405+ subjectPrev.copy(lookTarget);384406 385- // User adjust (zoom / orbit around the marble) with a debounced386- // return: after a few quiet seconds the shot eases back to the387- // standard follow framing.407+ // Drag angles return to the path-facing shot after a quiet spell;408+ // zoom is a persistent preference, including across teleport cuts.388409 if (389410 performance.now() - lastAdjustT > ADJUST_RETURN_AFTER * 1000 &&390- (zoomUser !== 1 || yawUser !== 0 || pitchUser !== 0)411+ (yawUser !== 0 || pitchUser !== 0)391412 ) {392413 const k = Math.exp(-ADJUST_RETURN_RATE * Math.max(dt, 1e-4));393- zoomUser = 1 + (zoomUser - 1) * k;394414 yawUser *= k;395415 pitchUser *= k;396- if (Math.abs(zoomUser - 1) < 1e-3) zoomUser = 1;397416 if (Math.abs(yawUser) < 1e-3) yawUser = 0;398417 if (Math.abs(pitchUser) < 1e-3) pitchUser = 0;399418 }
@@ -407,22 +426,23 @@ export function createCameras(
407426 desired.copy(lookTarget).add(rel);408427 }409428 410- // Occlusion: never let island mass sit between marble and camera —411- // the boom shortens to sit in front of the first hit along the412- // marble → camera ray (looking a margin PAST the desired spot, so413- // a wall just behind it still counts), snapping in and easing back414- // out via resolveBoom. The cam slides over shoulders instead of415- // clipping through them.429+ // 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;416434 const obs = opts.occluders ? opts.occluders() : [];417435 rayDir.copy(desired).sub(lookTarget);418436 const dist = rayDir.length();419437 if (obs.length > 0 && dist > 1e-4) {420438 rayDir.divideScalar(dist);421- const hit = thickHit(439+ const hit = cameraObstructionHit(440+ raycaster,422441 lookTarget,423442 rayDir,424443 dist + CLIP_MARGIN + NEAR_CLEARANCE,425444 obs,445+ wideView,426446 );427447 boomNow = resolveBoom(dist, hit, boomNow, dt);428448 desired.copy(lookTarget).addScaledVector(rayDir, boomNow);
@@ -430,8 +450,13 @@ export function createCameras(
430450 boomNow = dist;431451 }432452 433- // Frame-rate independent smoothing of the nominal 0.08/frame at 60fps.434- const a = 1 - Math.pow(1 - LERP, Math.max(dt, 1e-4) * 60);453+ // 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+ );435460 camera.position.lerp(desired, a);436461 437462 // The lerp above moves the REAL camera on its own path — while it
@@ -444,11 +469,13 @@ export function createCameras(
444469 const camDist = rayDir.length();445470 if (camDist > 1e-4) {446471 rayDir.divideScalar(camDist);447- const hit = thickHit(472+ const hit = cameraObstructionHit(473+ raycaster,448474 lookTarget,449475 rayDir,450476 camDist + NEAR_CLEARANCE,451477 obs,478+ wideView,452479 );453480 const safe = clampBoom(camDist, hit);454481 if (safe < camDist - 1e-6) {
@@ -514,10 +541,24 @@ export function createCameras(
514541 515542 dispose() {516543 controls.dispose();544+ removeEventListener("resize", reframe);517545 },518546 };519547 548+ // 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);555+520556 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;521562 if (ev.key === "c" || ev.key === "C") api.toggle();522563 });523564
M tests/camera_test.ts +141 -2
@@ -4,10 +4,149 @@
44 * The clamp must keep the camera strictly on the near side of any hit55 * surface (clipping is worse than any framing compromise), respect a66 * deliberate user zoom-in, and hold the minimum boom only when there is7- * room for it. The smoother snaps in and eases out.7+ * room for it. Wide views tolerate foreground occlusion while still8+ * protecting the lens. The boom smoother snaps in and eases out.89 */910 import { assert, assertEquals } from "@std/assert";10-import { clampBoom, resolveBoom } from "../src/client/camera.ts";11+import * as THREE from "three";12+import {13+ cameraObstructionHit,14+ clampBoom,15+ resolveBoom,16+} from "../src/client/camera.ts";17+18+const origin = new THREE.Vector3();19+const direction = new THREE.Vector3(0, 0, 1);20+function wall(z: number, depth: number): THREE.Mesh {21+ const mesh = new THREE.Mesh(new THREE.BoxGeometry(10, 10, depth));22+ mesh.position.z = z;23+ mesh.updateMatrixWorld(true);24+ return mesh;25+}26+27+Deno.test("camera occlusion: air between separate thin pieces is not solid", () => {28+ assertEquals(29+ cameraObstructionHit(new THREE.Raycaster(), origin, direction, 10, [30+ wall(3, 0.25),31+ wall(7, 0.25),32+ ]),33+ Infinity,34+ );35+});36+37+Deno.test("camera occlusion: separate instances do not form one obstruction", () => {38+ const mesh = new THREE.InstancedMesh(39+ new THREE.BoxGeometry(10, 10, 0.25),40+ new THREE.MeshBasicMaterial(),41+ 2,42+ );43+ mesh.setMatrixAt(0, new THREE.Matrix4().makeTranslation(0, 0, 3));44+ mesh.setMatrixAt(1, new THREE.Matrix4().makeTranslation(0, 0, 7));45+ mesh.updateMatrixWorld(true);46+ assertEquals(47+ cameraObstructionHit(new THREE.Raycaster(), origin, direction, 10, [mesh]),48+ Infinity,49+ );50+});51+52+Deno.test("camera occlusion: thin foreground does not hide a solid mass", () => {53+ assertEquals(54+ cameraObstructionHit(new THREE.Raycaster(), origin, direction, 10, [55+ wall(3, 0.25),56+ wall(7, 3),57+ ]),58+ 5.5,59+ );60+});61+62+Deno.test("camera occlusion: lens inside even a thin wall still pulls clear", () => {63+ assertEquals(64+ cameraObstructionHit(new THREE.Raycaster(), origin, direction, 3, [65+ wall(3, 0.25),66+ ]),67+ 2.875,68+ );69+});70+71+Deno.test("wide camera: passing foreground mass keeps the chosen framing", () => {72+ const mesh = wall(5, 3);73+ assertEquals(74+ cameraObstructionHit(new THREE.Raycaster(), origin, direction, 10, [mesh]),75+ 3.5,76+ );77+ assertEquals(78+ cameraObstructionHit(79+ new THREE.Raycaster(),80+ origin,81+ direction,82+ 10,83+ [mesh],84+ true,85+ ),86+ Infinity,87+ );88+});89+90+Deno.test("wide camera: containing mass still protects the lens", () => {91+ assertEquals(92+ cameraObstructionHit(93+ new THREE.Raycaster(),94+ origin,95+ direction,96+ 8,97+ [98+ wall(3, 0.25),99+ wall(7, 4),100+ ],101+ true,102+ ),103+ 5,104+ );105+});106+107+Deno.test("wide camera: containing instance is distinct from foreground", () => {108+ const mesh = new THREE.InstancedMesh(109+ new THREE.BoxGeometry(10, 10, 2),110+ new THREE.MeshBasicMaterial(),111+ 2,112+ );113+ mesh.setMatrixAt(0, new THREE.Matrix4().makeTranslation(0, 0, 3));114+ mesh.setMatrixAt(1, new THREE.Matrix4().makeTranslation(0, 0, 7));115+ mesh.updateMatrixWorld(true);116+ assertEquals(117+ cameraObstructionHit(118+ new THREE.Raycaster(),119+ origin,120+ direction,121+ 7,122+ [mesh],123+ true,124+ ),125+ 6,126+ );127+});128+129+Deno.test("wide camera: open foreground shell does not contain a distant lens", () => {130+ const mesh = new THREE.Mesh(new THREE.PlaneGeometry(10, 10));131+ mesh.rotation.y = Math.PI;132+ mesh.position.z = 3;133+ mesh.updateMatrixWorld(true);134+ assertEquals(135+ cameraObstructionHit(new THREE.Raycaster(), origin, direction, 10, [mesh]),136+ 3,137+ );138+ assertEquals(139+ cameraObstructionHit(140+ new THREE.Raycaster(),141+ origin,142+ direction,143+ 10,144+ [mesh],145+ true,146+ ),147+ Infinity,148+ );149+});11150 12151 Deno.test("clampBoom: clear ray keeps the desired boom exactly", () => {13152 assertEquals(clampBoom(7, Infinity), 7);