import { useEffect, useRef, useState, useCallback } from 'react' import { createPortal } from 'react-dom' import * as THREE from 'three' import type { CoasterSimulationResult } from '../types/api' import type { TerrainCaptureData } from './useTerrainCapture' import { geoToEnu } from './useTerrainCapture' import styles from './RideRenderer.module.css' // ── Types ────────────────────────────────────────────────────────────────────── type CameraMode = 'first' | 'third' interface Props { simResult: CoasterSimulationResult captureData: TerrainCaptureData[] onStop: () => void /** Called at ~4 Hz with the current track-fraction position [0,1]. */ onRideProgress?: (sFrac: number) => void } // ── Helpers ─────────────────────────────────────────────────────────────────── /** Largest index i such that arr[i] <= t (arr must be non-decreasing). */ function bisect(arr: number[], t: number): number { let lo = 0, hi = arr.length - 2 while (lo < hi) { const mid = (lo + hi + 1) >> 1 if (arr[mid] <= t) lo = mid else hi = mid - 1 } return lo } function formatRideTime(s: number): string { const m = Math.floor(s / 60) const sec = Math.floor(s % 60) return `${m}:${sec.toString().padStart(2, '0')}` } const UP = new THREE.Vector3(0, 1, 0) // ── Precomputed ride data in Three.js ENU coords ─────────────────────────────── interface RideData { timeArray: number[] sFrac: number[] // profile s_frac at each sample — parallel to timeArray centerline: THREE.Vector3[] rail1: THREE.Vector3[] rail2: THREE.Vector3[] totalDuration: number count: number } function buildRideData(simResult: CoasterSimulationResult): RideData { const { profile, rail_1, rail_2, origin } = simResult const n = Math.min(profile.s_frac.length, rail_1.length, rail_2.length) const r1 = (rail_1 as [number, number, number][]).slice(0, n) .map(([lon, lat, alt]) => geoToEnu(lon, lat, alt, origin)) const r2 = (rail_2 as [number, number, number][]).slice(0, n) .map(([lon, lat, alt]) => geoToEnu(lon, lat, alt, origin)) const cl = r1.map((a, i) => a.clone().lerp(r2[i], 0.5)) // Cumulative time using same 1 m/s velocity floor as backend const timeArray: number[] = [0] for (let i = 1; i < n; i++) { const ds = (profile.s_frac[i] - profile.s_frac[i - 1]) * profile.total_length_m const v = Math.max(profile.velocity_ms[i - 1], 1.0) timeArray.push(timeArray[i - 1] + ds / v) } const sFrac = profile.s_frac.slice(0, n) return { timeArray, sFrac, centerline: cl, rail1: r1, rail2: r2, totalDuration: timeArray[n - 1], count: n } } // ── Camera target computation (zero allocation per call) ────────────────────── // // All scratch objects are pre-allocated at module level so the hot rAF path // never triggers the garbage collector. const _mat = new THREE.Matrix4() const _dummy = new THREE.PerspectiveCamera() // PerspectiveCamera.lookAt → −Z toward target // Scratch vectors reused every frame const _scPos = new THREE.Vector3() const _scR1 = new THREE.Vector3() const _scR2 = new THREE.Vector3() const _scTang = new THREE.Vector3() const _scSide = new THREE.Vector3() const _scTUp = new THREE.Vector3() const _scLook = new THREE.Vector3() // Output objects — callers must copy out before the next call const _outPos = new THREE.Vector3() const _outQuat = new THREE.Quaternion() // Scratch for user drag rotation offset (first-person only) const _userOffsetQuat = new THREE.Quaternion() const _userOffsetEuler = new THREE.Euler(0, 0, 0, 'YXZ') interface CameraTarget { pos: THREE.Vector3; quat: THREE.Quaternion } /** Writes result into _outPos / _outQuat — no heap allocation. */ function computeCameraTarget( t: number, data: RideData, mode: CameraMode, ): CameraTarget { const { timeArray, centerline, rail1, rail2, count } = data const ct = Math.max(0, Math.min(t, timeArray[count - 1])) const i = bisect(timeArray, ct) const i1 = Math.min(i + 1, count - 1) const alpha = (timeArray[i1] > timeArray[i]) ? (ct - timeArray[i]) / (timeArray[i1] - timeArray[i]) : 0 _scPos.lerpVectors(centerline[i], centerline[i1], alpha) _scR1.lerpVectors(rail1[i], rail1[i1], alpha) _scR2.lerpVectors(rail2[i], rail2[i1], alpha) // Wider stencil for smoother forward tangent const pi = Math.max(0, i - 2) const pj = Math.min(count - 1, i1 + 2) _scTang.subVectors(centerline[pj], centerline[pi]).normalize() // Track-local "up": cross(tangent, rail1→rail2) → binormal _scSide.subVectors(_scR1, _scR2).normalize() _scTUp.crossVectors(_scTang, _scSide).normalize() if (_scTUp.dot(UP) < 0) _scTUp.negate() if (mode === 'first') { // Sit 1.5 m above centreline, look 10 m ahead _outPos.copy(_scPos).addScaledVector(UP, 1.5) _scLook.copy(_outPos).addScaledVector(_scTang, 10) _dummy.up.copy(_scTUp) } else { // 40 m behind + 12 m above, looking at the car _outPos.copy(_scPos).addScaledVector(_scTang, -40).addScaledVector(UP, 12) _scLook.copy(_scPos) _dummy.up.copy(UP) } _dummy.position.copy(_outPos) _dummy.lookAt(_scLook) _dummy.updateMatrixWorld(true) _mat.extractRotation(_dummy.matrixWorld) _outQuat.setFromRotationMatrix(_mat) return { pos: _outPos, quat: _outQuat } } // ── Terrain mesh builder (one per patch) ────────────────────────────────────── function buildTerrainMesh( patch: TerrainCaptureData, renderer: THREE.WebGLRenderer, visible: boolean, ): { mesh: THREE.Mesh; geo: THREE.BufferGeometry; mat: THREE.Material; tex: THREE.Texture } { const GRID = patch.gridSize const verts = patch.terrainVertices const posArr = new Float32Array(GRID * GRID * 3) const uvArr = new Float32Array(GRID * GRID * 2) for (let j = 0; j < GRID; j++) { for (let i = 0; i < GRID; i++) { const idx = j * GRID + i const v = verts[idx] posArr[idx * 3] = v.x posArr[idx * 3 + 1] = v.y - 0.5 posArr[idx * 3 + 2] = v.z uvArr[idx * 2] = i / (GRID - 1) uvArr[idx * 2 + 1] = j / (GRID - 1) } } const idxArr: number[] = [] for (let j = 0; j < GRID - 1; j++) { for (let i = 0; i < GRID - 1; i++) { const a = j * GRID + i, b = j * GRID + i + 1 const c = (j + 1) * GRID + i + 1, d = (j + 1) * GRID + i idxArr.push(a, b, d, b, c, d) } } const geo = new THREE.BufferGeometry() geo.setAttribute('position', new THREE.BufferAttribute(posArr, 3)) geo.setAttribute('uv', new THREE.BufferAttribute(uvArr, 2)) geo.setIndex(idxArr) geo.computeVertexNormals() const tex = new THREE.Texture(patch.imageBitmap) tex.colorSpace = THREE.SRGBColorSpace tex.anisotropy = renderer.capabilities.getMaxAnisotropy() tex.minFilter = THREE.LinearMipmapLinearFilter tex.magFilter = THREE.LinearFilter tex.needsUpdate = true const mat = new THREE.MeshLambertMaterial({ map: tex, side: THREE.FrontSide }) const mesh = new THREE.Mesh(geo, mat) mesh.visible = visible return { mesh, geo, mat, tex } } // ── Three.js scene builder ───────────────────────────────────────────────────── function buildScene(captureData: TerrainCaptureData[], rideData: RideData, renderer: THREE.WebGLRenderer) { const scene = new THREE.Scene() scene.background = new THREE.Color(0x87ceeb) scene.add(new THREE.AmbientLight(0xffffff, 0.7)) const sun = new THREE.DirectionalLight(0xfffbe6, 0.85) sun.position.set(200, 500, -300) scene.add(sun) const geos: THREE.BufferGeometry[] = [] const mats: THREE.Material[] = [] const texes: THREE.Texture[] = [] // ── Terrain patches — one mesh per patch, only the active one visible ──── const terrainMeshes = captureData.map((patch, i) => { const { mesh, geo, mat, tex } = buildTerrainMesh(patch, renderer, i === 0) geos.push(geo) mats.push(mat) texes.push(tex) scene.add(mesh) // Pre-upload texture to GPU so visibility swaps have zero hitch renderer.initTexture(tex) return mesh }) function setActivePatch(idx: number) { const clamped = Math.max(0, Math.min(idx, terrainMeshes.length - 1)) terrainMeshes.forEach((m, i) => { m.visible = i === clamped }) } // ── Coaster rails ────────────────────────────────────────────────────────── function addRail(pts: THREE.Vector3[]) { const step = Math.max(1, Math.floor(pts.length / 200)) const dpts = pts.filter((_, i) => i % step === 0 || i === pts.length - 1) const curve = new THREE.CatmullRomCurve3(dpts) const geo = new THREE.TubeGeometry(curve, Math.min(dpts.length * 3, 600), 0.075, 6, false) const mat = new THREE.MeshLambertMaterial({ color: 0xef4444 }) geos.push(geo) mats.push(mat) scene.add(new THREE.Mesh(geo, mat)) } addRail(rideData.rail1) addRail(rideData.rail2) return { scene, setActivePatch, disposeAll: () => { geos.forEach(g => g.dispose()) mats.forEach(m => m.dispose()) texes.forEach(t => t.dispose()) }, } } // ── Component ───────────────────────────────────────────────────────────────── export function RideRenderer({ simResult, captureData, onStop, onRideProgress }: Props) { const canvasRef = useRef(null) const rendererRef = useRef(null) const cameraRef = useRef(new THREE.PerspectiveCamera(75, 1, 0.1, 50000)) const sceneRef = useRef(null) const rafRef = useRef(null) const fpsElRef = useRef(null) // Ride playback refs (mutable, used inside rAF loop) const rideDataRef = useRef(null) const isPlayingRef = useRef(false) const rideTimeRef = useRef(0) const lastTimeUpdateRef = useRef(-1) // throttle for ride-time display (~4 Hz) const lastCursorUpdateRef = useRef(-1) // throttle for plot cursor (~1 Hz — Recharts SVG is expensive) const cameraModeRef = useRef('first') const prevWallRef = useRef(0) // FPS tracking (updated via DOM ref — zero React overhead) const fpsCountRef = useRef(0) const fpsLastRef = useRef(0) // Smooth camera state const smoothPosRef = useRef(new THREE.Vector3()) const smoothQuatRef = useRef(new THREE.Quaternion()) const needsInitRef = useRef(true) // Active terrain patch switching const setActivePatchRef = useRef<((idx: number) => void) | null>(null) const activePatchIdxRef = useRef(0) // First-person drag rotation state const userYawRef = useRef(0) // radians — current yaw offset from track forward const userPitchRef = useRef(0) // radians — current pitch offset const isDraggingRef = useRef(false) const lastDragXRef = useRef(0) const lastDragYRef = useRef(0) // React state (updated ~4 Hz) const [isPlaying, setIsPlaying] = useState(false) const [rideTime, setRideTime] = useState(0) const [totalDuration, setTotalDuration] = useState(0) const [cameraMode, setCameraMode] = useState('first') useEffect(() => { cameraModeRef.current = cameraMode }, [cameraMode]) // ── Build Three.js scene ──────────────────────────────────────────────────── useEffect(() => { const canvas = canvasRef.current if (!canvas) return // antialias: false — significant GPU cost saving; no pixelRatio scaling on retina const renderer = new THREE.WebGLRenderer({ canvas, antialias: false }) renderer.setPixelRatio(1) rendererRef.current = renderer const rideData = buildRideData(simResult) rideDataRef.current = rideData setTotalDuration(rideData.totalDuration) const { scene, setActivePatch, disposeAll } = buildScene(captureData, rideData, renderer) sceneRef.current = scene setActivePatchRef.current = setActivePatch activePatchIdxRef.current = 0 function onResize() { const w = window.innerWidth, h = window.innerHeight renderer.setSize(w, h) cameraRef.current.aspect = w / h cameraRef.current.updateProjectionMatrix() } window.addEventListener('resize', onResize) onResize() if (rideData.count > 1) { const t0 = computeCameraTarget(0, rideData, 'first') smoothPosRef.current.copy(t0.pos) smoothQuatRef.current.copy(t0.quat) cameraRef.current.position.copy(t0.pos) cameraRef.current.quaternion.copy(t0.quat) cameraRef.current.updateMatrixWorld() } renderer.render(scene, cameraRef.current) return () => { window.removeEventListener('resize', onResize) if (rafRef.current !== null) { cancelAnimationFrame(rafRef.current); rafRef.current = null } disposeAll() renderer.dispose() rendererRef.current = null sceneRef.current = null } }, [simResult, captureData]) // eslint-disable-line react-hooks/exhaustive-deps // ── rAF render loop ────────────────────────────────────────────────────────── // Smoothing time constants (seconds) const POS_TAU = 0.05 const ROT_TAU = 0.08 function startLoop() { function tick(wallMs: number) { const renderer = rendererRef.current const scene = sceneRef.current if (!renderer || !scene) return const dt = Math.min((wallMs - prevWallRef.current) / 1000, 0.1) prevWallRef.current = wallMs // ── FPS counter (DOM write, no React) ────────────────────────────────── fpsCountRef.current++ if (wallMs - fpsLastRef.current >= 500) { const elapsed = (wallMs - fpsLastRef.current) / 1000 const fps = Math.round(fpsCountRef.current / elapsed) if (fpsElRef.current) fpsElRef.current.textContent = `${fps} FPS` fpsCountRef.current = 0 fpsLastRef.current = wallMs } // ── Ride time ────────────────────────────────────────────────────────── let rideT = rideTimeRef.current if (isPlayingRef.current) { rideTimeRef.current += dt rideT = rideTimeRef.current // Ride time display: ~4 Hz (cheap — only updates RideRenderer itself) if (rideT - lastTimeUpdateRef.current > 0.25) { setRideTime(rideT) lastTimeUpdateRef.current = rideT } // Plot cursor: ~1 Hz (expensive — triggers Recharts SVG repaint in parent) if (onRideProgress && rideT - lastCursorUpdateRef.current > 1.0) { lastCursorUpdateRef.current = rideT const d = rideDataRef.current if (d) { const ci = bisect(d.timeArray, rideT) const ci1 = Math.min(ci + 1, d.count - 1) const ca = (d.timeArray[ci1] > d.timeArray[ci]) ? (rideT - d.timeArray[ci]) / (d.timeArray[ci1] - d.timeArray[ci]) : 0 onRideProgress(d.sFrac[ci] + (d.sFrac[ci1] - d.sFrac[ci]) * ca) } } const data = rideDataRef.current if (data && rideT >= data.totalDuration) { rideTimeRef.current = data.totalDuration rideT = data.totalDuration isPlayingRef.current = false setIsPlaying(false) setRideTime(rideT) } } // ── Active terrain patch ────────────────────────────────────────────── if (captureData.length > 1 && setActivePatchRef.current) { const d = rideDataRef.current if (d) { const pi = bisect(d.timeArray, rideT) const pi1 = Math.min(pi + 1, d.count - 1) const pa = d.timeArray[pi1] > d.timeArray[pi] ? (rideT - d.timeArray[pi]) / (d.timeArray[pi1] - d.timeArray[pi]) : 0 const frac = d.sFrac[pi] + (d.sFrac[pi1] - d.sFrac[pi]) * pa const target = Math.round(frac * (captureData.length - 1)) if (target !== activePatchIdxRef.current) { activePatchIdxRef.current = target setActivePatchRef.current(target) } } } // ── Camera ──────────────────────────────────────────────────────────── const data = rideDataRef.current if (data) { const target = computeCameraTarget(rideT, data, cameraModeRef.current) // First-person drag rotation: lerp yaw/pitch back to 0 when not dragging, // then bake the offset into the target quaternion. if (cameraModeRef.current === 'first') { if (!isDraggingRef.current) { const returnAlpha = 1 - Math.exp(-dt * 2.5) userYawRef.current *= (1 - returnAlpha) userPitchRef.current *= (1 - returnAlpha) if (Math.abs(userYawRef.current) < 0.0001) userYawRef.current = 0 if (Math.abs(userPitchRef.current) < 0.0001) userPitchRef.current = 0 } _userOffsetEuler.set(userPitchRef.current, userYawRef.current, 0, 'YXZ') _userOffsetQuat.setFromEuler(_userOffsetEuler) target.quat.multiply(_userOffsetQuat) } if (needsInitRef.current || isDraggingRef.current) { // Snap instantly: on init, or while dragging so the view tracks the finger smoothPosRef.current.copy(target.pos) smoothQuatRef.current.copy(target.quat) needsInitRef.current = false } else { const posAlpha = 1 - Math.exp(-dt / POS_TAU) const rotAlpha = 1 - Math.exp(-dt / ROT_TAU) smoothPosRef.current.lerp(target.pos, posAlpha) smoothQuatRef.current.slerp(target.quat, rotAlpha) } cameraRef.current.position.copy(smoothPosRef.current) cameraRef.current.quaternion.copy(smoothQuatRef.current) cameraRef.current.updateMatrixWorld() } renderer.render(scene, cameraRef.current) rafRef.current = requestAnimationFrame(tick) } prevWallRef.current = performance.now() fpsLastRef.current = performance.now() rafRef.current = requestAnimationFrame(tick) } // ── Playback controls ────────────────────────────────────────────────────── const play = useCallback(() => { if (!rideDataRef.current) return // Restart from beginning if ride has finished const restarting = rideTimeRef.current >= rideDataRef.current.totalDuration - 0.05 if (restarting) { rideTimeRef.current = 0 userYawRef.current = 0 userPitchRef.current = 0 setRideTime(0) needsInitRef.current = true // snap camera to start position if (onRideProgress) onRideProgress(0) } else { needsInitRef.current = false // resume with smooth camera } lastTimeUpdateRef.current = -1 lastCursorUpdateRef.current = -1 isPlayingRef.current = true setIsPlaying(true) }, [onRideProgress]) const pause = useCallback(() => { isPlayingRef.current = false setIsPlaying(false) }, []) const stop = useCallback(() => { isPlayingRef.current = false rideTimeRef.current = 0 setIsPlaying(false) setRideTime(0) onStop() }, [onStop]) // ── First-person drag-to-look event listeners ───────────────────────────── useEffect(() => { const canvas = canvasRef.current if (!canvas) return const DRAG_SENSITIVITY = 0.004 const MAX_PITCH = Math.PI / 2.5 // ±72° const MAX_YAW = Math.PI // ±180° function onMouseDown(e: MouseEvent) { if (cameraModeRef.current !== 'first') return e.preventDefault() // prevent text-selection flicker during drag isDraggingRef.current = true lastDragXRef.current = e.clientX lastDragYRef.current = e.clientY } function onMouseMove(e: MouseEvent) { if (!isDraggingRef.current) return const dx = e.clientX - lastDragXRef.current const dy = e.clientY - lastDragYRef.current lastDragXRef.current = e.clientX lastDragYRef.current = e.clientY userYawRef.current = Math.max(-MAX_YAW, Math.min(MAX_YAW, userYawRef.current - dx * DRAG_SENSITIVITY)) userPitchRef.current = Math.max(-MAX_PITCH, Math.min(MAX_PITCH, userPitchRef.current - dy * DRAG_SENSITIVITY)) } function onMouseUp() { isDraggingRef.current = false } function onTouchStart(e: TouchEvent) { if (cameraModeRef.current !== 'first' || e.touches.length === 0) return isDraggingRef.current = true lastDragXRef.current = e.touches[0].clientX lastDragYRef.current = e.touches[0].clientY } function onTouchMove(e: TouchEvent) { if (!isDraggingRef.current || e.touches.length === 0) return const dx = e.touches[0].clientX - lastDragXRef.current const dy = e.touches[0].clientY - lastDragYRef.current lastDragXRef.current = e.touches[0].clientX lastDragYRef.current = e.touches[0].clientY userYawRef.current = Math.max(-MAX_YAW, Math.min(MAX_YAW, userYawRef.current - dx * DRAG_SENSITIVITY)) userPitchRef.current = Math.max(-MAX_PITCH, Math.min(MAX_PITCH, userPitchRef.current - dy * DRAG_SENSITIVITY)) } function onTouchEnd() { isDraggingRef.current = false } canvas.addEventListener('mousedown', onMouseDown) window.addEventListener('mousemove', onMouseMove) window.addEventListener('mouseup', onMouseUp) canvas.addEventListener('touchstart', onTouchStart, { passive: true }) window.addEventListener('touchmove', onTouchMove, { passive: true }) window.addEventListener('touchend', onTouchEnd) return () => { canvas.removeEventListener('mousedown', onMouseDown) window.removeEventListener('mousemove', onMouseMove) window.removeEventListener('mouseup', onMouseUp) canvas.removeEventListener('touchstart', onTouchStart) window.removeEventListener('touchmove', onTouchMove) window.removeEventListener('touchend', onTouchEnd) } }, []) // Start render loop on mount; auto-play immediately useEffect(() => { needsInitRef.current = true startLoop() const data = rideDataRef.current if (data) { lastTimeUpdateRef.current = -1 lastCursorUpdateRef.current = -1 isPlayingRef.current = true setIsPlaying(true) } return () => { if (rafRef.current !== null) { cancelAnimationFrame(rafRef.current); rafRef.current = null } } }, []) // eslint-disable-line react-hooks/exhaustive-deps // ── UI ──────────────────────────────────────────────────────────────────── const isRideDone = !isPlaying && totalDuration > 0 && rideTime >= totalDuration - 0.05 const controls = (
Camera
{formatRideTime(rideTime)} / {formatRideTime(totalDuration)}
) return ( <>
{createPortal(controls, document.body)} ) }