threlte logo
@threlte/extras

useFollow

useFollow teaches a <CameraControls> instance to track a moving target. The <CameraControls> handles orbiting, collision avoidance, and damping. The hook handles the follow: it updates the controls’ target to match your character (or vehicle, or any Object3D) each frame, and adds follow-specific behaviors like lookAtOffset, dead zones, and look-ahead.

<script lang="ts">
  import { Canvas } from '@threlte/core'
  import { Pane, Slider, Checkbox, Button, Folder, List, Point } from 'svelte-tweakpane-ui'
  import Scene from './Scene.svelte'

  interface Preset {
    smoothTime: number
    distance: number
    minPolarAngle: number
    maxPolarAngle: number
    polarAngle: number
    azimuthLocked: boolean
    azimuthAngle: number
    pointerLock: boolean
    lookAtOffset: [number, number, number]
    deadZone: [number, number]
    lookAhead: number
    followSmoothTime: number
    trackRotation: boolean
    trackRotationSmoothTime: number
    trackRotationOffset: number
  }

  const presets = {
    'Third Person': {
      smoothTime: 0.2,
      distance: 6,
      minPolarAngle: 0.3,
      maxPolarAngle: 1.5,
      polarAngle: 1.1,
      azimuthLocked: false,
      azimuthAngle: 0,
      pointerLock: true,
      lookAtOffset: [0, 1, 0],
      deadZone: [0, 0],
      lookAhead: 0,
      followSmoothTime: 0.15,
      trackRotation: false,
      trackRotationSmoothTime: 0,
      trackRotationOffset: 0
    },
    Fixed: {
      smoothTime: 0.2,
      distance: 5,
      minPolarAngle: 0.4,
      maxPolarAngle: 1.4,
      polarAngle: 1.1,
      azimuthLocked: false,
      azimuthAngle: 0,
      pointerLock: false,
      lookAtOffset: [0, 1, 0],
      deadZone: [0, 0],
      lookAhead: 0,
      followSmoothTime: 0,
      trackRotation: true,
      trackRotationSmoothTime: 0.25,
      trackRotationOffset: Math.PI
    },
    'Top-Down': {
      smoothTime: 0.2,
      distance: 11,
      minPolarAngle: 0.6,
      maxPolarAngle: 0.6,
      polarAngle: 0.6,
      azimuthLocked: true,
      azimuthAngle: 0,
      pointerLock: false,
      lookAtOffset: [0, 0, 0],
      deadZone: [0, 0],
      lookAhead: 0,
      followSmoothTime: 0,
      trackRotation: false,
      trackRotationSmoothTime: 0,
      trackRotationOffset: 0
    },
    Sidescroller: {
      smoothTime: 0.25,
      distance: 7,
      minPolarAngle: Math.PI / 2,
      maxPolarAngle: Math.PI / 2,
      polarAngle: Math.PI / 2,
      azimuthLocked: true,
      azimuthAngle: 0,
      pointerLock: false,
      lookAtOffset: [0, 1, 0],
      deadZone: [1.5, 0.5],
      lookAhead: 0,
      followSmoothTime: 0.1,
      trackRotation: false,
      trackRotationSmoothTime: 0,
      trackRotationOffset: 0
    },
    Racing: {
      smoothTime: 0.08,
      distance: 6,
      minPolarAngle: 1,
      maxPolarAngle: 1,
      polarAngle: 1,
      azimuthLocked: true,
      azimuthAngle: 0,
      pointerLock: false,
      lookAtOffset: [0, 0.8, 0],
      deadZone: [0, 0],
      lookAhead: 0.4,
      followSmoothTime: 0.05,
      trackRotation: false,
      trackRotationSmoothTime: 0,
      trackRotationOffset: 0
    },
    Cinematic: {
      smoothTime: 0.6,
      distance: 14,
      minPolarAngle: 0.8,
      maxPolarAngle: 0.8,
      polarAngle: 0.8,
      azimuthLocked: true,
      azimuthAngle: 0,
      pointerLock: false,
      lookAtOffset: [0, 1.2, 0],
      deadZone: [0, 0],
      lookAhead: 0,
      followSmoothTime: 0.5,
      trackRotation: false,
      trackRotationSmoothTime: 0,
      trackRotationOffset: 0
    }
  } satisfies Record<string, Preset>

  type PresetName = keyof typeof presets
  const presetOptions = Object.fromEntries(Object.keys(presets).map((k) => [k, k])) as Record<
    PresetName,
    PresetName
  >

  let preset = $state<PresetName>('Third Person')

  let smoothTime = $state(0.2)
  let distance = $state(6)
  let minPolarAngle = $state(0.3)
  let maxPolarAngle = $state(1.5)
  let polarAngle = $state(1.1)
  let azimuthLocked = $state(false)
  let azimuthAngle = $state(0)
  let pointerLock = $state(true)
  let lookAtOffset = $state<[number, number, number]>([0, 1, 0])
  let deadZone = $state<[number, number]>([0, 0])
  let lookAhead = $state(0)
  let followSmoothTime = $state(0.15)
  let trackRotation = $state(false)
  let trackRotationSmoothTime = $state(0)
  let trackRotationOffset = $state(0)
  let collision = $state(true)
  let following = $state(true)

  const apply = (preset: Preset) => {
    smoothTime = preset.smoothTime
    distance = preset.distance
    minPolarAngle = preset.minPolarAngle
    maxPolarAngle = preset.maxPolarAngle
    polarAngle = preset.polarAngle
    azimuthLocked = preset.azimuthLocked
    azimuthAngle = preset.azimuthAngle
    pointerLock = preset.pointerLock
    lookAtOffset = preset.lookAtOffset
    deadZone = preset.deadZone
    lookAhead = preset.lookAhead
    followSmoothTime = preset.followSmoothTime
    trackRotation = preset.trackRotation
    trackRotationSmoothTime = preset.trackRotationSmoothTime
    trackRotationOffset = preset.trackRotationOffset
  }

  $effect(() => {
    apply(presets[preset])
  })
