threlte logo

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!