Three.js is the most-used JavaScript library for 3D graphics on the web. It wraps WebGL (and, since 2024-2025, WebGPU) into a friendly scene-graph API and ships with materials, lights, loaders, controls, post-processing, and animation built in. This 14-post course covers it from first scene to AI-assisted asset pipelines. Post 00 introduces the mental model, the rendering backends, and the boilerplate every Three.js app reuses.

Series map: 00 Foundations (this post) → 01 Geometry & Materials → 02 Cameras & Controls → 03 Lighting & Shadows → 04 Textures & PBR → 05 Asset Pipeline (GLTF/Draco/KTX2/Meshopt) → 06 Animation → 07 Instancing & Batching → 08 Low-End Performance → 09 Shaders (GLSL + TSL) → 10 Post-Processing & Color → 11 Physics & Interactivity → 12 React Three Fiber + Drei → 13 AI-Assisted 3D + NavMesh + Procedural.


Learning Outcome

  • Describe what Three.js is, what WebGL / WebGPU are, and how they fit together.
  • Set up a Vite project with Three.js installed at the right version.
  • Build the canonical "spinning cube" scene from scratch.
  • Understand the scene → camera → renderer trio and the animation loop.
  • Pick between WebGLRenderer and WebGPURenderer in 2026.
  • Use a perf overlay (stats-gl or r3f-perf) from day one.

Chapter 1 — What Three.js is

Terminology

  • WebGL — a browser API exposing OpenGL ES 2.0 (WebGL1) or 3.0 (WebGL2) for GPU rendering on a <canvas>.
  • WebGPU — modern successor to WebGL; lower overhead, compute shaders, parallel command encoding. Supported in every major browser since Safari 26 (September 2025).
  • Three.js — open-source JS library (Mr.doob et al., since 2010) on top of WebGL/WebGPU. Provides scene graph, cameras, lights, materials, geometry, animation, loaders, controls, post-processing.
  • Scene graph — tree of Object3D nodes (meshes, groups, lights, cameras) representing the 3D world.
  • Render loop — a function called every browser frame (requestAnimationFrame) that updates state and tells the renderer to draw.

Why Three.js (not raw WebGL)

Raw WebGL requires hundreds of lines of boilerplate to draw a single textured cube: shaders, buffers, uniforms, projection math, all hand-rolled. Three.js collapses that into ~10 lines and adds dozens of features (PBR materials, GLTF loader, shadow maps, OrbitControls, post-processing, animation mixers) that would each take days to implement.

Where Three.js sits

1
2
3
4
5
6
7
8
9
10
11
Application code
▲
│
Three.js ← scene graph, materials, lights, controls, loaders
▲
│
WebGL2 / WebGPU ← browser graphics API
▲
│
GPU driver + hardware

Chapter 2 — Project setup

Standard setup uses Vite + npm. Single-file scripts also work via the import map below.

1
2
3
4
5
6
7
npm create vite@latest threejs-hello -- --template vanilla
cd threejs-hello
npm install
npm install three
npm install -D vite-plugin-glsl # only if you'll write shaders later
npm run dev # opens http://localhost:5173

Pin the Three.js version in package.json ("three": "^0.184.0" at time of writing) — Three.js evolves fast and minor versions sometimes break things.

Option B — Import map (no build)

For a one-page demo without a bundler:

1
2
3
4
5
6
7
8
9
10
<script type="importmap">
{
"imports": {
"three": "https://cdn.jsdelivr.net/npm/three@0.184.0/build/three.module.js",
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.184.0/examples/jsm/"
}
}
</script>

<script type="module" src="main.js"></script>

Then in main.js:

1
2
3
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
1
2
3
4
5
6
7
8
9
10
11
threejs-hello/
├── index.html
├── package.json
├── public/ # static assets served as-is
│ └── models/ # .glb files
└── src/
├── main.js # entry: instantiate scene
├── scene.js # scene + lights
├── controls.js
└── shaders/ # .glsl files (once we reach Post 09)

Chapter 3 — The scene-camera-renderer trio

Every Three.js app needs three things:

  1. Scene — the container of objects (THREE.Scene).
  2. Camera — the viewpoint (THREE.PerspectiveCamera or THREE.OrthographicCamera).
  3. Renderer — produces pixels from scene + camera (THREE.WebGLRenderer or THREE.WebGPURenderer).

Plus:

  1. Mesh = geometry + material — what you actually see.
  2. Render loop — drives renderer.render(scene, camera) once per frame.

First scene — the spinning cube

src/main.js:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
import * as THREE from 'three';

// 1. scene
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x101820);

// 2. camera
const camera = new THREE.PerspectiveCamera(
75, // field of view (deg)
window.innerWidth / window.innerHeight, // aspect
0.1, // near plane
1000 // far plane
);
camera.position.set(2, 2, 4);
camera.lookAt(0, 0, 0);

// 3. renderer
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);

// 4. mesh
const cube = new THREE.Mesh(
new THREE.BoxGeometry(1, 1, 1),
new THREE.MeshStandardMaterial({ color: 0x66ccff, roughness: 0.4 })
);
scene.add(cube);

// lights (MeshStandardMaterial needs them)
scene.add(new THREE.AmbientLight(0xffffff, 0.4));
const sun = new THREE.DirectionalLight(0xffffff, 1.0);
sun.position.set(5, 10, 7);
scene.add(sun);

// 5. animation loop
renderer.setAnimationLoop(() => {
cube.rotation.x += 0.01;
cube.rotation.y += 0.012;
renderer.render(scene, camera);
});

// handle resize
window.addEventListener('resize', () => {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
});

That is the canonical "hello, world" of Three.js. The remaining 13 posts deepen each part.

The spinning-cube app: the scene tree, the camera and the per-frame update, draw and show loop

setAnimationLoop vs requestAnimationFrame

renderer.setAnimationLoop(fn) is preferred over a manual requestAnimationFrame loop because:

  • It works automatically inside WebXR sessions (the XR runtime drives the loop).
  • The WebGPURenderer requires it.
  • Pausing is one call: renderer.setAnimationLoop(null).

Chapter 4 — WebGLRenderer vs WebGPURenderer (2026)

WebGPU shipped in Chrome 113 (2023), Firefox 141, and Safari 26 (Sept 2025). Roughly 95 % of users now have a WebGPU-capable browser; the remaining 5 % should fall back to WebGL2.

WebGLRenderer WebGPURenderer
Backend WebGL2 WebGPU
Performance baseline 2-10× speedup on draw-call-heavy scenes
Compute shaders no yes
Storage buffers no yes
Shader language GLSL WGSL (via TSL)
Material system classic + TSL compat layer TSL NodeMaterial (Three.js Shading Language)
Stability (2026) mature stable since r184

Practical guidance:

  • New project in 2026: start with WebGPURenderer + a WebGL2 fallback.
  • Need TSL / NodeMaterial / compute: WebGPURenderer required (no new node features land in WebGL).
  • Stuck on old browsers / wide reach: WebGLRenderer still ships and is fine.

Pattern: detect + fall back

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import * as THREE from 'three';
import WebGPU from 'three/addons/capabilities/WebGPU.js';
import WebGL from 'three/addons/capabilities/WebGL.js';

let renderer;
if (await WebGPU.IsAvailableAsync()) {
const { WebGPURenderer } = await import('three/webgpu');
renderer = new WebGPURenderer({ antialias: true });
await renderer.init();
} else if (WebGL.isWebGL2Available()) {
renderer = new THREE.WebGLRenderer({ antialias: true });
} else {
document.body.append(WebGL.getWebGL2ErrorMessage());
throw new Error('no GPU available');
}

TSL compatibility layer

Three.js r184+ ships NodeMaterial for both backends — WebGPU is preferred but WebGL2 can render TSL materials via a glsl translator. New node features only land on the WebGPU path.


Chapter 5 — Performance overlay from day one

Add a perf monitor to every learning project. You will catch problems early.

stats-gl (vanilla Three.js)

