Every three.js developer eventually hits the same three walls. First, a mesh you just added simply does not render β€” no error, no warning, just an empty canvas. Second, you change a single material flag like needsUpdate and the frame hitches for a hundred milliseconds. Third, renderer.render(scene, camera) looks like one function call, yet something in there manages matrices, lights, shadows, shaders, and hundreds of draw calls.

All three mysteries resolve the same way: by reading the source. The answers live in a handful of files under src/ β€” and none of them are magic. An invisible mesh is usually the renderer silently dropping your object at one of its four checkpoints (visible, layers, frustum culling, or a program that never got compiled). The hitch after needsUpdate = true is a full GLSL recompile triggered by a cache-key miss. And the one render call is a fixed, readable sequence of stages.

This post walks that flow in the current dev branch β€” from the scene graph down to gl.drawElements β€” with every claim pinned to a real file and line.

three.js overview architecture: scene graph, renderer core, render lists, shader programs, GPU buffers, state and draw

Overview: the six stages between renderer.render() and a WebGL draw call. Every node is a real file in the src/ tree.

Reading the overview from left to right:

three.js detail architecture: updateMatrixWorld, projectObject, frustum culling, render list sorting, program cache, GLSL assembly, buffer upload, draw submission

Detail view: the same flow expanded β€” matrix contract, traversal, three render queues, the program cache, buffer bookkeeping, and the two draw paths.

One render() Call, Six Stages

Strip away the XR and output-pass branches and render() is a strict sequence. First, matrix sync: scene.updateMatrixWorld() runs if scene.matrixWorldAutoUpdate is true (L1666), then the camera updates its own chain. Second, per-scene render state and render list are fetched and re-initialized (L1704) β€” these are pooled per scene, not allocated per frame. Third, projectObject fills the list. Fourth, the list sorts. Fifth, the shadow map pass re-uses the collected light array (L1749). Sixth, three queue passes execute: opaque, transmissive, transparent (L2001). The order is the contract: culling runs against final camera matrices, and lights are gathered before any material asks β€œhow many directional lights do I have?” Notably, this entire flow is target-agnostic: whatever setRenderTarget, L2960 last selected receives the draws, which is how shadow maps, transmission passes, and post-processing effects reuse the exact same stages instead of duplicating them.

The Scene Graph Is a Matrix Contract

Object3D.updateMatrixWorld, L1194 is the whole story of why objects appear where they do. If matrixAutoUpdate is on, the local matrix recomposes from position/quaternion/scale. If matrixWorldNeedsUpdate or force is set, matrixWorld is rebuilt from the parent chain (L1207) β€” and the dirty flag propagates downward by forcing children to recompute. That is why an object whose ancestors moved still lands in the right place: dirtiness flows down the tree in one pass.

Two practical consequences fall out of this code. If you set matrixAutoUpdate = false for performance and forget to call updateMatrix() after moving an object, the renderer will faithfully draw your stale matrix β€” nothing warns you. And frustum culling tests geometry.boundingSphere, not vertices: Mesh.intersectsFrustum, L236 compares the sphere against the frustum, computing it lazily if null (L258). If you overwrite position attributes at runtime without updating the bounding sphere, the renderer may cull an object that is right in front of the camera β€” the fourth invisible-mesh checkpoint, and the least obvious one.

Finally, the per-draw matrices are derived, not stored: for each render item, modelViewMatrix is recomputed as camera.matrixWorldInverse Γ— object.matrixWorld (L2172). The scene graph does not know about cameras; the draw loop does.

projectObject: Culling and Bucketing

The traversal function (L1873) is a recursive switch on object type. For every node: bail if not visible (L1874), then a bitwise layers.test against the camera (L1876) β€” layers are the cheap way to render subsets of a scene without restructuring it. Meshes, lines, and points additionally survive only if ! object.frustumCulled || object.intersectsFrustum( frustum ) (L1926). Lights take a different branch entirely: they are pushed into the render state (L1890), which is why light collection is free of the render list.

Survivors become render items in WebGLRenderList: three plain arrays β€” opaque, transmissive, transparent (L59). The bucket decision is one branch on material.transparent (L140). Sorting then runs two strategies: painterSortStable, L1 draws opaque front-to-back (early-Z rejects hidden pixels), while reversePainterSortStable, L31 draws transparent back-to-front so blending composes correctly. The third queue exists because refraction needs the opaque frame as input β€” transmissive objects render in a separate pass. Note the renderer never sorts geometry within a mesh; sorting granularity is one draw item.

