Three.js From Zero · Article 01

Foundations: Scene, Camera, Renderer, Loop

Foundations: Scene, Camera, Renderer, Loop

Before you touch materials, shaders, or models, you need an honest mental model of what Three.js actually is. Most confusion later in the series comes from getting this layer wrong — so we’ll take it seriously.

By the end of this article you’ll have rendered a spinning cube, understood the three independent pieces that made it happen, and know why each line in the setup exists.

Here’s the demo you just watched, running in this page. The whole thing is ~50 lines. We’ll build it from scratch and name every concept.

The three independent pieces

Every Three.js app you’ll ever write has three things that are totally independent of each other:

  1. A Scene — a tree of objects (meshes, lights, groups). Think of it as the world.
  2. A Camera — a point of view with a lens. Think of it as what gets looked at and how.
  3. A Renderer — takes a scene and a camera and produces pixels on a <canvas>. Think of it as the act of drawing.

You can have multiple cameras looking at the same scene. You can swap renderers (WebGLRendererWebGPURenderer) without touching the scene. You can render two different scenes using the same camera. This separation is the core of the API.

Install Three.js

If you’re following along in your own project:

pnpm add three
pnpm add -D @types/three

We’ll use r170+ (the current series). Everything in this tutorial works on r170 through r184. We’ll call out a few things that differ on WebGPU in Article 08.

The minimum viable scene

Here’s every concept from the demo, one at a time.

1. The scene

import * as THREE from 'three';

const scene = new THREE.Scene();
scene.background = new THREE.Color(0x0a0a0a);

A Scene is a specialized Object3D that you put everything else inside. Setting scene.background to a color is the simplest way to avoid a transparent canvas.

You can also set scene.background to a Texture (for a skybox) or leave it null to composite over the page. We’ll use all three later.

2. The camera

const camera = new THREE.PerspectiveCamera(
  75,                       // FOV (vertical, degrees)
  width / height,           // aspect ratio
  0.1,                      // near clipping plane
  100,                      // far clipping plane
);
camera.position.set(2, 2, 3);
camera.lookAt(0, 0, 0);

Four arguments, each with a physical meaning:

  • FOV — how wide your “eyes” are. 75° is a pleasant default; games often use 90°; cinematic shots dip to 35–50°.
  • Aspect — width / height of the canvas. If you set this wrong, everything looks stretched.
  • Near / far — Three.js won’t render geometry closer than near or farther than far. Keep near as large as you can get away with — depth buffer precision is logarithmic, and near = 0.0001 gives you ugly z-fighting.

PerspectiveCamera is what you want 95% of the time. OrthographicCamera is for 2D overlays, isometric art, and UI.

3. The renderer

const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.setSize(canvasWidth, canvasHeight);
renderer.outputColorSpace = THREE.SRGBColorSpace;

mountElement.appendChild(renderer.domElement);

Four lines, four non-obvious details:

  • antialias: true — MSAA on the default framebuffer. Free smoothing on edges. Turn it off only if you need every millisecond (mobile) or you’re using post-processing (which has its own AA).
  • setPixelRatio capped at 2 — Retina screens can report devicePixelRatio of 3 or 4. Rendering at 4× resolution tanks FPS for almost no visible gain. Clamp it.
  • setSize — sets both the CSS size of the canvas and its internal drawing buffer. Always call this when your container resizes.
  • outputColorSpace = SRGBColorSpace — the single most important one-liner for correct color. Without this, your textures will look washed out and your bright highlights will clip ugly. We’ll go deep on color in Article 04, but set this now and don’t unset it.

4. A mesh — the simplest visible thing

const cube = new THREE.Mesh(
  new THREE.BoxGeometry(1, 1, 1),
  new THREE.MeshNormalMaterial(),
);
scene.add(cube);

A Mesh is geometry + material. That’s literally its definition. Change the geometry and the shape changes; change the material and the surface changes.

We’re using MeshNormalMaterial on purpose: it colors each face based on its normal vector, so it’s visible without any lights. Zero setup, maximum learning. We’ll replace it with a real PBR material in Article 03.

5. The animation loop

renderer.setAnimationLoop((t) => {
  cube.rotation.x = t * 0.0005;
  cube.rotation.y = t * 0.001;
  renderer.render(scene, camera);
});

