Mouse-Controlled Obstacle-Avoiding Third-Person Camera

Mouse-Controlled Obstacle-Avoiding Third-Person Camera

Reference implementation for mouse-look camera: pointer-lock orbit controls, stable root/shoulder follow, swept-sphere obstacle avoidance, damped retraction and recovery, preserved zoom intent, and close-character fading.

ThirdPersonCameraRig

  • camera
  • third-person
  • mouse-look
  • collision-avoidance
  • babylonjs
  • reset-lobby
Sign in to save

overview

How the mouse-driven third-person camera works.

A Babylon.js third-person camera rig with two cooperating layers:

  1. mouseLook uses Pointer Lock mouse deltas to update the ArcRotateCamera azimuth (alpha) and pitch (beta). Pitch is clamped to the camera limits; capture pauses on Escape, blur, or tab hiding.
  2. protectCamera anchors the orbit target to a stable root-based shoulder pivot, sweeps a small sphere through collision geometry, retracts the camera before obstacles, suppresses doorway pumping, and restores the intended zoom smoothly.

The player body faces the camera heading (-π/2 - camera.alpha), while movement is camera-relative. Scroll changes a separate preferred arm length so temporary collision retraction does not destroy the user's zoom choice.

behavior.controls

Player-facing input behavior.

[
  "Click the canvas or press Enter while it is focused to capture the mouse.",
  "Horizontal mouse movement changes camera azimuth.",
  "Vertical mouse movement changes and clamps pitch.",
  "Mouse wheel changes preferred camera distance from 2m to 12m.",
  "Escape, window blur, or document hiding releases/pauses capture."
]

behavior.obstruction

Obstacle-avoidance and follow behavior.

[
  "Root pivot sits 1.35m above the character with a 0.28m shoulder offset.",
  "A 0.18m-radius swept collider checks both the shoulder offset and camera arm.",
  "A 0.45m anticipation buffer starts camera compression before contact.",
  "Inward/outward damping is 0.10s/0.35s; a 0.18s hold suppresses doorway oscillation.",
  "A hard collision limit overrides damping so the lens never clips into a sudden obstacle.",
  "Preferred zoom remains independent from the temporarily applied collision radius.",
  "Character meshes fade when the applied camera radius is below 1.5m and become fully transparent by 0.8m.",
  "Large character teleports immediately reacquire the pivot and obstruction distance."
]

tuning

Camera defaults and obstruction tuning in metres, radians, and seconds.

{
  "rig": {
    "pivotHeight": 1.35,
    "sweepRadius": 0.18,
    "anticipation": 0.45,
    "dampInSeconds": 0.1,
    "dampOutSeconds": 0.35,
    "shoulderOffset": 0.28,
    "doorwayHoldSeconds": 0.18,
    "mouseSensitivityRadiansPerPixel": 0.0025
  },
  "camera": {
    "beta": 1.35,
    "maxZ": 300,
    "minZ": 0.02,
    "alpha": -1.5707963267948966,
    "defaultRadius": 3.4,
    "lowerBetaLimit": 0.35,
    "upperBetaLimit": 2.791592653589793,
    "maxPreferredRadius": 12,
    "minPreferredRadius": 2
  }
}

code.mouseLook

Pointer-lock mouse capture and orbit input.

typescript
import type { ArcRotateCamera } from '@babylonjs/core'

