@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 / maxPolarAngle | lookAhead |
minAzimuthAngle / maxAzimuthAngle | followSmoothTime |
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
lookAtOffsetat runtime is snappy — the offset applies instantly without dragging through the smoothing window. - Collision (
colliderMeshes), zoom limits, and otherCameraControlsfeatures apply correctly to the smoothed pose. - User orbit/zoom input still smooths via
CameraControls’ ownsmoothTime.
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— aVector3representing 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).