Skip to content

Three.js Official Manual, Distilled

A Three.js guide for coding agents. Also covers three.js installation, three addons import, three/addons/, importmap three.js, OrbitControls import, GLTFLoader import, and 22 more.

Show all 28 aliases

three.js installation, three addons import, three/addons/, importmap three.js, OrbitControls import, GLTFLoader import, how to update things, needsUpdate, matrixAutoUpdate, updateProjectionMatrix, how to dispose of objects, dispose three.js, three.js memory leak, color management, LinearSRGBColorSpace, texture.colorSpace, outputColorSpace, AgXToneMapping, WebGL compatibility check, loading 3d models, glTF loading, matrix transformations, updateWorldMatrix, scene graph traversal, layers three.js, render target, WebGLRenderTarget, draw calls merge geometries

Distilled from the official docs and manual at threejs.org (checked against the r186 dev source). Focuses on the operational knowledge course material usually skips: module setup, update flags, disposal, color pipeline, matrices, layers, and render targets.

Installation and module setup

  • Two official install paths: npm plus a build tool (Vite is the documented choice), or CDN plus import maps with no build step
  • Either way, code runs as ES modules (<script type="module">) and needs a local server during development (npx vite, npx serve ., npx http-server). Opening index.html from the filesystem does not work for module and asset loading
npm install --save three
npm install --save-dev vite
  • CDN path: declare both three and the three/addons/ prefix in one import map, pinned to the same version
<script type="importmap">
{
  "imports": {
    "three": "https://cdn.jsdelivr.net/npm/three@<version>/build/three.module.js",
    "three/addons/": "https://cdn.jsdelivr.net/npm/three@<version>/examples/jsm/"
  }
}
</script>
  • Mixing three.js versions or CDNs in one page duplicates the library and breaks instanceof checks. Import everything from one version, one source
  • TypeScript types are community maintained: @types/three from the three-ts-types project, not shipped by the core package

Addons: the three/addons/ convention

  • The core three package contains only the engine. Controls, loaders, postprocessing, and helpers live in examples/jsm/ and are imported per file via the three/addons/ alias. Nothing extra to install, but every addon needs an explicit import
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
  • Older tutorials import from three/examples/jsm/.... That path still resolves from npm, but three/addons/ is the documented convention and the one the import-map setup defines
  • Only ObjectLoader ships in the core build. Every other loader (GLTF, OBJ, FBX, DRACO) is an addon import

WebGL compatibility check

  • Run the check before rendering anything, and show the returned error element instead of a blank canvas
import WebGL from 'three/addons/capabilities/WebGL.js';

if (WebGL.isWebGL2Available()) {
  animate();
} else {
  document.getElementById('container')
    .appendChild(WebGL.getWebGL2ErrorMessage());
}
  • The current API checks WebGL 2 (isWebGL2Available). Older isWebGLAvailable era checks targeted WebGL 1, which the current renderer no longer uses

How to update things: the needsUpdate map

Three.js caches aggressively. After the first render, most changes need an explicit flag. Per resource type:

  • Object transforms: nothing needed by default, matrixAutoUpdate is true. For static objects, set object.matrixAutoUpdate = false and call object.updateMatrix() only when you change the transform
  • Geometry attributes: after the first render, editing values in a BufferAttribute requires attribute.needsUpdate = true. Buffers cannot be resized, only their values can change. Pre-allocate for the maximum count and use geometry.setDrawRange(start, count) to control how much renders
const MAX_POINTS = 500;
const positions = new Float32Array(MAX_POINTS * 3);
geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
geometry.setDrawRange(0, 2);
// later, after editing positions:
geometry.attributes.position.needsUpdate = true;
  • After moving vertices, recompute geometry.computeBoundingBox() and computeBoundingSphere() or frustum culling works off stale volumes
  • Materials: uniform-style values (color, opacity, GL state like depthTest and blending) change freely. material.needsUpdate = true is required only when the shader program's structure changes: adding or removing a texture, toggling fog, vertex colors, morphing, transparency, or alpha test. The docs warn this recompiles the shader and can hitch a frame; the suggested workaround is dummy values (a white 1x1 texture, a zero-intensity light) so the program shape never changes
  • Textures: texture.needsUpdate = true after modifying image, canvas, video, or data textures. Render-target textures update automatically
  • Cameras: position updates automatically; changing fov, aspect, near, or far requires camera.updateProjectionMatrix()
  • InstancedMesh: after setMatrixAt, set instanceMatrix.needsUpdate = true and recompute the mesh-level computeBoundingBox() / computeBoundingSphere(), which supersede the geometry's volumes for culling and raycasting. SkinnedMesh has the same mesh-level bounding volume pattern, computed from the current bone pose

