clone

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

Camera jump ROOT CAUSE: frame() ran OrbitControls.update() in follow mode

Dami committed 0f1080a
The jump was never occlusion: endless re-frames the camera on every
segment build (ensure -> cams.frame(box), meant for orbit + far-plane),
and frame() ended with controls.update(). OrbitControls.enabled gates
INPUT, not update() — update() unconditionally snaps camera.position to
the orbit spherical around the freshly-set box center. So every stream
LOAD EVENT yanked the follow cam to the orbit 'default angle' and the
follow lerp floated it back — exactly the user's words, on any terrain.

Found by instrumentation (dev handle __session + __ctx camera probes):
19 jump events in 3 sim-minutes, each a ~60u teleport with a 0.39/100ms
decay tail, each coinciding with a built-window change; samplePoint and
the marble were continuous throughout. After gating ALL orbit-side
effects (target/position/lookAt/update) behind mode==='orbit':
0 events in 3 sim-minutes. Orbit toggle unaffected (same path).

Hardening kept from the same hunt:
- Island occluders are MASS meshes only (cliff blocks + caps) — a tree
  frond crossing the sightline no longer pulls the boom in.
- Thin obstructions (< 1.6u blocked span, measured by a reversed cast)
  never trigger pull-in: arches/rails/grazes read better as a brief
  clip-through than a boom teleport; a far end inside a mass stays
  thick (the anti-clip guarantee).
- Genuine subject teleports (respawns) now CUT to the fresh framing
  (lookInit reset + position snap) instead of lerping across the world
  through whatever stands in between.
