Skip to content

Three.js Fundamentals

A Three.js guide for coding agents. Also covers what is webgl, why use three.js, three.js setup, scene camera renderer, first three.js scene, black screen nothing renders, and 21 more.

Show all 27 aliases

what is webgl, why use three.js, three.js setup, scene camera renderer, first three.js scene, black screen nothing renders, mesh geometry material, position rotation scale, quaternion vs euler, gimbal lock, lookAt, group objects, scene graph, animation loop, requestAnimationFrame, delta time, Clock getElapsedTime, animation speed differs per screen, PerspectiveCamera, OrthographicCamera, near and far z-fighting, OrbitControls, how do I make the canvas fullscreen, resize handler, devicePixelRatio blurry render, BufferGeometry custom vertices, Float32Array position attribute

WebGL vs Three.js

  • WebGL is a JavaScript API that draws triangles into a canvas on the GPU. The GPU computes thousands of vertex positions and pixel colors in parallel, which is what makes it fast
  • Positioning points and coloring pixels is done in shaders, fed with data like transform and camera matrices. Native WebGL needs roughly 100 lines to draw one triangle, so perspective, lights, and models are painful to hand-roll
  • Three.js sits directly above WebGL: it generates the shaders and matrices for you, but stays close enough to the metal that you can still write custom shaders and matrices later
  • Being low level is WebGL's strength too: more control, better optimization potential. Three.js trades a little of that for enormous productivity
  • Three.js is MIT licensed, updated roughly monthly. Pin versions in package.json if you care about lesson or tutorial parity

Project setup

  • You need a local server: opening an HTML file directly blocks loading of modules, textures, and models for security reasons. Use Vite
  • Minimal flow: npm init -y, npm install vite three, add "dev": "vite" and "build": "vite build" scripts, then npm run dev
  • Load your script with <script type="module" src="./script.js">. The type="module" is required for ES imports
  • Import the core library as import * as THREE from 'three'. Everything core hangs off THREE (always uppercase). Not every class lives there: addons like OrbitControls need their own import path
  • Files in a static/ (public) folder are served from the root URL, useful later for textures and models
  • Sharing a project: never share node_modules/, the receiver runs npm install which rebuilds it from package.json (exact versions if package-lock.json is present)

Minimal scene: four things or nothing renders

You need a scene, at least one object, a camera, and a renderer.

const scene = new THREE.Scene()

const mesh = new THREE.Mesh(
    new THREE.BoxGeometry(1, 1, 1),
    new THREE.MeshBasicMaterial({ color: 0xff0000 })
)
scene.add(mesh)

const sizes = { width: window.innerWidth, height: window.innerHeight }
const camera = new THREE.PerspectiveCamera(75, sizes.width / sizes.height, 0.1, 100)
camera.position.z = 3
scene.add(camera)

const canvas = document.querySelector('canvas.webgl')
const renderer = new THREE.WebGLRenderer({ canvas })
renderer.setSize(sizes.width, sizes.height)
renderer.render(scene, camera)
  • A Mesh is geometry (shape) plus material (appearance). Both are constructor arguments
  • Colors accept 0xff0000, '#ff0000', 'red', or a THREE.Color instance
  • Objects not added to the scene do not render. Add the camera to the scene too: it works without, but skipping it causes bugs later
  • Black screen gotcha: everything defaults to position (0, 0, 0), so the camera sits inside the cube. Move the camera back with camera.position.z = 3 before rendering. Forward/backward is the z axis
  • Put an existing <canvas class="webgl"> in the HTML and pass it via the canvas option; renderer.setSize(...) sizes the canvas element for you, so do not set width or height in CSS

Transforms

Every class inheriting Object3D (Mesh, cameras, Group, lights) has position, rotation, quaternion, and scale. Three.js compiles them into matrices internally; you never touch matrices for basic work.

  • Axis convention: y up, x right, z toward the viewer (backward). One unit means whatever you decide, pick a real-world scale per project (1 unit = 1 meter for a house)
  • Set transforms before the render call, or they simply will not appear in that frame
  • Debug orientation with new THREE.AxesHelper(2) added to the scene: red = x, green = y, blue = z