</script>

<Pane
  title=""
  position="fixed"
>
  <List
    label="preset"
    bind:value={preset}
    options={presetOptions}
  />
  <Folder title="CameraControls">
    <Slider
      label="smoothTime"
      bind:value={smoothTime}
      min={0}
      max={1}
      step={0.01}
    />
    <Slider
      label="distance"
      bind:value={distance}
      min={1}
      max={20}
      step={0.1}
    />

    <Slider
      label="minPolarAngle"
      bind:value={minPolarAngle}
      min={0}
      max={Math.PI}
      step={0.01}
    />
    <Slider
      label="maxPolarAngle"
      bind:value={maxPolarAngle}
      min={0}
      max={Math.PI}
      step={0.01}
    />
    <Checkbox
      label="azimuthLocked"
      bind:value={azimuthLocked}
    />
    <Checkbox
      label="pointerLock"
      bind:value={pointerLock}
    />
    <Checkbox
      label="collision"
      bind:value={collision}
    />
  </Folder>
  <Folder title="useFollow">
    <Point
      label="lookAtOffset"
      bind:value={lookAtOffset}
      min={-3}
      max={3}
      step={0.05}
    />
    <Point
      label="deadZone"
      bind:value={deadZone}
      min={0}
      max={3}
      step={0.05}
    />
    <Slider
      label="lookAhead"
      bind:value={lookAhead}
      min={0}
      max={1}
      step={0.01}
    />
    <Slider
      label="followSmoothTime"
      bind:value={followSmoothTime}
      min={0}
      max={1}
      step={0.01}
    />
    <Checkbox
      label="trackRotation"
      bind:value={trackRotation}
    />
    <Slider
      label="trackRotationSmoothTime"
      bind:value={trackRotationSmoothTime}
      min={0}
      max={1}
      step={0.01}
    />
    <Slider
      label="trackRotationOffset"
      bind:value={trackRotationOffset}
      min={-Math.PI}
      max={Math.PI}
      step={0.01}
    />
    <Checkbox
      label="following"
      bind:value={following}
    />
  </Folder>
  <Button
    title="Reset preset"
    on:click={() => apply(presets[preset])}
  />