// Pointer-lock controls adapted from ultimate-fighter's Game scene.
export function mouseLook(canvas: HTMLCanvasElement, camera: ArcRotateCamera, changed: (captured: boolean, error?: string) => void, host = window, page = document) {
  let pending = false, disposed = false
  const captured = () => page.pointerLockElement === canvas
  const failure = () => { if (!disposed && !captured()) changed(false, 'Mouse capture failed. Click Resume to try again.') }
  const capture = async () => {
    if (disposed || pending || captured()) return
    pending = true
    try { await canvas.requestPointerLock() } catch { failure() }
    finally { pending = false }
  }
  const pause = () => {
    if (captured()) page.exitPointerLock()
    changed(false)
  }
  const lockChanged = () => {
    if (disposed) return
    if (captured()) canvas.focus()
    changed(captured())
  }
  const move = (event: MouseEvent) => {
    if (!captured() || page.hidden) return
    camera.alpha += event.movementX * 0.0025
    camera.beta = Math.max(camera.lowerBetaLimit!, Math.min(camera.upperBetaLimit!, camera.beta - event.movementY * 0.0025))
  }
  const key = (event: KeyboardEvent) => {
    if (event.repeat || event.ctrlKey || event.metaKey || event.altKey) return
    if (event.code === 'Escape' && captured()) { event.preventDefault(); pause() }
    else if (event.code === 'Enter' && page.activeElement === canvas && !captured()) { event.preventDefault(); void capture() }
  }
  const visibility = () => { if (page.hidden) pause() }
  canvas.addEventListener('click', capture)
  page.addEventListener('mousemove', move)
  page.addEventListener('pointerlockchange', lockChanged)
  page.addEventListener('pointerlockerror', failure)
  page.addEventListener('visibilitychange', visibility)
  host.addEventListener('keydown', key)
  host.addEventListener('blur', pause)
  return {
    capture,
    dispose() {
      disposed = true
      canvas.removeEventListener('click', capture)
      page.removeEventListener('mousemove', move)
      page.removeEventListener('pointerlockchange', lockChanged)
      page.removeEventListener('pointerlockerror', failure)
      page.removeEventListener('visibilitychange', visibility)
      host.removeEventListener('keydown', key)
      host.removeEventListener('blur', pause)
      if (captured()) page.exitPointerLock()
    }
  }
}

code.cameraObstruction

Collision-aware camera shoulder pivot, arm compression, recovery, and avatar fade.

typescript
import { ArcRotateCamera, Material, Vector3, type ArcRotateCameraMouseWheelInput, type Scene, type AbstractMesh, type TransformNode } from '@babylonjs/core'

// Tuned for Xbot's scale, following Cinemachine Third Person Follow / Deoccluder:
// https://docs.unity3d.com/Packages/com.unity.cinemachine@3.1/manual/CinemachineThirdPersonFollow.html
const PIVOT_HEIGHT = 1.35, SHOULDER = 0.28, ANTICIPATION = 0.45
const DAMP_IN = 0.10, DAMP_OUT = 0.35, HOLD = 0.18

export function protectCamera(scene: Scene, camera: ArcRotateCamera, character: TransformNode, body: AbstractMesh | null = null) {
  const collider = scene.collisionCoordinator.createCollider()
  collider._radius.setAll(0.18)
  let wanted = camera.radius, applied = camera.radius, held = camera.radius, holdTime = 0
  let pivotY = character.position.y + PIVOT_HEIGHT
  const previous = character.position.clone()
  const meshes = character.getChildMeshes().map(mesh => ({ mesh, visibility: mesh.visibility }))
  const materials = [...new Set(meshes.map(({ mesh }) => mesh.material).filter(material => material !== null))]
    .map(material => ({ material, transparencyMode: material.transparencyMode }))
  camera.lowerRadiusLimit = 0.05
  // Input controls the intended arm length, never the temporary collision distance.
  const wheel = camera.inputs.attached.mousewheel as ArcRotateCameraMouseWheelInput
  wheel.customComputeDeltaFromMouseWheel = delta => {
    wanted = Math.max(2, Math.min(12, wanted * Math.exp(-delta * 0.001)))
    return 0
  }
  function sweep(from: Vector3, displacement: Vector3) {
    let result = from.add(displacement)
    scene.collisionCoordinator.getNewPosition(from, displacement, collider, 3, body,
      (_id, position) => { result = position }, camera.uniqueId, false)
    return result
  }
  function update(dt = Math.min(0.1, scene.getEngine().getDeltaTime() / 1000 || 1 / 60)) {
    if (scene.activeCamera !== camera) return
    const teleported = Vector3.DistanceSquared(previous, character.position) > 4
    previous.copyFrom(character.position)
    const height = character.position.y + PIVOT_HEIGHT
    pivotY = teleported ? height : pivotY + (height - pivotY) * (1 - Math.exp(-dt / 0.12))
    pivotY = Math.max(height - 0.25, Math.min(height + 0.25, pivotY))
    // Root-based pivot: animation sway and body turns cannot move the orbit anchor.
    const pivot = new Vector3(character.position.x, pivotY, character.position.z)
    for (const mesh of scene.meshes) if (mesh.checkCollisions) mesh.computeWorldMatrix(true)
    const shoulder = new Vector3(-Math.sin(camera.alpha) * SHOULDER, 0, Math.cos(camera.alpha) * SHOULDER)
    camera.target.copyFrom(sweep(pivot, shoulder))
    const direction = new Vector3(Math.cos(camera.alpha) * Math.sin(camera.beta), Math.cos(camera.beta), Math.sin(camera.alpha) * Math.sin(camera.beta))
    // Look beyond the preferred camera position to begin easing before hitting a wall.
    const length = wanted + ANTICIPATION
    const hitDistance = Vector3.Distance(camera.target, sweep(camera.target, direction.scale(length)))
    const hardLimit = Math.max(0.05, hitDistance - 0.02)
    const softLimit = Math.max(0.05, Math.min(wanted, hitDistance - ANTICIPATION))
    if (teleported) { held = softLimit; holdTime = 0 }
    if (softLimit < held - 0.01) { held = softLimit; holdTime = HOLD }
    else {
      holdTime = Math.max(0, holdTime - dt)
      if (holdTime === 0) held = softLimit
    }
    const goal = Math.min(held, softLimit)
    const damping = goal < applied ? DAMP_IN : DAMP_OUT
    applied += (goal - applied) * (1 - Math.exp(-dt / damping))
    // Safety wins if an obstacle appears suddenly; damping must never put the lens inside it.
    applied = Math.min(applied, hardLimit)
    camera.radius = applied
    camera.getViewMatrix(true)
    const visibility = Math.max(0, Math.min(1, (applied - 0.8) / 0.7))
    for (const entry of meshes) entry.mesh.visibility = entry.visibility * visibility
    for (const entry of materials) entry.material.transparencyMode = visibility < 1 ? Material.MATERIAL_ALPHABLEND : entry.transparencyMode
  }
  scene.onBeforeRenderObservable.add(() => update())
  return update
}

