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.
Mouse-Controlled Obstacle-Avoiding Third-Person Camera
ThirdPersonCameraRig
overview
How the mouse-driven third-person camera works.
A Babylon.js third-person camera rig with two cooperating layers:
mouseLookuses Pointer Lock mouse deltas to update theArcRotateCameraazimuth (alpha) and pitch (beta). Pitch is clamped to the camera limits; capture pauses on Escape, blur, or tab hiding.protectCameraanchors 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.
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.
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.
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.
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.
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)
})