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). Openingindex.htmlfrom the filesystem does not work for module and asset loading
npm install --save three
npm install --save-dev vite
- CDN path: declare both
threeand thethree/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
instanceofchecks. Import everything from one version, one source - TypeScript types are community maintained:
@types/threefrom the three-ts-types project, not shipped by the core package
Addons: the three/addons/ convention
- The core
threepackage contains only the engine. Controls, loaders, postprocessing, and helpers live inexamples/jsm/and are imported per file via thethree/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, butthree/addons/is the documented convention and the one the import-map setup defines - Only
ObjectLoaderships 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). OlderisWebGLAvailableera 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,
matrixAutoUpdateis true. For static objects, setobject.matrixAutoUpdate = falseand callobject.updateMatrix()only when you change the transform - Geometry attributes: after the first render, editing values in a
BufferAttributerequiresattribute.needsUpdate = true. Buffers cannot be resized, only their values can change. Pre-allocate for the maximum count and usegeometry.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()andcomputeBoundingSphere()or frustum culling works off stale volumes - Materials: uniform-style values (color, opacity, GL state like depthTest and blending) change freely.
material.needsUpdate = trueis 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 = trueafter modifying image, canvas, video, or data textures. Render-target textures update automatically - Cameras: position updates automatically; changing
fov,aspect,near, orfarrequirescamera.updateProjectionMatrix() - InstancedMesh: after
setMatrixAt, setinstanceMatrix.needsUpdate = trueand recompute the mesh-levelcomputeBoundingBox()/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.matrixis the transform relative to the parent.object.matrixWorldis the transform in world space, the product of every ancestor matrix down to the object- Default flow per frame: the renderer reads
position,quaternion, andscale, rebuildsmatrix(becausematrixAutoUpdateis true), then propagatesmatrixWorld(becausematrixWorldAutoUpdateis true) - Rotation is stored as a quaternion internally; Euler input goes through
setRotationFromEulerand friends, which keeps the quaternion in sync - Writing
object.matrixdirectly is supported, but then you must setmatrixAutoUpdate = falseand never callupdateMatrix(), 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 exampleupdateWorldMatrix(true, false)refreshes ancestors first so this object'smatrixWorldis correct without touching children - World-space helpers:
getWorldPosition(target),getWorldQuaternion(target),getWorldScale(target), pluslocalToWorld(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, andapplyMatrix4has 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)andgetObjectByProperty(prop, value)return the first depth-first match or undefined addreparents (removing from any previous parent),removedetaches,clear()removes all children. Removing from the scene does not free GPU memory, see disposal below
Layers
- Every
Object3Dhas alayersproperty: 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.layersandcamera.layersshare at least one layer. That is the whole mechanism: per-camera visibility without touchingvisibleflags
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
setreplaces membership,enable/disable/toggleedit one layer,enableAll/disableAllcover 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 attributeMaterial.dispose(): the shader program, once no other material shares itTexture.dispose(): the WebGLTexture. Disposing a material does NOT dispose its textures, and one texture can back many materials. If the texture wraps anImageBitmap, also callImageBitmap.close()yourselfWebGLRenderTarget.dispose(): texture, framebuffer, and renderbufferSkeleton.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 onetracker.dispose() - Verify with
renderer.info: counts of geometries, textures, and programs in memory. Some internal entries (fromscene.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.enabledistrueby default andrenderer.outputColorSpacedefaults toSRGBColorSpace, so a current default setup is already correct - Migration note the docs flag: since r152 this is the default. Older code used
renderer.outputEncoding = sRGBEncodingandtexture.encoding; those becameoutputColorSpaceandtexture.colorSpace, and the encoding constants are gone Colorsetters 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
- Color data (
- 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. CustomShaderMaterialfragment shaders that write to screen must end with#include <colorspace_fragment> - Render targets: an
SRGBColorSpacetarget 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.toneMappingmaps open-domain HDR lighting into displayable range, withrenderer.toneMappingExposure(default 1) as the scale. The renderer default isNoToneMapping- The full set of constants in the current source (r186 dev):
NoToneMapping,LinearToneMapping,ReinhardToneMapping,CineonToneMapping,ACESFilmicToneMapping,CustomToneMapping,AgXToneMapping,NeutralToneMapping AgXToneMappingandNeutralToneMappingare 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
WebGLRenderTargetis 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 needsdispose()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 configuredDRACOLoaderinstance passed to the GLTFLoader - Official troubleshooting ladder for an invisible or broken model:
- Check the console and pass an
onErrorcallback - Scale by 1000 either way; wildly different unit conventions put models inside the camera near plane or beyond far
- Add a light; unlit PBR materials render black
- Check the network tab for texture 404s; paths inside model files must be relative
- Cross-check the file in an external glTF viewer; if it fails everywhere, the asset is at fault, not your code
- Check the console and pass an
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
InstancedMeshwithsetMatrixAtplusinstanceMatrix.needsUpdate - Freeze static transforms:
matrixAutoUpdate = falseplus a one-timeupdateMatrix()skips per-frame matrix rebuilds across the whole static set - Avoid
material.needsUpdateat runtime; structure materials up front (dummy maps, dummy lights) so the shader never recompiles mid-session - Pre-allocate geometry buffers and drive visibility with
setDrawRangeinstead 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.callsandrenderer.info.memoryas the ground truth for draw-call and memory work - Heavy scenes can move rendering off the main thread with
OffscreenCanvasin a worker (has its own manual chapter, with feature detection required)
Common mistakes
- Importing addons from mismatched versions or two CDNs, breaking
instanceofand 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.fovoraspectwithoutupdateProjectionMatrix() - Setting
matrixAutoUpdate = falseand then callingupdateMatrix()after writingobject.matrixdirectly, wiping the manual matrix - Reading
matrixWorldorgetWorldPositionbefore any render without first callingupdateWorldMatrix(true, false) - Adding or removing children inside a
traversecallback - Assuming
scene.remove(mesh)frees GPU memory; geometry, material, and each texture need their owndispose() - Disposing a shared texture or skeleton still used by another material or mesh
- Forgetting
texture.colorSpace = SRGBColorSpaceon color maps, or setting it on normal/roughness maps where it corrupts the data - Double color conversion in postprocessing chains instead of one
OutputPassat 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