position and scale are Vector3

mesh.position.set(0.7, -0.6, 1)
mesh.position.length()                    // distance from origin
mesh.position.distanceTo(camera.position) // distance to another Vector3
mesh.position.normalize()                 // length 1, direction kept
mesh.scale.set(2, 0.25, 0.5)
  • Scale defaults to (1, 1, 1). Avoid negative scale values: they flip axis orientation and cause bugs later

rotation: Euler, in radians, order-dependent

mesh.rotation.x = Math.PI * 0.25   // radians, Math.PI = half turn
mesh.rotation.reorder('YXZ')       // change application order, default is XYZ
  • rotation is a Euler, not a Vector3. Rotating one axis reorients the others because rotations apply in order (x, then y, then z by default), which can produce gimbal lock where an axis loses effect
  • quaternion expresses the same rotation mathematically and avoids the order problem. Updating rotation updates quaternion and vice versa, so use whichever fits; engines internally prefer quaternions
  • object.lookAt(targetVector3) rotates the object so its -z axis points at the target. Works for cameras, cannons, eyes. camera.lookAt(mesh.position) is the common case

Combining and grouping

  • Combining position, rotation, and scale in any code order gives the same result: they describe the object's state, not a sequence
  • Use THREE.Group to move, rotate, or scale a set of objects as one unit (a whole house). Group inherits Object3D, so it has all the same transform properties. Add children with group.add(child) and add the group to the scene

Animation loop

Animation is stop motion: move objects, render, repeat every frame via requestAnimationFrame, which runs your callback once on the next frame; recursion makes the loop.

const tick = () => {
    // update objects here
    renderer.render(scene, camera)
    window.requestAnimationFrame(tick)
}
tick()

Frame-rate independence is mandatory

  • mesh.rotation.y += 0.01 runs twice as fast on a 120Hz screen. Scale movement by elapsed or delta time so speed is identical on every device
// Manual delta time
let time = Date.now()
const tick = () => {
    const currentTime = Date.now()
    const deltaTime = currentTime - time   // ms since last frame, ~16 at 60fps
    time = currentTime
    mesh.rotation.y += 0.001 * deltaTime
    // ...
}
// Built-in Clock: seconds since instantiation
const clock = new THREE.Clock()
const tick = () => {
    const elapsedTime = clock.getElapsedTime()
    mesh.rotation.y = elapsedTime                 // one radian per second
    mesh.position.x = Math.cos(elapsedTime)       // circular motion
    mesh.position.y = Math.sin(elapsedTime)
    // ...
}
  • Assigning from elapsedTime (absolute) is often simpler than accumulating deltas
  • Avoid clock.getDelta() unless you know the Clock internals; it interacts badly with getElapsedTime and produces unwanted results
  • Cameras animate the same way; pair with camera.lookAt(mesh.position) to keep the subject framed

Animation libraries (GSAP)

  • For tweens from A to B (easing, delays, timelines), use a library: gsap.to(mesh.position, { duration: 1, delay: 1, x: 2 })
  • GSAP runs its own internal requestAnimationFrame to update values, but you still must render each frame in your own tick or nothing visibly moves
  • Rule of thumb: infinite procedural motion (carousel) needs no library; choreographed motion (sword swing) wants one

Cameras

  • Camera is an abstract base. Practical types: PerspectiveCamera (real-life perspective), OrthographicCamera (no perspective, constant size regardless of distance, RTS-style), plus ArrayCamera (split screen), StereoCamera (VR parallax), CubeCamera (six-direction renders for environment maps)

PerspectiveCamera parameters

