threlte logo

ThirdPersonCamera

Inspired by SimonDev’s ThirdPersonCamera.

Use ‘W’ and ‘S’ to move forward and backwards, and ‘A’ and ‘D’ to rotate the camera.

<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="third-person"
>
  <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 { CapsuleGeometry, Euler, Vector3 } from 'three'
  import { T, useTask, useThrelte } from '@threlte/core'
  import { RigidBody, CollisionGroups, Collider } from '@threlte/rapier'
  import { createEventDispatcher } from 'svelte'
  import Controller from './ThirdPersonControls.svelte'

  export let position = [0, 3, 5]
  export let radius = 0.3
  export let height = 1.7
  export let speed = 6

  let capsule
  let capRef
  $: if (capsule) {
    capRef = capsule
  }
  let rigidBody

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

  const temp = new Vector3()
  const dispatch = createEventDispatcher()

  let grounded = false
  $: grounded ? dispatch('groundenter') : dispatch('groundexit')

  useTask(() => {
    if (!rigidBody || !capsule) return
    // get direction
    const velVec = temp.fromArray([0, 0, forward - backward]) // left - right

    // sort rotate and multiply by speed
    velVec.applyEuler(new Euler().copy(capsule.rotation)).multiplyScalar(speed)

    // don't override falling velocity
    const linVel = rigidBody.linvel()
    temp.y = linVel.y
    // finally set the velocities and wake up the body
    rigidBody.setLinvel(temp, true)

    // when body position changes update camera position
    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.PerspectiveCamera
  makeDefault
  fov={90}
>
  <Controller bind:object={capRef} />
</T.PerspectiveCamera>

<T.Group
  bind:ref={capsule}
  {position}
  rotation.y={Math.PI}
>
  <RigidBody
    bind:rigidBody
    enabledRotations={[false, false, false]}
  >
    <CollisionGroups groups={[0]}>
      <Collider
        shape={'capsule'}
        args={[height / 2 - radius, radius]}
      />
      <T.Mesh geometry={new CapsuleGeometry(0.3, 1.8 - 0.3 * 2)} />
    </CollisionGroups>

    <CollisionGroups groups={[15]}>
      <Collider
        sensor
        shape={'ball'}
        args={[radius * 1.2]}
        position={[0, -height / 2 + radius, 0]}
      />
    </CollisionGroups>
  </RigidBody>
</T.Group>
<script>
  import { T } from '@threlte/core'
  import { AutoColliders, CollisionGroups, Debug } from '@threlte/rapier'
  import { BoxGeometry, MeshStandardMaterial } from 'three'
  import Door from '../../rapier/world/Door.svelte'
  import Player from './Player.svelte'
</script>

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

<Debug />

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

<CollisionGroups groups={[0, 15]}>
  <AutoColliders
    shape={'cuboid'}
    position={[0, -0.5, 0]}
  >
    <T.Mesh
      receiveShadow
      geometry={new BoxGeometry(100, 1, 100)}
      material={new MeshStandardMaterial()}
    />
  </AutoColliders>
</CollisionGroups>

<CollisionGroups groups={[0]}>
  <!-- position={{ x: 2 }} -->
  <Player />
  <Door />

  <!-- WALLS -->
  <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>
<script lang="ts">
  import { createEventDispatcher, onDestroy } from 'svelte'
  import { Camera, Vector2, Vector3, Quaternion } from 'three'
  import { useThrelte, useParent, useTask } from '@threlte/core'

  export let object
  export let rotateSpeed = 1.0

  $: if (object) {
    // console.log(object)
    // object.position.y = 10
    // // Calculate the direction vector towards (0, 0, 0)
    // const target = new Vector3(0, 0, 0)
    // const direction = target.clone().sub(object.position).normalize()
    // // Extract the forward direction from the object's current rotation matrix
    // const currentDirection = new Vector3(0, 1, 0)
    // currentDirection.applyQuaternion(object.quaternion)
    // // Calculate the axis and angle to rotate the object
    // const rotationAxis = currentDirection.clone().cross(direction).normalize()
    // const rotationAngle = Math.acos(currentDirection.dot(direction))
    // // Rotate the object using rotateOnAxis()
    // object.rotateOnAxis(rotationAxis, rotationAngle)
  }

  export let idealOffset = { x: -0.5, y: 2, z: -3 }
  export let idealLookAt = { x: 0, y: 1, z: 5 }

  const currentPosition = new Vector3()
  const currentLookAt = new Vector3()

  let isOrbiting = false
  let pointerDown = false

  const rotateStart = new Vector2()
  const rotateEnd = new Vector2()
  const rotateDelta = new Vector2()

  const axis = new Vector3(0, 1, 0)
  const rotationQuat = new Quaternion()

  const { renderer, invalidate } = useThrelte()

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

  const dispatch = createEventDispatcher()

  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>')
  }

  domElement.addEventListener('pointerdown', onPointerDown)
  domElement.addEventListener('pointermove', onPointerMove)
  domElement.addEventListener('pointerleave', onPointerLeave)
  domElement.addEventListener('pointerup', onPointerUp)

  onDestroy(() => {
    domElement.removeEventListener('pointerdown', onPointerDown)
    domElement.removeEventListener('pointermove', onPointerMove)
    domElement.removeEventListener('pointerleave', onPointerLeave)
    domElement.removeEventListener('pointerup', onPointerUp)
  })

  // This is basically your update function
  useTask((delta) => {
    // the object's position is bound to the prop
    if (!object) return

    // camera is based on character so we rotation character first
    rotationQuat.setFromAxisAngle(axis, -rotateDelta.x * rotateSpeed * delta)
    object.quaternion.multiply(rotationQuat)

    // then we calculate our ideal's
    const offset = vectorFromObject(idealOffset)
    const lookAt = vectorFromObject(idealLookAt)

    // and how far we should move towards them
    const t = 1.0 - Math.pow(0.001, delta)
    currentPosition.lerp(offset, t)
    currentLookAt.lerp(lookAt, t)

    // then finally set the camera
    $camera.position.copy(currentPosition)
    $camera.lookAt(currentLookAt)
  })

  function onPointerMove(event: PointerEvent) {
    const { x, y } = event
    if (pointerDown && !isOrbiting) {
      // calculate distance from init down
      const distCheck =
        Math.sqrt(Math.pow(x - rotateStart.x, 2) + Math.pow(y - rotateStart.y, 2)) > 10
      if (distCheck) {
        isOrbiting = true
      }
    }
    if (!isOrbiting) return

    rotateEnd.set(x, y)
    rotateDelta.subVectors(rotateEnd, rotateStart).multiplyScalar(rotateSpeed)
    rotateStart.copy(rotateEnd)

    invalidate()
    dispatch('change')
  }

  function onPointerDown(event: PointerEvent) {
    const { x, y } = event
    rotateStart.set(x, y)
    pointerDown = true
  }

  function onPointerUp() {
    rotateDelta.set(0, 0)
    pointerDown = false
    isOrbiting = false
  }

  function onPointerLeave() {
    rotateDelta.set(0, 0)
    pointerDown = false
    isOrbiting = false
  }

  function vectorFromObject(vec: { x: number; y: number; z: number }) {
    const { x, y, z } = vec
    const ideal = new Vector3(x, y, z)
    ideal.applyQuaternion(object.quaternion)
    ideal.add(new Vector3(object.position.x, object.position.y, object.position.z))
    return ideal
  }

  function onKeyDown(event: KeyboardEvent) {
    switch (event.key) {
      case 'a':
        rotateDelta.x = -2 * rotateSpeed
        break
      case 'd':
        rotateDelta.x = 2 * rotateSpeed
        break
      default:
        break
    }
  }

  function onKeyUp(event: KeyboardEvent) {
    switch (event.key) {
      case 'a':
        rotateDelta.x = 0
        break
      case 'd':
        rotateDelta.x = 0
        break
      default:
        break
    }
  }
</script>

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