</Pane>

<div>
  <Canvas>
    <Scene
      {smoothTime}
      {distance}
      {minPolarAngle}
      {maxPolarAngle}
      {polarAngle}
      {azimuthLocked}
      {azimuthAngle}
      {pointerLock}
      {lookAtOffset}
      {deadZone}
      {lookAhead}
      {followSmoothTime}
      {trackRotation}
      {trackRotationSmoothTime}
      {trackRotationOffset}
      {collision}
      {following}
    />
  </Canvas>
</div>

<style>
  div {
    height: 100%;
  }
</style>
<script lang="ts">
  import { GLTF, useGltfAnimations } from '@threlte/extras'

  interface Props {
    action: 'idle' | 'run' | 'walk'
  }

  let { action = 'idle' }: Props = $props()

  let { gltf, actions } = useGltfAnimations()
  let currentAction = 'idle'

  $effect(() => {
    $actions?.['idle']?.play()
  })

  $effect(() => {
    transitionTo(action, 0.2)
  })

  function transitionTo(next: string, duration = 0.2) {
    const current = $actions[currentAction]
    const nextAnim = $actions[next]
    if (!nextAnim || current === nextAnim) return
    nextAnim.enabled = true
    if (current) {
      current.crossFadeTo(nextAnim, duration, true)
    }
    nextAnim.play()
    currentAction = next
  }
</script>

<GLTF
  bind:gltf={$gltf}
  url="https://threejs.org/examples/models/gltf/Xbot.glb"
  oncreate={(scene) => {
    scene.traverse((child) => {
      child.castShadow = true
    })
  }}