Three options for running the loop:

  • renderer.setAnimationLoop(callback) — use this. It plugs into the browser’s requestAnimationFrame, pauses when the tab is hidden, and is the only option compatible with WebXR and WebGPU.
  • requestAnimationFrame directly — works but is the old way.
  • Render on demand (don’t loop at all) — valid for static scenes or docs pages. We’ll cover it in a later tip.

t is a millisecond timestamp. Divide by 1000 for seconds. Never use performance.now() inside the callback when t is already handed to you.

6. Responsive canvas

const onResize = () => {
  const { clientWidth: w, clientHeight: h } = mount;
  camera.aspect = w / h;
  camera.updateProjectionMatrix();   // <- easy to forget
  renderer.setSize(w, h);
};
new ResizeObserver(onResize).observe(mount);

Two easy mistakes:

  1. Forgetting camera.updateProjectionMatrix(). The camera caches its projection matrix; if you don’t invalidate it, your new aspect ratio has no effect until the next full re-create.
  2. Listening to window.resize only. ResizeObserver on the actual container is more accurate — containers can resize without the window resizing (sidebars, layout shifts).

7. Dispose — the one beginners skip

// when the component unmounts / the page changes:
cube.geometry.dispose();
(cube.material as THREE.Material).dispose();
renderer.dispose();

Three.js allocates GPU memory that the garbage collector can’t see. If you render a new scene every route change and don’t dispose the old one, your tab will leak memory until it crashes. Build the habit now — we’ll do it in every demo in this series.

Textures, render targets, and post-processing passes also need disposal. Article 04 has the full checklist.

The whole thing, top to bottom

import * as THREE from 'three';

const mount = document.getElementById('app')!;

const scene = new THREE.Scene();
scene.background = new THREE.Color(0x0a0a0a);

const camera = new THREE.PerspectiveCamera(75, mount.clientWidth / mount.clientHeight, 0.1, 100);
camera.position.set(2, 2, 3);
camera.lookAt(0, 0, 0);

const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
renderer.setSize(mount.clientWidth, mount.clientHeight);
renderer.outputColorSpace = THREE.SRGBColorSpace;
mount.appendChild(renderer.domElement);

const cube = new THREE.Mesh(
  new THREE.BoxGeometry(1, 1, 1),
  new THREE.MeshNormalMaterial(),
);
scene.add(cube);
scene.add(new THREE.AxesHelper(2));
scene.add(new THREE.GridHelper(10, 10, 0x222222, 0x111111));

renderer.setAnimationLoop((t) => {
  cube.rotation.x = t * 0.0005;
  cube.rotation.y = t * 0.001;
  renderer.render(scene, camera);
});

addEventListener('resize', () => {
  camera.aspect = mount.clientWidth / mount.clientHeight;
  camera.updateProjectionMatrix();
  renderer.setSize(mount.clientWidth, mount.clientHeight);
});

That’s it. Everything else in this series — materials, lights, models, shaders, post-processing — bolts onto this backbone.

Common first-time pitfalls

  • Black canvas. Did you add the mesh to the scene? Is the camera inside the cube? Are there any lights (if you used MeshStandardMaterial instead of MeshNormalMaterial)?
  • Blurry or pixelated output. You forgot renderer.setSize or renderer.setPixelRatio.
  • Stretched geometry. Your camera aspect doesn’t match the canvas. Check the resize handler.
  • Nothing visible but no errors. near / far clipping — try camera.position.z = 5 and a larger far.
  • Texture colors look washed out. outputColorSpace isn’t SRGBColorSpace. Revisit in Article 04.

Exercises

  1. Change the material to MeshBasicMaterial({ color: 'tomato', wireframe: true }). Notice the mesh is now flat-shaded — no normals matter because it’s unlit. (We’ll fix that in Article 03.)
  2. Swap BoxGeometry for TorusKnotGeometry(0.6, 0.2, 128, 16) and watch every face color shift as it rotates.
  3. Add a second camera at a different position and toggle between them with a key press. Confirm the scene doesn’t need to change at all.

What’s next

In Article 02 — Geometry & the Mesh we go under the hood: every primitive, BufferGeometry, indices and attributes, and how to build geometry from scratch.