Skip to content

Instantly share code, notes, and snippets.

@ChrisCrossCrash
Created February 17, 2022 17:29
Show Gist options
  • Save ChrisCrossCrash/cab92b6e4690412732d87665840d541f to your computer and use it in GitHub Desktop.
Save ChrisCrossCrash/cab92b6e4690412732d87665840d541f to your computer and use it in GitHub Desktop.
R3F First Person Walking Controls

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 />
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment