We need a custom implementation of PointerLockControls.js
so we can set the mouse sensitivity (using lookSens
)
// CustomThreePLControls.js
import { Euler, EventDispatcher, Vector3 } from 'three'
const _euler = new Euler(0, 0, 0, 'YXZ')
const _vector = new Vector3()
const _changeEvent = { type: 'change' }
const _lockEvent = { type: 'lock' }
const _unlockEvent = { type: 'unlock' }
const _PI_2 = Math.PI / 2
class PointerLockControls extends EventDispatcher {
constructor(camera, domElement, lookSens = 0.0015) {
super()
if (domElement === undefined) {
console.warn(
'THREE.PointerLockControls: The second parameter "domElement" is now mandatory.'
)
domElement = document.body
}
this.domElement = domElement
this.isLocked = false
// Set to constrain the pitch of the camera
// Range is 0 to Math.PI radians
this.minPolarAngle = 0 // radians
this.maxPolarAngle = Math.PI // radians
// The mouse sensitivity (this is what makes this custom)
this.lookSens = lookSens
const scope = this
function onMouseMove(event) {
if (scope.isLocked === false) return
const movementX =
event.movementX || event.mozMovementX || event.webkitMovementX || 0
const movementY =
event.movementY || event.mozMovementY || event.webkitMovementY || 0
_euler.setFromQuaternion(camera.quaternion)
_euler.y -= movementX * scope.lookSens
_euler.x -= movementY * scope.lookSens
_euler.x = Math.max(
_PI_2 - scope.maxPolarAngle,
Math.min(_PI_2 - scope.minPolarAngle, _euler.x)
)
camera.quaternion.setFromEuler(_euler)
scope.dispatchEvent(_changeEvent)
}
function onPointerlockChange() {
if (
scope.domElement.ownerDocument.pointerLockElement === scope.domElement
) {
scope.dispatchEvent(_lockEvent)
scope.isLocked = true
} else {
scope.dispatchEvent(_unlockEvent)
scope.isLocked = false
}
}
function onPointerlockError() {
console.error('THREE.PointerLockControls: Unable to use Pointer Lock API')
}
this.connect = function () {
scope.domElement.ownerDocument.addEventListener('mousemove', onMouseMove)
scope.domElement.ownerDocument.addEventListener(
'pointerlockchange',
onPointerlockChange
)
scope.domElement.ownerDocument.addEventListener(
'pointerlockerror',
onPointerlockError
)
}
this.disconnect = function () {
scope.domElement.ownerDocument.removeEventListener(
'mousemove',
onMouseMove
)
scope.domElement.ownerDocument.removeEventListener(
'pointerlockchange',
onPointerlockChange
)
scope.domElement.ownerDocument.removeEventListener(
'pointerlockerror',
onPointerlockError
)
}
this.dispose = function () {
this.disconnect()
}
this.getObject = function () {
// retaining this method for backward compatibility
return camera
}
this.getDirection = (function () {
const direction = new Vector3(0, 0, -1)
return function (v) {
return v.copy(direction).applyQuaternion(camera.quaternion)
}
})()
this.moveForward = function (distance) {
// move forward parallel to the xz-plane
// assumes camera.up is y-up
_vector.setFromMatrixColumn(camera.matrix, 0)
_vector.crossVectors(camera.up, _vector)
camera.position.addScaledVector(_vector, distance)
}
this.moveRight = function (distance) {
_vector.setFromMatrixColumn(camera.matrix, 0)
camera.position.addScaledVector(_vector, distance)
}
this.lock = function () {
this.domElement.requestPointerLock()
}
this.unlock = function () {
scope.domElement.ownerDocument.exitPointerLock()
}
this.connect()
}
}
export { PointerLockControls }
Then we need a custom implementation of Drei's PointerLockControls
to take advantage of the our custom controls.
// PointerLockControls.tsx
import { ReactThreeFiber, useThree } from '@react-three/fiber'
import * as React from 'react'
import * as THREE from 'three'
import { PointerLockControls as PointerLockControlsImpl } from './CustomThreePLControls'
export type PointerLockControlsProps = ReactThreeFiber.Object3DNode<
PointerLockControlsImpl,
typeof PointerLockControlsImpl
> & {
selector?: string
camera?: THREE.Camera
lookSens?: number
onChange?: (e?: THREE.Event) => void
onLock?: (e?: THREE.Event) => void
onUnlock?: (e?: THREE.Event) => void
}
export const PointerLockControls = React.forwardRef<
PointerLockControlsImpl,
PointerLockControlsProps
>(({ selector, onChange, onLock, onUnlock, lookSens, ...props }, ref) => {
const { camera, ...rest } = props
const gl = useThree(({ gl }) => gl)
const defaultCamera = useThree(({ camera }) => camera)
const invalidate = useThree(({ invalidate }) => invalidate)
const explCamera = camera || defaultCamera
const [controls] = React.useState(
() => new PointerLockControlsImpl(explCamera, gl.domElement, lookSens)
)
React.useEffect(() => {
const callback = (e: THREE.Event) => {
invalidate()
if (onChange) onChange(e)
}
controls?.addEventListener?.('change', callback)
if (onLock) controls?.addEventListener?.('lock', onLock)
if (onUnlock) controls?.addEventListener?.('unlock', onUnlock)
return () => {
controls?.removeEventListener?.('change', callback)
if (onLock) controls?.addEventListener?.('lock', onLock)
if (onUnlock) controls?.addEventListener?.('unlock', onUnlock)
}
}, [onChange, onLock, onUnlock, controls, invalidate])
React.useEffect(() => {
const handler = () => controls?.lock()
const elements = selector
? Array.from(document.querySelectorAll(selector))
: [document]
elements.forEach(
(element) => element && element.addEventListener('click', handler)
)
return () => {
elements.forEach((element) =>
element ? element.removeEventListener('click', handler) : undefined
)
}
}, [controls, selector])
return controls ? (
<primitive ref={ref} dispose={undefined} object={controls} {...rest} />
) : null
})
Finally, we create our custom FirstPersonWalkingControls.tsx
:
// FirstPersonWalkingControls.tsx
import { useRef, useEffect } from 'react'
import { useFrame } from '@react-three/fiber'
import { PointerLockControls } from './PointerLockControls'
import * as THREE from 'three'
interface keyState {
w: boolean
a: boolean
s: boolean
d: boolean
}
const listenedKeys = ['w', 'a', 's', 'd'] as const
const handleKeyDown = (
e: KeyboardEvent,
keyRef: React.MutableRefObject<keyState>
) => {
const key = e.key as keyof keyState
if (listenedKeys.includes(key)) {
keyRef.current[key] = true
}
}
const handleKeyUp = (
e: KeyboardEvent,
keyRef: React.MutableRefObject<keyState>
) => {
const key = e.key as keyof keyState
if (listenedKeys.includes(key)) {
keyRef.current[key] = false
}
}
type FirstPersonWalkingControlsProps = {
/** The height of the character in meters. */
height?: number
}
export const FirstPersonWalkingControls = (
props: FirstPersonWalkingControlsProps
) => {
const keysRef = useRef<keyState>({ w: false, a: false, s: false, d: false })
const cameraDirRef = useRef(new THREE.Vector3())
useEffect(() => {
document.addEventListener('keydown', (e) => handleKeyDown(e, keysRef))
document.addEventListener('keyup', (e) => handleKeyUp(e, keysRef))
return () => {
document.removeEventListener('keydown', (e) => handleKeyDown(e, keysRef))
document.removeEventListener('keyup', (e) => handleKeyUp(e, keysRef))
}
})
useFrame((state, delta) => {
const { camera } = state
const keys = keysRef.current
const cameraDir = cameraDirRef.current
const speed = delta * 10
camera.position.y = props.height || 1.5
camera.getWorldDirection(cameraDir)
cameraDir.y = 0
cameraDir.normalize()
cameraDir.multiplyScalar(speed)
if (keys.w) {
camera.position.add(cameraDir)
}
if (keys.s) {
camera.position.sub(cameraDir)
}
if (keys.a) {
camera.position.sub(cameraDir.clone().cross(new THREE.Vector3(0, 1, 0)))
}
if (keys.d) {
camera.position.add(cameraDir.clone().cross(new THREE.Vector3(0, 1, 0)))
}
})
return <PointerLockControls />
}