The scene graph and the render loop
A Three.js scene is not a flat pile of meshes but a tree of transforms that compose down each branch, and nothing on screen moves or even appears a second time unless you personally re-trigger the pipeline every frame — this lesson takes apart both structures, the scene graph that produces the model matrix from lesson 4 and the render loop you write by hand to drive it.
The scene graph and the render loop
Every lesson so far has quietly assumed a single mesh, sitting alone, transformed once. Real scenes aren't like that. A car has wheels that spin independently of the body that's driving down a road; a solar system has moons orbiting planets that orbit a sun; a character has a hand attached to a forearm attached to an upper arm attached to a shoulder. None of those relationships are expressible as one flat list of triangles with one matrix. Three.js's answer is a scene graph — a tree of objects whose transforms are defined relative to their parent and composed down the branches — and it's where the model matrix from the MVP matrices lesson actually comes from.
The second thing this lesson takes apart is just as easy to overlook because Three.js makes the friendly path so smooth: nothing renders itself. There is no background process redrawing your scene when something moves. You are personally responsible for calling renderer.render again, every single frame, and if you stop calling it the picture freezes exactly where it is — forever. That's the render loop, and it's the same requestAnimationFrame heartbeat from the frame budget lesson, just with a different consumer running inside it.
Every node is a local transform plus a parent
The base class for almost everything placeable in a Three.js scene — Mesh, Group, Camera, even Object3D itself — carries the same three properties: position, quaternion (rotation), and scale. Three.js calls these the object's local transform, and "local" is the operative word: they describe where this object sits relative to its parent, not relative to the world's origin.
const moon = new THREE.Mesh(moonGeometry, moonMaterial);
moon.position.set(2, 0, 0); // 2 units from its parent, not from world originThat local transform gets packed into a 4×4 matrix, object.matrix, the same kind of matrix lesson 4 built by hand out of translation, rotation, and scale. On its own, that matrix only tells you where the moon sits relative to whatever it's attached to. To know where the moon actually is — in world space, the coordinate system the camera and the rest of the pipeline care about — you need to know where its parent is too, and where its parent's parent is, all the way up to the root.
World matrix = parent's world matrix × local matrix
This is the entire mechanical idea of the scene graph, stated as one multiplication, applied recursively:
node.matrixWorld = parent.matrixWorld × node.matrixWalk that up the tree and a deeply nested object's final position is the product of every ancestor's local transform, chained together. The classic illustration is a solar system: a sun at the origin, a planet orbiting the sun, a moon orbiting the planet.
const sun = new THREE.Group();
const planet = new THREE.Mesh(planetGeometry, planetMaterial);
planet.position.set(5, 0, 0); // 5 units from the sun
sun.add(planet);
const moon = new THREE.Mesh(moonGeometry, moonMaterial);
moon.position.set(1, 0, 0); // 1 unit from the planet
planet.add(moon);The moon's position never mentions the number 6. It doesn't need to — its world position falls out of the chain: sun's world matrix (identity, if the sun sits at the origin) times the planet's local matrix (translate by 5) times the moon's local matrix (translate by 1). Move the sun anywhere in the world and every planet and moon under it moves with it, without a single line of code touching the planet or moon directly, because the multiplication that produces their world matrices includes the sun's world matrix as a factor. That's the entire point of parenting something: it inherits every transform above it, automatically, for free.
Notice the tree shape: a Group (or any Object3D) with children isn't itself a drawable mesh — it's a pure organizational node, existing only to give its children a shared transform to inherit. Grouping objects purely to move them together, with no geometry of its own, is one of the most common uses of Group in real scenes.
updateMatrixWorld: the traversal that keeps it all honest
Multiplying "parent's world matrix times local matrix" only produces the right answer if the parent's world matrix is already correct by the time you compute the child's. Three.js guarantees that by traversing the scene graph top-down — root first, then each child in turn — recomputing matrixWorld for every node before moving on to its children. This traversal is updateMatrixWorld, and it happens automatically once per frame during renderer.render, walking the entire tree from scene downward.
The upshot: by the time any mesh is actually drawn, its matrixWorld already reflects every ancestor's current position, rotation, and scale, fully composed. That matrixWorld is the model matrix — literally the same matrix lesson 4 described as one of the three that get multiplied together in the vertex shader (modelViewProjection = projection × view × model). The scene graph isn't a separate concept from the MVP matrices; it's the machinery that produces the "model" third of that product, one level of nesting at a time, instead of you hand-writing a single flat transform per mesh.
Three.js does not render itself
Here is the part that surprises people coming from other frameworks: creating a Scene, adding meshes to it, and calling renderer.render(scene, camera) once will draw exactly one frame — and only one. Change a mesh's position afterward and nothing happens on screen, because nothing is watching for that change and nothing is calling render again. There's no reactive re-render, no dirty-checking, no observer pattern. The picture is whatever the last call to render produced, and it sits there unchanged until you call render again.
That means an animated Three.js scene requires you to personally re-invoke the pipeline, over and over, on a schedule — and the correct schedule, for exactly the reasons the frame budget lesson laid out, is requestAnimationFrame. This is the render loop:
function animate() {
requestAnimationFrame(animate); // re-register for the next frame
cube.rotation.y += 0.01; // mutate a local transform
renderer.render(scene, camera); // re-run the whole pipeline
}
animate();This is exactly the same rAF heartbeat the frame-budget lesson described and the same one GSAP's ticker is built on — one browser-provided "just before the next paint" callback. The difference is only who's subscribed to it. The frame-budget lesson's rAF callback recomputed CSS values; GSAP's ticker calls tick(dt) on every active tween; here, your animate function calls renderer.render. Same clock, different consumer, same 16.6ms deadline to beat.
What renderer.render actually does per call
Each call to renderer.render(scene, camera) is not just "run the pipeline once." It's a small sequence of its own, every frame:
- Traverse the scene graph and recompute
matrixWorldfor every object, top-down, exactly as described above — this is where a mutatedpositionorrotationfinally propagates into a usable model matrix. - Cull — decide which objects are actually inside the camera's view frustum and worth drawing at all. (This becomes the whole subject of the next lesson: skipping objects here directly reduces how much work the rest of the pipeline does.)
- For each visible mesh, set up state and issue a draw call — bind that mesh's geometry, activate its material's compiled shader program, upload its (now-current) model matrix as a uniform, and call the WebGL equivalent of
drawArrays/drawElementsfrom the state-machine lesson. One mesh, roughly one draw call — the exact cost model the next lesson is entirely about.
So "call render every frame" is really "re-walk the tree, decide what's visible, and re-issue the whole bind-and-draw sequence for everything that survives" — every single frame, sixty times a second, whether or not anything actually changed.
Mutating transforms between frames is how animation happens
Put the two halves together and the picture is complete: the render loop is the clock that keeps calling render; the scene graph is the structure that turns whatever your local transforms currently say into the world matrices the vertex shader receives. Animation, mechanically, is nothing more than changing a position, rotation, or scale value sometime before the next render call — the traversal picks up whatever you last set and recomposes it.
import gsap from "gsap";
// The exact same "animate anything with a plain property" idea from
// GSAP's how-gsap-animates-anything lesson, aimed at a mesh's transform
// instead of a DOM element's style.
gsap.to(cube.rotation, { y: Math.PI * 2, duration: 2, repeat: -1 });
function animate() {
requestAnimationFrame(animate);
renderer.render(scene, camera); // GSAP's ticker already updated cube.rotation.y
}
animate();Nothing about cube.rotation knows or cares whether you changed it with cube.rotation.y += 0.01 inside your own animate function or let GSAP's ticker change it on your behalf. Either way, the value is just sitting on the Object3D by the time the next updateMatrixWorld traversal runs, and it gets folded into that object's world matrix like any other number. The render loop doesn't drive the animation; it only drives redrawing. What actually moves the frame-to-frame picture is whatever mutated the scene graph in between two render calls.
Where this goes next
Everything above assumed each mesh costs roughly one draw call and didn't ask what that costs, or how many you can afford. That's the real bottleneck in most 3D scenes — not triangle count, but the sheer number of times the CPU has to tell the GPU "draw this." Draw calls: the real performance model picks up exactly where the render loop's per-mesh draw step left off.
Go deeper
- Three.js manual — The Scene Graph — The official walkthrough of Object3D parenting, with the same sun/planet/moon composition example built out interactively.
- Three.js docs — Object3D — The base class reference for position/quaternion/scale, matrix, matrixWorld, and matrixAutoUpdate — the exact properties this lesson describes.
- MDN — requestAnimationFrame — The one-shot, timestamp-carrying primitive the render loop is built directly on, shared with the animation and GSAP modules' own rAF lessons.
Check yourself
Answer out loud, as if an interviewer asked. If you hand-wave, reread that section.
- What three properties make up an Object3D's local transform, and what are they measured relative to?
- Write out the recursive rule that turns a node's local matrix into its matrixWorld. Why must the traversal happen root-first, top-down?
- In the sun/planet/moon example, why does the moon's position property never need to mention the distance from the sun?
- What matrix from the MVP-matrices lesson is a mesh's matrixWorld actually equal to, once the scene graph traversal has run?
- Why does moving a scene freeze permanently if you stop calling requestAnimationFrame, even though nothing else in the code changed?
- List, in order, the three things a single renderer.render(scene, camera) call does each time it runs.
- Explain why gsap.to(cube.rotation, {...}) can drive a Three.js animation even though GSAP knows nothing about scene graphs or matrices.