new THREE.PerspectiveCamera(fov, aspect, near, far)
new THREE.PerspectiveCamera(75, sizes.width / sizes.height, 0.1, 100)
  • fov is the vertical angle in degrees. Small = zoomed telephoto, large = fisheye distortion. Practical range 45 to 75
  • aspect is canvas width / height. Keep width and height in a shared sizes object; you will need them for resize handling
  • near / far clip what renders. Do not use extremes like 0.0001 and 9999999: depth precision collapses and coplanar faces flicker (z-fighting). Use reasonable values like 0.1 and 100, widen only when needed

OrthographicCamera parameters

const aspectRatio = sizes.width / sizes.height
new THREE.OrthographicCamera(-1 * aspectRatio, 1 * aspectRatio, 1, -1, 0.1, 100)
  • Takes left, right, top, bottom extents instead of fov. Multiply left and right by the aspect ratio, otherwise the square render area is stretched to the rectangular canvas and cubes look flattened

Custom mouse control pattern

  • Normalize cursor to a centered range so values are clean: cursor.x = event.clientX / sizes.width - 0.5
  • Invert y: cursor.y = -(event.clientY / sizes.height - 0.5) because clientY grows downward while Three.js y grows upward
  • Full orbit from the cursor: camera.position.x = Math.sin(cursor.x * Math.PI * 2) * 2, camera.position.z = Math.cos(cursor.x * Math.PI * 2) * 2, then camera.lookAt(mesh.position). Sin and cos with the same angle place a point on a circle; a full turn is 2 * Math.PI

Built-in controls

  • Catalog: OrbitControls (orbit, pan, zoom around a target), TrackballControls (OrbitControls without the vertical angle limit), FlyControls / FirstPersonControls (free flight, the latter with a fixed up axis), PointerLockControls (FPS-style pointer lock, rotation only, you still write movement and physics), TransformControls and DragControls (object gizmos, not camera)
  • Before committing to a built-in control, list the features you need and check the class covers them; bending a control class past its design is worse than writing your own
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'

const controls = new OrbitControls(camera, canvas)
controls.enableDamping = true

const tick = () => {
    controls.update()   // required every frame when damping is on
    // ...
}
  • OrbitControls is NOT on the THREE namespace; it lives in three/examples/jsm/ and must be imported separately (kept out of core to reduce bundle weight)
  • Instantiate after the camera exists, passing the camera and the DOM element that receives mouse events
  • Change the orbit center via controls.target (a Vector3), then call controls.update() once for it to take effect
  • enableDamping adds inertia and friction, but only works if controls.update() runs in the tick loop

Fullscreen, resize, pixel ratio

Fill the viewport

const sizes = { width: window.innerWidth, height: window.innerHeight }
* { margin: 0; padding: 0; }
html, body { overflow: hidden; }
.webgl { position: fixed; top: 0; left: 0; outline: none; }
  • Browser default margins cause white edges and scrollbars: zero them and fix the canvas top-left
  • outline: none removes Chrome's blue focus outline on canvas drag
  • Never set canvas width/height in CSS; renderer.setSize(...) owns that

The resize handler: three updates, in order

window.addEventListener('resize', () => {
    sizes.width = window.innerWidth
    sizes.height = window.innerHeight

    camera.aspect = sizes.width / sizes.height
    camera.updateProjectionMatrix()

    renderer.setSize(sizes.width, sizes.height)
    renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
})
  • After changing camera properties like aspect, you MUST call camera.updateProjectionMatrix() or the change never applies
  • renderer.setSize also resizes the canvas element for you

Pixel ratio

  • Blurry render or stair-step aliasing means the device pixel ratio is above 1 and the renderer is drawing at 1. Fix with renderer.setPixelRatio(...)
  • Cap it: Math.min(window.devicePixelRatio, 2). Ratio 2 means 4x the pixels, ratio 3 means 9x, and the highest ratios ship on the weakest devices (phones). Above 2 the eye barely sees a difference but the frame rate and battery do
  • Set pixel ratio at renderer creation AND inside the resize handler: users moving a window between monitors with different ratios usually trigger a resize, which covers the change

