threlte logo
Camera

Pointer Lock Controls

A remix of threejs’ PointerLockControls. It uses the Pointer Lock API.

Use-case

Controlling the camera in a 1st-person video game.

  • click the scene to lock the pointer to the scene
  • press ‘Esc’ to release pointer
<script lang="ts">
	import { useTweakpane } from '$lib/useTweakpane'
	import { Canvas } from '@threlte/core'
	import { World } from '@threlte/rapier'
	import Scene from './Scene.svelte'

	const { pane, action } = useTweakpane()

	pane.addBlade({
		view: 'text',
		text: "Use the 'wasd' keys to move around",
		lineCount: 3
	})
</script>

<div use:action />

<Canvas>
	<World>
		<Scene />
	</World>
</Canvas>
<script lang="ts">
  import type { RigidBody as RapierRigidBody } from '@dimforge/rapier3d-compat'
  import { T, useFrame, useThrelte } from '@threlte/core'
  import { RigidBody, CollisionGroups, Collider } from '@threlte/rapier'
  import { onDestroy } from 'svelte'
  import { PerspectiveCamera, Vector3 } from 'three'
  import PointerLockControls from './PointerLockControls.svelte'

  export let position: [x: number, y: number, z: number] = [0, 0, 0]
  let radius = 0.3
  let height = 1.7
  export let speed = 6

  let rigidBody: RapierRigidBody
  let lock: () => void
  let cam: PerspectiveCamera

  let forward = 0
  let backward = 0
  let left = 0
  let right = 0

  const t = new Vector3()

  const lockControls = () => lock()

  const { renderer } = useThrelte()

  renderer.domElement.addEventListener('click', lockControls)

  onDestroy(() => {
    renderer.domElement.removeEventListener('click', lockControls)
  })

  useFrame(() => {
    if (!rigidBody) return
    // get direction
    const velVec = t.fromArray([right - left, 0, backward - forward])
    // sort rotate and multiply by speed
    velVec.applyEuler(cam.rotation).multiplyScalar(speed)
    // don't override falling velocity
    const linVel = rigidBody.linvel()
    t.y = linVel.y
    // finally set the velocities and wake up the body
    rigidBody.setLinvel(t, true)

    // when body position changes update position prop for camera
    const pos = rigidBody.translation()
    position = [pos.x, pos.y, pos.z]
  })

  function onKeyDown(e: KeyboardEvent) {
    switch (e.key) {
      case 's':
        backward = 1
        break
      case 'w':
        forward = 1
        break
      case 'a':
        left = 1
        break
      case 'd':
        right = 1
        break
      default:
        break
    }
  }

  function onKeyUp(e: KeyboardEvent) {
    switch (e.key) {
      case 's':
        backward = 0
        break
      case 'w':
        forward = 0
        break
      case 'a':
        left = 0
        break
      case 'd':
        right = 0
        break
      default:
        break
    }
  }
</script>

<svelte:window
  on:keydown|preventDefault={onKeyDown}
  on:keyup={onKeyUp}
/>

<T.Group position.y={0.9}>
  <T.PerspectiveCamera
    makeDefault
    fov={90}
    bind:ref={cam}
    position.x={position[0]}
    position.y={position[1]}
    position.z={position[2]}
    on:create={({ ref }) => {
      ref.lookAt(new Vector3(0, 2, 0))
    }}
  >
    <PointerLockControls bind:lock />
  </T.PerspectiveCamera>
</T.Group>

<T.Group {position}>
  <RigidBody
    bind:rigidBody
    {position}
    enabledRotations={[false, false, false]}
  >
    <CollisionGroups groups={[0]}>
      <Collider
        shape={'capsule'}
        args={[height / 2 - radius, radius]}
      />
    </CollisionGroups>

    <CollisionGroups groups={[15]}>
      <T.Group position={[0, -height / 2 + radius, 0]}>
        <Collider
          sensor
          shape={'ball'}
          args={[radius * 1.2]}
        />
      </T.Group>
    </CollisionGroups>
  </RigidBody>