code.worldIntegration

World setup showing ArcRotateCamera configuration, mouseLook/protectCamera wiring, and body heading coupling.

typescript
import { ArcRotateCamera, Color3, Color4, Engine, LoadAssetContainerAsync, MeshBuilder, Scene, StandardMaterial, Vector3 } from '@babylonjs/core'
import '@babylonjs/loaders/glTF'
import { avatars } from './avatars'
import { mouseLook } from './mouse-look'
import { characterInput } from './character-input'
import { FLOOR_SIZE } from './movement'
import { protectCamera } from './camera-obstruction'
import { worldControl } from './world-control'
import { worldLighting } from './world-lighting'

export async function createWorld(engine: Engine, signal: AbortSignal, status: (message: string) => void, captureChanged: (captured: boolean, error?: string) => void = () => {}) {
  const scene = new Scene(engine)
  scene.useRightHandedSystem = true
  scene.clearColor = new Color4(0.09, 0.1, 0.14, 1)
  const canvas = engine.getRenderingCanvas()!
  const camera = new ArcRotateCamera('third-person', -Math.PI / 2, 1.35, 3.4, new Vector3(0, 1.35, 0), scene)
  camera.minZ = 0.02
  camera.maxZ = 300
  camera.lowerRadiusLimit = 2
  camera.upperRadiusLimit = 12
  camera.lowerBetaLimit = 0.35
  camera.upperBetaLimit = Math.PI - 0.35
  camera.panningSensibility = 0
  camera.inputs.removeByType('ArcRotateCameraKeyboardMoveInput')
  camera.inputs.removeByType('ArcRotateCameraPointersInput')
  camera.attachControl(canvas, false)
  const lighting = worldLighting(scene)
  const floor = MeshBuilder.CreateGround('floor', { width: FLOOR_SIZE, height: FLOOR_SIZE }, scene)
  const material = new StandardMaterial('floor', scene)
  material.diffuseColor = new Color3(0.23, 0.25, 0.3)
  material.specularColor = Color3.Black()
  floor.material = material
  floor.checkCollisions = true
  floor.receiveShadows = true

  try {
    const assets = await LoadAssetContainerAsync(`${import.meta.env.BASE_URL}assets/characters/mixamo-xbot/fighter.glb`, scene)
    signal.throwIfAborted()
    const spawn = avatars(scene, assets)
    const player = spawn('player', 'You').humanoid
    const root = player.root
    const remote = await worldControl(scene, player, camera, signal, status, spawn, lighting)
    let accumulator = 0
    const input = characterInput(canvas, () => !scene.isDisposed && document.pointerLockElement === canvas, () => {
      accumulator = 0
      player.movement.jumpBuffer = 0
      if (!remote.active) player.movement.vx = player.movement.vz = 0
    })
    const mouse = mouseLook(canvas, camera, (captured, error) => { input.clear(); captureChanged(captured, error) })
    scene.onBeforeRenderObservable.add(() => {
      if (document.hidden || document.pointerLockElement !== canvas || remote.capturing || remote.active) return
      if (input.takeInteract()) { void remote.interact(); return }
      accumulator += Math.min(0.1, engine.getDeltaTime() / 1000)
      while (accumulator >= 1 / 120) {
        const movement = player.movement
        const distance = movement.distance
        const right = Number(input.has('KeyD', 'ArrowRight')) - Number(input.has('KeyA', 'ArrowLeft'))
        const forward = Number(input.has('KeyW', 'ArrowUp')) - Number(input.has('KeyS', 'ArrowDown'))
        movement.update(1 / 120, right, forward, camera.alpha, input.has('ShiftLeft', 'ShiftRight'), input.takeJump(), input.has('Space'))
        movement.heading = -Math.PI / 2 - camera.alpha
        player.animate(1 / 120, distance)
        accumulator -= 1 / 120
      }
    })
    protectCamera(scene, camera, root, player.collider)
    scene.onDisposeObservable.add(() => { mouse.dispose(); input.dispose() })
    return { scene, save: remote.save, resume: mouse.capture }
  } catch (error) {
    scene.dispose()
    throw error
  }
}

