Three names you meet on day one of web 3D — what each one actually is, how they fit together, and the practical notes nobody tells you up front.
By Majid Mazouchi
a cube, projected & rotated — the math WebGL runs on your GPU. try the dials:
npm
The delivery service. A registry of reusable JavaScript packages and the command-line tool that installs them — including Three.js itself.
Three.js
The friendly toolkit. A JavaScript library that gives you scenes, cameras, lights, and meshes so you never write raw GPU code.
WebGL
The raw engine API. A low-level browser interface that sends triangles and small GPU programs (shaders) to the graphics card.
GPU
The hardware. Thousands of small cores drawing millions of triangles per frame, 60 times a second.
Layer 1 · The Engine
WebGL — the browser's hotline to your graphics card
In simple words: WebGL (Web Graphics Library) is a JavaScript API built into every modern browser that lets a web page talk directly to the GPU — the graphics chip in your laptop or phone. Before WebGL, the browser drew everything with the CPU; with WebGL, a web page can render fast, hardware-accelerated 2D and 3D graphics inside a <canvas> element, with no plugins installed.
WebGL is based on OpenGL ES, the graphics standard used in mobile devices. It doesn't know what a "cube" or a "car" or a "light" is. It only understands a few primitive things: vertices (points in space), triangles (three vertices stitched together), textures (images glued onto triangles), and shaders — tiny programs, written in a C-like language called GLSL, that the GPU runs for every vertex and every pixel, in parallel.
Analogy: WebGL is the engine control unit of a car. Immensely powerful, but it speaks in torque maps and injector timings — not "drive me to the airport." You can program it directly, but you will write a lot of low-level code to do even simple things.
How a frame actually gets drawn
Every WebGL frame follows the same pipeline: your JavaScript uploads vertex data into GPU buffers → a vertex shader moves each point into screen position (this is where rotation and perspective math happens — like the cube at the top of this page) → the GPU fills in the triangles → a fragment shader decides the color of every pixel → the result lands on the canvas. Drawing one plain triangle in raw WebGL takes roughly 50–80 lines of setup code. That is exactly the pain Three.js exists to remove.
The pipeline, drawn out
Vertex data
Your JS uploads positions, normals & UVs into GPU buffers.
JavaScript
→
Vertex shader
Runs once per vertex: rotates & projects each point to screen space.
GLSL · you write this
→
Rasterizer
Fixed hardware: stitches triangles, finds every pixel they cover.
hardware
→
Fragment shader
Runs once per pixel: lighting, texture lookup, final color.
GLSL · you write this
→
Framebuffer
Finished pixels are composited onto the canvas.
output
Only two stages are programmable — the two shaders — and that is the entire trick of real-time graphics: you supply two small functions, and the GPU runs them millions of times in parallel.
Feel the lighting math
Most shading reduces to one dot product: brightness = how directly a surface faces the light, written as max(0, N · L) where N is the surface normal and L points toward the light. Drag the light around the cube below — faces turning toward it catch light, faces turning away fall dark. A fragment shader evaluates exactly this, per pixel, per frame.
Practical notes — WebGL
You will rarely write raw WebGL. Almost everyone uses a library (Three.js, Babylon.js, PixiJS). Learn the concepts — pipeline, shaders, buffers — not the boilerplate.
WebGL 2 is the current version and is supported in all modern browsers. Its successor, WebGPU, is rolling out now and offers compute shaders and lower overhead — but WebGL remains the safe, universal target in 2026.
Context loss is real. The browser can take the GPU away (tab switch, driver reset, too many canvases). Production apps listen for webglcontextlost and restore gracefully.
Performance killer #1 is draw calls, not triangle count. 100 separate small meshes are slower than 1 merged mesh with the same triangles. Batch, merge, instance.
Mobile GPUs are weaker and thermally limited. Test on a mid-range phone early; cap pixel ratio (devicePixelRatio) at 2 to avoid rendering 4× the pixels you need.
Shaders fail silently-ish. A GLSL typo gives you a black canvas, not a stack trace. Check the browser console and use gl.getShaderInfoLog() when going low-level.
Layer 2 · The Toolkit
Three.js — 3D for humans
In simple words: Three.js is a free, open-source JavaScript library (created by Ricardo Cabello, "mr.doob", in 2010) that wraps WebGL in concepts a person can actually think in. Instead of buffers and shaders, you work with a Scene (the 3D world), a Camera (your viewpoint), Meshes (shape + material), Lights, and a Renderer that draws it all each frame. The 80 lines of raw WebGL for a triangle become about 10 lines of Three.js for a fully lit, spinning cube.
Analogy: if WebGL is the engine control unit, Three.js is the whole drivable car — steering wheel, pedals, dashboard. You say "turn left"; it handles the injector timings.
The mental model: scene graph
Everything in Three.js lives in a tree called the scene graph. The scene is the root; objects are children; objects can contain other objects. Move a parent, and its children move with it — a wheel attached to a car body follows the car. Each object carries a position, rotation, and scale. This single idea covers 80% of everyday Three.js work.
The canonical "hello cube" — the whole program
import * as THREE from'three';
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(75, innerWidth/innerHeight, 0.1, 1000);
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(innerWidth, innerHeight);
document.body.appendChild(renderer.domElement);
const cube = new THREE.Mesh(
new THREE.BoxGeometry(1, 1, 1), // shapenew THREE.MeshStandardMaterial({ color: 0xC75B12 }) // skin
);
scene.add(cube);
scene.add(new THREE.DirectionalLight(0xffffff, 2));
camera.position.z = 3;
renderer.setAnimationLoop(() => { // runs ~60×/second
cube.rotation.x += 0.01;
cube.rotation.y += 0.01;
renderer.render(scene, camera);
});
Notice the division of labor: geometry is the shape, material is the surface, and a mesh marries the two. Lights only affect materials that respond to light (MeshStandardMaterial does; MeshBasicMaterial ignores lighting entirely — a classic "why is my object black?" gotcha in reverse).
Practical notes — Three.js
"My screen is black" is the universal first bug. Checklist: is the camera moved back (camera.position.z = 3)? Is there a light (if using a lit material)? Did you call renderer.render() inside the loop? Is the canvas actually sized?
Use glTF (.glb) for 3D models. It's the "JPEG of 3D" — compact, fast, and the format Three.js supports best via GLTFLoader. Avoid OBJ/FBX for the web when you can.
Dispose what you remove. Removing a mesh from the scene does not free GPU memory. Call geometry.dispose(), material.dispose(), texture.dispose() or long-running apps will leak.
Three.js versions move fast (a release roughly every month, named r160, r170, …) and APIs do break. Pin your version in package.json and read the migration guide before upgrading.
OrbitControls is your best friend while developing — it gives you mouse rotate/zoom/pan in two lines, imported from three/addons/controls/OrbitControls.js.
For thousands of similar objects (particles, fleet markers, point clouds) use InstancedMesh — one draw call instead of thousands.
React user? Look at react-three-fiber, a renderer that expresses Three.js scenes as React components — same engine underneath.
Proof by Contrast
The same triangle, twice
The fastest way to understand why Three.js exists is to draw the simplest possible thing — one flat triangle — both ways. Click between the tabs.
// 1. Get a context
const gl = canvas.getContext('webgl2');
// 2. Write two GPU programs in GLSL
const vs = `#version 300 es
in vec2 aPos;
void main() { gl_Position = vec4(aPos, 0.0, 1.0); }`;
const fs = `#version 300 es
precision highp float;
out vec4 color;
void main() { color = vec4(0.78, 0.36, 0.07, 1.0); }`;
// 3. Compile and link them, with error handling
function compile(type, src) {
const s = gl.createShader(type);
gl.shaderSource(s, src);
gl.compileShader(s);
if (!gl.getShaderParameter(s, gl.COMPILE_STATUS))
throw new Error(gl.getShaderInfoLog(s));
return s;
}
const prog = gl.createProgram();
gl.attachShader(prog, compile(gl.VERTEX_SHADER, vs));
gl.attachShader(prog, compile(gl.FRAGMENT_SHADER, fs));
gl.linkProgram(prog);
// 4. Upload the triangle's vertices into a GPU buffer
const verts = new Float32Array([0, 0.7, -0.7, -0.7, 0.7, -0.7]);
const buf = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, buf);
gl.bufferData(gl.ARRAY_BUFFER, verts, gl.STATIC_DRAW);
// 5. Describe the buffer's memory layout to the shader
const vao = gl.createVertexArray();
gl.bindVertexArray(vao);
const loc = gl.getAttribLocation(prog, 'aPos');
gl.enableVertexAttribArray(loc);
gl.vertexAttribPointer(loc, 2, gl.FLOAT, false, 0, 0);
// 6. Finally: clear and draw
gl.viewport(0, 0, canvas.width, canvas.height);
gl.clearColor(0.98, 0.96, 0.93, 1);
gl.clear(gl.COLOR_BUFFER_BIT);
gl.useProgram(prog);
gl.drawArrays(gl.TRIANGLES, 0, 3);
// ...and it's still unlit, fixed-size, and non-interactive.
import * as THREE from'three';
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(75, 1, 0.1, 10);
camera.position.z = 2;
const renderer = new THREE.WebGLRenderer({ canvas });
const tri = new THREE.Mesh(
new THREE.CircleGeometry(0.8, 3), // a 3-sided "circle" = trianglenew THREE.MeshBasicMaterial({ color: 0xC75B12 })
);
scene.add(tri);
renderer.render(scene, camera);
Same pixels. The raw version isn’t harder thinking — it’s bookkeeping: compiling shaders, binding buffers, describing memory layouts. Three.js does the bookkeeping and hands back the concepts you actually care about. And when you eventually do need a custom shader, Three.js lets you inject your own GLSL via ShaderMaterial — the ladder down is always there.
Layer 0 · The Supply Chain
npm — how the code reaches your project
In simple words: npm (Node Package Manager) is two things wearing one name. First, it is the world's largest software registry — npmjs.com — a public warehouse of over three million reusable JavaScript packages, Three.js among them. Second, it is a command-line tool, installed automatically with Node.js, that downloads those packages into your project and keeps track of which versions you depend on.
Analogy: npm is an app store plus a bill of materials. The registry is the store; package.json is the parts list taped to your project saying exactly which components, at which versions, it is built from.
The three files that matter
package.json — your project's manifest: name, scripts, and a list of dependencies with version ranges (e.g. "three": "^0.182.0", where ^ means "this or any compatible newer minor version"). package-lock.json — the exact, frozen versions actually installed, so a teammate (or CI server) reproduces your setup bit-for-bit; always commit it. node_modules/ — the folder where downloaded packages physically live; never commit it, never edit it, and feel free to delete and reinstall it when things get weird.
A complete Three.js project from an empty folder
npm create vite@latest my-3d-app # scaffold a project (pick "Vanilla")cd my-3d-app
npm install # download dependencies into node_modulesnpm install three # add Three.js to package.jsonnpm run dev # local dev server with live reloadnpm run build # bundle optimized files for deployment
Why the bundler (Vite, in this example)? Browsers don't read node_modules. A build tool takes your import * as THREE from 'three' statements, finds the code in node_modules, and bundles everything into plain files a browser can load. npm supplies the parts; the bundler assembles the product.
Practical notes — npm
npm install vs npm ci: use install day-to-day; use ci in pipelines — it installs exactly what the lockfile says and fails loudly on mismatch.
npx runs a package without installing it globally — e.g. npx serve . to spin up a quick static server. Great for one-off tools.
Semantic versioning decoded: in 1.4.2, major.minor.patch. Major = breaking changes. Caution: Three.js sits at 0.x, where every minor bump may break things — another reason to pin it.
Supply-chain hygiene: every package can pull in dozens of others. Run npm audit for known vulnerabilities, prefer well-maintained packages, and be suspicious of typo-squatted names (three vs threejs — the former is the real one).
The classic fix for a corrupted setup: delete node_modules and package-lock.json, then npm install fresh.
Alternatives exist — pnpm (faster, disk-efficient), yarn, bun — all reading the same package.json and the same registry. npm is the default that's always there.
One sentence each, side by side
What it is
You use it when…
You write
WebGL
Low-level browser API to the GPU
You need custom shaders or are building an engine yourself
GLSL + verbose JS setup
Three.js
High-level 3D library built on WebGL
You want scenes, models, lights, cameras — i.e. almost always
Readable JavaScript
npm
Package registry + install tool
You add Three.js (or anything) to a real project
Terminal commands
The full story in one line: npm delivers Three.js into your project; Three.js translates your scene into WebGL calls; WebGL drives the GPU; the GPU paints pixels — sixty times a second.
What’s Next
WebGPU — the successor, briefly
In simple words: WebGL wraps a 2007-era graphics standard; GPUs and the native APIs that drive them (Vulkan, Metal, Direct3D 12) have changed shape since. WebGPU is the web’s new graphics API built around that modern shape: much less overhead per draw call, explicit control over GPU resources, a new shader language called WGSL, and — the headline feature — compute shaders, which let you run general number-crunching on the GPU (particle systems, physics, signal processing, even ML inference) without disguising it as a drawing problem.
WebGPU now ships in the major browsers, but a long tail of older devices and locked-down corporate machines still lacks it, so WebGL remains the universal fallback for a while yet. The practical path: learn the concepts on WebGL and Three.js — they transfer almost one-to-one. Three.js itself offers a WebGPURenderer and TSL (Three.js Shading Language) that compile to both backends: your scene code stays identical, and the renderer uses the best engine the device offers. For most application developers, WebGPU is an implementation detail your library handles — unless you need compute, in which case it’s the whole point.
The ecosystem map — who’s who around Three.js
Three.js rarely travels alone. These are the companions you’ll meet in real projects:
react-three-fiber + drei
Three.js, declared as React components
A React renderer for Three.js: your scene becomes JSX, state changes re-render the 3D world. drei adds dozens of ready-made helpers — cameras, controls, loaders, text. The default choice if your app is already React.
Babylon.js
The batteries-included alternative
A complete engine rather than a library: built-in physics, GUI, audio, and an excellent in-browser inspector. Popular for games and product configurators. Pick one ecosystem and go deep; concepts transfer between them.
Blender → glTF
The asset pipeline
Blender is the free tool where models get made; .glb export is one click. Then optimize with gltf-transform or gltfpack: Draco/meshopt geometry compression and KTX2 textures can shrink files 5–10×.
Rapier / cannon-es
Physics engines
Rigid-body dynamics, collisions, joints. They compute the motion; you copy positions into your Three.js meshes each frame. Rapier (Rust→WASM) is the modern favorite.
lil-gui · stats.js
Develop & tune
lil-gui gives instant slider panels for any parameter — invaluable for dialing in lights and materials. stats.js puts an FPS meter in the corner so regressions are visible immediately.
Spector.js
The frame debugger
A browser extension that captures one rendered frame and shows every WebGL call, draw by draw, with full GPU state. When the screen does something inexplicable, this is the truth serum.
One honorable mention for 2D: PixiJS uses the same GPU pipeline for flat content — sprites, charts, maps — and is often the right call when the third dimension isn’t earning its keep.
Performance cookbook
The cardinal rule from the WebGL section bears repeating: the GPU is rarely the bottleneck; the conversation with it is. Each draw call carries fixed CPU overhead, so performance work is mostly about saying fewer, bigger things to the GPU.
Recipes, in order of payoff
Budget draw calls first. Check renderer.info.render.calls each frame; a few hundred is a healthy ceiling on mobile. Triangle counts matter far less than call counts.
Merge what never moves (BufferGeometryUtils.mergeGeometries) and instance what repeats (InstancedMesh): both collapse many calls into one.
Cap the pixel ratio:renderer.setPixelRatio(Math.min(devicePixelRatio, 2)). A 3× retina screen otherwise renders 9× the pixels of 1× — for sharpness nobody can see.
Compress assets: Draco or meshopt for geometry, KTX2/Basis for textures (they stay compressed in GPU memory, unlike JPEG/PNG which decompress to full size).
Use LOD (THREE.LOD): swap in simpler meshes as objects recede. Distant detail is invisible but not free.
Tame shadows: they re-render the scene per shadow-casting light. Limit casters, shrink shadow-map sizes, or bake static shadows into textures.
Don’t allocate in the loop. Creating new THREE.Vector3() per frame feeds the garbage collector and causes stutter; reuse scratch objects.
Measure before optimizing: stats.js for frame rate, renderer.info for calls and memory, Spector.js when you need to see the actual GPU conversation.
Common errors, decoded
Nine classics. Most 3D-on-the-web debugging time goes to the first three.
Symptom
Likely cause
Fix
Black screen, console clean
Camera at the origin (inside the object), no light with a lit material, or render() never called
Move the camera back (camera.position.z = 3), add a light or use MeshBasicMaterial to test, verify the animation loop runs
Failed to resolve module specifier "three"
Bare import 'three' in a plain HTML file — browsers can’t read node_modules
Use a bundler (Vite) or add an import map pointing at a CDN copy
THREE is not defined
Mixing the old global-script world with the ES-modules world
Pick modules: <script type="module"> + import * as THREE from 'three' everywhere
Texture won’t load; CORS error in console
Page opened via file:// or image hosted without CORS headers
Serve locally (npx serve or npm run dev); host assets with Access-Control-Allow-Origin
Model "loads" but nothing appears
Wrong units/scale (a 1 mm part vs a 1 m world) or pivot far from origin
Compute new THREE.Box3().setFromObject(model), then center and rescale; check exporter unit settings
Surfaces flicker/stripe where they meet
Z-fighting: coplanar faces, or a depth buffer stretched across a huge near–far range
Offset overlapping geometry slightly; tighten camera.near/far to the range you actually use
Everything looks blurry
Canvas CSS size doesn’t match its drawing-buffer size; device pixel ratio ignored
renderer.setSize(w, h) on resize plus setPixelRatio(Math.min(devicePixelRatio, 2))
Memory climbs; tab eventually crashes
Meshes removed from the scene but GPU resources never freed
Call dispose() on geometry, material, and textures when removing objects
Canvas suddenly goes blank mid-session
WebGL context lost (GPU reset, too many contexts, backgrounded tab on mobile)
Listen for webglcontextlost/restored; keep one canvas per page where possible
Applied
3D for engineering dashboards
Where this stack earns its keep in engineering work: spatial telemetry is simply easier to read in 3D, and a browser-based view deploys to anyone with a URL — no install, no license seat. Three patterns cover most of it.
Point clouds
THREE.Points with a BufferGeometry renders an entire cloud — lidar sweeps, vibration measurement grids, CMM scans — as one draw call, comfortably into the millions of points. Positions live in a single Float32Array; add a per-vertex color attribute and the cloud itself becomes the heat map (temperature, intensity, deviation from CAD). For lidar-scale data, decimate or stream by octree — the approach tools like Potree use — rather than shipping every point to the browser.
Sensor overlays on a vehicle model
Export the vehicle (or subsystem) as .glb with components named in Blender — those names survive into the scene graph, so scene.getObjectByName('inverter') just works. Then map signals to parts: tint a component’s material by health state, pulse its emissive color on an active fault, and use a Raycaster so clicking a part opens its diagnostic panel. For floating labels pinned to 3D positions, CSS2DRenderer renders ordinary HTML anchored to objects — crisp text, free styling, no texture tricks.
Why InstancedMesh matters for fleet visualization
A fleet view is the textbook instancing case: thousands of identical shapes, each with its own position and status. Naively that’s thousands of draw calls and a slideshow; with InstancedMesh it is one geometry, one material, one call:
// 10,000 vehicles — ONE draw callconst fleet = new THREE.InstancedMesh(carGeometry, carMaterial, 10000);
const m = new THREE.Matrix4();
const statusColor = {
ok: new THREE.Color('#2E7D4F'),
warn: new THREE.Color('#C7901A'),
fault: new THREE.Color('#B3273A')
};
vehicles.forEach((v, i) => {
m.setPosition(v.x, 0, v.y);
fleet.setMatrixAt(i, m);
fleet.setColorAt(i, statusColor[v.health]); // per-instance health code
});
fleet.instanceMatrix.needsUpdate = true;
scene.add(fleet);
Practical notes — dashboards
Keep data prep off the render thread. Parse, decimate, and bin telemetry in a Web Worker; hand the main thread finished typed arrays. A 60 fps loop has ~16 ms per frame — spend none of it parsing JSON.
Update only what changed: rewrite the affected instances and set instanceMatrix.needsUpdate / instanceColor.needsUpdate — don’t rebuild the mesh per tick.
Raycasting works on instances too: the intersection result carries an instanceId, so click-to-inspect scales to the whole fleet.
Divide the labor: 3D answers where (which corner, which component, which region of the fleet); conventional charts answer when and how much. Link them through shared selection state rather than forcing either to do both jobs.
Wall displays run for days: the dispose discipline and context-loss handling from earlier sections stop being theoretical the first time a kiosk goes black overnight.
Glossary — one sentence each
vertex
A point in 3D space, usually carrying extras: a normal, a UV, a color.
triangle
Three vertices; the only shape GPUs truly draw — everything is built from them.
geometry
The shape: arrays of vertices and the triangles connecting them.
material
The surface: color, roughness, metalness, maps — how light treats the shape.
mesh
Geometry + material, placed in the scene; the basic visible object.
texture
An image sampled by shaders — for color, bumps, roughness, or arbitrary data.
UV coordinates
Per-vertex 2D coordinates saying which part of a texture sticks where.
normal
The "outward" direction of a surface; the key input to lighting math.
shader
A small program the GPU runs per vertex or per pixel, massively in parallel.
GLSL / WGSL
The C-like languages shaders are written in — GLSL for WebGL, WGSL for WebGPU.
draw call
One "draw this" command to the GPU; the unit of rendering overhead.
scene graph
The parent–child tree of objects; children inherit their parent’s transform.
frustum
The camera’s pyramid of visible space; objects outside it are skipped (culled).
raycasting
Shooting a line from the camera through the mouse to find what was clicked.
instancing
Drawing many copies of one shape, with per-copy transforms, in a single call.
Your first week — a working path
Seven sessions, each buildable in an evening, each standing on the last:
Scaffold and spin.npm create vite@latest, npm install three, then reproduce the hello-cube from this page from memory. Getting a clean dev loop running is the lesson.
Take control. Add OrbitControls and a window-resize handler (update camera aspect + renderer size). Now you can inspect anything you build from any angle.
Play with light. Swap Basic → Standard → Physical materials, move a DirectionalLight around, add AxesHelper and GridHelper, and wire a few parameters to lil-gui sliders. Intuition for materials is built here, not read.
Load something real. Export a .glb from Blender (or download a free one), load it with GLTFLoader, then center and scale it using Box3 — the rite of passage covered in the errors table.
Make it respond. Add a Raycaster: hover highlights a part, click selects it and prints its name. This is the seed of every configurator and dashboard.
Set the mood. Add an HDRI environment map (RGBELoader + scene.environment) and watch PBR materials come alive with realistic reflections.
Ship something tiny. A product viewer, a data sculpture, a fleet map — scope it to one screen. npm run build, deploy dist/ to any static host, send the link to a friend.
References & where to learn more
Three.js — official site, docs & examples. The examples gallery is the best way to discover what's possible. threejs.org · manual at threejs.org/manual
Three.js Fundamentals (official manual). The recommended structured introduction, from first cube to custom shaders. threejs.org/manual/#en/fundamentals
Discover three.js — a free, carefully written online book on building real apps. discoverthreejs.com