Description
The hook owns camera positioning and targeting in the frame loop, using frame-rate-independent, distance-aware damping for smooth overview-to-detail transitions. An optional pointer offset lets the camera follow cursor movement without routing animation through React state.
The demo pairs three authored shots with live near/far clipping updates and camera-driven LOD changes. Use this pattern for product views, scene tours, and other controlled camera movements that need imperative retargeting with lightweight pointer interaction.
Agent skill
Add this resource
$add-camera-controlsImplementation
Source Code
useCameraControls
'use client'
import { useFrame, useThree } from '@react-three/fiber'
import { useCallback, useRef } from 'react'
import { Vector3 } from 'three/webgpu'
const MIN_TRANSITION_SPEED = 1.5
const MAX_TRANSITION_SPEED = 4.8
const TRANSITION_DISTANCE_SCALE = 0.2
type CameraState = {
currentPosition: Vector3
goalPosition: Vector3
currentTarget: Vector3
goalTarget: Vector3
pointerOffset: Vector3
composedTarget: Vector3
isPointerEnabled: boolean
}
type Props = {
pointerLookAt?: {
xRange: number
yRange: number
speed: number
}
}
const useCameraControls = ({ pointerLookAt }: Props = {}) => {
const camera = useThree((s) => s.camera)
const cameraStateRef = useRef<CameraState | null>(null)
if (cameraStateRef.current == null) {
cameraStateRef.current = {
currentPosition: camera.position.clone(),
goalPosition: camera.position.clone(),
currentTarget: new Vector3(),
goalTarget: new Vector3(),
pointerOffset: new Vector3(),
composedTarget: new Vector3(),
isPointerEnabled: true,
}
}
const setLookAt = useCallback(
(
positionX: number,
positionY: number,
positionZ: number,
targetX: number,
targetY: number,
targetZ: number,
transition = false,
) => {
const cameraState = cameraStateRef.current!
cameraState.goalPosition.set(positionX, positionY, positionZ)
cameraState.goalTarget.set(targetX, targetY, targetZ)
if (transition) return
cameraState.currentPosition.copy(cameraState.goalPosition)
cameraState.currentTarget.copy(cameraState.goalTarget)
},
[],
)
const setIsPointerEnabled = useCallback((enabled: boolean) => {
const cameraState = cameraStateRef.current!
cameraState.isPointerEnabled = enabled
}, [])
const getDistanceToTarget = useCallback(() => {
const cameraState = cameraStateRef.current!
return cameraState.currentPosition.distanceTo(cameraState.currentTarget)
}, [])
useFrame(({ pointer }, delta) => {
const cameraState = cameraStateRef.current!
const transitionDistance = Math.max(
cameraState.currentPosition.distanceTo(cameraState.goalPosition),
cameraState.currentTarget.distanceTo(cameraState.goalTarget),
)
const transitionSpeed = Math.min(
MAX_TRANSITION_SPEED,
Math.max(MIN_TRANSITION_SPEED, transitionDistance * TRANSITION_DISTANCE_SCALE),
)
const transitionAlpha = 1 - Math.exp(-transitionSpeed * delta)
cameraState.currentPosition.lerp(cameraState.goalPosition, transitionAlpha)
cameraState.currentTarget.lerp(cameraState.goalTarget, transitionAlpha)
if (pointerLookAt) {
const pointerAlpha = 1 - Math.exp(-pointerLookAt.speed * delta)
const pointerX = cameraState.isPointerEnabled ? pointer.x * pointerLookAt.xRange : 0
const pointerY = cameraState.isPointerEnabled ? pointer.y * pointerLookAt.yRange : 0
cameraState.pointerOffset.set(
cameraState.pointerOffset.x + (pointerX - cameraState.pointerOffset.x) * pointerAlpha,
cameraState.pointerOffset.y + (pointerY - cameraState.pointerOffset.y) * pointerAlpha,
0,
)
} else {
cameraState.pointerOffset.set(0, 0, 0)
}
camera.position.copy(cameraState.currentPosition)
cameraState.composedTarget.copy(cameraState.currentTarget).add(cameraState.pointerOffset)
camera.lookAt(cameraState.composedTarget)
}, -1)
return { setLookAt, setIsPointerEnabled, getDistanceToTarget }
}
export default useCameraControls