../

Fundamentals

Three.js (r18x, ES modules, @types/three) from the ground up: installing and importing, the scene / camera / renderer triad, the render loop, sizing and pixel ratio, the scene graph and transforms, cameras, controls, color management and freeing GPU memory. Next: Geometry & materials, Lights & shadows; the raw API underneath is WebGL.

Install & imports

npm i three
npm i -D @types/three   # keep its minor = three's minor
Entry pointWhat it gives you
threecore: Scene, Mesh, WebGLRenderer, math, loaders base
three/addons/*examples: controls, loaders, post-processing, utils (maps to examples/jsm/*)
three/addonsone barrel with every addon (convenient, heavier for dev servers)
three/webgpucore plus WebGPURenderer, node materials, RenderPipeline
three/tslThree Shading Language functions (Fn, uniform, uv, …)
import * as THREE from "three";
import { Mesh, Scene, WebGLRenderer } from "three";
import { OrbitControls } from
  "three/addons/controls/OrbitControls.js";
import { GLTFLoader } from
  "three/addons/loaders/GLTFLoader.js";
  • Keep the .js extension on addon paths: the package exports maps files, not bare names.
  • With WebGPURenderer, import from three/webgpu (it re-exports the core). Since r171 both entry points share one three.core.js, so addons that import three still see the same classes.
  • Two different copies of three (two versions in node_modules, or npm plus a CDN) do break things: instanceof fails, caches split, and three logs "Multiple instances of Three.js being imported".
  • Version tags: npm 0.186.x = release r186. There is no semver: any minor can break; read the migration guide (opens in a new tab) before bumping.

No bundler: an import map, with the version pinned in both URLs.

<script type="importmap">
{
  "imports": {
    "three": "https://cdn.jsdelivr.net/npm/three@0.186.1/build/three.module.js",
    "three/addons/": "https://cdn.jsdelivr.net/npm/three@0.186.1/examples/jsm/"
  }
}
</script>
<script type="module" src="./main.js"></script>

TypeScript setup

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "lib": ["ES2022", "DOM", "DOM.Iterable"],
    "strict": true,
    "skipLibCheck": true
  }
}
Typing tipWhy
moduleResolution: "bundler" (or "nodenext")needed to resolve three/addons/* through exports
@types/three version = three's versionthe typings track releases one-for-one
Mesh<BoxGeometry, MeshStandardMaterial>generics narrow .geometry and .material
obj.isMesh style flagstype guards that survive duplicate copies of three; instanceof does not
import type { GLTF } from ".../GLTFLoader.js"result type of loadAsync
userData is Record<string, any>wrap it in your own typed accessor
ColorRepresentationnumber | string | Color for any color parameter
import { BoxGeometry, Mesh, MeshStandardMaterial }
  from "three";
import type { Object3D } from "three";
 
type Box = Mesh<BoxGeometry, MeshStandardMaterial>;
const box: Box = new Mesh(
  new BoxGeometry(1, 1, 1),
  new MeshStandardMaterial({ color: "tomato" }),
);
box.material.roughness = 0.4; // typed, no cast
 
function isMesh(o: Object3D): o is Mesh {
  return (o as Mesh).isMesh === true;
}

Scene, camera, renderer

 Scene (root Object3D)
 ├── Mesh  = BufferGeometry + Material
 ├── Group ── Mesh, Mesh …
 ├── Light(s)
 └── (camera may be a child too)
          │
 renderer.render(scene, camera)
          │  per frame: update world matrices → frustum cull
          │  → sort opaque front-to-back, transparent back-to-front
          │  → compile/cache programs → draw calls
          ▼
 <canvas> drawing buffer  (tone mapping + sRGB on output)
const canvas = document.querySelector<HTMLCanvasElement>(
  "#app",
)!;
const renderer = new WebGLRenderer({
  canvas,
  antialias: true,                // MSAA on the canvas
  powerPreference: "high-performance",
});
const scene = new Scene();
scene.background = new Color(0x202025);
 
const camera = new PerspectiveCamera(
  50,                              // vertical fov, degrees
  canvas.clientWidth / canvas.clientHeight,
  0.1,                             // near
  100,                             // far
);
camera.position.set(0, 1.5, 4);
camera.lookAt(0, 0, 0);
WebGLRenderer optionDefaultNotes
canvasnew canvasappend renderer.domElement if you let it create one
antialiasfalseMSAA on the default framebuffer only
alphafalsetransparent canvas; pair with setClearColor(c, 0)
powerPreference"default""high-performance" / "low-power"
preserveDrawingBufferfalseneeded for late toDataURL(); slower
logarithmicDepthBufferfalsehuge scale ranges; disables early-z
reversedDepthBufferfalsebetter precision, needs EXT_clip_control (recent releases)
stencilfalseenable for stencil tricks

WebGL1 support was removed (r163): WebGLRenderer needs WebGL2. The WebGPU path is WebGPURenderer, which falls back to WebGL2.

Render loop

renderer.setAnimationLoop(cb) is requestAnimationFrame managed by three: it also drives WebXR sessions (plain rAF does not) and is the documented loop for WebGPURenderer. Pass null to stop.

import { Timer } from "three"; // core since r179
 
const timer = new Timer();
timer.connect(document); // no giant delta after tab switch
 
renderer.setAnimationLoop((time: DOMHighResTimeStamp) => {
  timer.update(time);
  const dt = timer.getDelta();  // s since last update
  const t = timer.getElapsed(); // s, total
  cube.rotation.y += dt * 0.8;  // frame-rate independent
  cube.position.y = Math.sin(t) * 0.25;
  renderer.render(scene, camera);
});
TimerClock
Statuscurrent, in coredeprecated since r183 (logs a warning)
Readsupdate() once per frame, then read freelygetDelta() mutates; calling twice gives ~0
Hidden tabsconnect(document) clamps via Page Visibilityhuge first delta when you come back
SpeedsetTimescale(0.5)none

Resizing & pixel ratio

Three sizes the drawing buffer as CSS size × pixel ratio. Cap the ratio: a 3× phone at full ratio renders 9× the pixels of 1×, and fragment cost scales with pixel count.

renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
 
function resize(): void {
  const w = canvas.clientWidth;
  const h = canvas.clientHeight;
  renderer.setSize(w, h, false); // false: leave CSS alone
  camera.aspect = w / h;
  camera.updateProjectionMatrix(); // required after edits
}
CallEffect
setSize(w, h, updateStyle = true)buffer = w × h × pixelRatio; also writes CSS size unless false
setPixelRatio(r)multiplier used by setSize; call it first
getDrawingBufferSize(v2)real pixel size (for render targets, post-processing)
camera.updateProjectionMatrix()after changing fov, aspect, near, far, zoom, ortho bounds
renderer.setViewport / setScissorsplit screen, picture-in-picture

Let CSS own layout (canvas { width: 100%; height: 100%; display: block }) and pass false. The display: block removes the inline-element gap under the canvas.

Coordinates & units

ConventionValue
Handednessright-handed
Up+Y (Object3D.DEFAULT_UP)
Camera looks downits local -Z
Screen+X right, +Y up in NDC; DOM clientY grows down
Anglesradians everywhere (MathUtils.degToRad), except PerspectiveCamera.fov in degrees
Unitsarbitrary, but 1 unit = 1 meter matches glTF and physically based lights
Rotation orderEuler "XYZ" by default (rotation.order)
Face windingcounter-clockwise = front

Blender exports glTF as +Y up meters, so models usually drop in at the right scale. Other tools may need scale.setScalar(0.01) (cm) or a -Math.PI / 2 X rotation (Z-up sources).

Object3D transforms

Everything in the graph (meshes, lights, cameras, groups) is an Object3D.

Property / methodNotes
position: Vector3local, relative to parent; mutate in place (position.set, .x = )
rotation: Eulerradians; kept in sync with quaternion
quaternion: Quaternionprefer for interpolation (slerp) and composing rotations
scale: Vector3scale.setScalar(2); negative flips winding
lookAt(v)point local +Z (cameras and lights: -Z) at a world position
up: Vector3used by lookAt; change it before calling
rotateX/Y/Z(rad), rotateOnAxisrotate in local space
rotateOnWorldAxis(axis, rad)rotate about a world axis
translateX/Y/Z(d), translateOnAxismove along local axes
visiblefalse skips the object and its children
renderOrderforce draw order (mostly for transparency)
layers32 bit mask; camera and raycaster only see enabled layers
frustumCulledtrue; set false for objects moved in a vertex shader
userDatayour data; copied by clone() and serialized by toJSON()
// position, rotation, scale are readonly references:
// mutate them, never reassign
mesh.position.set(1, 0, -2);
mesh.rotation.set(0, Math.PI / 4, 0);
mesh.scale.setScalar(0.5);
 
// quaternion from axis-angle, then slerp toward it
const target = new Quaternion().setFromAxisAngle(
  new Vector3(0, 1, 0), Math.PI,
);
mesh.quaternion.slerp(target, 1 - Math.exp(-6 * dt));

Scene graph

const pivot = new Group(); // empty transform node
scene.add(pivot);
pivot.add(moon);           // moon now orbits with pivot
moon.position.x = 3;       // local: 3 from pivot
 
pivot.remove(moon);        // or moon.removeFromParent()
other.attach(moon);        // re-parent, keep world pose
scene.clear();             // remove all children
 
scene.traverse((o) => { o.castShadow = true; });
scene.traverseVisible(cb); // skips hidden branches
obj.traverseAncestors(cb);
scene.getObjectByName("Wheel_FL");
scene.getObjectsByProperty("isLight", true);
Local ↔ worldCall
world positionobj.getWorldPosition(out)
world rotation / scalegetWorldQuaternion(out), getWorldScale(out)
forward directiongetWorldDirection(out) (local +Z; cameras: -Z)
point to worldobj.localToWorld(v) (mutates v)
point to localobj.worldToLocal(v)
force update nowobj.updateMatrixWorld(true); updateWorldMatrix(parents, children)

The getWorld* helpers update the object's matrices first; matrixWorld read directly is only fresh after a render or updateMatrixWorld().

Matrices & updates

FlagDefaultMeaning
matrixAutoUpdatetruerebuild matrix from position/quaternion/scale every frame
matrixWorldAutoUpdatetruerenderer recomputes matrixWorld for this subtree
matrixWorldNeedsUpdatefalseset after writing matrix by hand
Object3D.DEFAULT_MATRIX_AUTO_UPDATEtruestatic default for new objects
// static scenery: compute once, then stop per-frame work
building.updateMatrix();
building.matrixAutoUpdate = false;
// after moving it later: building.updateMatrix()
 
// drive a transform by matrix instead of PRS
obj.matrixAutoUpdate = false;
obj.matrix.compose(pos, quat, scl);
obj.matrixWorldNeedsUpdate = true;

Turning matrixAutoUpdate off for thousands of static objects is a cheap CPU win. Beyond that, use instancing.

Cameras

PerspectiveCameraOrthographicCamera
Args(fov, aspect, near, far)(left, right, top, bottom, near, far)
Projectionforeshorteningparallel lines stay parallel
Zoommove it, change fov, or zoomzoom (no size change with distance)
Resizeupdate aspectupdate the four bounds
Use for3D scenesCAD, isometric, 2D, UI overlays, shadows of directional lights
// orthographic: `viewH` world units visible vertically
const viewH = 10;
const aspect = canvas.clientWidth / canvas.clientHeight;
const ortho = new OrthographicCamera(
  (-viewH * aspect) / 2, (viewH * aspect) / 2,
  viewH / 2, -viewH / 2,
  0.1, 100,
);

Depth precision. A perspective depth buffer spends most of its precision near near; the far/near ratio decides whether distant surfaces fight (z-fighting).

FixDetail
Raise nearthe biggest single win: 0.1 not 0.0001
Lower farcover the scene, no more
logarithmicDepthBuffer: trueplanet-to-pebble scales; costs early-z
reversedDepthBuffer: truefloat depth reversed; best precision where supported
polygonOffset on a materialdecals and coplanar overlays
camera.layersdraw a second pass (e.g. first-person weapon) with its own near/far

Controls

All controls are addons and extend the core Controls base: enabled, connect(el), disconnect(), dispose(), update(delta?).

ClassBehavior
OrbitControlsorbit a target; keeps "up" up; zoom and pan
MapControlsorbit variant: left-drag pans, for maps and top-down views
TrackballControlsfree tumbling, no fixed up; call update() every frame
ArcballControlsarcball rotation with gizmos
FlyControls6-DOF flight with keys and mouse
FirstPersonControlslook with mouse, move with keys (no pointer lock)
PointerLockControlsFPS mouse-look via the Pointer Lock API
TransformControlstranslate/rotate/scale gizmo; add controls.getHelper() to the scene
DragControlsdrag objects on a plane
import { OrbitControls } from
  "three/addons/controls/OrbitControls.js";
 
const controls = new OrbitControls(camera, canvas);
controls.target.set(0, 1, 0);
controls.enableDamping = true;   // inertia
controls.dampingFactor = 0.08;
controls.minDistance = 1;
controls.maxDistance = 20;
controls.maxPolarAngle = Math.PI / 2; // not below ground
controls.update();               // apply target now
 
renderer.setAnimationLoop(() => {
  controls.update(); // required with damping/autoRotate
  renderer.render(scene, camera);
});

Listen to "change" for render-on-demand, "start" / "end" to pause other interaction, and call controls.dispose() when tearing down (it removes DOM listeners).

Helpers & debugging

HelperShows
AxesHelper(size)X red, Y green, Z blue
GridHelper(size, divisions)ground grid on XZ
PolarGridHelperpolar grid
BoxHelper(obj) / Box3Helper(box3)bounding box
ArrowHelper(dir, origin, len)a vector
CameraHelper(cam)frustum (also for shadow cameras)
DirectionalLightHelper, PointLightHelper, SpotLightHelper, HemisphereLightHelperlights
SkeletonHelper(root)bones
VertexNormalsHelper (addon)normals
scene.add(new AxesHelper(1), new GridHelper(10, 10));
console.table(renderer.info.render);  // calls, triangles
console.log(renderer.info.memory);    // geometries, textures
console.log(renderer.info.programs?.length); // shaders

Tooling: Spector.js (opens in a new tab) captures raw GL calls, lil-gui (three/addons/libs/lil-gui.module.min.js) tweaks parameters, Stats (three/addons/libs/stats.module.js) shows FPS.

Color management

Since r152 color management is on by default. Lighting math happens in linear sRGB; inputs in sRGB are converted in, output is converted back to sRGB.

 hex / CSS colors ──(treated as sRGB)──┐
 color textures (map, emissiveMap)     ├─► linear working space
   texture.colorSpace = SRGBColorSpace ─┘       │ lighting, blending
 data textures (normal, roughness…)             ▼
   colorSpace = NoColorSpace (default)   tone mapping
                                                │
                           renderer.outputColorSpace = SRGBColorSpace
                                                ▼
                                             canvas
SettingDefaultRule
ColorManagement.enabledtrueleave on; false restores pre-r152 behavior
ColorManagement.workingColorSpaceLinearSRGBColorSpaceleave it
renderer.outputColorSpaceSRGBColorSpaceleave it (post-processing: see OutputPass)
texture.colorSpaceNoColorSpaceset SRGBColorSpace on color maps you load yourself
new Color(0xff8800), "#ff8800"sRGB inputconverted to linear on the way in
color.setRGB(r, g, b)linear inputpass SRGBColorSpace as 4th arg for sRGB
color.getHex() / getStyle()sRGB outputround-trips with the hex you set
const tex = await new TextureLoader().loadAsync(
  "/albedo.jpg",
);
tex.colorSpace = SRGBColorSpace;  // color data
const nrm = await new TextureLoader().loadAsync(
  "/normal.png",
); // data: leave NoColorSpace

GLTFLoader sets color spaces for you. The removed properties you still meet in old tutorials: renderer.outputEncoding, texture.encoding, sRGBEncoding, renderer.physicallyCorrectLights, renderer.useLegacyLights.

Disposal & memory

JavaScript GC frees the JS objects, not the GPU buffers, textures and programs behind them. Removing a mesh from the scene frees nothing on the GPU.

ResourceFree withNotes
BufferGeometrygeometry.dispose()vertex/index buffers
Materialmaterial.dispose()program released when no material uses it
Texturetexture.dispose()not freed by material.dispose()
ImageBitmap sourcebitmap.close()after texture.dispose() if you own it
WebGLRenderTargetrt.dispose()its textures and depth
PMREMGeneratorpmrem.dispose()after generating your env maps
Controlscontrols.dispose()DOM listeners
Skeletonskeleton.dispose()bone texture
Rendererrenderer.dispose()whole context teardown; forceContextLoss() to free it immediately
Object3D.dispose()only fires a "dispose" eventdoes not free geometry or materials

renderer.info.memory.geometries / .textures counting up while you swap content is a leak. Resources shared between meshes (one material on 100 meshes) must be disposed once, when nothing uses them.

Black screen checklist

SymptomLikely causeFix
Nothing at allcamera inside or behind the objectcamera.position.z = 5; camera.lookAt(0,0,0)
Nothing at allobject outside near–farcheck units; raise far or scale the model
Nothing at allcanvas 0×0CSS height on parent; display: block; call setSize
Nothing at allforgot renderer.render or the loopsetAnimationLoop
Black meshlit material, no lightsadd lights or scene.environment; or MeshBasicMaterial
Black meshlight intensity too low for physical unitspoint/spot in candela: try 50–500 at a few meters
Black / invisiblenormals missing or invertedcomputeVertexNormals(); side: DoubleSide to test
Plane invisible from one sideback-face cullingside: DoubleSide or rotate it
Stretched imageaspect not updatedcamera.aspect = w/h; updateProjectionMatrix()
Blurry on HiDPIpixel ratio 1setPixelRatio(Math.min(devicePixelRatio, 2))
Washed out / too dark colorscolor space mix-upmap.colorSpace = SRGBColorSpace; data maps untouched
Model tiny or hugeunit mismatchmeasure with new Box3().setFromObject(m)
Flicker between surfacesz-fightingraise near, polygonOffset, separate geometry
WebGPURenderer throws on rendernot initializedawait renderer.init() or use setAnimationLoop
"Multiple instances" warningtwo versions installed, or npm plus CDNnpm ls three; align versions; resolve.dedupe: ["three"] in Vite

Recipes

Minimal typed scene

Use as the starting point for any page: cube, light, environment-free, capped DPR, typed.

import {
  BoxGeometry, Color, DirectionalLight, HemisphereLight,
  Mesh, MeshStandardMaterial, PerspectiveCamera, Scene,
  Timer, WebGLRenderer,
} from "three";
 
const canvas = document.querySelector("canvas")!;
const renderer = new WebGLRenderer({
  canvas, antialias: true,
});
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
renderer.setSize(innerWidth, innerHeight, false);
 
const scene = new Scene();
scene.background = new Color("#1d1f24");
const camera = new PerspectiveCamera(
  50, innerWidth / innerHeight, 0.1, 50,
);
camera.position.set(2, 1.5, 3);
camera.lookAt(0, 0, 0);
 
scene.add(new HemisphereLight("#dfe8ff", "#3a2e24", 1));
const sun = new DirectionalLight("#ffffff", 2.5);
sun.position.set(3, 5, 2);
scene.add(sun);
 
const cube = new Mesh(
  new BoxGeometry(1, 1, 1),
  new MeshStandardMaterial({ color: "#4f8cff" }),
);
scene.add(cube);
 
const timer = new Timer();
timer.connect(document);
renderer.setAnimationLoop((t) => {
  timer.update(t);
  cube.rotation.y += timer.getDelta();
  renderer.render(scene, camera);
});

Resize with ResizeObserver

Use when the canvas lives in a layout (grid cell, panel) rather than filling the window.

import type { PerspectiveCamera, WebGLRenderer }
  from "three";
 
export function autoResize(
  renderer: WebGLRenderer,
  camera: PerspectiveCamera,
  maxDpr = 2,
): () => void {
  const canvas = renderer.domElement;
  const ro = new ResizeObserver(([entry]) => {
    const { width, height } = entry.contentRect;
    if (width === 0 || height === 0) return;
    renderer.setPixelRatio(
      Math.min(devicePixelRatio, maxDpr),
    );
    renderer.setSize(width, height, false);
    camera.aspect = width / height;
    camera.updateProjectionMatrix();
  });
  ro.observe(canvas);
  return () => ro.disconnect();
}

contentRect is in CSS pixels, so a DPR-only change (dragging the window to another monitor) does not fire the observer; add a matchMedia listener on the resolution media query if that matters.

Dispose a subtree

Use when unloading a level or model; frees geometry, materials and every texture they reference.

import type {
  BufferGeometry, Material, Object3D, Texture,
} from "three";
 
type Drawable = Object3D & {
  geometry: BufferGeometry;
  material: Material | Material[];
};
const isDrawable = (o: Object3D): o is Drawable =>
  "geometry" in o && "material" in o;
 
function disposeMaterial(m: Material): void {
  for (const value of Object.values(m)) {
    const tex = value as Texture | null;
    if (tex?.isTexture) tex.dispose();
  }
  m.dispose();
}
 
export function disposeTree(root: Object3D): void {
  root.traverse((o) => {
    if (!isDrawable(o)) return;
    o.geometry.dispose();
    const mats = Array.isArray(o.material)
      ? o.material : [o.material];
    mats.forEach(disposeMaterial);
  });
  root.removeFromParent();
}

Skip anything shared with objects that stay in the scene (cached materials, a texture atlas).

Render on demand

Use for viewers and configurators: no work at all while nothing moves, battery friendly.

let queued = false;
 
function requestRender(): void {
  if (queued) return;
  queued = true;
  requestAnimationFrame(() => {
    queued = false;
    // damping emits "change" again until it settles
    controls.update();
    renderer.render(scene, camera);
  });
}
 
controls.addEventListener("change", requestRender);
new ResizeObserver(() => {
  resize();          // from "Resizing & pixel ratio"
  requestRender();
}).observe(canvas);
requestRender();     // first frame
// also call it after loading assets or changing materials

Frame an object

Use after loading a model of unknown size: center it and pull the camera back to fit.

import { Box3, Sphere, Vector3 } from "three";
import type { Object3D, PerspectiveCamera } from "three";
 
export function frame(
  obj: Object3D, cam: PerspectiveCamera, pad = 1.2,
): Vector3 {
  const sphere = new Box3()
    .setFromObject(obj)
    .getBoundingSphere(new Sphere());
  const fov = (cam.fov * Math.PI) / 180;
  const dist = (sphere.radius * pad) / Math.sin(fov / 2);
  const dir = new Vector3(1, 0.6, 1).normalize();
  cam.position.copy(sphere.center)
    .addScaledVector(dir, dist);
  cam.near = dist / 100;
  cam.far = dist * 100;
  cam.updateProjectionMatrix();
  cam.lookAt(sphere.center);
  return sphere.center; // use as controls.target
}

References