camera-controls
You may have come up against limitations with Three.js’s <OrbitalControls/>
.
Camera Controls is a suitable alternative
which supports smooth transitions and a variety of additional features.
<script lang="ts">
import Scene from './Scene.svelte'
import type CC from 'camera-controls'
import { Button, Checkbox, Pane, Separator } from 'svelte-tweakpane-ui'
import { Canvas } from '@threlte/core'
import { DEG2RAD } from 'three/src/math/MathUtils.js'
import type { Mesh } from 'three'
let controls = $state<CC>()
let mesh = $state<Mesh>()
/**
* controls.enabled can not be bound to since its not reactive
*/
let enabled = $state(true)
$effect(() => {
if (controls !== undefined) {
controls.enabled = enabled
}
})
</script>
<Pane
title="Camera Controls"
position="fixed"
>
<Button
title="rotate theta 45deg"
on:click={() => {
controls?.rotate(45 * DEG2RAD, 0, true)
}}
/>
<Button
title="rotate theta -90deg"
on:click={() => {
controls?.rotate(-90 * DEG2RAD, 0, true)
}}
/>
<Button
title="rotate theta 360deg"
on:click={() => {
controls?.rotate(360 * DEG2RAD, 0, true)
}}
/>
<Button
title="rotate phi 20deg"
on:click={() => {
controls?.rotate(0, 20 * DEG2RAD, true)
}}
/>
<Separator />
<Button
title="truck(1, 0)"
on:click={() => {
controls?.truck(1, 0, true)
}}
/>
<Button
title="truck(0, 1)"
on:click={() => {
controls?.truck(0, 1, true)
}}
/>
<Button
title="truck(-1, -1)"
on:click={() => {
controls?.truck(-1, -1, true)
}}
/>
<Separator />
<Button
title="dolly 1"
on:click={() => {
controls?.dolly(1, true)
}}
/>
<Button
title="dolly -1"
on:click={() => {
controls?.dolly(-1, true)
}}
/>
<Separator />
<Button
title="zoom `camera.zoom / 2`"
on:click={() => {
controls?.zoom(controls.camera.zoom / 2, true)
}}
/>
<Button
title="zoom `- camera.zoom / 2`"
on:click={() => {
controls?.zoom(-controls.camera.zoom / 2, true)
}}
/>
<Separator />
<Button
title="move to ( 3, 5, 2)"
on:click={() => {
controls?.moveTo(3, 5, 2, true)
}}
/>
<Button
title="fit to the bounding box of the mesh"
on:click={() => {
if (mesh !== undefined && controls !== undefined) {
controls.fitToBox(mesh, true)
}
}}
/>
<Separator />
<Button
title="set position to ( -5, 2, 1 )"
on:click={() => {
controls?.setPosition(-5, 2, 1, true)
}}
/>
<Button
title="look at ( 3, 0, -3 )"
on:click={() => {
controls?.setTarget(3, 0, -3, true)
}}
/>
<Button
title="move to ( 1, 2, 3 ), look at ( 1, 1, 0 )"
on:click={() => {
controls?.setLookAt(1, 2, 3, 1, 1, 0, true)
}}
/>
<Separator />
<Button
title="move to somewhere between ( -2, 0, 0 ) -> ( 1, 1, 0 ) and ( 0, 2, 5 ) -> ( -1, 0, 0 )"
on:click={() => {
controls?.lerpLookAt(-2, 0, 0, 1, 1, 0, 0, 2, 5, -1, 0, 0, Math.random(), true)
}}
/>
<Separator />
<Button
title="reset"
on:click={() => {
controls?.reset(true)
}}
/>
<Button
title="saveState"
on:click={() => {
controls?.saveState()
}}
/>
<Separator />
<Checkbox
bind:value={enabled}
label="enabled"
/>
</Pane>
<Canvas>
<Scene
bind:controls
bind:mesh
/>
</Canvas>
import CC from 'camera-controls'
import type { Camera } from './types'
import {
Box3,
Matrix4,
Quaternion,
Raycaster,
Sphere,
Spherical,
Vector2,
Vector3,
Vector4
} from 'three'
export default class CameraControls extends CC {
static #installed = false
constructor(element: HTMLElement, camera: Camera) {
if (!CameraControls.#installed) {
CC.install({
THREE: {
Box3,
Matrix4,
Quaternion,
Raycaster,
Sphere,
Spherical,
Vector2,
Vector3,
Vector4
}
})
CameraControls.#installed = true
}
super(camera, element)
}
}
<script lang="ts">
import CameraControls from './CameraControls'
import type CC from 'camera-controls'
import type { ColorRepresentation } from 'three'
import { Grid } from '@threlte/extras'
import { Mesh, PerspectiveCamera } from 'three'
import { T } from '@threlte/core'
import { useTask, useThrelte } from '@threlte/core'
let {
color = '#ff3e00',
controls = $bindable(),
mesh = $bindable()
}: {
color?: ColorRepresentation
controls: CC | undefined
mesh?: Mesh
} = $props()
const { dom, invalidate } = useThrelte()
const camera = new PerspectiveCamera()
controls = new CameraControls(dom, camera)
$effect(() => {
return () => {
controls.dispose()
}
})
controls.setPosition(5, 5, 5)
useTask(
(delta) => {
if (controls.update(delta)) {
invalidate()
}
},
{ autoInvalidate: false }
)
</script>
<T
is={camera}
makeDefault
/>
<T.Mesh
oncreate={(ref) => {
mesh = ref
}}
position.y={0.5}
>
<T.BoxGeometry />
<T.MeshBasicMaterial
{color}
wireframe
/>
</T.Mesh>
<Grid
sectionColor={color}
sectionThickness={1}
cellColor="#cccccc"
gridSize={40}
/>
import type { PerspectiveCamera, OrthographicCamera } from 'three'
export type Camera = OrthographicCamera | PerspectiveCamera
The CameraControls
class in the example extends the camera-controls library’s
CameraControls
class.
It automatically installs camera-controls
if not already installed when a new instance is created and connects the instance to the element
passed into its constructor.
Updating the Controls
Updating the controls happens in a task in the <Scene>
component.
The controls are disposed when the <Scene>
component unmounts. This is an important step because it removes all the event listeners that are added when the CameraControls instance is created and connected to the dom.
You could put both of these actions in the constructor of the CameraControls
class if you didn’t want to do it in the component but be careful using threlte’s useTask
since it accesses context set up by the <Canvas>
component.
Prevent SSR Externalization
If you are using SvelteKit or Vite for building your app, you may need to externalize the camera-controls
library.
To externalize the camera-controls
library put the following in your vite-config.js
or vite-config.ts
.
// vite-config.ts
export default defineConfig({
plugins: [sveltekit()],
ssr: {
noExternal: ['camera-controls']
}
})
The camera-controls package features include first-person, third-person, pointer-lock, fit-to-bounding-sphere and much more!