tests.mouseLook

Tests for pointer-lock gating, pitch limits, pause/recovery, errors, and cleanup.

javascript
import test from 'node:test'
import assert from 'node:assert/strict'
import { mouseLook } from '../src/lib/mouse-look.ts'

test('mouse capture gates look, allows upward pitch, releases on Escape/blur and cleans up', async () => {
  const host = new EventTarget(), page = new EventTarget(), canvas = new EventTarget()
  const camera = { alpha: -Math.PI/2, beta: 1.35, lowerBetaLimit: 0.35, upperBetaLimit: Math.PI-0.35 }
  const changes = []
  page.hidden = false; page.pointerLockElement = null
  canvas.focus = () => { page.activeElement = canvas }
  canvas.requestPointerLock = async () => { page.pointerLockElement = canvas; page.dispatchEvent(new Event('pointerlockchange')) }
  page.exitPointerLock = () => { page.pointerLockElement = null; page.dispatchEvent(new Event('pointerlockchange')) }
  const mouse = mouseLook(canvas, camera, (...args) => changes.push(args), host, page)
  const move = (x,y) => page.dispatchEvent(Object.assign(new Event('mousemove'), {movementX:x, movementY:y}))
  const key = code => host.dispatchEvent(Object.assign(new Event('keydown', {cancelable:true}), {code}))
  move(100,100); assert.equal(camera.beta,1.35)
  await mouse.capture(); assert.equal(page.activeElement,canvas)
  move(3000,-400); assert(camera.alpha > Math.PI); assert(camera.beta > Math.PI/2)
  move(0,-10000); assert.equal(camera.beta,camera.upperBetaLimit)
  move(0,10000); assert.equal(camera.beta,camera.lowerBetaLimit)
  key('Escape'); assert.equal(page.pointerLockElement,null); assert.equal(changes.at(-1)[0],false)
  const alpha=camera.alpha; move(100,0); assert.equal(camera.alpha,alpha)
  key('Enter'); await Promise.resolve(); assert.equal(page.pointerLockElement,canvas)
  host.dispatchEvent(new Event('blur')); assert.equal(page.pointerLockElement,null)
  canvas.requestPointerLock = async () => { throw Error('denied') }
  await mouse.capture(); assert.match(changes.at(-1)[1],/capture failed/)
  mouse.dispose(); const count=changes.length
  page.dispatchEvent(new Event('pointerlockerror')); assert.equal(changes.length,count)
})

tests.cameraObstruction

Tests for retraction, preserved zoom, fade, shoulder stability, anticipation, doorway hold, teleport reacquisition, and frame-rate independence.

javascript
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { NullEngine, Scene, ArcRotateCamera, Vector3, TransformNode, MeshBuilder, PBRMaterial, Material } from '@babylonjs/core'
import { protectCamera } from '../src/lib/camera-obstruction.ts'

