threlte logo

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 { Pane, Text } from 'svelte-tweakpane-ui'
  import { Canvas } from '@threlte/core'
  import { World } from '@threlte/rapier'
  import Scene from './Scene.svelte'
</script>

<Pane
  position="fixed"
  title="pointer-lock"
>
  <Text
    value="Use the 'wasd' keys to move around"
    disabled
  />
</Pane>

<div>
  <Canvas>
    <World>
      <Scene />
    </World>
  </Canvas>
</div>

<style>
  div {
    position: relative;
    height: 100%;
    width: 100%;
  }
</style>
<script lang="ts">
  import type { RigidBody as RapierRigidBody } from '@dimforge/rapier3d-compat'
  import { T } from '@threlte/core'
  import { HTML } from '@threlte/extras'
  import { AutoColliders, Collider, CollisionGroups, RigidBody } from '@threlte/rapier'
  import { cubicIn, cubicOut } from 'svelte/easing'
  import { tweened } from 'svelte/motion'
  import { blur } from 'svelte/transition'
  import {
    BoxGeometry,
    Euler,
    type Group,
    MeshStandardMaterial,
    Quaternion,
    MathUtils
  } from 'three'

  let open = false
  let objectsInSensor = 0
  $: open = objectsInSensor > 0

  let group: Group
  let doorRigidBody: RapierRigidBody

  let doorRotationClosed = 0
  let doorRotationOpen = -105 * MathUtils.DEG2RAD
  let doorRotation = tweened(doorRotationClosed)
  $: doorRotation.set(open ? doorRotationOpen : doorRotationClosed, {
    easing: open ? cubicOut : cubicIn
  })

  const q = new Quaternion()
  const e = new Euler()

  const applyDoorRotation = (rotation: number) => {
    if (!group || !doorRigidBody) return
    group.getWorldQuaternion(q)
    e.setFromQuaternion(q)
    e.y += rotation
    q.setFromEuler(e)
    doorRigidBody.setNextKinematicRotation(q)
  }

  $: if (group && doorRigidBody) applyDoorRotation($doorRotation)
</script>

@@ -1,152 +0,0 @@
<T.Group bind:ref={group}>
  <!-- FRAME -->
  <AutoColliders shape={'cuboid'}>
    <!-- SIDE FRAME A -->
    <T.Mesh
      receiveShadow
      castShadow
      position={[0.7, 1.125, 0]}
      geometry={new BoxGeometry(0.3, 2.25, 0.3)}
      material={new MeshStandardMaterial()}
    />

    <!-- SIDE FRAME B -->
    <T.Mesh
      receiveShadow
      castShadow
      position={[-0.7, 1.125, 0]}
      geometry={new BoxGeometry(0.3, 2.25, 0.3)}
      material={new MeshStandardMaterial()}
    />

    <!-- TOP FRAME -->
    <T.Mesh
      receiveShadow
      castShadow
      position.y={2.4}
      geometry={new BoxGeometry(1.4 + 0.3, 0.3, 0.3)}
      material={new MeshStandardMaterial()}
    />
  </AutoColliders>

  <HTML
    transform
    position.y={3}
    pointerEvents={'none'}
  >
    {#key open}
      <small
        in:blur={{
          amount: 15,
          duration: 300
        }}
        out:blur={{
          amount: 15,
          duration: 300
        }}
        class="door"
        class:closed={!open}
        class:open
      >
        {open ? 'UNLOCKED' : 'LOCKED'}
      </small>
    {/key}
  </HTML>

  <!-- DOOR -->
  <T.Group position={[-0.5, 1.125, 0]}>
    <RigidBody
      bind:rigidBody={doorRigidBody}
      type={'kinematicPosition'}
    >
      <AutoColliders shape={'cuboid'}>
        <T.Mesh
          receiveShadow
          castShadow
          position.x={0.5}
          geometry={new BoxGeometry(1, 2.25, 0.1)}
          material={new MeshStandardMaterial()}
        />
      </AutoColliders>
    </RigidBody>
  </T.Group>

  <CollisionGroups groups={[15]}>
    <T.Group position={[0, 1.5, 0]}>
      <Collider
        shape={'cuboid'}
        args={[1, 1.35, 1.5]}
        sensor
        onsensorenter={() => (objectsInSensor += 1)}
        onsensorexit={() => (objectsInSensor -= 1)}
      />
    </T.Group>
  </CollisionGroups>
</T.Group>

<style>
  .door {
    padding-left: 0.5rem;
    padding-right: 0.5rem;
    padding-top: 0.25rem;
    padding-bottom: 0.25rem;
    color: rgb(255, 255, 255);
    border-radius: 0.375rem;
    position: absolute;
    transform: translateX(-50%) translateY(-50%);
  }

  .closed {
    background-color: rgb(239, 68, 68);
  }

  .open {
    background-color: rgb(34, 197, 94);
  }
</style>
<script lang="ts">
  import { T } from '@threlte/core'
  import { AutoColliders } from '@threlte/rapier'
</script>

@@ -1,13 +0,0 @@
<T.Group position={[0, -0.5, 0]}>
  <AutoColliders shape={'cuboid'}>
    <T.Mesh receiveShadow>
      <T.BoxGeometry args={[100, 1, 100]} />
      <T.MeshStandardMaterial />
    </T.Mesh>
  </AutoColliders>
</T.Group>
<script lang="ts">
  import type { RigidBody as RapierRigidBody } from '@dimforge/rapier3d-compat'
  import { T, useTask, 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)
  })

  useTask(() => {
    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]}
    oncreate={(ref) => {
      ref.lookAt(new Vector3(0, 2, 0))
    }}
  >
    <PointerLockControls bind:lock />
  </T.PerspectiveCamera>
</T.Group>

<T.Group {position}>
  <RigidBody
    bind:rigidBody
    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 { isInstanceOf, useParent, useThrelte } from '@threlte/core'
  import { onDestroy } from 'svelte'
  import { Euler } from 'three'

  // 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
  export let onchange: (() => void) | undefined = undefined
  export let onlock: (() => void) | undefined = undefined
  export let onunlock: (() => void) | undefined = undefined

  let isLocked = false

  const { renderer, invalidate } = useThrelte()

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

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

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

  const onChange = () => {
    invalidate()
    onchange?.()
  }

  export const lock = () => domElement.requestPointerLock()
  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
    if (!isInstanceOf($camera, 'Camera')) {
      throw new Error('Parent missing: <PointerLockControls> need to be a child of a <Camera>')
    }

    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) {
      onlock?.()
      isLocked = true
    } else {
      onunlock?.()
      isLocked = false
    }
  }

  function onPointerlockError() {
    console.error('PointerLockControls: Unable to use Pointer Lock API')
  }
</script>
<script lang="ts">
  import { T } from '@threlte/core'
  import { Environment } from '@threlte/extras'
  import { AutoColliders, CollisionGroups } from '@threlte/rapier'
  import { BoxGeometry, MeshStandardMaterial } from 'three'
  import Door from './Door.svelte'
  import Player from './Player.svelte'
  import Ground from './Ground.svelte'
</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 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.