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.jsonif 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, thennpm run dev - Load your script with
<script type="module" src="./script.js">. Thetype="module"is required for ES imports - Import the core library as
import * as THREE from 'three'. Everything core hangs offTHREE(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 runsnpm installwhich rebuilds it frompackage.json(exact versions ifpackage-lock.jsonis 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 aTHREE.Colorinstance - 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 withcamera.position.z = 3before rendering. Forward/backward is thezaxis - Put an existing
<canvas class="webgl">in the HTML and pass it via thecanvasoption;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:
yup,xright,ztoward 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
rotationis aEuler, not aVector3. 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 effectquaternionexpresses the same rotation mathematically and avoids the order problem. Updatingrotationupdatesquaternionand vice versa, so use whichever fits; engines internally prefer quaternionsobject.lookAt(targetVector3)rotates the object so its-zaxis 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.Groupto 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 withgroup.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.01runs 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 withgetElapsedTimeand 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
Camerais an abstract base. Practical types:PerspectiveCamera(real-life perspective),OrthographicCamera(no perspective, constant size regardless of distance, RTS-style), plusArrayCamera(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)
fovis the vertical angle in degrees. Small = zoomed telephoto, large = fisheye distortion. Practical range 45 to 75aspectis canvas width / height. Keep width and height in a sharedsizesobject; you will need them for resize handlingnear/farclip what renders. Do not use extremes like0.0001and9999999: depth precision collapses and coplanar faces flicker (z-fighting). Use reasonable values like0.1and100, 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,bottomextents 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)becauseclientYgrows downward while Three.jsygrows 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, thencamera.lookAt(mesh.position). Sin and cos with the same angle place a point on a circle; a full turn is2 * 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),TransformControlsandDragControls(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
THREEnamespace; it lives inthree/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 callcontrols.update()once for it to take effect enableDampingadds inertia and friction, but only works ifcontrols.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: noneremoves 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 callcamera.updateProjectionMatrix()or the change never applies renderer.setSizealso 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 ondocument- Safari needs the
webkitprefixed variants for all three APIs; feature-detect both dblclickdoes 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
BufferGeometryand share methods liketranslate(...),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 withwireframe: trueon the materialSphereGeometry(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
3inBufferAttribute(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
indexproperty 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)orscene.add(camera) - Animating with fixed per-frame increments, so speed varies with the refresh rate
- Expecting
THREE.OrbitControlsto exist instead of importing fromthree/examples/jsm/ - Enabling damping without calling
controls.update()every frame - Changing
camera.aspectwithoutcamera.updateProjectionMatrix() - Passing raw
window.devicePixelRatiotosetPixelRatio, tanking mobile performance - Using extreme near/far values and hitting z-fighting
- Setting canvas dimensions in CSS instead of letting
renderer.setSizehandle them - Naming a custom position attribute anything other than
'position' - Negative scale values, which flip axes and breed downstream bugs