CAMERA TRACKING
A STATIONARY OBJECT
Make a cinematic camera: it swoops along a smooth path between pillars and high above the scene, while the object it films never moves and always stays in the center of the shot. Two lines of code do the heavy lifting: getPointAt() and lookAt().
By Shane Brumback · Updated November 2, 2026 · about 12 minute read
LIVE DEMO
The monument on the right of this page is filmed live by the tracking camera. Launch the full-screen demo and switch between the Tracking camera and Orbit (drag) to see the flight path the camera follows. Launch asks for a free Google sign-in first. Press Esc or Exit to return.
Free Google sign-in · Buttons: Tracking / Orbit · Orbit: drag, scroll to zoom · Esc to exit
Introduction
In films, a tracking shot moves the camera while the subject stays framed. In games you see the same idea in intro fly-overs, replay cameras, product viewers and "look at the objective" moments.
The good news: Three.js makes it simple. You need a path for the camera to travel, and one call, camera.lookAt(target), that turns the camera toward a point every frame. Add a little smoothing and it feels like a professional camera operator.
What You Will Learn
- How
camera.lookAt()aims a camera at any point in 3D space. - How to build a smooth looping path with
THREE.CatmullRomCurve3and read positions withgetPointAt(). - Frame-rate independent smoothing with
lerpandMath.exp. - How to hand the camera back and forth between your code and OrbitControls.
Tracking vs Following vs Orbiting
- Tracking a stationary object (this lesson): the camera moves, the object stays still, and the camera keeps turning to face it.
- Following a moving object: the camera chases something that moves, like a third-person game camera. See making one object follow another.
- Orbiting: the camera circles a point because the user drags. OrbitControls does this, and it is the second mode in the demo.
Beginner Tips
- Why does getPointAt() run from 0 to 1? The whole path is treated as one unit. 0 is the start, 0.5 is halfway and 1 is back at the start. That makes speed control easy.
- Use
getPointAt()instead ofgetPoint(). getPointAt spaces positions by real distance, so the camera does not speed up on long stretches between points. - Keep path points away from walls and objects, or the camera will fly through them.
- Move the camera slowly. About 40 seconds per lap feels cinematic; fast paths make viewers dizzy.
Step-by-Step Code Breakdown
Every excerpt below is copied exactly from the full code further down, which is the same code running the live scene on this page.
1. Scene, camera and renderer
In plain words: we make the 3D world with an evening-blue sky, light fog so far-away pillars fade, a camera, and a renderer with shadows switched on so the monument sits firmly on the ground.
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x0c1426);
scene.fog = new THREE.Fog(0x0c1426, 30, 90);
const camera = new THREE.PerspectiveCamera(50, innerWidth / innerHeight, 0.1, 200);
camera.position.set(14, 6, 14);
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
renderer.setSize(innerWidth, innerHeight);
renderer.shadowMap.enabled = true;
document.body.appendChild(renderer.domElement);
2. Lights and ground
In plain words: a sky-and-ground light gives soft fill, and one sun light casts shadows. The grid on the ground is important for this lesson: when the camera moves, the lines slide past and your eye can feel the motion.
scene.add(new THREE.HemisphereLight(0xbfd4ff, 0x1a1408, 1.2));
const sun = new THREE.DirectionalLight(0xffffff, 2.2);
sun.position.set(10, 18, 6);
sun.castShadow = true;
sun.shadow.mapSize.set(1024, 1024);
sun.shadow.camera.left = sun.shadow.camera.bottom = -20;
sun.shadow.camera.right = sun.shadow.camera.top = 20;
scene.add(sun);
const ground = new THREE.Mesh(new THREE.PlaneGeometry(200, 200), new THREE.MeshStandardMaterial({ color: 0x141c2b, roughness: 1 }));
ground.rotation.x = -Math.PI / 2;
ground.receiveShadow = true;
scene.add(ground);
scene.add(new THREE.GridHelper(200, 100, 0x2ad1d6, 0x22304a));
3. Build the stationary object
In plain words: the star of the scene never moves: a glowing knot on a stone pedestal. We keep its position in a Vector3 called target, the point the camera will always aim at. A ring of pillars around it gives the camera something to fly between.
const monument = new THREE.Group();
const pedestal = new THREE.Mesh(new THREE.CylinderGeometry(1.6, 2, 2, 32), new THREE.MeshStandardMaterial({ color: 0x8a8f99, roughness: 0.8 }));
pedestal.position.y = 1;
const knot = new THREE.Mesh(
new THREE.TorusKnotGeometry(1.1, 0.35, 160, 24),
new THREE.MeshStandardMaterial({ color: 0xffb347, emissive: 0x5a2a00, metalness: 0.6, roughness: 0.25 })
);
knot.position.y = 3.6;
monument.add(pedestal, knot);
monument.traverse((o) => { if (o.isMesh) { o.castShadow = true; o.receiveShadow = true; } });
scene.add(monument);
const target = new THREE.Vector3(0, 3.2, 0); // the point we always look at
for (let i = 0; i < 10; i++) {
const a = (i / 10) * Math.PI * 2;
const pillar = new THREE.Mesh(new THREE.BoxGeometry(1, 6, 1), new THREE.MeshStandardMaterial({ color: 0x3a4a66 }));
pillar.position.set(Math.cos(a) * 9, 3, Math.sin(a) * 9);
pillar.castShadow = true;
scene.add(pillar);
}
4. Draw the camera's flight path
In plain words: a CatmullRomCurve3 is a smooth curve that passes through every point you give it. Our points swoop low between the pillars, climb high above the monument and pull far back, so you can see the monument from every distance. closed: true joins the end back to the start so the flight loops forever. A thin line shows the path itself.
const path = new THREE.CatmullRomCurve3([
new THREE.Vector3(14, 5, 0),
new THREE.Vector3(6, 2, 6),
new THREE.Vector3(-4, 1.5, 12),
new THREE.Vector3(-14, 9, 4),
new THREE.Vector3(-8, 14, -10),
new THREE.Vector3(4, 4, -7),
new THREE.Vector3(18, 7, -14)
], true);
const pathLine = new THREE.Line(
new THREE.BufferGeometry().setFromPoints(path.getPoints(300)),
new THREE.LineBasicMaterial({ color: 0x2ad1d6, transparent: true, opacity: 0.35 })
);
scene.add(pathLine);
5. Track the object while the camera flies
In plain words: this is the whole trick. Every frame we move the camera to the next spot on the path with getPointAt(), then call camera.lookAt(target) so it turns to face the monument. The object stays still and dead-center while the world swings around it. We also aim through a smoothed point called aim: lerp moves it a small step toward the target each frame, so the camera turns gently instead of snapping. Math.exp keeps that smoothing the same at 30 or 144 frames per second.
let mode = 'track', t = 0;
const aim = target.clone();
function track(dt) {
t = (t + dt * 0.025) % 1; // 40 seconds per lap
camera.position.copy(path.getPointAt(t));
aim.lerp(target, 1 - Math.exp(-4 * dt)); // smooth, frame-rate independent
camera.lookAt(aim);
}
6. Switch between tracking and orbit
In plain words: for comparison you can hand the camera to OrbitControls and drag it yourself. When you switch back, the tracking camera picks up from the path again. Showing the flight path only in orbit mode lets you see exactly where the tracking camera travels.
const ui = document.getElementById('ui');
for (const m of ['track', 'orbit']) {
const b = document.createElement('button');
b.textContent = m === 'track' ? 'Tracking camera' : 'Orbit (drag)';
b.onclick = () => { mode = m; controls.target.copy(target); };
ui.appendChild(b);
}
7. Run the render loop
In plain words: each frame we spin the knot a little (it rotates in place; its position never changes), then either run the tracking camera or let OrbitControls drive. The path line is hidden while tracking because the camera is sitting on it.
const clock = new THREE.Clock();
function animate() {
requestAnimationFrame(animate);
const dt = Math.min(clock.getDelta(), 0.05);
knot.rotation.y += dt * 0.6;
pathLine.visible = mode === 'orbit';
if (mode === 'track') track(dt);
else controls.update();
renderer.render(scene, camera);
}
animate();
The Full Code
Paste this into a file named index.html. It needs no image or model files, so it runs from anywhere. Launch Full Code opens this exact code as a standalone page in a new window.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Three.js Camera Tracking a Stationary Object</title>
<style>
body { margin: 0; overflow: hidden; background: #000; font-family: system-ui, sans-serif; }
#ui { position: fixed; left: 16px; bottom: 16px; display: flex; gap: 8px; flex-wrap: wrap; }
#ui button { padding: 10px 16px; border: 1px solid rgba(42,209,214,0.5); border-radius: 8px; background: rgba(10,15,28,0.85); color: #fff; font: 600 14px system-ui, sans-serif; text-transform: capitalize; cursor: pointer; }
#ui button:hover { background: #2ad1d6; color: #06252a; }
</style>
<script type="importmap">
{
"imports": {
"three": "https://unpkg.com/three@0.160.0/build/three.module.js",
"three/addons/": "https://unpkg.com/three@0.160.0/examples/jsm/"
}
}
</script>
</head>
<body>
<div id="ui"></div>
<script type="module">
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
// --- 1. Scene, camera and renderer ---
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x0c1426);
scene.fog = new THREE.Fog(0x0c1426, 30, 90);
const camera = new THREE.PerspectiveCamera(50, innerWidth / innerHeight, 0.1, 200);
camera.position.set(14, 6, 14);
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
renderer.setSize(innerWidth, innerHeight);
renderer.shadowMap.enabled = true;
document.body.appendChild(renderer.domElement);
// Orbit controls for the "Orbit (drag)" mode. Zoom is off on the page so
// vertical swipes still scroll on phones; the full-screen demo turns zoom on.
const controls = new OrbitControls(camera, renderer.domElement);
controls.target.set(0, 3.2, 0);
controls.enableDamping = true;
controls.enableZoom = false;
controls.enablePan = false;
renderer.domElement.style.touchAction = 'pan-y';
// --- 2. Lights and ground ---
scene.add(new THREE.HemisphereLight(0xbfd4ff, 0x1a1408, 1.2));
const sun = new THREE.DirectionalLight(0xffffff, 2.2);
sun.position.set(10, 18, 6);
sun.castShadow = true;
sun.shadow.mapSize.set(1024, 1024);
sun.shadow.camera.left = sun.shadow.camera.bottom = -20;
sun.shadow.camera.right = sun.shadow.camera.top = 20;
scene.add(sun);
const ground = new THREE.Mesh(new THREE.PlaneGeometry(200, 200), new THREE.MeshStandardMaterial({ color: 0x141c2b, roughness: 1 }));
ground.rotation.x = -Math.PI / 2;
ground.receiveShadow = true;
scene.add(ground);
scene.add(new THREE.GridHelper(200, 100, 0x2ad1d6, 0x22304a));
// --- 3. Build the stationary object ---
const monument = new THREE.Group();
const pedestal = new THREE.Mesh(new THREE.CylinderGeometry(1.6, 2, 2, 32), new THREE.MeshStandardMaterial({ color: 0x8a8f99, roughness: 0.8 }));
pedestal.position.y = 1;
const knot = new THREE.Mesh(
new THREE.TorusKnotGeometry(1.1, 0.35, 160, 24),
new THREE.MeshStandardMaterial({ color: 0xffb347, emissive: 0x5a2a00, metalness: 0.6, roughness: 0.25 })
);
knot.position.y = 3.6;
monument.add(pedestal, knot);
monument.traverse((o) => { if (o.isMesh) { o.castShadow = true; o.receiveShadow = true; } });
scene.add(monument);
const target = new THREE.Vector3(0, 3.2, 0); // the point we always look at
for (let i = 0; i < 10; i++) {
const a = (i / 10) * Math.PI * 2;
const pillar = new THREE.Mesh(new THREE.BoxGeometry(1, 6, 1), new THREE.MeshStandardMaterial({ color: 0x3a4a66 }));
pillar.position.set(Math.cos(a) * 9, 3, Math.sin(a) * 9);
pillar.castShadow = true;
scene.add(pillar);
}
// --- 4. Draw the camera's flight path ---
const path = new THREE.CatmullRomCurve3([
new THREE.Vector3(14, 5, 0),
new THREE.Vector3(6, 2, 6),
new THREE.Vector3(-4, 1.5, 12),
new THREE.Vector3(-14, 9, 4),
new THREE.Vector3(-8, 14, -10),
new THREE.Vector3(4, 4, -7),
new THREE.Vector3(18, 7, -14)
], true);
const pathLine = new THREE.Line(
new THREE.BufferGeometry().setFromPoints(path.getPoints(300)),
new THREE.LineBasicMaterial({ color: 0x2ad1d6, transparent: true, opacity: 0.35 })
);
scene.add(pathLine);
// --- 5. Track the object while the camera flies ---
let mode = 'track', t = 0;
const aim = target.clone();
function track(dt) {
t = (t + dt * 0.025) % 1; // 40 seconds per lap
camera.position.copy(path.getPointAt(t));
aim.lerp(target, 1 - Math.exp(-4 * dt)); // smooth, frame-rate independent
camera.lookAt(aim);
}
// --- 6. Switch between tracking and orbit ---
const ui = document.getElementById('ui');
for (const m of ['track', 'orbit']) {
const b = document.createElement('button');
b.textContent = m === 'track' ? 'Tracking camera' : 'Orbit (drag)';
b.onclick = () => { mode = m; controls.target.copy(target); };
ui.appendChild(b);
}
// --- 7. Run the render loop ---
const clock = new THREE.Clock();
function animate() {
requestAnimationFrame(animate);
const dt = Math.min(clock.getDelta(), 0.05);
knot.rotation.y += dt * 0.6;
pathLine.visible = mode === 'orbit';
if (mode === 'track') track(dt);
else controls.update();
renderer.render(scene, camera);
}
animate();
addEventListener('resize', () => {
camera.aspect = innerWidth / innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(innerWidth, innerHeight);
});
</script>
</body>
</html>
What To Try Next
- Make the target move: give the knot a slow bobbing motion and see that lookAt still keeps it centered.
- Ease the speed: slow
tdown near interesting points by using a second curve for timing. - Change the lens during the flight: animate
camera.fovand callcamera.updateProjectionMatrix()for a dolly-zoom effect. - Track animated objects next in how to make one object follow another.
Frequently Asked Questions
How do I make the camera look at an object in Three.js?
Call camera.lookAt(x, y, z) or camera.lookAt(vector) with the object's position. If the camera or object moves, call it again every frame in your animation loop.
How do I move a camera along a path in Three.js?
Create a THREE.CatmullRomCurve3 from a list of Vector3 points. Each frame, increase a value t between 0 and 1 and copy curve.getPointAt(t) into camera.position. Pass true as the second argument to make the path loop.
Why does my camera shake or snap when tracking?
Snapping usually means the look target jumps. Aim at a smoothed point instead: aim.lerp(target, 1 - Math.exp(-4 * dt)) moves it a little each frame and stays smooth at any frame rate.
Can I use OrbitControls and lookAt together?
Not at the same time. OrbitControls overwrites the camera position and rotation in controls.update(). Run either your tracking code or controls.update() each frame, and set controls.target to the object when you switch.
Is this camera tracking tutorial free?
Yes. It is completely free. The full code is on the page and Launch Full Code opens it in a new window. Only the full-screen live demo asks for a free Google sign-in.