How to add a debug camera to Three.js
Three.js ships OrbitControls, FlyControls and
PointerLockControls, and none of them is the camera you get in a game
engine's editor viewport. This page shows how to add that camera in three steps,
with code for vanilla Three.js, React Three Fiber, WebGLRenderer and
WebGPURenderer, and an honest list of what it will not do.
What a debug camera is
A debug camera is a free-flying camera you use to inspect a scene rather than to present it. You fly with WASD, look with the mouse, pass straight through geometry, and frame any object on a keypress. Unity calls it the scene view, Unreal calls it the editor viewport, and in games it is usually called noclip, freecam or spectator mode.
You want one the moment a scene stops being a single object on a turntable: to
check whether a mesh is really floating, to look behind a wall, to find the thing
that ended up at (0, -4000, 0), or to record a flythrough. Three.js
does not ship one, so you either write it or add a library.
three-freecam is that library,
at 1.81 kB minified and gzipped with no dependencies, and the rest of this page
uses it.
Step 1: install three-freecam
npm i three-freecam
three is a peer dependency, so the library uses the copy of Three.js
you already have. Any version from 0.150 works. There are no other dependencies.
No build step? Use an import map and a CDN:
<script type="importmap">
{
"imports": {
"three": "https://unpkg.com/three@0.186.0/build/three.module.js",
"three-freecam": "https://unpkg.com/three-freecam@0.2.0/dist/index.js"
}
}
</script>
Step 2: create a FreeCam
Construct it once, after the camera and the renderer exist. It takes the camera it should drive and the element that should receive mouse input, which is almost always the canvas Three.js renders into.
import { FreeCam } from "three-freecam";
const fly = new FreeCam(camera, renderer.domElement);
Passing the canvas rather than document keeps right-drag and the
wheel scoped to the viewport, so the rest of your page still scrolls and still has
a context menu. Keyboard input is listened for on window, so the
canvas does not need focus for WASD to work.
Options are all optional, and all of them are live properties afterwards:
const fly = new FreeCam(camera, renderer.domElement, {
moveSpeed: 12, // world units per second
boost: 4, // multiplier while Shift is held
lookSpeed: 0.0022, // radians per pixel
damping: 0, // 0 is instant, like the editor
invertY: false,
pointerLock: true, // only while dragging, not for the whole session
keys: { forward: ["KeyW"], back: ["KeyS"] },
});
fly.moveSpeed = 40; // change anything at any time
Step 3: call update(delta) every frame
One line in the render loop, before render().
delta is how many seconds the last frame took.
renderer.setAnimationLoop(() => {
fly.update(delta);
renderer.render(scene, camera);
});
If your loop already tracks a delta, pass that. If it gives you
milliseconds, divide by 1000. If it gives you nothing, keep a
THREE.Clock: clock.getDelta() returns seconds since the
last call and resets, so call it exactly once per frame.
That is the whole integration. Right-drag the canvas and fly.
The controls you get
| Input | Action |
|---|---|
| Right-drag | Look around |
W A S D or arrows | Fly, while looking or not |
Q / E | Down / up, on the world vertical |
Shift | Boost |
| Wheel while right-dragging | Change fly speed |
| Middle-drag | Pan |
| Wheel | Dolly toward the pivot |
Alt + left-drag | Orbit the pivot |
F | Frame the pivot |
The pivot is the point orbit turns around and the wheel dollies
toward. By default it sits a short way in front of the camera and follows it, so
both work with no setup. fly.focus(object) moves it onto something and
backs the camera off far enough to see the whole thing.
Plain left-drag is deliberately left alone, so your own picking, gizmos and selection keep working while the debug camera is live.
How it differs from OrbitControls, FlyControls and PointerLockControls
| three-freecam | OrbitControls | FlyControls | PointerLockControls | |
|---|---|---|---|---|
| Goes anywhere in the scene | yes | no, always circles a target | yes | yes |
| Can also orbit a target | yes, alt-drag | yes, that is all it does | no | no |
| WASD flight built in | yes | no | yes | no, you write the movement |
| Left mouse stays free for your app | yes | no, it is the orbit drag | yes | no, it captures everything |
| Speed changed on the fly | yes, wheel while looking | not applicable | no, set movementSpeed | you write it |
| Frame an object | yes, focus() and F | no | no | no |
| Cursor stays usable | yes, locked only while dragging | yes | yes | no, locked until Escape |
| Roll | never, horizon stays level | never | yes, it can tip over | never |
| Size | 1.81 kB minzipped | bundled with three | bundled with three | bundled with three |
Use OrbitControls when
The scene is one object and the user is looking at it: a product viewer, a model preview, a configurator. Orbiting is the whole interaction and a fixed target is a feature, not a limitation. Do not reach for a debug camera here.
Use FlyControls when
You want an aircraft or on-rails feel where roll is wanted and the camera is expected to tip over. It has no pivot, no orbit, no object framing, and its speed is a property you set rather than something the user dials in mid-flight.
Use PointerLockControls when
You are building a first-person game. It takes the cursor for the entire session, which is exactly right for gameplay and exactly wrong for a tool that also has buttons, panels and a DOM UI. It also gives you pointer look only: the movement is yours to write.
Use a debug camera when
You want the camera from an editor viewport: inspect a large scene, debug
placement, fly to a corner, then alt-drag around the thing you found and press
F to frame it. That combination, where look, fly, pan, orbit and
framing share one pivot and left-click is still yours, is the gap the other three
leave.
You can keep both
A debug camera does not have to replace your normal controls. Ship
OrbitControls to your users and put the debug camera behind a key:
const fly = new FreeCam(camera, renderer.domElement);
fly.enabled = false;
addEventListener("keydown", (e) => {
if (e.code !== "Backquote") return;
fly.enabled = !fly.enabled;
orbit.enabled = !fly.enabled;
});
enabled only gates input, so nothing is torn down and flipping it back
resumes where you left off.
Complete examples
Vanilla Three.js with WebGLRenderer
Everything below except the two FreeCam lines is the standard Three.js
setup, shown in full so it is clear where camera,
renderer and delta come from.
import * as THREE from "three";
import { FreeCam } from "three-freecam";
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
document.body.appendChild(renderer.domElement);
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(
60,
window.innerWidth / window.innerHeight,
0.1,
1000,
);
camera.position.set(5, 5, 10);
scene.add(new THREE.GridHelper(50, 50));
scene.add(new THREE.HemisphereLight(0xffffff, 0x444444, 2));
// The debug camera. Two lines.
const fly = new FreeCam(camera, renderer.domElement, { moveSpeed: 16 });
const clock = new THREE.Clock();
renderer.setAnimationLoop(() => {
fly.update(clock.getDelta()); // seconds since the last frame
renderer.render(scene, camera);
});
window.addEventListener("resize", () => {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
});
WebGPURenderer
Identical, because the debug camera writes camera.position and
camera.quaternion and nothing else. It never touches the renderer, so
it has no opinion about which one draws the frame.
import * as THREE from "three/webgpu";
import { FreeCam } from "three-freecam";
const renderer = new THREE.WebGPURenderer({ antialias: true });
await renderer.init();
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);
const camera = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 0.1, 1000);
const fly = new FreeCam(camera, renderer.domElement);
const clock = new THREE.Clock();
renderer.setAnimationLoop(() => {
fly.update(clock.getDelta());
renderer.render(scene, camera);
});
React Three Fiber
A null component inside the <Canvas>.
useFrame already hands you a delta in seconds, and the only thing to
remember is dispose() on unmount, because the keyboard listeners live
on window and would otherwise outlive the canvas.
import { Canvas, useFrame, useThree } from "@react-three/fiber";
import { useEffect, useMemo } from "react";
import { FreeCam } from "three-freecam";
function DebugCamera({ enabled = true, ...options }) {
const { camera, gl } = useThree();
const fly = useMemo(
() => new FreeCam(camera, gl.domElement, options),
[camera, gl], // eslint-disable-line react-hooks/exhaustive-deps
);
useEffect(() => () => fly.dispose(), [fly]);
useEffect(() => {
fly.enabled = enabled;
}, [fly, enabled]);
useFrame((_, delta) => fly.update(delta));
return null;
}
export default function App() {
return (
<Canvas camera={{ position: [5, 5, 10], fov: 60 }}>
<gridHelper args={[50, 50]} />
<hemisphereLight intensity={2} />
{import.meta.env.DEV && <DebugCamera moveSpeed={16} />}
</Canvas>
);
}
Gating on import.meta.env.DEV means the debug camera is a development
tool and never reaches production. If you use Drei's
<OrbitControls /> as well, give it
enabled={!debugging} so only one of them owns the camera at a time.
The rest of the API
fly.update(dt); // once per frame, dt in seconds
fly.focus(object); // frame an Object3D, measured from its bounds
fly.focus(new THREE.Vector3(0, 2, 0)); // or just aim at a point
fly.placeAt(position, lookAt); // jump the camera somewhere
fly.enabled = false; // ignore input, tear nothing down
fly.pivot; // Vector3, what orbit and dolly work against
fly.dispose(); // remove every listener
Two things worth knowing:
-
focus()with anObject3Dmeasures its bounding sphere and backs off far enough for the whole thing to fit the vertical field of view. With aVector3it just aims at that point.Fcalls it on the current pivot. -
dispose()matters. The keyboard and pointer-move listeners are onwindow, so they outlive the canvas if you do not call it.
Limitations, honestly
Stated plainly, so you can tell in a minute whether this fits rather than finding out in an hour.
- No touch or pointer-gesture support. It is a mouse and keyboard tool, which is what a debug camera normally is. Touch is not on the roadmap. The demo on this site does nothing useful on a phone.
- Pan needs a middle mouse button. There is no built-in trackpad fallback. Look, fly, orbit and dolly are all fine without one.
- No collision, gravity or clipping. It flies through geometry by design. If you need a camera that collides with the world, you want a character controller, which is a different kind of library.
-
No roll. Pitch is clamped near 90 degrees and the horizon stays
level. If you want a camera that can tip over,
FlyControlsis the right tool. -
focus()assumes a perspective camera. The framing maths readsfov. Everything else works with any camera; anOrthographicCamerajust needs an explicit distance. -
It writes the camera every frame. Anything that moves the same
camera after
update()wins, so pick one owner. This is the usual cause of "my camera does not move". -
No easing on
focus()orplaceAt(). They jump. Tween them yourself if you want a glide. - No saved viewpoints, gizmo, minimap or UI. By design. That is how it stays at 1.81 kB.
-
Keys are physical, not layout-aware. It uses
KeyboardEvent.code, soKeyWis the same physical key on AZERTY. Remap it yourself if you wantZQSD.
Questions
What is a debug camera in Three.js?
When should I use this instead of OrbitControls?
OrbitControls when the scene is one object the user looks at: a
product viewer, a model preview, a configurator. Use a debug camera when you need
to get inside a large scene, because OrbitControls always circles a
target and cannot go where the orbit does not reach.
How is it different from FlyControls?
FlyControls is aircraft-style: it rolls, it can tip over, and it has
no pivot, no orbit and no object framing. three-freecam keeps the horizon level,
clamps pitch, and adds pan, orbit around a pivot, and F to frame an
object, which is what an editor viewport does.
How is it different from PointerLockControls?
PointerLockControls takes the cursor for the whole session, which is
right for a first-person game and wrong for a tool with buttons and panels.
three-freecam locks the pointer only while the right button is held, and it ships
the movement, pan, orbit and framing that PointerLockControls leaves
for you to write.
Does it work with React Three Fiber?
useFrame gives you the delta already; just remember
dispose() on unmount.
Does it work with WebGPURenderer?
camera.position and camera.quaternion and nothing else,
so the renderer behind it makes no difference.
Which Three.js versions are supported?
three is a peer dependency, >=0.150.0. CI runs the
test suite against 0.150, 0.168 and the latest release on every push, so the
declared range is actually tested rather than hopeful.
Why is my camera not moving?
update(delta) is not being called
in the loop. delta is in milliseconds rather than seconds, which
makes everything 1000 times too fast rather than too slow. Or something else
writes camera.position after update runs, such as
OrbitControls still being enabled or a lookAt inside
your loop.
How do I change the keys?
Pass keys, using
KeyboardEvent.code
values. It is a partial override, so anything you leave out keeps its default.
new FreeCam(camera, dom, {
keys: { forward: ["KeyZ"], left: ["KeyQ"] },
});
Can I use it for a cinematic flythrough?
damping is for. 0 is the editor feel,
instant on and instant off. Raise it toward 1 for a weighted glide,
which records much better. The smoothing is framerate independent, so the same
value behaves the same at 30 and 144 fps. Try the
damping slider in the demo.
Does it handle collision, gravity or clipping?
Is it TypeScript-friendly and tree-shakeable?
FreeCam, FreeCamOptions and FreeCamKeys.
The package is ESM only with sideEffects: false, so a bundler drops
it entirely if you do not import it.
How do I ship it in development only?
import.meta.env.DEV in Vite, and construct it only in that branch.
It is 1.81 kB, so shipping it to production is not expensive either, in which
case put it behind a hotkey with fly.enabled.