Matrix transformations: local vs world

  • object.matrix is the transform relative to the parent. object.matrixWorld is the transform in world space, the product of every ancestor matrix down to the object
  • Default flow per frame: the renderer reads position, quaternion, and scale, rebuilds matrix (because matrixAutoUpdate is true), then propagates matrixWorld (because matrixWorldAutoUpdate is true)
  • Rotation is stored as a quaternion internally; Euler input goes through setRotationFromEuler and friends, which keeps the quaternion in sync
  • Writing object.matrix directly is supported, but then you must set matrixAutoUpdate = false and never call updateMatrix(), which would rebuild the matrix from position/quaternion/scale and overwrite your work
object.matrix.makeRotationFromQuaternion(quaternion);
object.matrix.setPosition(startPosition);
object.matrixAutoUpdate = false;
  • Reading world state outside the render loop can be stale. updateMatrixWorld() refreshes this object and descendants. updateWorldMatrix(updateParents, updateChildren) gives finer control, for example updateWorldMatrix(true, false) refreshes ancestors first so this object's matrixWorld is correct without touching children
  • World-space helpers: getWorldPosition(target), getWorldQuaternion(target), getWorldScale(target), plus localToWorld(v) / worldToLocal(v). All take a target object to fill, none allocate for you
  • attach(child) adds a child while preserving its world transform (recomputing its local transform). It does not support non-uniformly scaled parents, and applyMatrix4 has the same decompose limitation

Scene graph traversal

  • traverse(cb) visits the object and every descendant. traverseVisible(cb) skips invisible objects and their whole subtree. traverseAncestors(cb) walks up the parent chain
  • The docs' hard rule: do not modify the scene graph inside any traverse callback. Collect nodes into an array first, then add or remove after the walk
  • Lookups: getObjectByName(name) and getObjectByProperty(prop, value) return the first depth-first match or undefined
  • add reparents (removing from any previous parent), remove detaches, clear() removes all children. Removing from the scene does not free GPU memory, see disposal below

Layers

  • Every Object3D has a layers property: membership in up to 32 layers (0 to 31) stored as a bit mask. Everything starts on layer 0
  • An object renders only if object.layers and camera.layers share at least one layer. That is the whole mechanism: per-camera visibility without touching visible flags
mesh.layers.set(1);      // only layer 1
mesh.layers.enable(2);   // add layer 2
camera.layers.enable(1); // camera now sees layers 0 and 1
mesh.layers.test(camera.layers); // true if they share a layer
  • set replaces membership, enable/disable/toggle edit one layer, enableAll/disableAll cover the mask
  • Typical uses: selective bloom, editor-only helpers, first-person weapon cameras, splitting UI overlays from the world

How to dispose of objects

  • WebGL resources are not garbage collected. Removing a mesh from the scene frees nothing; you must call dispose() yourself, because three.js cannot know whether you will reuse the resource
  • What has dispose() and what it frees:
    • BufferGeometry.dispose(): the WebGLBuffer per attribute
    • Material.dispose(): the shader program, once no other material shares it
    • Texture.dispose(): the WebGLTexture. Disposing a material does NOT dispose its textures, and one texture can back many materials. If the texture wraps an ImageBitmap, also call ImageBitmap.close() yourself
    • WebGLRenderTarget.dispose(): texture, framebuffer, and renderbuffer
    • Skeleton.dispose() for skinned meshes, but only when no other mesh shares the skeleton
    • Addons: controls, postprocessing passes, and the renderer itself have dispose(). Disposed controls and renderers cannot be reused; disposed geometries, materials, and textures get silently recreated on next use, costing a recompile or re-upload that frame rather than throwing
  • Level-switch pattern from the docs: traverse the outgoing scene and dispose geometries, materials, and textures. For dynamic apps, the manual's ResourceTracker pattern wraps everything at creation (track(new THREE.BoxGeometry()), track(gltf.scene)), recursing into geometry, material arrays, material texture slots, and shader uniforms, then frees the whole batch with one tracker.dispose()
  • Verify with renderer.info: counts of geometries, textures, and programs in memory. Some internal entries (from scene.background, scene.environment, material.envMap) persist after cleanup and are reused, not leaked
  • Disposing a texture whose image has not loaded yet is a no-op, nothing is allocated until load completes

