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 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 { 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 './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 { isInstanceOf, useParent, useTask, useThrelte } from '@threlte/core'
  import { createEventDispatcher, onDestroy } from 'svelte'
  import { Quaternion, Vector2, Vector3 } from 'three'

  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()

  if (!isInstanceOf($camera, '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}
/>