154 tests, check clean.
commit
0f1080aae7dc216188cbd12ac44594608e353762
parent
590d93c728676400e61502f49982031a10e0c136
author
Dami <dami.etoile@gmail.com>
3 files changed, +72 -8
Msrc/client/camera.ts63+++++++++++++++++++++++++++---
Msrc/client/endless.ts13+++++-
Msrc/client/islandRender.ts4++
M src/client/camera.ts +57 -6
@@ -59,6 +59,9 @@ const MIN_BOOM = 0.8;
5959 /** Boom release: snap IN when a mass intrudes (clipping is worse than a6060 * pop), ease back OUT at this exp rate per second once it clears. */6161 const BOOM_RELEASE_RATE = 3;62+/** Look-target discontinuity that reads as a TELEPORT (respawns move the63+ * subject tens of units): the camera cuts to the fresh framing. */64+const TELEPORT_CUT = 5;6265 6366 /**6467 * Occlusion clamp for the follow boom (pure — tests/camera_test.ts).
@@ -187,8 +190,15 @@ export function createCameras(
187190 const lookNow = new THREE.Vector3();188191 const raycaster = new THREE.Raycaster();189192 const rayDir = new THREE.Vector3();193+ const backDir = new THREE.Vector3();194+ const backOrigin = new THREE.Vector3();190195 /** Smoothed boom length (occlusion pull-in state); <0 = uninitialized. */191196 let boomNow = -1;197+ /** Obstructions thinner than this never pull the boom in: a tunnel198+ * arch, a rail lip or a grazed cliff corner crossing the sightline199+ * for a moment reads far better as a brief clip-through than as a200+ * boom teleport (user report: "camera jumps then floats back"). */201+ const THIN_SPAN = 1.6;192202 /** Nearest occluder hit distance from `origin` along `dir` (unit),193203 * looking no farther than `far`; Infinity when clear. */194204 const nearestHit = (
@@ -202,6 +212,24 @@ export function createCameras(
202212 const hits = raycaster.intersectObjects(obs as THREE.Object3D[], true);203213 return hits.length > 0 ? hits[0].distance : Infinity;204214 };215+ /** nearestHit, but THIN obstructions count as clear: the blocked span216+ * is measured with a reversed cast from the far end (front faces only217+ * — a far end sitting INSIDE a mass sees no exit and stays thick,218+ * which is exactly when the clamp must fire). */219+ const thickHit = (220+ origin: THREE.Vector3,221+ dir: THREE.Vector3,222+ far: number,223+ obs: readonly THREE.Object3D[],224+ ): number => {225+ const entry = nearestHit(origin, dir, far, obs);226+ if (entry === Infinity) return Infinity;227+ backDir.copy(dir).negate();228+ backOrigin.copy(origin).addScaledVector(dir, far);229+ const exit = nearestHit(backOrigin, backDir, far - entry, obs);230+ if (exit === Infinity) return entry; // far end inside the mass231+ return far - entry - exit >= THIN_SPAN ? entry : Infinity;232+ };205233 const bankA = new THREE.Vector3();206234 const bankB = new THREE.Vector3();207235 const bankC = new THREE.Vector3();
@@ -224,16 +252,23 @@ export function createCameras(
224252 const radius = Math.max(size.x, size.y, size.z) * 0.5 || 8;225253 const fov = THREE.MathUtils.degToRad(camera.fov);226254 const dist = (radius / Math.sin(fov / 2)) * 1.15;227- controls.target.copy(center);255+ camera.far = Math.max(500, dist * 4);256+ camera.updateProjectionMatrix();257+ // Orbit-side effects ONLY in orbit mode. OrbitControls.enabled258+ // gates input, not update(): an update() here in follow mode SNAPS259+ // the camera to the orbit spherical around the new box center —260+ // endless re-frames on every segment build, so every stream load261+ // yanked the follow cam to the "default angle" and let the lerp262+ // float it back (the user-reported jump; measured as a ~60-unit263+ // teleport with a 0.39/100ms decay tail at each build event).228264 if (mode === "orbit") {265+ controls.target.copy(center);229266 camera.position.copy(center).add(230267 new THREE.Vector3(0.75, 0.55, 0.75).normalize().multiplyScalar(dist),231268 );232269 camera.lookAt(center);270+ controls.update();233271 }234- camera.far = Math.max(500, dist * 4);235- camera.updateProjectionMatrix();236- controls.update();237272 },238273 239274 retarget(nextCurve, nextMarble) {
@@ -313,6 +348,19 @@ export function createCameras(
313348 lookTarget.copy(activeMarble.obj.position);314349 lookTarget.addScaledVector(upNow, LOOK_UP);315350 351+ // Subject teleported (respawn, checkpoint skip): CUT to the fresh352+ // framing instead of flying the old camera across the world — the353+ // lerp flight dragged the boom through island masses and the354+ // anti-clip clamp snapped it mid-route, reading as "camera jumps355+ // then floats back" (measured: a 60-unit snap with a decay tail).356+ if (lookInit && lookTarget.distanceTo(lookNow) > TELEPORT_CUT) {357+ lookInit = false;358+ boomNow = -1;359+ zoomUser = 1;360+ yawUser = 0;361+ pitchUser = 0;362+ }363+316364 // User adjust (zoom / orbit around the marble) with a debounced317365 // return: after a few quiet seconds the shot eases back to the318366 // standard follow framing.
@@ -349,7 +397,7 @@ export function createCameras(
349397 const dist = rayDir.length();350398 if (obs.length > 0 && dist > 1e-4) {351399 rayDir.divideScalar(dist);352- const hit = nearestHit(400+ const hit = thickHit(353401 lookTarget,354402 rayDir,355403 dist + CLIP_MARGIN + NEAR_CLEARANCE,
@@ -375,7 +423,7 @@ export function createCameras(
375423 const camDist = rayDir.length();376424 if (camDist > 1e-4) {377425 rayDir.divideScalar(camDist);378- const hit = nearestHit(426+ const hit = thickHit(379427 lookTarget,380428 rayDir,381429 camDist + NEAR_CLEARANCE,
@@ -388,7 +436,10 @@ export function createCameras(
388436 }389437 }390438 if (!lookInit) {439+ // Fresh framing (retarget / teleport cut): place the camera at440+ // the resolved desired position outright — no cross-world lerp.391441 lookNow.copy(lookTarget);442+ camera.position.copy(desired);392443 lookInit = true;393444 } else {394445 lookNow.lerp(lookTarget, a);
M src/client/endless.ts +11 -2
M src/client/islandRender.ts +4 -0