Lesson 41 - Three.js Game Development
Three.js Game Cameras: Follow, Orbit, First Person, and Terrain Safety
The camera is part of the control system. We will build camera rigs that reveal the next action, avoid clipping into terrain, and remain comfortable with mouse or touch input.
1. Represent rotation as yaw and pitch
Yaw rotates around the world up axis; pitch looks up and down. Clamp pitch before converting it into a quaternion so the camera never flips.
let yaw = 0;
let pitch = 0;
const euler = new THREE.Euler(0, 0, 0, "YXZ");
function applyLook(dx, dy) {
yaw -= dx * 0.0022;
pitch = THREE.MathUtils.clamp(pitch - dy * 0.0022, -1.35, 1.35);
euler.set(pitch, yaw, 0);
camera.quaternion.setFromEuler(euler);
}
How this code works
Execution flow
Pointer deltas accumulate into yaw and pitch angles, pitch is clamped to a comfortable range, and an Euler in YXZ order is converted into the camera quaternion. Roll remains zero so the horizon stays stable.
Key Three.js decisions
YXZ applies world-like yaw before local pitch and is a common first-person camera order. Quaternion assignment gives Three.js a stable orientation representation while the human-readable yaw and pitch values remain easy to clamp and save.
Adapt it
Scale sensitivity separately for mouse and touch, invert pitch as an option, and damp deltas for gamepad look. A turret can reuse the angles with narrower pitch limits while applying yaw to the base and pitch to the barrel child.
Watch out
Allowing pitch beyond about ninety degrees flips controls and disorients players. Recreating Euler and Quaternion objects on every pointer event creates needless garbage, so retain them and update their values as the sample does.
2. Build a damped chase camera
Calculate the desired position from player orientation, then use exponential damping. Unlike a fixed lerp factor, this feels similar at different frame rates.
const desired = new THREE.Vector3();
const offset = new THREE.Vector3(0, 3.2, 7.5);
function updateChaseCamera(dt) {
desired.copy(offset).applyQuaternion(player.quaternion).add(player.position);
const blend = 1 - Math.exp(-7 * dt);
camera.position.lerp(desired, blend);
camera.lookAt(player.position.x, player.position.y + 1.2, player.position.z);
}
How this code works
Execution flow
The desired local offset rotates with the player, moves into world space, and is added to player position. Exponential damping blends the current camera toward that target, then lookAt aims slightly above the player's origin.
Key Three.js decisions
Applying the player quaternion keeps the camera behind its facing direction. The expression one minus exp of negative rate times delta is frame-rate-independent damping, unlike a fixed lerp fraction that changes feel between 30 and 144 FPS.
Adapt it
Increase offset Z for racing speed, raise Y for platform visibility, or blend several target offsets during boost and combat. Use a separate smoothed look target to reduce camera jitter when the visual model contains animation noise.
Watch out
Mutating the shared offset vector with applyQuaternion every frame causes it to rotate repeatedly unless copied first. A camera that clips through walls also needs an obstruction ray or sphere cast between the player and desired position.
3. Keep the camera above terrain
Sample terrain under the camera and enforce a small clearance. A raycast can replace the height function when caves or overhangs matter.
function keepCameraAboveTerrain(camera, terrainHeightAt) {
const floor = terrainHeightAt(camera.position.x, camera.position.z);
camera.position.y = Math.max(camera.position.y, floor + 0.65);
}
const raycaster = new THREE.Raycaster();
raycaster.set(player.position, desired.clone().sub(player.position).normalize());
raycaster.far = player.position.distanceTo(desired);
How this code works
Execution flow
The terrain height function samples the surface below the camera and enforces a minimum clearance. The optional raycaster then starts at the player, points toward the desired camera location, and limits its search to that segment.
Key Three.js decisions
A height query is fast for single-valued terrain, while Raycaster handles walls and arbitrary meshes. Setting raycaster.far to the desired distance prevents unrelated geometry behind the camera from affecting placement.
Adapt it
Shorten the chase-camera distance to the first obstruction and add a small surface margin. In caves or buildings, replace height-only protection with layer-filtered collision geometry so ceilings and overhangs are considered too.
Watch out
Height fields cannot represent caves, bridges, or vertical walls, so using only terrainHeightAt may still clip. Raycasting against every decorative mesh is expensive and noisy; use dedicated camera-collision layers or simplified proxy geometry.
4. Switch modes without snapping
Store mode-specific targets but preserve the current camera transform. The next update damps toward the new rig instead of teleporting the view.
const cameraModes = {
close: new THREE.Vector3(0, 2.4, 5),
wide: new THREE.Vector3(0, 5.5, 12),
cockpit: new THREE.Vector3(0, 1.45, 0.25),
};
let cameraMode = "close";
function setCameraMode(mode) {
if (cameraModes[mode]) cameraMode = mode;
}
How this code works
Execution flow
A table maps mode names to camera offsets, and setCameraMode validates the requested key before changing state. The regular camera update reads the new target while preserving current position, allowing existing damping to perform the transition.
Key Three.js decisions
Vector3 presets keep camera composition data separate from input and simulation. Validating keys prevents undefined offsets, while one shared update function avoids duplicated follow logic for close, wide, and cockpit views.
Adapt it
Add mode-specific field of view, look height, and damping values alongside each offset. Blend FOV with the same exponential approach and call updateProjectionMatrix after changing it; persist the player's preferred mode in settings.
Watch out
Directly assigning camera.position during mode selection creates a visible pop and can place it inside geometry. If cockpit mode rotates incorrectly, decide whether its offset belongs in player-local space or a dedicated socket on the vehicle model.
5. Real Supagames example
Production source: js/environment-engine/core/engine.js
Play the published example: Valley Racer
The environment demo combines first-person movement, mouse look, terrain sampling, and configurable camera behavior. Valley Racer and Peak Hopper provide chase-camera patterns with different damping needs.
The teaching snippets deliberately isolate one Three.js responsibility at a time. The production file also handles integration, lifecycle, quality settings, and game-specific state, so read it after the smaller examples make sense.
6. Common mistakes
- Creating geometry, materials, vectors, or arrays inside the frame loop and causing avoidable garbage collection pauses.
- Treating the Three.js scene graph as the only source of gameplay state instead of keeping explicit JavaScript state.
- Testing only on a fast desktop and leaving pixel ratio, shadows, and object counts uncapped on mobile devices.
7. Build checklist
- Serve ES modules through HTTP during development instead of opening the page through file://.
- Test resize, pause, restart, and cleanup paths as carefully as the first successful frame.
- Use browser performance tools and an on-screen FPS sample before guessing where a slowdown comes from.
- Verify keyboard, pointer, and touch controls without letting game shortcuts break forms or page navigation.