UNPKG

@threlte/extras

Version:

Utilities, abstractions and plugins for your Threlte apps

182 lines (181 loc) 7.8 kB
import { Euler, Object3D, Quaternion, Vector3 } from 'three'; import { useTask, useThrelte } from '@threlte/core'; const EPSILON = 1e-6; export const useFollow = (optionsFn) => { const { invalidate } = useThrelte(); const { target, controls, deadZone, lookAtOffset, lookAheadSmoothTime = 0.15, lookAhead = 0, trackRotation = false, trackRotationOffset = 0, trackRotationSmoothTime = 0, followSmoothTime = 0 } = $derived(optionsFn?.() ?? {}); const deadZoneX = $derived(deadZone?.[0] ?? 0); const deadZoneY = $derived(deadZone?.[1] ?? 0); let initialized = false; let smoothingInitialized = false; let prevTarget = null; const targetWorld = new Vector3(); const lastTargetWorld = new Vector3(); const velocity = new Vector3(); const smoothedVelocity = new Vector3(); const trackedTarget = new Vector3(); const lookAtPoint = new Vector3(); const tempDiff = new Vector3(); const cameraRight = new Vector3(); const cameraUp = new Vector3(); const cameraForward = new Vector3(); const inputForward = new Vector3(); const inputRight = new Vector3(); const smoothedTracked = new Vector3(); const lag = new Vector3(); const smoothedLookAt = new Vector3(); const targetQuat = new Quaternion(); const trackEuler = new Euler(0, 0, 0, 'YXZ'); const targetForward = new Vector3(); const targetRight = new Vector3(); const scratchScale = new Vector3(); const { task } = useTask(Symbol('useFollow'), (delta) => { if (target !== prevTarget) { initialized = false; smoothingInitialized = false; prevTarget = target ?? null; } if (!controls || !target) return; target.updateWorldMatrix(true, false); if (trackRotation) { target.matrixWorld.decompose(targetWorld, targetQuat, scratchScale); } else { targetWorld.setFromMatrixPosition(target.matrixWorld); } if (lookAhead !== 0 && initialized && delta > 0) { velocity.subVectors(targetWorld, lastTargetWorld).divideScalar(delta); const scaledLookAheadSmoothTime = Math.max(0.001, lookAheadSmoothTime); const velT = 1 - Math.exp(-delta / scaledLookAheadSmoothTime); smoothedVelocity.lerp(velocity, velT); } else { velocity.set(0, 0, 0); smoothedVelocity.set(0, 0, 0); } lastTargetWorld.copy(targetWorld); if (!initialized) { trackedTarget.copy(targetWorld); } else if (deadZoneX > 0 || deadZoneY > 0) { const cam = controls.camera; cameraRight.setFromMatrixColumn(cam.matrixWorld, 0).normalize(); cameraUp.setFromMatrixColumn(cam.matrixWorld, 1).normalize(); cameraForward.setFromMatrixColumn(cam.matrixWorld, 2).negate().normalize(); tempDiff.subVectors(targetWorld, trackedTarget); const dx = tempDiff.dot(cameraRight); const dy = tempDiff.dot(cameraUp); const dz = tempDiff.dot(cameraForward); if (deadZoneX > 0) { if (Math.abs(dx) > deadZoneX) { trackedTarget.addScaledVector(cameraRight, dx > 0 ? dx - deadZoneX : dx + deadZoneX); } } else { trackedTarget.addScaledVector(cameraRight, dx); } if (deadZoneY > 0) { if (Math.abs(dy) > deadZoneY) { trackedTarget.addScaledVector(cameraUp, dy > 0 ? dy - deadZoneY : dy + deadZoneY); } } else { trackedTarget.addScaledVector(cameraUp, dy); } trackedTarget.addScaledVector(cameraForward, dz); } else { trackedTarget.copy(targetWorld); } lookAtPoint.copy(trackedTarget); if (lookAtOffset) { lookAtPoint.x += lookAtOffset[0] ?? 0; lookAtPoint.y += lookAtOffset[1] ?? 0; lookAtPoint.z += lookAtOffset[2] ?? 0; } if (lookAhead !== 0 && smoothedVelocity.lengthSq() > EPSILON) { lookAtPoint.addScaledVector(smoothedVelocity, lookAhead); } if (!smoothingInitialized) { smoothedTracked.copy(trackedTarget); smoothingInitialized = true; } if (followSmoothTime > EPSILON) { const t = 1 - Math.exp(-delta / followSmoothTime); smoothedTracked.lerp(trackedTarget, t); lag.subVectors(smoothedTracked, trackedTarget); smoothedLookAt.copy(lookAtPoint).add(lag); controls.moveTo(smoothedLookAt.x, smoothedLookAt.y, smoothedLookAt.z, false); } else { smoothedTracked.copy(trackedTarget); controls.moveTo(lookAtPoint.x, lookAtPoint.y, lookAtPoint.z, false); } if (trackRotation) { trackEuler.setFromQuaternion(targetQuat, 'YXZ'); const targetAzimuth = trackEuler.y + trackRotationOffset; const current = controls.azimuthAngle; let arc = targetAzimuth - current; arc = Math.atan2(Math.sin(arc), Math.cos(arc)); const rotSmooth = Math.max(0, trackRotationSmoothTime); const rotT = rotSmooth <= EPSILON ? 1 : 1 - Math.exp(-delta / rotSmooth); controls.azimuthAngle = current + arc * rotT; } initialized = true; invalidate(); }, { autoInvalidate: false }); const getInputDirection = (right, forward, out) => { const cam = controls?.camera; out.set(0, 0, 0); if (!cam) return out; cam.getWorldDirection(inputForward); inputForward.y = 0; if (inputForward.lengthSq() < EPSILON) return out; inputForward.normalize(); inputRight.set(-inputForward.z, 0, inputForward.x); return out.addScaledVector(inputRight, right).addScaledVector(inputForward, forward); }; const getTargetDirection = (right, forward, out) => { out.set(0, 0, 0); if (!target) return out; target.updateWorldMatrix(true, false); targetRight.setFromMatrixColumn(target.matrixWorld, 0); targetForward.setFromMatrixColumn(target.matrixWorld, 2); targetRight.y = 0; targetForward.y = 0; if (targetForward.lengthSq() < EPSILON) return out; targetRight.normalize(); targetForward.normalize(); return out.addScaledVector(targetRight, right).addScaledVector(targetForward, forward); }; return { /** Internal task, exposed for ordering other tasks via `after`/`before`. */ task, /** * Project a 2D input into a world-space direction aligned with the * camera's horizontal basis. `right` maps to the camera's right axis, * `forward` to its forward axis (both flattened to the XZ plane). Writes * to `out` and returns it. * * Used to make character movement feel correct regardless of how the * user has orbited the camera: `W` always means "away from camera". */ getInputDirection, /** * Project a 2D input into a world-space direction aligned with the * target's own horizontal basis — `forward` follows the target's local * `+Z` axis, `right` follows its local `+X`. Writes to `out` and returns * it. * * Use this instead of `getInputDirection` when `trackRotation` is on: * camera-relative input combined with rotation tracking creates a * feedback loop and the character will spin. */ getTargetDirection }; };