Occluders option on the follow cam (island instanced meshes): desired position pulled in front of the first marble->camera hit, so the cam slides over shoulders instead of clipping through mass. Banking lean: camera rolls into the upcoming curve (signed three-sample turn, speed- scaled, smoothed; rotateZ(+phi) banks right). Both modes.
- commit
- 23e0277b92cebe4ef13b790d02a38708b368ac47
- parent
- 521b01343d3614f26dacaf98667b2fe43c294157
- author
- Dami <dami.etoile@gmail.com>
| M | src/ | 82 | ++++++++++++++++++++++++++++++ |
| M | src/ | 18 | ++++++- |
@@ -46,6 +46,11 @@ export interface CameraOptions {
4646 * farther back (slalom-scale track pieces would swallow the default). */4747 trail?: number;4848 height?: number;49+ /** Objects the follow cam must not clip through (island masses): the50+ * desired position is pulled in front of the first hit along the51+ * marble → camera ray. Resolved per frame — pass a closure over the52+ * live scene. */53+ occluders?: () => readonly THREE.Object3D[];4954 }5055 5156 export function createCameras(@@ -75,6 +80,12 @@ export function createCameras(
7580 const desired = new THREE.Vector3();7681 const lookTarget = new THREE.Vector3();7782 const lookNow = new THREE.Vector3();83+ const raycaster = new THREE.Raycaster();84+ const rayDir = new THREE.Vector3();85+ const bankA = new THREE.Vector3();86+ const bankB = new THREE.Vector3();87+ const bankC = new THREE.Vector3();88+ let bankNow = 0;7889 let lookInit = false;7990 8091 const api: Cameras = {@@ -160,6 +171,33 @@ export function createCameras(
160171 lookTarget.copy(activeMarble.obj.position);161172 lookTarget.y += LOOK_UP;162173 174+ // Occlusion: never let island mass sit between marble and camera —175+ // pull the desired position in front of the first hit (small176+ // margin), so the cam slides over shoulders instead of clipping177+ // through them.178+ if (opts.occluders) {179+ const obs = opts.occluders();180+ if (obs.length > 0) {181+ rayDir.copy(desired).sub(lookTarget);182+ const dist = rayDir.length();183+ if (dist > 1e-4) {184+ rayDir.divideScalar(dist);185+ raycaster.set(lookTarget, rayDir);186+ raycaster.far = dist;187+ const hits = raycaster.intersectObjects(188+ obs as THREE.Object3D[],189+ true,190+ );191+ if (hits.length > 0) {192+ desired.copy(lookTarget).addScaledVector(193+ rayDir,194+ Math.max(0.8, hits[0].distance - 0.35),195+ );196+ }197+ }198+ }199+ }200+163201 // Frame-rate independent smoothing of the nominal 0.08/frame at 60fps.164202 const a = 1 - Math.pow(1 - LERP, Math.max(dt, 1e-4) * 60);165203 camera.position.lerp(desired, a);@@ -170,6 +208,50 @@ export function createCameras(
170208 lookNow.lerp(lookTarget, a);171209 }172210 camera.lookAt(lookNow);211+212+ // Banking lean (M13): roll into the upcoming curve — signed turn213+ // from three path samples around the marble; rotateZ(+φ) banks214+ // right, a right turn has (d1×d2).y < 0, so φ = −θ·gain.215+ let bankTarget = 0;216+ const SPAN = 1.5;217+ const s = activeMarble.s;218+ const sampled = pathSampler219+ ? pathSampler(s - SPAN, bankA) && pathSampler(s, bankB) &&220+ pathSampler(s + SPAN, bankC)221+ : activeCurve222+ ? (() => {223+ const total = activeCurve.getLength();224+ if (total <= SPAN * 2) return false;225+ const at = (ss: number, out: THREE.Vector3) =>226+ activeCurve!.getPointAt(227+ (((ss % total) + total) % total) / total,228+ out,229+ );230+ at(s - SPAN, bankA);231+ at(s, bankB);232+ at(s + SPAN, bankC);233+ return true;234+ })()235+ : false;236+ if (sampled) {237+ const d1x = bankB.x - bankA.x;238+ const d1z = bankB.z - bankA.z;239+ const d2x = bankC.x - bankB.x;240+ const d2z = bankC.z - bankB.z;241+ const crossY = d1z * d2x - d1x * d2z;242+ const dot = d1x * d2x + d1z * d2z;243+ const turn = Math.atan2(crossY, dot);244+ bankTarget = Math.max(245+ -0.32,246+ Math.min(247+ 0.32,248+ -turn * (0.1 + activeMarble.speed * 0.035),249+ ),250+ );251+ }252+ bankNow += (bankTarget - bankNow) *253+ (1 - Math.pow(1 - 0.05, Math.max(dt, 1e-4) * 60));254+ camera.rotateZ(bankNow);173255 },174256 175257 dispose() {@@ -106,8 +106,13 @@ export function bootEndless(
106106 });107107 108108 // Slalom-scale pieces would swallow the default follow cam — ride109- // higher and farther back. (Camera collision is a known polish item.)110- const cams = createCameras(ctx, null, null, { trail: 7, height: 3.1 });109+ // higher and farther back; island masses are occluders (the follow cam110+ // slides over shoulders instead of clipping through them).111+ const cams = createCameras(ctx, null, null, {112+ trail: 7,113+ height: 3.1,114+ occluders: () => session?.islandMeshes() ?? [],115+ });111116 const setCamLabel = (mode: CamMode) => {112117 if (ui.camBtn) {113118 ui.camBtn.textContent = mode === "orbit" ? "follow cam" : "orbit cam";@@ -393,6 +398,15 @@ class Session {
393398 return true;394399 }395400 401+ /** Island instanced meshes of the built window (camera occluders). */402+ islandMeshes(): THREE.Object3D[] {403+ const out: THREE.Object3D[] = [];404+ for (const seg of this.built.values()) {405+ for (const d of seg.disposables) out.push(...d.meshes);406+ }407+ return out;408+ }409+396410 /** Arc-length range currently sampleable (the built window). */397411 private builtBand(): [number, number] {398412 let lo = Infinity;