How a Material Becomes a GLSL Program

This is the caching story behind your needsUpdate hitch. getParameters, L54 folds the material type (a shaderID lookup at L62), the light counts from render state, fog, environment, morph attributes, and precision into a plain parameter object. getProgramCacheKey, L411 serializes every parameter and boolean into a string β€” that string is the shader identity.

The renderer’s getProgram, L2210 keeps a Map per material keyed by that string (L2246) and early-outs when the program and the light-state version are unchanged (L2252). On a miss, acquireProgram consults the global programsMap (L644) β€” two materials with identical state share one program β€” and only then constructs a WebGLProgram (L652).

Inside WebGLProgram, the GLSL is assembled textually: #include chunks are expanded and loops unrolled (L752), light counts are baked into the source, then gl.linkProgram runs (L820) with driver diagnostics collected when debug.checkShaderErrors is on (L825). So material.needsUpdate = true does not β€œrefresh” anything β€” it forces a new cache key and a full compile. The mitigation is pre-warming: renderer.compile(scene, camera) (L1400) walks the graph once and pays the compile cost before the first visible frame.

The Last Mile: Buffers and Draw Calls

After a program is chosen, each item falls through to renderBufferDirect, L1200. Inside, three things happen in order: setProgram runs (or reuses) the shader for this exact material/camera/scene combination (L1206), state.setMaterial configures blending, depth, and face culling β€” including flipping the winding order when a mesh has a negative-determinant world matrix β€” and finally the index buffer decides which draw path fires. If material.wireframe is on, the geometry’s index buffer is transparently swapped for a wireframe attribute that WebGLGeometries synthesizes and caches (L158); the draw range is then doubled to account for the expanded index list.

The buffer layer beneath is deliberately lazy and differential. objects.update(object) (WebGLObjects, L5) refreshes the geometry and any instanced attributes like instanceMatrix (L32); WebGLGeometries.get registers a dispose listener per geometry (L62) so GPU memory is reclaimed the moment a JavaScript geometry is garbage-collected β€” no manual cleanup API to remember. And uploads are partial: WebGLAttributes.updateBuffer, L80 merges adjacent or overlapping updateRanges before sending only those slices with gl.bufferSubData. Editing 1% of a large position attribute does not re-upload the other 99%. Two draw implementations finish the job β€” non-indexed geometry goes through drawArrays, indexed through drawElements β€” and both increment the counters you see in renderer.info.

From npm Install to First Frame

The dev workflow is unusually minimal. npm run dev runs node utils/build/dev.js && node utils/server.js -p 8080 (package.json, L50). The build step writes five re-export stubs into build/ β€” three.module.js is literally one line re-exporting src/Three.js β€” so during development there is no bundler at all; the dev server serves the real src/ files, and imports resolve through the ./src/* package export (L17). The optimizing production build is separate: npm run build invokes rollup (L47). The test suite is browser-driven β€” npm test chains eslint with two Puppeteer-based unit runners (L46). A first frame therefore needs only: create renderer, add meshes, call setAnimationLoop (L1613) with render() inside β€” and optionally compile() first if startup jank matters. If you enjoy this kind of source dive, the Godot scene tree and Bevy ECS are the two most instructive peers; the Node tooling here and the TypeScript typing ecosystem around three.js are worth a look too.

Honest Limits

The rendering path described here is the WebGLRenderer path, and it has hard edges. WebGL 1 is gone: passing a WebGL 1 context throws β€œWebGL 1 is not supported since r163” (L102). The WebGPU renderer lives in the same tree (WebGPURenderer, L28) and ships as a separate build artifact via the ./webgpu export (L18), but it does not use the WebGLPrograms cache or the GLSL assembly described above β€” it is the Node/TSL material system with its own backend. On the CPU side, both painter sorts run every frame when sortObjects is true, so tens of thousands of transparent items are a JavaScript sorting cost, not a GPU one. And culling granularity is exactly one object with one bounding sphere: no occlusion culling, no portal systems β€” three.js deliberately leaves spatial partitioning to you.

Watch PyShine on YouTube

Contents