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>
| M | src/ | 63 | +++++++++++++++++++++++++++--- |
| M | src/ | 13 | +++++- |
| M | src/ | 4 | ++ |
@@ -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);@@ -267,6 +267,9 @@ export function bootEndless(
267267 navigator.clipboard?.writeText(location.href).catch(() => {});268268 });269269 270+ // Debug handle (dev tooling, same spirit as __ctx): harness probes271+ // reach the live session for camera/sampling forensics.272+ Object.defineProperty(globalThis, "__session", { get: () => session });270273 installCua(ctx, () => ({271274 mode: "endless",272275 seed: session?.seed ?? "",@@ -997,11 +1000,17 @@ class Session {
9971000 return this.camUp;9981001 }9991002 1000- /** Island instanced meshes of the built window (camera occluders). */1003+ /** Island MASS meshes of the built window (camera occluders) —1004+ * vegetation/decor never pulls the boom in (a crossing tree frond1005+ * read as a camera jump). */10011006 islandMeshes(): THREE.Object3D[] {10021007 const out: THREE.Object3D[] = [];10031008 for (const seg of this.built.values()) {1004- for (const d of seg.disposables) out.push(...d.meshes);1009+ for (const d of seg.disposables) {1010+ for (const m of d.meshes) {1011+ if (m.userData.occluder) out.push(m);1012+ }1013+ }10051014 }10061015 return out;10071016 }@@ -261,6 +261,10 @@ export function buildIslandInstanced(
261261 if (imesh.instanceColor) imesh.instanceColor.needsUpdate = true;262262 imesh.computeBoundingSphere();263263 imesh.userData.animInstances = anim;264+ // Camera occluders are the MASSES only (cliff blocks + caps) —265+ // pulling the boom in for a tree frond crossing the sightline266+ // read as a camera jump (user report).267+ imesh.userData.occluder = isBlock || isCap;264268 group.add(imesh);265269 meshes.push(imesh);266270 }