1
2
npm install stats-gl
1
2
3
4
5
6
7
8
9
10
11
import Stats from 'stats-gl';
const stats = new Stats({ trackGPU: true });
document.body.appendChild(stats.dom);

renderer.setAnimationLoop(() => {
stats.begin();
// ... update + render ...
stats.end();
stats.update();
});

Shows FPS, CPU ms, and GPU ms (WebGL timer extension or WebGPU timestamp queries).

renderer.info (programmatic counters)

1
2
3
4
5
console.log(renderer.info);
// { calls: 12, triangles: 14580, points: 0, lines: 0,
// memory: { geometries: 8, textures: 12 },
// programs: [...] }

Watch calls (draw calls — target < ~100 for 60 fps on mobile) and triangles.

Spector.js (browser extension)

A Chrome/Firefox extension that captures a single frame and dumps every WebGL command + GPU state — the debugger for "why is my scene broken". Install from the Chrome Web Store; alt + click the extension icon on your page.


Chapter 6 — Mental model: matrices, transforms, the render path

Three.js hides almost all the math, but understanding the pipeline helps debug:

  1. Local transform — each Object3D has position, rotation (Euler or Quaternion), scale. These compose into a 4×4 matrix.
  2. World transform — matrixWorld = parent's matrixWorld × local matrix. Updated automatically before rendering.
  3. View matrix — inverse of the camera's world matrix.
  4. Projection matrix — derived from camera type (perspective or orthographic).
  5. The vertex shader does gl_Position = projection × view × world × position.

Practical implications:

  • Move a parent group → all children move with it.
  • object.position.set(...) is in local space. For world coordinates, use object.getWorldPosition(target).
  • Rotation order matters with Euler (object.rotation.order = 'YXZ' for camera-like).
  • Use quaternions for safe interpolation and avoiding gimbal lock.

Practice Exercises

Exercise 1 — Spinning cube

Reproduce Chapter 3's code in a fresh Vite project. Confirm a blue cube rotates on a dark background.

Exercise 2 — Add OrbitControls

1
2
3
4
5
6
7
8
9
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;

renderer.setAnimationLoop(() => {
controls.update(); // required when damping is on
renderer.render(scene, camera);
});

Drag, scroll, right-drag — full orbit / dolly / pan. Covered properly in Post 02.

Exercise 3 — Stats + draw call check

Add stats-gl. Confirm draw calls ≈ 2 (cube + clear). Add 100 cubes in a for loop, observe draw calls explode to ~100. Foreshadows Post 07 (Instancing).

Exercise 4 — WebGPU swap

Refactor to the WebGPURenderer detection pattern in Chapter 4. Open in Chrome / Edge — should use WebGPU. Open in a WebGPU-disabled browser to confirm WebGL fallback.

Exercise 5 — Resize fix

Resize the browser window. Confirm the scene fills the canvas correctly (no stretching). If broken, check the resize handler.


Cheat-sheet

What How
Install npm install three
Scene const scene = new THREE.Scene()
Perspective camera new THREE.PerspectiveCamera(75, w/h, 0.1, 1000)
Renderer (WebGL) new THREE.WebGLRenderer({ antialias: true })
Renderer (WebGPU) new (await import('three/webgpu')).WebGPURenderer() + await renderer.init()
Mesh new THREE.Mesh(geometry, material)
Add to scene scene.add(mesh)
Render loop renderer.setAnimationLoop(() => { renderer.render(scene, camera) })
Resize update camera.aspect, camera.updateProjectionMatrix(), renderer.setSize()
Pixel ratio cap renderer.setPixelRatio(Math.min(devicePixelRatio, 2))
Counters renderer.info.{calls, triangles, memory}
Perf overlay stats-gl (vanilla) or r3f-perf (R3F, Post 12)

What's next

Post 01 covers geometry and materials: BufferGeometry, the built-in shapes, and the material family from MeshBasicMaterial to MeshPhysicalMaterial. After that, cameras + controls + the math of looking around (Post 02), then lighting and shadows (Post 03).


References

Note: this post is in progress. Planned additions: animated diagram of the render pipeline; comparison of project starters (Vite vanilla / R3F starter / threlte).