</T.Group>
<script lang="ts">
  import { createEventDispatcher, onDestroy } from 'svelte'
  import { Euler, Camera } from 'three'
  import { useThrelte, useParent } from '@threlte/core'

  // Set to constrain the pitch of the camera
  // Range is 0 to Math.PI radians
  export let minPolarAngle = 0 // radians
  export let maxPolarAngle = Math.PI // radians
  export let pointerSpeed = 1.0

  let isLocked = false

  const { renderer, invalidate } = useThrelte()

  const domElement = renderer.domElement
  const camera = useParent()
  const dispatch = createEventDispatcher()

  const _euler = new Euler(0, 0, 0, 'YXZ')
  const _PI_2 = Math.PI / 2

  if (!renderer) {
    throw new Error('Threlte Context missing: Is <PointerLockControls> a child of <Canvas>?')
  }

  const isCamera = (p: any): p is Camera => {
    return p.isCamera
  }

  if (!isCamera($camera)) {
    throw new Error('Parent missing: <PointerLockControls> need to be a child of a <Camera>')
  }

  const onChange = () => {
    invalidate('PointerLockControls: change event')
    dispatch('change')
  }

  export const lock = () => domElement.requestPointerLock({
    unadjustedMovement: true,
  })
  export const unlock = () => document.exitPointerLock()

  domElement.addEventListener('mousemove', onMouseMove)
  domElement.ownerDocument.addEventListener('pointerlockchange', onPointerlockChange)
  domElement.ownerDocument.addEventListener('pointerlockerror', onPointerlockError)

  onDestroy(() => {
    domElement.removeEventListener('mousemove', onMouseMove)
    domElement.ownerDocument.removeEventListener('pointerlockchange', onPointerlockChange)
    domElement.ownerDocument.removeEventListener('pointerlockerror', onPointerlockError)
  })

  function onMouseMove(event: MouseEvent) {
    if (!isLocked) return
    if (!$camera) return

    const { movementX, movementY } = event

    _euler.setFromQuaternion($camera.quaternion)

    _euler.y -= movementX * 0.002 * pointerSpeed
    _euler.x -= movementY * 0.002 * pointerSpeed

    _euler.x = Math.max(_PI_2 - maxPolarAngle, Math.min(_PI_2 - minPolarAngle, _euler.x))

    $camera.quaternion.setFromEuler(_euler)

    onChange()
  }

  function onPointerlockChange() {
    if (document.pointerLockElement === domElement) {
      dispatch('lock')
      isLocked = true
    } else {
      dispatch('unlock')
      isLocked = false
    }
  }

  function onPointerlockError() {
    console.error('PointerLockControls: Unable to use Pointer Lock API')
  }
</script>
<script lang="ts">
  import { T, useFrame } from '@threlte/core'
  import { Environment } from '@threlte/extras'
  import { AutoColliders, CollisionGroups } from '@threlte/rapier'
  import { spring } from 'svelte/motion'
  import { BoxGeometry, Mesh, MeshStandardMaterial, Vector3 } from 'three'
  import Door from '../../rapier/world/Door.svelte'
  import Player from './Player.svelte'
  import Ground from '../../rapier/world/Ground.svelte'

  let playerMesh: Mesh
  let positionHasBeenSet = false
  const smoothPlayerPosX = spring(0)
  const smoothPlayerPosZ = spring(0)
  const t3 = new Vector3()

  useFrame(() => {
    if (!playerMesh) return
    playerMesh.getWorldPosition(t3)
    smoothPlayerPosX.set(t3.x, {
      hard: !positionHasBeenSet
    })
    smoothPlayerPosZ.set(t3.z, {
      hard: !positionHasBeenSet
    })
    if (!positionHasBeenSet) positionHasBeenSet = true
  })
</script>

<Environment
  path="/hdr/"
  files="shanghai_riverside_1k.hdr"
/>

<T.DirectionalLight
  castShadow
  position={[8, 20, -3]}
/>

<T.GridHelper
  args={[50]}
  position.y={0.01}
/>

<CollisionGroups groups={[0, 15]}>
  <Ground />
</CollisionGroups>

<CollisionGroups groups={[0]}>
  <Player
    bind:playerMesh
    position={[0, 2, 3]}
  />

  <Door />

  <AutoColliders shape={'cuboid'}>
    <T.Mesh
      receiveShadow
      castShadow
      position.x={30 + 0.7 + 0.15}
      position.y={1.275}
      geometry={new BoxGeometry(60, 2.55, 0.15)}
      material={new MeshStandardMaterial({
        transparent: true,
        opacity: 0.5,
        color: 0x333333
      })}
    />
    <T.Mesh
      receiveShadow
      castShadow
      position.x={-30 - 0.7 - 0.15}
      position.y={1.275}
      geometry={new BoxGeometry(60, 2.55, 0.15)}
      material={new MeshStandardMaterial({
        transparent: true,
        opacity: 0.5,
        color: 0x333333
      })}
    />
  </AutoColliders>
</CollisionGroups>

Explanation

When the scene is clicked, the pointer is locked to the scene, and now pointer movements will control the angle of the camera in the scene.

  1. there is no need to click and drag, like with e.g. OrbitControls.
  2. Pointer lock lets you access mouse events even when the cursor goes past the boundary of the browser or screen

To explain the 2nd point, find a Threlte scene which uses OrbitControls for it’s camera. Now click and drag the cursor left until you hit the edge of your screen. When you hit the edge, the camera will stop rotating. But in a video game, we want to be able to for example, turn to spin clockwise as many times as we like. Hence why we need to lock the pointer.

This pointer locking behaviour is performed by basically any native video game when it is run on a computer.