Fullscreen toggle on double click

window.addEventListener('dblclick', () => {
    const fullscreenElement = document.fullscreenElement || document.webkitFullscreenElement
    if (!fullscreenElement) {
        if (canvas.requestFullscreen) canvas.requestFullscreen()
        else if (canvas.webkitRequestFullscreen) canvas.webkitRequestFullscreen()
    } else {
        if (document.exitFullscreen) document.exitFullscreen()
        else if (document.webkitExitFullscreen) document.webkitExitFullscreen()
    }
})
  • requestFullscreen() is called on the element you want fullscreened (the canvas); exiting is on document
  • Safari needs the webkit prefixed variants for all three APIs; feature-detect both
  • dblclick does not fire on Chrome for Android

Geometries and BufferGeometry

  • A geometry is vertices (3D point coordinates) plus faces (triangles joining them). Vertices can carry more than position: UV coordinates, normals, anything. Geometries also drive particles (one particle per vertex)
  • All built-ins inherit BufferGeometry and share methods like translate(...), rotateX(...), normalize()
  • Built-in catalog worth knowing exists: Box, Plane, Circle, Cone, Cylinder, Ring, Torus, TorusKnot, Dodecahedron, Octahedron, Tetrahedron, Icosahedron, Sphere, Shape (from a path), Tube, Extrude, Lathe, Text. Check each doc page for parameters before using
  • BoxGeometry(width, height, depth, widthSegments, heightSegments, depthSegments): segments subdivide faces into more triangles (1 segment = 2 triangles per face, 2 segments = 8). Visualize with wireframe: true on the material
  • SphereGeometry(radius, widthSegments, heightSegments): more subdivisions = smoother sphere, but more vertices and faces cost performance. Add detail only where it shows
  • Complex precise shapes belong in a 3D authoring tool, exported and imported. Simple procedural shapes are worth building in code

Custom geometry with BufferAttribute

const geometry = new THREE.BufferGeometry()

const positionsArray = new Float32Array([
    0, 0, 0,   // vertex 1: x, y, z
    0, 1, 0,   // vertex 2
    1, 0, 0    // vertex 3
])

const positionsAttribute = new THREE.BufferAttribute(positionsArray, 3)
geometry.setAttribute('position', positionsAttribute)
  • Vertex data goes in a Float32Array: fixed length, floats only, laid out flat as x, y, z, x, y, z, ...
  • The 3 in BufferAttribute(array, 3) is the item size: how many array values make one vertex attribute (3 for a position)
  • The attribute name MUST be 'position': the built-in shaders look up that exact name to place vertices
  • Faces are created automatically from vertex order, three vertices per triangle
  • Sizing the array for N triangles: count * 3 * 3 (N triangles, 3 vertices each, 3 floats each)
const count = 50
const positionsArray = new Float32Array(count * 3 * 3)
for (let i = 0; i < count * 3 * 3; i++) {
    positionsArray[i] = (Math.random() - 0.5) * 4
}
  • The index property lets triangles share vertices (a cube corner belongs to several faces), shrinking the attribute array and improving performance

Common mistakes

  • Rendering before moving the camera out of the object, then debugging a "broken" black screen
  • Forgetting scene.add(mesh) or scene.add(camera)
  • Animating with fixed per-frame increments, so speed varies with the refresh rate
  • Expecting THREE.OrbitControls to exist instead of importing from three/examples/jsm/
  • Enabling damping without calling controls.update() every frame
  • Changing camera.aspect without camera.updateProjectionMatrix()
  • Passing raw window.devicePixelRatio to setPixelRatio, tanking mobile performance
  • Using extreme near/far values and hitting z-fighting
  • Setting canvas dimensions in CSS instead of letting renderer.setSize handle them
  • Naming a custom position attribute anything other than 'position'
  • Negative scale values, which flip axes and breed downstream bugs

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: "fundamentals" })
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