Threenix DevelopersGet the Agent Skill

Loading preview...

Component

Cinematic Camera Controls in React Three Fiber with WebGPU

July 29, 2026
CameraLODAnimationR3FWebGPUTSLTypescript

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

Implementation

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