Color management

  • The pipeline: inputs are mostly sRGB, all lighting math runs in Linear-sRGB (the working space), output converts back to sRGB for the display. THREE.ColorManagement.enabled is true by default and renderer.outputColorSpace defaults to SRGBColorSpace, so a current default setup is already correct
  • Migration note the docs flag: since r152 this is the default. Older code used renderer.outputEncoding = sRGBEncoding and texture.encoding; those became outputColorSpace and texture.colorSpace, and the encoding constants are gone
  • Color setters treat hex and CSS input as sRGB and convert to linear on the way in; getters convert back. Direct component writes (color.r = 0.5) are taken as linear as-is. Both directions accept an explicit color-space argument to override
color.setHex(0x808080);      // stored linear: r = 0.21404...
color.getHex();              // 0x808080 again
color.setHex(0x808080, THREE.LinearSRGBColorSpace); // no conversion
  • texture.colorSpace rules, the part that silently breaks renders:
    • Color data (map, emissiveMap, sRGB PNG/JPG): texture.colorSpace = THREE.SRGBColorSpace
    • Non-color data (normal, roughness, metalness, AO, height): leave the default THREE.NoColorSpace
    • HDR / light data (EXR environments, lightmaps): THREE.LinearSRGBColorSpace
  • Symptom table from the docs: one wrong texture looks too dark or too light; missing output conversion darkens the whole scene; double conversion (common with postprocessing) washes it out; wrong on both ends looks roughly right in brightness but shading breaks harshly under lighting changes and no light tweaking fixes it
  • Postprocessing: color-space conversion happens at the end of the chain via OutputPass. Custom ShaderMaterial fragment shaders that write to screen must end with #include <colorspace_fragment>
  • Render targets: an SRGBColorSpace target keeps precision in 8 bits; a linear target needs half-float or better to avoid banding
  • glTF 2.0 declares color spaces correctly, which is one of the reasons it is the recommended format. Older formats often do not, so verify early in a reference viewer

Tone mapping

  • renderer.toneMapping maps open-domain HDR lighting into displayable range, with renderer.toneMappingExposure (default 1) as the scale. The renderer default is NoToneMapping
  • The full set of constants in the current source (r186 dev): NoToneMapping, LinearToneMapping, ReinhardToneMapping, CineonToneMapping, ACESFilmicToneMapping, CustomToneMapping, AgXToneMapping, NeutralToneMapping
  • AgXToneMapping and NeutralToneMapping are the newer additions; Neutral (based on the Khronos PBR commerce recommendation) aims to preserve material colors, AgX is robust against hue skew at high intensity, ACESFilmic is the long-standing cinematic default in examples
  • Tone mapping operates on linear working-space values before the sRGB output conversion, so it composes with the color-management defaults rather than replacing them

Render targets

  • A WebGLRenderTarget is a texture you render into, then sample like any other texture. This is the primitive behind shadows, postprocessing, picking buffers, and in-scene screens or mirrors
const renderTarget = new THREE.WebGLRenderTarget(512, 512);

// in the render loop:
renderer.setRenderTarget(renderTarget);
renderer.render(rtScene, rtCamera);
renderer.setRenderTarget(null); // back to the canvas
renderer.render(scene, camera);

// use it:
const material = new THREE.MeshPhongMaterial({ map: renderTarget.texture });
  • The camera used for the target must match the target's aspect ratio, not the canvas's. If the target tracks canvas size, resize with renderTarget.setSize(w, h) and update that camera's projection matrix alongside the main one
  • Targets allocate depth and stencil by default; pass { depthBuffer: false, stencilBuffer: false } when you do not need them
  • Render-target textures never need needsUpdate, and the target itself needs dispose() when retired

Loading 3D models

  • Official recommendation: glTF (.glb or .gltf). It is designed for runtime delivery: compact, fast to parse, and covers meshes, PBR materials, textures, skinning, morph targets, animations, lights, and cameras. Use FBX, OBJ, or COLLADA only when glTF is not available
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';