/>
<script lang="ts">
  import { Group, MathUtils, Vector3, type Mesh } from 'three'
  import { T, useTask } from '@threlte/core'
  import {
    CameraControls,
    CameraControlsRef,
    Grid,
    HTML,
    useFollow,
    useGamepad,
    useInputMap,
    useKeyboard
  } from '@threlte/extras'
  import Character from './Character.svelte'

  interface Props {
    smoothTime: number
    distance: number
    minPolarAngle: number
    maxPolarAngle: number
    polarAngle: number
    azimuthLocked: boolean
    azimuthAngle: number
    pointerLock: boolean
    lookAtOffset: [number, number, number]
    deadZone: [number, number]
    lookAhead: number
    followSmoothTime: number
    trackRotation: boolean
    trackRotationSmoothTime: number
    trackRotationOffset: number
    collision: boolean
    following: boolean
  }

  const {
    smoothTime,
    distance,
    minPolarAngle,
    maxPolarAngle,
    polarAngle,
    azimuthLocked,
    azimuthAngle,
    pointerLock,
    lookAtOffset,
    deadZone,
    lookAhead,
    followSmoothTime,
    trackRotation,
    trackRotationSmoothTime,
    trackRotationOffset,
    collision,
    following
  }: Props = $props()

  const keyboard = useKeyboard(() => ({ capture: true }))
  const gamepad = useGamepad()
  const input = useInputMap(
    ({ key, gamepadButton, gamepadAxis }) => ({
      moveLeft: [
        key('a'),
        key('ArrowLeft'),
        gamepadButton('directionalLeft'),
        gamepadAxis('leftStick', 'x', -1)
      ],
      moveRight: [
        key('d'),
        key('ArrowRight'),
        gamepadButton('directionalRight'),
        gamepadAxis('leftStick', 'x', 1)
      ],
      moveForward: [
        key('w'),
        key('ArrowUp'),
        gamepadButton('directionalTop'),
        gamepadAxis('leftStick', 'y', -1)
      ],
      moveBack: [
        key('s'),
        key('ArrowDown'),
        gamepadButton('directionalBottom'),
        gamepadAxis('leftStick', 'y', 1)
      ],
      sprint: [key('Shift'), gamepadButton('leftBumper')]
    }),
    { keyboard, gamepad }
  )
  keyboard.on('keydown', (e) => {
    if (e.key.startsWith('Arrow')) e.preventDefault()
  })

  const character = new Group()

  let controls = $state.raw<CameraControlsRef>()
  let pillarMeshes = $state<Mesh[]>([])
  let rotation = $state(0)

  const follow = useFollow(() => ({
    target: following ? character : undefined,
    controls,
    lookAtOffset,
    deadZone,
    lookAhead,
    followSmoothTime,
    trackRotation,
    trackRotationSmoothTime,
    trackRotationOffset
  }))

  const colliderMeshes = $derived(collision ? pillarMeshes.filter(Boolean) : [])
  const azimuthMin = $derived(azimuthLocked ? 0 : -Infinity)
  const azimuthMax = $derived(azimuthLocked ? 0 : Infinity)

  $effect(() => {
    if (!controls) return
    controls.dollyTo(distance, true)
    controls.rotateTo(azimuthAngle, polarAngle, true)
  })

  const sprinting = $derived(input.action('sprint').pressed)
  const moveX = $derived(input.axis('moveLeft', 'moveRight'))
  const moveY = $derived(input.axis('moveForward', 'moveBack'))
  const translating = $derived(trackRotation ? moveY !== 0 : moveX !== 0 || moveY !== 0)
  const action = $derived<'idle' | 'run' | 'walk'>(
    translating ? (sprinting ? 'run' : 'walk') : 'idle'
  )

  const walkSpeed = 2
  const runSpeed = 4.5
  const rotationSpeed = 10
  const turnSpeed = 2.5
  const worldMove = new Vector3()
  let targetRotation = 0

  useTask(
    (delta) => {
      const speed = sprinting ? runSpeed : walkSpeed

      if (trackRotation) {
        rotation -= moveX * turnSpeed * delta
        follow.getTargetDirection(0, -moveY, worldMove)
        character.position.addScaledVector(worldMove, speed * delta)
      } else {
        follow.getInputDirection(moveX, -moveY, worldMove)
        character.position.addScaledVector(worldMove, speed * delta)

        if (translating) {
          targetRotation = Math.atan2(worldMove.x, worldMove.z)
        }

        let diff = targetRotation - rotation
        diff = MathUtils.euclideanModulo(diff + Math.PI, Math.PI * 2) - Math.PI
        rotation += diff * Math.min(1, rotationSpeed * delta)
      }
    },
    { after: input.task, before: follow.task }
  )

  const rightStick = gamepad.stick('rightStick')

  const orbitSpeed = 2.8 // radians/sec at full stick

  useTask(
    (delta) => {
      if (!controls || trackRotation) return

      const { x, y } = rightStick

      if (x === 0 && y === 0) return

      controls.rotate(-x * orbitSpeed * delta, -y * orbitSpeed * delta, true)
    },
    { after: [gamepad.task, follow.task] }
  )

  let liveDistance = $state(0)
  useTask(
    () => {
      if (!controls) return
      liveDistance = controls.camera.position.distanceTo(character.position)
    },
    { after: follow.task }
  )

  const pillars: [number, number][] = Array.from({ length: 8 }, (_, i) => {
    const angle = ((i + 0.5) * Math.PI * 2) / 8
    return [Math.cos(angle) * 5, Math.sin(angle) * 5]
  })
</script>

<T.PerspectiveCamera
  makeDefault
  fov={55}
  position={[0, 3, 6]}
/>

<CameraControls
  bind:ref={controls}
  {pointerLock}
  {smoothTime}
  {distance}
  {minPolarAngle}
  {maxPolarAngle}
  minAzimuthAngle={azimuthMin}
  maxAzimuthAngle={azimuthMax}
  {colliderMeshes}
  mouseButtons.wheel={CameraControlsRef.ACTION.NONE}
/>

<T.DirectionalLight
  position={[5, 10, 5]}
  intensity={1.5}
  castShadow
/>
<T.AmbientLight intensity={0.4} />