test('camera retracts before walls, keeps intended zoom, restores distance, and fades the close humanoid', () => {
  const engine = new NullEngine(), scene = new Scene(engine)
  try {
    const root = new TransformNode('character', scene)
    const body = MeshBuilder.CreateBox('body', {}, scene); body.parent = root
    body.material = new PBRMaterial('opaque-gltf', scene)
    body.material.transparencyMode = Material.MATERIAL_OPAQUE
    const camera = new ArcRotateCamera('camera', 0, Math.PI / 2, 5, new Vector3(0, 1, 0), scene)
    scene.activeCamera = camera
    const wall = MeshBuilder.CreateBox('wall', { width: 0.1, height: 3, depth: 3 }, scene)
    wall.position.set(2, 1, 0); wall.checkCollisions = true
    const update = protectCamera(scene, camera, root)
    update()
    assert(camera.radius < 1.8 && camera.radius > 1.5)
    for (let i = 0; i < 60; i++) update()
    assert(camera.radius < 1.8)
    camera.inputs.attached.mousewheel.customComputeDeltaFromMouseWheel(-Math.log(1.2) / 0.001) // zoom out while blocked
    update()
    wall.position.x = 1.1
    update()
    assert(body.visibility < 0.1, 'fade before the torso fills the view')
    assert(body.material.needAlphaBlendingForMesh(body), 'opaque glTF materials must render the fade')
    wall.position.x = 0.5
    update()
    assert(camera.radius < 0.3)
    assert(body.visibility < 0.1)
    wall.dispose()
    for (let i = 0; i < 200; i++) update()
    assert(camera.radius > 5.9 && camera.radius <= 6)
    assert.equal(body.visibility, 1)
    assert.equal(body.material.transparencyMode, Material.MATERIAL_OPAQUE)
  } finally { engine.dispose() }
})

test('stable shoulder pivot, anticipatory compression, doorway hold and frame-rate independent recovery', () => {
  function simulate(fps) {
    const engine = new NullEngine(), scene = new Scene(engine)
    try {
      const root = new TransformNode('character', scene)
      const camera = new ArcRotateCamera('camera', -Math.PI / 2, Math.PI / 2, 3.4, Vector3.Zero(), scene)
      scene.activeCamera = camera
      const update = protectCamera(scene, camera, root)
      update(1 / fps)
      assert(Math.abs(camera.target.x - 0.28) < 0.001)
      assert.equal(camera.target.y, 1.35)
      root.rotation.y = 2 // walking sideways or turning must not swing the camera
      update(1 / fps)
      assert.equal(camera.alpha, -Math.PI / 2)
      assert(Math.abs(camera.target.z) < 0.001)
      const wall = MeshBuilder.CreateBox('wall', { width: 8, height: 4, depth: 0.1 }, scene)
      wall.position.set(0, 2, -4.3); wall.checkCollisions = true
      let last = camera.radius, maxStep = 0
      for (let i = 0; i < fps; i++) {
        wall.position.z = -4.3 + 1.5 * (i + 1) / fps
        update(1 / fps)
        assert(camera.position.z > wall.position.z + 0.23, 'camera stays in front of the wall')
        assert(camera.radius <= last + 1e-6)
        maxStep = Math.max(maxStep, last - camera.radius)
        last = camera.radius
      }
      assert(maxStep < 1.8 / fps, `continuous approach should ease, not snap: ${maxStep}`)
      const close = camera.radius
      wall.setEnabled(false)
      for (let i = 0; i < Math.round(fps * 0.1); i++) update(1 / fps)
      assert(camera.radius <= close + 1e-6, 'brief doorway gap must not pump camera outward')
      wall.setEnabled(true)
      for (let i = 0; i < fps; i++) update(1 / fps)
      wall.dispose()
      for (let i = 0; i < fps; i++) update(1 / fps)
      const recovered = camera.radius
      root.position.set(8, 2, 5) // teleport: immediately reacquire the anchor
      update(1 / fps)
      assert(Math.abs(camera.target.y - 3.35) < 0.001)
      assert(Math.abs(camera.target.x - 8.28) < 0.001)
      assert(Math.abs(camera.target.z - 5) < 0.001)
      return recovered
    } finally { engine.dispose() }
  }
  assert(Math.abs(simulate(30) - simulate(120)) < 0.02)
})

references.cinemachineThirdPersonFollow

https://docs.unity3d.com/Packages/com.unity.cinemachine@3.1/manual/CinemachineThirdPersonFollow.html

references.cinemachineDeoccluder

https://docs.unity3d.com/Packages/com.unity.cinemachine@3.1/manual/CinemachineDeoccluder.html