Threenix Developers
Get the Agent Skill

Loading preview...

Component

Cinematic Camera Controls in React Three Fiber with WebGPU

July 29, 2026
CameraCamera TransitionsAnimationR3FWebGPUTSLTypescript

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-controls

Implementation

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