<Grid
  cellColor="#444444"
  sectionColor="#ff3e00"
  sectionSize={5}
  cellSize={1}
  gridSize={[30, 30]}
  fadeDistance={15}
  infiniteGrid
  fadeOrigin={[0, 0, 0]}
/>

<T.Mesh
  rotation.x={-Math.PI / 2}
  position.y={-0.01}
  receiveShadow
>
  <T.CircleGeometry args={[20, 72]} />
  <T.MeshStandardMaterial color="#eaeaea" />
</T.Mesh>

{#each pillars as [x, z], i (i)}
  <T.Mesh
    bind:ref={pillarMeshes[i]}
    position={[x, 1.5, z]}
    castShadow
    receiveShadow
  >
    <T.BoxGeometry args={[1, 3, 1]} />
    <T.MeshStandardMaterial color="#4a90d9" />
  </T.Mesh>
{/each}

<T
  is={character}
  rotation.y={rotation}
>
  <Character {action} />
  <T.Group position.y={2.4}>
    <HTML
      center
      transform={false}
    >
      <div class="overlay">
        <p class="hint">
          {trackRotation ? 'W/S move · A/D turn' : 'WASD to move'} · Shift to sprint{pointerLock
            ? ' · click to look'
            : ''}
        </p>
        <div class="stats">
          <span>distance</span>
          <span class="value">{liveDistance.toFixed(2)}</span>
        </div>
        <div
          class="badge"
          class:on={following}
        >
          {following ? action : 'paused'}
        </div>
      </div>
    </HTML>
  </T.Group>
</T>

<style>
  .overlay {
    font-family: 'Inter', system-ui, sans-serif;
    color: white;
    text-align: center;
    user-select: none;
    pointer-events: none;
    width: 260px;
    text-shadow: 0 1px 2px rgba(0, 0, 0, 0.6);
  }

  .hint {
    font-size: 12px;
    opacity: 0.8;
    margin: 0 0 4px;
  }

  .stats {
    display: flex;
    align-items: center;
    justify-content: center;
    gap: 6px;
    font-size: 12px;
    opacity: 0.8;
  }

  .value {
    font-family: 'JetBrains Mono', monospace;
  }

  .badge {
    display: inline-block;
    margin-top: 4px;
    font-size: 11px;
    padding: 2px 10px;
    border-radius: 4px;
    font-weight: 600;
    background: rgba(255, 255, 255, 0.15);
  }

  .on {
    background: rgba(74, 144, 217, 0.8);
  }
</style>
<script lang="ts">
  import { Group } from 'three'
  import { T } from '@threlte/core'
  import { CameraControls, useFollow, type CameraControlsRef } from '@threlte/extras'

  let controls = $state.raw<CameraControlsRef>()

  let character = new Group()

  const follow = useFollow(() => ({
    target: character,
    controls,
    lookAtOffset: [0, 1, 0]
  }))
</script>

<T.PerspectiveCamera
  makeDefault
  position={[0, 3, 6]}
/>

<CameraControls bind:ref={controls} />

<T is={character}>
  <!-- character mesh -->
</T>

Options

Because useFollow is a layer on <CameraControls>, camera behavior is configured on the component, follow behavior on the hook.

Belongs on <CameraControls>Belongs on useFollow
smoothTime (orbit/zoom damping)lookAtOffset
minDistance / maxDistance (zoom)deadZone
minPolarAngle / maxPolarAnglelookAhead
minAzimuthAngle / maxAzimuthAnglefollowSmoothTime
colliderMeshes (collision)trackRotation
pointerLock (input mode)target

Everything on the left is already documented on <CameraControls> — set it as a prop and useFollow will cooperate.

lookAtOffset

Offset added to the target’s world position before it’s sent to the controls. Use it to look at the character’s head or chest rather than their feet:

const follow = useFollow(() => ({
  target,
  controls,
  lookAtOffset: [0, 1.6, 0]
}))

Dead zone

The target may drift this far from the currently tracked position (in camera-space right/up axes) before the camera starts to follow. Any axis left at 0 has no dead zone on that axis.

const follow = useFollow(() => ({
  target,
  controls,
  deadZone: [0.5, 0.3]
}))

Look-Ahead

Shift the tracked point in the target’s direction of motion, expressed in seconds of preview. 0.3 means “place the camera where the target will be in 300 ms at its current velocity.”

const follow = useFollow(() => ({
  target,
  controls,
  lookAhead: 0.3
}))

The velocity that drives lookAhead is itself smoothed (default time constant 0.15 s) so the offset ramps in when the character starts moving and eases out when they stop, instead of snapping. Tune it with lookAheadSmoothTime — lower for a snappier response, higher for a softer ease.

const follow = useFollow(() => ({
  target,
  controls,
  lookAhead: 0.4,
  lookAheadSmoothTime: 0.25
}))

followSmoothTime

Seconds of lag between the character’s movement and the camera’s position, for a trailing / cinematic follow. 0 (the default) snaps the camera lock-step with the character.

Only the tracked base position is smoothed — lookAtOffset and lookAhead are added unsmoothed on top, and the smoothed target is passed to CameraControls before its own update runs, so:

  • Adjusting lookAtOffset at runtime is snappy — the offset applies instantly without dragging through the smoothing window.
  • Collision (colliderMeshes), zoom limits, and other CameraControls features apply correctly to the smoothed pose.
  • User orbit/zoom input still smooths via CameraControls’ own smoothTime.
const follow = useFollow(() => ({
  target,
  controls,
  followSmoothTime: 0.2
}))

trackRotation

Drive the CameraControls azimuth from the target’s Y-axis world rotation so the camera turns with the character. User orbit input on the horizontal axis is effectively overridden while this is on; polar orbit and zoom still work if enabled.

const follow = useFollow(() => ({
  target,
  controls,
  trackRotation: true,
  trackRotationSmoothTime: 0.25
}))

trackRotationSmoothTime tunes how quickly the camera eases into the character’s heading. 0 (the default) locks the azimuth to the target’s yaw every frame; higher values ramp the rotation in.

trackRotationOffset shifts the azimuth after the target’s yaw is applied. Use Math.PI to sit the camera behind a character whose local +Z axis is its forward (the common convention, e.g. when character.rotation.y = Math.atan2(velocity.x, velocity.z)). Use 0 for a character whose local -Z is forward.

Use getTargetDirection, not getInputDirection, with trackRotation. Camera-relative input (W = away from camera) combined with rotation tracking forms a feedback loop — the character tries to face away from the camera, the camera follows the character, “away from camera” is now a new direction, and the character spins. See getTargetDirection.

Don’t combine trackRotation with minAzimuthAngle/maxAzimuthAngle limits on <CameraControls> — the tracker will fight the limits each frame.

getInputDirection

A helper that projects a 2D input onto the camera’s horizontal basis — call it from your movement task to make WASD / stick input always feel correct regardless of how the user has orbited.

follow.getInputDirection(right, forward, out)
  • right — sideways input amount (positive = to camera’s right).
  • forward — forward input amount (positive = away from the camera).
  • out — a Vector3 representing the world-space direction. Length of the vector reflects the magnitude of (right, forward), so diagonal input gives a diagonal world direction.
const worldMove = new Vector3()

useTask((delta) => {
  const move = input.vector('moveLeft', 'moveRight', 'moveForward', 'moveBack')
  follow.getInputDirection(move.x, -move.y, worldMove)
  character.position.addScaledVector(worldMove, speed * delta)
})

useInputMap’s input.vector returns y = -1 when “forward” is pressed, so pass -move.y as the forward amount.

getTargetDirection

The target-relative counterpart to getInputDirection — projects a 2D input onto the target’s own horizontal basis (forward follows the target’s local +Z axis, right follows its local +X). Use this for tank-style controls, or whenever trackRotation is on:

follow.getTargetDirection(right, forward, out)

With trackRotation, a typical tank input loop looks like:

useTask((delta) => {
  // A/D directly rotate the character…
  character.rotation.y -= input.axis('moveLeft', 'moveRight') * turnSpeed * delta
  // …W/S translate along the character's current forward.
  follow.getTargetDirection(0, -input.axis('moveForward', 'moveBack'), worldMove)
  character.position.addScaledVector(worldMove, speed * delta)
})

No yaw feedback, because the input no longer depends on the camera.

Task ordering

useFollow runs its update in task, in mainStage. It sets the controls target (with followSmoothTime smoothing baked in) and azimuth, ahead of <CameraControls>’ own update in the same stage. Schedule character movement before task so the camera sees the final target position for the frame.

const follow = useFollow(() => ({
  target,
  controls,
  lookAtOffset: [0, 1.6, 0]
}))

useTask(
  () => {
    // run your movement
  },
  { before: follow.task }
)

Common patterns

Third-person character

Mouse orbit + zoom handled by <CameraControls>; WASD moves the character; useFollow keeps the camera aimed at the character’s head.

Movement needs to be camera-relative so W always means “away from camera” regardless of how the user has orbited. Use follow.getInputDirection to convert the 2D input vector into a world-space direction aligned with the camera’s horizontal basis.

<script lang="ts">
  import { Vector3, Group } from 'three'
  import { T, useTask } from '@threlte/core'
  import {
    CameraControls,
    useFollow,
    useInputMap,
    useKeyboard,
    type CameraControlsRef
  } from '@threlte/extras'

  let controls = $state.raw<CameraControlsRef>()

  let character = new Group()

  const keyboard = useKeyboard()
  const input = useInputMap(
    ({ key }) => ({
      moveLeft: [key('a')],
      moveRight: [key('d')],
      moveForward: [key('w')],
      moveBack: [key('s')]
    }),
    { keyboard }
  )

  const follow = useFollow(() => ({
    target: character,
    controls,
    lookAtOffset: [0, 1, 0]
  }))

  const worldMove = new Vector3()

  useTask(
    (delta) => {
      if (!character) return
      const move = input.vector('moveLeft', 'moveRight', 'moveForward', 'moveBack')
      follow.getInputDirection(move.x, -move.y, worldMove)
      character.position.addScaledVector(worldMove, 5 * delta)
    },
    { after: input.task, before: follow.task }
  )
</script>

<T.PerspectiveCamera
  makeDefault
  position={[0, 3, 6]}
/>

<CameraControls
  bind:ref={controls}
  minDistance={2}
  maxDistance={10}
  minPolarAngle={0.3}
  maxPolarAngle={1.5}
/>

<T is={character}>
  <T.Mesh>
    <T.BoxGeometry />
    <T.MeshStandardMaterial color="orange" />
  </T.Mesh>
</T>

Top-down

Lock the polar and azimuth angles on <CameraControls> and the camera becomes a pure top-down follower. Orbit input still works within whatever limits you allow.

<CameraControls
  bind:ref={controls}
  minPolarAngle={0.01}
  maxPolarAngle={0.01}
  minAzimuthAngle={0}
  maxAzimuthAngle={0}
  minDistance={10}
  maxDistance={20}
/>

Collision avoidance

<CameraControls> does a swept collision test against any meshes you hand it. Pass the obstacles as colliderMeshes:

<script lang="ts">
  import type { Mesh } from 'three'
  let walls = $state<Mesh[]>([])
</script>

<CameraControls
  bind:ref={controls}
  colliderMeshes={walls}
/>

{#each wallData as data, i}
  <T.Mesh bind:ref={walls[i]}>...</T.Mesh>
{/each}

No option on useFollow is needed — collision is entirely a CameraControls concern.

Pointer lock

By default <CameraControls> uses click-and-drag to rotate. Flip pointerLock on the component to switch to mouse-look: one click locks the pointer, subsequent movement rotates the camera directly, and Escape releases the lock.

<CameraControls
  bind:ref={controls}
  pointerLock
/>

Tune rotation speed with pointerLockSensitivity (radians per pixel, default 0.003).