Core Idea
Three.js is a scene-graph and material library, not an engine: no physics, no game loop, no editor — just the 3D layer, done well, over WebGL (and WebGPU, which it targets with the same code). Its offer is a change of vocabulary: you think in objects, cameras, and materials, and it translates — the gl-ladder on the WebGL page shows exactly how many raw calls each of your lines stands on. The four spheres above are its heart: the same geometry under four of the library’s actual materials, each a bigger bottled shader than the last.
The Mental Model
Three things exist: a Scene (the world), a Camera (the view), and a Renderer (the machine that turns one into pixels through the other — renderer.render(scene, camera) per frame). Everything in the scene is an Object3D — Mesh, Group, Light, even the Camera — carrying a local position/rotation/scale and a list of children, so the whole world is one transform hierarchy: move a parent and the children follow (transformations has that machinery on an articulated arm). A renderable Mesh is exactly two ingredients:
new THREE.Mesh(geometry, material) // shape + look
BufferGeometry is a thin, honest wrapper over the typed-array streams of buffers; a Material is a pre-written shader with knobs.
The Ladder
Hover the spheres above — each tier adds a term to the recipe and multiplies the per-pixel bill: Basic runs no lighting at all (the light moves, it doesn’t care), Lambert adds diffuse, Phong adds the specular highlight, and Standard/Physical swap the classic recipe for a physically-based one with roughness and metalness — the slider that only means something on the fourth sphere. What each term is belongs to shading; what matters here is that picking a material is picking a price, and downgrading tiers where realism isn’t needed is one of the cheapest wins on a fill-bound frame.
What render() Actually Does
Every frame, the library walks the tree updating each object’s matrixWorld (parent × local), frustum-culls whatever’s bounding sphere falls outside the camera, and issues one draw call per visible object — which is why draw-call counts map one-to-one onto scene objects, and why the object count, not the polygon count, is usually what makes a frame CPU-bound. The escape valves are InstancedMesh and geometry merging (draw-call batching — thousands of copies, one call); the diagnostic is one property: renderer.info.render.calls.
The Escape Hatches
The abstraction is deliberately leaky, and each leak is a door:
| Hatch | Drops you into |
|---|---|
geometry.attributes.position + needsUpdate |
the raw buffer, rewritable per frame |
ShaderMaterial |
your own GLSL, both pipeline slots |
| TSL (node materials) | shader logic in JS, compiled to GLSL or WGSL for WebGPU |
onBeforeRender, renderer.info |
the frame loop and its accounting |
Loading Reality
Real scenes are mostly GLTF — “the JPEG of 3D” — one file bundling geometry, PBR materials, textures, hierarchy, and animations, loaded in a single GLTFLoader call. One classic first-hour bug: a loaded model renders black until a light exists — the materials respond to light, they don’t emit it.
The .glb doesn’t care where it came from: hand-modeled in Blender, generated from a prompt, or authored in a visual editor like Spline (a design tool that is itself a UI over WebGL — no relation to spline curves) — the export lands in the same loader, and Three.js code takes over from there. Visual tools compose with, rather than replace, the programmatic layer: author an asset visually, then control cameras, shaders, and the render loop in code.
Connections
Three.js stands on WebGL and WebGPU and feeds the GPU pipeline like everything else — it just writes the GLSL and manages the state machine for you. Its BufferGeometry is the buffers story verbatim, its scene graph is transformations’ hierarchy industrialized, its materials are shading’s recipe sold by the tier, and its per-object draw calls are where the 16 ms budget is usually won or lost.