three-freecam

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.

Latest version of three-freecam on npm Minified and gzipped size of three-freecam three-freecam has zero runtime dependencies
Try the live demo Install from npm View source

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-dragLook around
W A S D or arrowsFly, while looking or not
Q / EDown / up, on the world vertical
ShiftBoost
Wheel while right-draggingChange fly speed
Middle-dragPan
WheelDolly toward the pivot
Alt + left-dragOrbit the pivot
FFrame 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:

Limitations, honestly

Stated plainly, so you can tell in a minute whether this fits rather than finding out in an hour.

Questions

What is a debug camera in Three.js?
A free-flying camera you use to inspect a scene rather than present it: fly anywhere with WASD, look with the mouse, pass through geometry, frame any object. It is the camera a game engine gives you in its editor viewport. Three.js does not ship one, so you either write it or add a library.
When should I use this instead of OrbitControls?
Use 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?
Yes, see the React Three Fiber example above. useFrame gives you the delta already; just remember dispose() on unmount.
Does it work with WebGPURenderer?
Yes, and the code is identical to the WebGL version. It writes 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?
Almost always one of three things. 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?
Yes, that is what 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?
No, and it will not. A debug camera flies through walls on purpose. If you need a camera that collides with the world, you want a character controller.
Is it TypeScript-friendly and tree-shakeable?
Both. It is written in TypeScript and ships its own declarations, exporting 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 it behind your bundler's dev flag, for example 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.

Try the live demo Install from npm View source