Description
The hook owns camera positioning, targeting, and the lens in the frame loop, using frame-rate-independent, distance-aware damping for smooth overview-to-detail transitions. Named poses provide authored framings that can be selected, damped between, and anchored to a moving object, so a follow rig can switch shot without external transition state. Zoom is stored as the camera's own zoom multiplier with its own fixed time constant, so a shot moves the camera and the lens together. An optional pointer offset lets the camera follow cursor movement without routing animation through React state.
The demo pairs three authored poses, each with its own lens value, 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 or selectable framings with lightweight pointer interaction.
Agent skill
Add this resource
$threenix-add-component camera-controlsImplementation
Source Code
useCameraControls
'use client'
import { useFrame, useThree } from '@react-three/fiber'
import { type RefObject, useCallback, useRef } from 'react'
import { Vector3, type Vector3Tuple } from 'three/webgpu'
const MIN_TRANSITION_SPEED = 1.5
const MAX_TRANSITION_SPEED = 4.8
const TRANSITION_DISTANCE_SCALE = 0.2
const ZOOM_TRANSITION_SPEED = 4
const DEFAULT_ZOOM = 1
/** Below this residual the remaining change is imperceptible, so the lens snaps to the goal. */
const ZOOM_SNAP_THRESHOLD = 0.001
/** Cameras with a lens. Both perspective and orthographic cameras expose `zoom`. */
type ZoomCamera = { zoom: number }
type CameraState = {
currentPosition: Vector3
goalPosition: Vector3
currentTarget: Vector3
goalTarget: Vector3
pointerOffset: Vector3
composedTarget: Vector3
currentZoom: number
goalZoom: number
isPointerEnabled: boolean
/** Per-call damping override for the z axis, blended toward it by zDampingBlend. */
zDampingOverride: number | undefined
zDampingBlend: number
}
const supportsZoom = (camera: unknown): camera is ZoomCamera =>
typeof (camera as Partial<ZoomCamera>).zoom === 'number'
/**
* A named camera framing. Offsets are measured from the pose anchor, or from the world origin
* when the hook has no `poseAnchorRef`, so absolute presets need no anchor at all.
*/
export type CameraPose = {
positionOffset: Vector3Tuple
lookAtOffset: Vector3Tuple
/** Optional lens multiplier applied while this pose is selected. Omit to leave the lens alone. */
zoom?: number
}
export type CameraPoses = Record<string, CameraPose>
/** Resolve a pose into absolute position and look-target vectors without allocating. */
export const resolveCameraPose = (
pose: CameraPose,
anchor: Vector3Tuple | null | undefined,
outPosition: Vector3,
outLookAt: Vector3,
): void => {
const anchorX = anchor?.[0] ?? 0
const anchorY = anchor?.[1] ?? 0
const anchorZ = anchor?.[2] ?? 0
outPosition.set(
anchorX + pose.positionOffset[0],
anchorY + pose.positionOffset[1],
anchorZ + pose.positionOffset[2],
)
outLookAt.set(
anchorX + pose.lookAtOffset[0],
anchorY + pose.lookAtOffset[1],
anchorZ + pose.lookAtOffset[2],
)
}
type Props<PoseName extends string = string> = {
pointerLookAt?: {
xRange: number
yRange: number
speed: number
}
/** Named framings the host can switch between. */
poses?: Record<PoseName, CameraPose>
/** Pose offsets follow this live anchor, so a selected pose tracks a moving target. */
poseAnchorRef?: RefObject<Vector3Tuple>
/** Pose to select on mount. Use `setPose` for later changes. */
initialPose?: NoInfer<PoseName>
/** Cap on the distance-aware transition speed. Raise it for a fast-moving follow target. */
maxTransitionSpeed?: number
}
const useCameraControls = <PoseName extends string = string>({
initialPose,
maxTransitionSpeed = MAX_TRANSITION_SPEED,
pointerLookAt,
poseAnchorRef,
poses,
}: Props<PoseName> = {}) => {
const camera = useThree((s) => s.camera)
const cameraStateRef = useRef<CameraState | null>(null)
const activePoseRef = useRef<PoseName | null>(initialPose ?? null)
const posesRef = useRef(poses)
posesRef.current = poses
if (cameraStateRef.current == null) {
const initialZoom = supportsZoom(camera) ? camera.zoom : DEFAULT_ZOOM
cameraStateRef.current = {
currentPosition: camera.position.clone(),
goalPosition: camera.position.clone(),
currentTarget: new Vector3(),
goalTarget: new Vector3(),
pointerOffset: new Vector3(),
composedTarget: new Vector3(),
currentZoom: initialZoom,
goalZoom: initialZoom,
isPointerEnabled: true,
zDampingOverride: undefined,
zDampingBlend: 1,
}
}
const setLookAt = useCallback(
(
positionX: number,
positionY: number,
positionZ: number,
targetX: number,
targetY: number,
targetZ: number,
transition = false,
zDampingOverride?: number,
zDampingBlend = 1,
) => {
const cameraState = cameraStateRef.current!
// An imperative aim takes over from a selected preset until the host selects another pose.
activePoseRef.current = null
cameraState.goalPosition.set(positionX, positionY, positionZ)
cameraState.goalTarget.set(targetX, targetY, targetZ)
cameraState.zDampingOverride = zDampingOverride
cameraState.zDampingBlend = zDampingBlend
if (transition) return
cameraState.currentPosition.copy(cameraState.goalPosition)
cameraState.currentTarget.copy(cameraState.goalTarget)
},
[],
)
const setPose = useCallback((pose: PoseName | null) => {
if (pose !== null && !posesRef.current?.[pose]) return
activePoseRef.current = pose
// A pose transition is uniform; do not inherit a previous frame's z-axis override.
const cameraState = cameraStateRef.current!
cameraState.zDampingOverride = undefined
cameraState.zDampingBlend = 1
}, [])
const getPose = useCallback(() => activePoseRef.current, [])
const setZoom = useCallback((zoom: number, transition = false) => {
const cameraState = cameraStateRef.current!
cameraState.goalZoom = zoom
if (transition) return
cameraState.currentZoom = zoom
}, [])
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)
}, [])
const getZoom = useCallback(() => {
const cameraState = cameraStateRef.current!
return cameraState.currentZoom
}, [])
useFrame(
({ pointer }, delta) => {
const cameraState = cameraStateRef.current!
const activePose = activePoseRef.current
const pose = activePose === null ? undefined : posesRef.current?.[activePose]
if (pose) {
resolveCameraPose(
pose,
poseAnchorRef?.current,
cameraState.goalPosition,
cameraState.goalTarget,
)
if (pose.zoom !== undefined) cameraState.goalZoom = pose.zoom
}
const transitionDistance = Math.max(
cameraState.currentPosition.distanceTo(cameraState.goalPosition),
cameraState.currentTarget.distanceTo(cameraState.goalTarget),
)
const transitionSpeed = Math.min(
maxTransitionSpeed,
Math.max(MIN_TRANSITION_SPEED, transitionDistance * TRANSITION_DISTANCE_SCALE),
)
const transitionAlpha = 1 - Math.exp(-transitionSpeed * delta)
const { zDampingOverride, zDampingBlend } = cameraState
if (zDampingOverride === undefined || zDampingBlend <= 0) {
cameraState.currentPosition.lerp(cameraState.goalPosition, transitionAlpha)
cameraState.currentTarget.lerp(cameraState.goalTarget, transitionAlpha)
} else {
const zDamping =
transitionSpeed + (zDampingOverride - transitionSpeed) * zDampingBlend
const zTransitionAlpha = 1 - Math.exp(-zDamping * delta)
cameraState.currentPosition.x +=
(cameraState.goalPosition.x - cameraState.currentPosition.x) * transitionAlpha
cameraState.currentPosition.y +=
(cameraState.goalPosition.y - cameraState.currentPosition.y) * transitionAlpha
cameraState.currentPosition.z +=
(cameraState.goalPosition.z - cameraState.currentPosition.z) * zTransitionAlpha
cameraState.currentTarget.x +=
(cameraState.goalTarget.x - cameraState.currentTarget.x) * transitionAlpha
cameraState.currentTarget.y +=
(cameraState.goalTarget.y - cameraState.currentTarget.y) * transitionAlpha
cameraState.currentTarget.z +=
(cameraState.goalTarget.z - cameraState.currentTarget.z) * zTransitionAlpha
}
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)
if (!supportsZoom(camera)) return
// The lens keeps its own fixed time constant: transition distance is measured in world units
// and would saturate on a zoom delta expressed as a bare multiplier.
cameraState.currentZoom +=
(cameraState.goalZoom - cameraState.currentZoom) *
(1 - Math.exp(-ZOOM_TRANSITION_SPEED * delta))
if (Math.abs(cameraState.currentZoom - cameraState.goalZoom) < ZOOM_SNAP_THRESHOLD) {
cameraState.currentZoom = cameraState.goalZoom
}
// The projection rebuild, and its matrix inverse, only run while the lens is still moving.
if (camera.zoom === cameraState.currentZoom) return
camera.zoom = cameraState.currentZoom
camera.updateProjectionMatrix()
},
{ priority: 1 },
)
return {
setLookAt,
setZoom,
setIsPointerEnabled,
getDistanceToTarget,
getZoom,
setPose,
getPose,
}
}
export type CameraControls = ReturnType<typeof useCameraControls<string>>
export default useCameraControls