const loader = new GLTFLoader();
loader.load('model.glb', (gltf) => {
  scene.add(gltf.scene);
}, undefined, (error) => {
  console.error(error);
});
  • Draco-compressed assets (KHR_draco_mesh_compression) additionally need a configured DRACOLoader instance passed to the GLTFLoader
  • Official troubleshooting ladder for an invisible or broken model:
    1. Check the console and pass an onError callback
    2. Scale by 1000 either way; wildly different unit conventions put models inside the camera near plane or beyond far
    3. Add a light; unlit PBR materials render black
    4. Check the network tab for texture 404s; paths inside model files must be relative
    5. Cross-check the file in an external glTF viewer; if it fails everywhere, the asset is at fault, not your code

Libraries and plugins ecosystem

The official (community-maintained) list, by category, for when core three.js is not enough:

  • Physics: rapier, cannon-es, ammo.js, Jolt, Oimo.js, enable3d
  • Postprocessing: postprocessing (pmndrs), beyond the built-in EffectComposer addons
  • Raycast performance: three-mesh-bvh (bounding volume hierarchy, orders faster raycasts on big meshes)
  • Path tracing: three-gpu-pathtracer
  • File formats: urdf-loader, 3d-tiles-renderer-js, IFC.js
  • Text and layout: troika-three-text (SDF text, no font geometry), three-mesh-ui
  • Particles: three.quarks, three-nebula
  • IK: THREE.IK, fullik, closed-chain-ik
  • Game AI / navigation: yuka, three-pathfinding, recast-navigation-js
  • Wrappers and frameworks: react-three-fiber, Threlte (Svelte), tresjs (Vue), A-Frame, Needle Engine, Lume, ECSY

Performance tips from the official material

  • Draw calls dominate. Each Mesh is at least one draw call. The manual's demo merges roughly 19,000 boxes into one mesh with BufferGeometryUtils.mergeGeometries(geometries) and goes from under 20 fps to 60 fps. The tradeoff: merged parts can no longer move independently; per-part color survives via vertex colors (vertexColors: true)
  • For many copies of one geometry that DO move independently, use InstancedMesh with setMatrixAt plus instanceMatrix.needsUpdate
  • Freeze static transforms: matrixAutoUpdate = false plus a one-time updateMatrix() skips per-frame matrix rebuilds across the whole static set
  • Avoid material.needsUpdate at runtime; structure materials up front (dummy maps, dummy lights) so the shader never recompiles mid-session
  • Pre-allocate geometry buffers and drive visibility with setDrawRange instead of rebuilding geometries
  • Dispose retired resources; leaked textures at 4 to 6 MB each (per the manual's estimate for uncompressed 1024x1024) add up fast in level-switching apps
  • Turn off unneeded depth/stencil buffers on render targets
  • Watch renderer.info.render.calls and renderer.info.memory as the ground truth for draw-call and memory work
  • Heavy scenes can move rendering off the main thread with OffscreenCanvas in a worker (has its own manual chapter, with feature detection required)

Common mistakes

  • Importing addons from mismatched versions or two CDNs, breaking instanceof and duplicating the engine
  • Editing buffer attribute values after the first render without attribute.needsUpdate = true, so nothing moves
  • Trying to grow a BufferGeometry; buffers are fixed-size, pre-allocate and use setDrawRange
  • Toggling a material between textured and untextured without material.needsUpdate, or setting it every frame and recompiling constantly
  • Changing camera.fov or aspect without updateProjectionMatrix()
  • Setting matrixAutoUpdate = false and then calling updateMatrix() after writing object.matrix directly, wiping the manual matrix
  • Reading matrixWorld or getWorldPosition before any render without first calling updateWorldMatrix(true, false)
  • Adding or removing children inside a traverse callback
  • Assuming scene.remove(mesh) frees GPU memory; geometry, material, and each texture need their own dispose()
  • Disposing a shared texture or skeleton still used by another material or mesh
  • Forgetting texture.colorSpace = SRGBColorSpace on color maps, or setting it on normal/roughness maps where it corrupts the data
  • Double color conversion in postprocessing chains instead of one OutputPass at the end
  • Rendering a render target with a camera whose aspect matches the canvas instead of the target
  • Loading glTF with Draco compression but no DRACOLoader configured
  • Shipping without the WebGL2 availability check, so unsupported browsers get a silent black page

Use this guidance in your coding agent

Install the Better Design MCP once. Your agent then loads this page with one call.

get-three-js-guide({ topic: "threejs-official-manual" })
claude mcp add --scope user better-design --transport http https://better-design.com/api/mcp --header "Authorization: Bearer <YOUR_API_KEY>"
Browse related design systems