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, 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 { onDestroy } from 'svelte'
import { Euler, Camera, PerspectiveCamera } 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
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<PerspectiveCamera>()
const _euler = new Euler(0, 0, 0, 'YXZ')
const _PI_2 = Math.PI / 2
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()
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
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 '../../rapier/world/Door.svelte'
import Player from './Player.svelte'
import Ground from '../../rapier/world/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.
- there is no need to click and drag, like with e.g.
OrbitControls
. -
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.