A production blueprint for three.js architecture: the scene graph, the renderer/scene/camera lifecycle, module boundaries, a single render loop, disciplined disposal, and a decision matrix for choosing an approach.
Most Three.js demos are one file that boots a renderer, spins a cube, and never unmounts. A production website is different: the 3D scene mounts and unmounts as users navigate, competes with the rest of the page for the main thread and GPU memory, and has to survive months of feature work by more than one developer. Three.js scene architecture is the set of decisions that keep that codebase predictable — what owns the render loop, where the boundary between scene code and application code sits, and how every GPU resource is released. This guide is the structure we use at AETumi, an AI-native 3D web platform, to ship Three.js scenes that stay maintainable.
What is Three.js scene architecture?
At its core a Three.js app has three long-lived objects: a WebGLRenderer that talks to the GPU, a Scene that is the root of a tree of objects, and a Camera that defines the view. Everything visible is an Object3D parented somewhere under the scene — this tree is the scene graph. Three.js architecture is how you arrange those primitives plus the four operations every scene needs — initialise, update, resize, and dispose — into modules with clear ownership. Get it right and a 3D landing page behaves like any other well-factored component; get it wrong and you inherit stutter, leaks, and code nobody wants to touch.
Why architecture matters more than the shader
The visual is rarely what breaks a 3D site. What breaks it is the second feature, the route change, and the phone with 4 GB of RAM. A scene with no ownership rules ends up with several requestAnimationFrame loops fighting each other, event listeners that outlive the canvas, and textures that are never freed. A clean three.js architecture answers three questions up front: who runs the frame, who owns each object in the scene graph, and who frees it. The rest of this article is those three answers, with code.
What you'll learn
- How to model a scene as a graph and use groups instead of manual math.
- A single-owner lifecycle for the renderer, scene and camera.
- A module contract (
init / update / resize / dispose) that scales to many scenes. - One render loop, and a disposal path that actually frees GPU memory.
- A decision matrix for vanilla Three.js vs React Three Fiber vs a wrapped component.
Model the scene as a graph, not a flat list
The most common beginner mistake is to add every mesh directly to the scene and then reposition each one by hand every frame. The scene graph exists precisely so you don't have to. Parent related objects under a THREE.Group, transform the group, and every descendant follows. This is the same idea AETumi ships in its component library — a rig group you can rotate as a unit.
// PROBLEM: you want a model and its satellites to move as one unit.
// SOLUTION: parent them under a Group and transform the group.
const world = new THREE.Group(); // a subtree you own
scene.add(world);
const rig = new THREE.Group();
world.add(rig);
const core = new THREE.Mesh(coreGeo, coreMat);
rig.add(core); // child inherits rig's transform
const moon = new THREE.Mesh(moonGeo, moonMat);
moon.position.set(2.2, 0, 0);
core.add(moon); // nested: moon orbits with core
// One line animates the whole subtree — no per-mesh bookkeeping:
rig.rotation.y = elapsed * 0.4;
Why it matters: transforms compose down the tree, so grouping turns a page of trigonometry into one rotation. It also gives you natural units to show, hide, or dispose together. When you remove world from the scene, its entire subtree comes with it.
Give the renderer, scene and camera one owner
These three objects live for the whole lifetime of the scene, so create them once, in one place, and never scatter that responsibility. The renderer holds a WebGL context — a limited resource; a browser tab that creates renderer after renderer will eventually lose the context. Centralising creation also gives you a single home for colour space, tone mapping and pixel-ratio decisions.
// A single factory owns the three long-lived roots.
function createStage(mount) {
const renderer = new THREE.WebGLRenderer({ antialias: true, powerPreference: 'high-performance' });
renderer.setPixelRatio(Math.min(devicePixelRatio || 1, 2)); // cap DPR — retina kills fill rate
renderer.setSize(mount.clientWidth, mount.clientHeight);
renderer.outputColorSpace = THREE.SRGBColorSpace;
mount.appendChild(renderer.domElement);
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(45, mount.clientWidth / mount.clientHeight, 0.1, 100);
camera.position.set(0, 1.5, 6);
return { renderer, scene, camera };
}
This factory is the boundary between "engine setup" and "your scene". Notice the pixel-ratio cap: on a high-density display, rendering at full devicePixelRatio can quadruple fill-rate cost for no visible gain — clamping to 2 is the single most reliable performance win in production three.js architecture.
Draw a hard module boundary: init, update, resize, dispose
Every scene you ship should expose the same small contract. The page doesn't need to know what is inside; it only calls four methods. This is the boundary that lets one team maintain twenty scenes, and it is exactly how AETumi's example scenes are shaped.
// A scene module is anything that satisfies this contract.
export function createSceneGraphHero(mount) {
const { renderer, scene, camera } = createStage(mount);
const root = new THREE.Group(); scene.add(root); // the rotating root of the graph
const boxGeo = new THREE.BoxGeometry(0.6, 0.6, 0.6);
const mat = () => new THREE.MeshStandardMaterial({ color: 0x7c5cff, roughness: 0.35 });
const node = (x, y, z, parent) => { // a box parented into the tree
const g = new THREE.Group(); g.position.set(x, y, z);
g.add(new THREE.Mesh(boxGeo, mat()));
parent.add(g); // child inherits parent’s transform
return g;
};
const world = node(-1.6, -0.4, 0, root); // root -> world
node(-0.8, -1.6, 0, world); // world -> mesh (grandchild)
const rig = node(1.6, -0.4, 0, root); // root -> rig
node(0.9, -1.6, 0, node(0, -1.6, 0, rig)); // rig -> core -> satellite
scene.add(new THREE.HemisphereLight(0xc9bcff, 0x1a1428, 1.1));
return {
update(t) { root.rotation.y = t * 0.3; renderer.render(scene, camera); }, // whole tree inherits
resize(w, h) { camera.aspect = w / h; camera.updateProjectionMatrix(); renderer.setSize(w, h); },
dispose() { boxGeo.dispose(); renderer.dispose(); renderer.domElement.remove(); }
};
}
Why it matters: the application layer — a React effect, a router hook, or a plain script — depends only on update / resize / dispose. You can swap the scene's internals, change the geometry, or rewrite the shader without touching a line of page code. That decoupling is the whole point of module boundaries.
Run exactly one render loop
Two requestAnimationFrame loops rendering the same scene doubles GPU work and desynchronises animation. Keep a single loop, own its handle so you can cancel it, and honour prefers-reduced-motion by drawing one representative frame instead of animating.
const reduceMotion = matchMedia('(prefers-reduced-motion: reduce)').matches;
let rafId = null;
const clock = new THREE.Clock();
function loop() {
scene.update(clock.getElapsedTime()); // the ONLY place render() is called
rafId = requestAnimationFrame(loop);
}
if (reduceMotion) {
scene.update(0); // one static frame, no coupling to time
} else {
rafId = requestAnimationFrame(loop);
}
// later, on teardown: if (rafId) cancelAnimationFrame(rafId);
Why it matters: a single, cancellable loop is what makes a scene safe to unmount. Pair it with an IntersectionObserver so the loop parks when the canvas scrolls off-screen, and the scene stops costing anything the moment it isn't visible.
See the scene graph move
The interactive hero at the top of this article is a live, working Three.js scene — labelled node boxes wired into a parent → child tree: a root scene node branching to world, camera and rig groups, with a mesh and a light under world and a satellite nested three levels deep under rig → core. Rotate the root group and every descendant node and its connecting line moves with it — that is the scene graph doing the work, not per-node math. Hover any node to light its whole subtree. The scene uses Three.js r160 over an ES-module import map, a single animation frame, gates work with an IntersectionObserver, parks on tab-hide, respects prefers-reduced-motion with one static frame, and shows a fallback where WebGL is unavailable. Here is its full source:
import * as THREE from 'three';
(function(){
const canvas = document.getElementById('ae-canvas');
const stage = document.getElementById('ae-stage');
const fallback = document.getElementById('ae-fallback');
if (!canvas || !stage) return;
let gl = null;
try { gl = canvas.getContext('webgl2') || canvas.getContext('webgl'); } catch (e) { gl = null; }
if (!gl) { if (fallback) fallback.style.display = 'grid'; return; }
const VIOLET = 0x7c5cff, LILAC = 0xa56bff, BASE_EMIT = 0x2a2150;
const reduceMotion = matchMedia('(prefers-reduced-motion: reduce)').matches;
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, powerPreference: 'high-performance' });
renderer.setPixelRatio(Math.min(devicePixelRatio || 1, 2)); // cap DPR
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x0b0a10);
const camera = new THREE.PerspectiveCamera(42, 16/10, 0.1, 100);
camera.position.set(0, 0.15, 8.6);
scene.add(new THREE.HemisphereLight(0xc9bcff, 0x141018, 1.15));
const key = new THREE.DirectionalLight(0xffffff, 1.35); key.position.set(3, 5, 4); scene.add(key);
// ---- a labelled node = a small box + a text sprite ----
const boxGeo = new THREE.BoxGeometry(0.62, 0.62, 0.62);
const nodeMeshes = [];
function makeLabel(text){
const c = document.createElement('canvas'); c.width = 256; c.height = 72;
const x = c.getContext('2d');
x.font = 'bold 30px Arial, sans-serif'; x.textAlign = 'center'; x.textBaseline = 'middle';
x.fillStyle = '#f5f4f7'; x.fillText(text, 128, 40);
const tex = new THREE.CanvasTexture(c); tex.anisotropy = 4;
const spr = new THREE.Sprite(new THREE.SpriteMaterial({ map: tex, transparent: true, depthTest: false, depthWrite: false }));
spr.scale.set(1.5, 0.42, 1); spr.position.set(0, 0.66, 0);
return spr;
}
function node(label){
const g = new THREE.Group();
const mat = new THREE.MeshStandardMaterial({ color: VIOLET, roughness: 0.35, metalness: 0.25, emissive: new THREE.Color(BASE_EMIT), emissiveIntensity: 0.4 });
const box = new THREE.Mesh(boxGeo, mat);
box.userData.node = true; box.userData.mat = mat;
g.add(box); g.add(makeLabel(label));
nodeMeshes.push(box);
return g;
}
// parent a child AND draw its connector in the parent's local space, so the
// line inherits every transform the parent receives — that IS the scene graph.
function connect(parentGroup, childGroup){
const geo = new THREE.BufferGeometry().setFromPoints([ new THREE.Vector3(0,0,0), childGroup.position.clone() ]);
parentGroup.add(new THREE.Line(geo, new THREE.LineBasicMaterial({ color: LILAC, transparent: true, opacity: 0.5 })));
parentGroup.add(childGroup);
}
// ---- build the tree: root -> {world, camera, rig}; world -> {mesh, light}; rig -> core -> satellite
const root = new THREE.Group(); scene.add(root);
const rootNode = node('scene'); rootNode.position.set(0, 1.9, 0); root.add(rootNode); // on the rotation axis
const world = node('world'); world.position.set(-2.4, -0.1, 0.2);
const camN = node('camera'); camN.position.set(0, -0.1, -1.8);
const rig = node('rig'); rig.position.set(2.4, -0.1, 0.2);
connect(rootNode, world); connect(rootNode, camN); connect(rootNode, rig);
const mesh = node('mesh'); mesh.position.set(-1.0, -1.7, 0.1);
const light = node('light'); light.position.set(0.7, -1.7, -0.3);
connect(world, mesh); connect(world, light);
const core = node('core'); core.position.set(0.2, -1.7, 0.3);
connect(rig, core);
const sat = node('satellite'); sat.position.set(1.15, -0.2, 0.0);
connect(core, sat); // deepest node: it orbits with core, which follows rig, which follows root
const subtrees = [world, camN, rig]; // top-level branches the auto-highlight cycles through
function resetAll(){
for (const m of nodeMeshes){ m.userData.mat.emissive.setHex(BASE_EMIT); m.userData.mat.emissiveIntensity = 0.4; m.scale.setScalar(1); }
}
function lightSubtree(group){
group.traverse((o)=>{ if (o.isMesh && o.userData.node){ o.userData.mat.emissive.setHex(LILAC); o.userData.mat.emissiveIntensity = 1.05; o.scale.setScalar(1.12); } });
}
// ---- hover: light the hovered node's whole subtree ----
const ray = new THREE.Raycaster(); const ndc = new THREE.Vector2(); let hovered = null;
canvas.addEventListener('pointermove', (ev)=>{
const r = canvas.getBoundingClientRect();
ndc.x = ((ev.clientX - r.left)/r.width)*2 - 1;
ndc.y = -((ev.clientY - r.top)/r.height)*2 + 1;
ray.setFromCamera(ndc, camera);
const hit = ray.intersectObjects(nodeMeshes, false)[0];
hovered = hit ? hit.object.parent : null; // the node group
}, { passive: true });
canvas.addEventListener('pointerleave', ()=>{ hovered = null; }, { passive: true });
function size(){
const w = stage.clientWidth, h = stage.clientHeight;
renderer.setSize(w, h, false);
camera.aspect = w / h; camera.updateProjectionMatrix();
}
size();
const clock = new THREE.Clock();
let rafId = null, visible = true, autoIdx = 0, lastSwap = 0;
function draw(t){
root.rotation.y = t * 0.32; // rotate the ROOT — the whole tree inherits it
root.rotation.x = Math.sin(t * 0.35) * 0.09;
resetAll();
if (hovered){ lightSubtree(hovered); }
else {
if (t - lastSwap > 2.3){ lastSwap = t; autoIdx = (autoIdx + 1) % subtrees.length; }
lightSubtree(subtrees[autoIdx]);
}
renderer.render(scene, camera); // the ONLY render() call
}
function frame(){ draw(clock.getElapsedTime()); rafId = requestAnimationFrame(frame); }
function start(){ if (rafId == null && visible && !document.hidden) rafId = requestAnimationFrame(frame); }
function stop(){ if (rafId != null){ cancelAnimationFrame(rafId); rafId = null; } }
if (reduceMotion){ draw(0.6); } // one representative static frame, no animation
else {
const io = new IntersectionObserver((es)=>{ visible = es[0].isIntersecting; visible ? start() : stop(); }, { threshold: 0.05 });
io.observe(stage);
document.addEventListener('visibilitychange', ()=>{ document.hidden ? stop() : start(); });
start();
}
addEventListener('resize', size, { passive: true });
})();
Free GPU memory: dispose is not optional
JavaScript garbage-collects objects, but it cannot free the GPU-side buffers behind a geometry, material, texture or render target. If you unmount a scene without calling dispose() on those resources, the memory stays allocated — navigate between three routes and you can exhaust it. The disciplined pattern is to traverse the scene graph and dispose everything you created, then the renderer.
function disposeScene(scene, renderer) {
scene.traverse((obj) => {
if (obj.geometry) obj.geometry.dispose();
const mats = Array.isArray(obj.material) ? obj.material : [obj.material];
for (const m of mats) {
if (!m) continue;
for (const k in m) { // free any texture-valued property
const v = m[k];
if (v && v.isTexture) v.dispose();
}
m.dispose();
}
});
renderer.dispose(); // release the WebGL context's own caches
renderer.domElement.remove();
}
Why it matters: this is the difference between a 3D site that survives a browsing session and one that crashes the tab. Treat dispose() as the mirror image of init() — anything you create in setup, you release in teardown, in the same module.
devicePixelRatio of 3, an uncapped renderer draws nine times the pixels of a logical 1× buffer. Clamp with renderer.setPixelRatio(Math.min(devicePixelRatio, 2)) and you recover most of the frame budget instantly — no shader rewrite, no LOD system. Measure with a rolling FPS average and step the ratio down further on sustained drops before you reach for heavier techniques.render() behind a needsRender flag set by controls, resize and transitions, and park the loop when the scene settles. Our interactive viewer does exactly this: the loop stops itself at rest and wakes on interaction, which is meaningful battery on mobile.Production trade-offs: performance, accessibility, mobile, SEO
3D is a cost you choose to pay, so architect for the visitor who can't or won't pay it. Performance: load the Three.js bundle after first paint so it never blocks the initial render, and split it out of the critical path. Accessibility: give the canvas a role="img" and a real aria-label, honour reduced-motion, and make any control a native focusable element. Mobile: cap DPR, prefer cheaper materials, and pause the loop off-screen. SEO: a canvas is invisible to crawlers, so the meaningful content and headings must exist as real HTML — the 3D scene is an enhancement layered on top, never the only source of text.
Decision matrix: which architecture for which project
There is no single right answer — the right three.js architecture depends on who owns the render loop and how the rest of your app manages state. The decision tree below narrows it in two questions, and the matrix that follows lays out the trade-offs.
| Approach | Best for | Loop ownership | Trade-off |
|---|---|---|---|
| Vanilla Three.js module | Standalone scenes, landing heroes, static-exported sites | You own a single rAF | Full control; you write the lifecycle plumbing yourself |
| React Three Fiber (R3F) | React apps needing components, hooks and state-driven scenes | R3F owns the loop; you write components | Declarative and composable; adds a reconciler and React coupling |
| Wrapped Web Component | Dropping one scene into many non-React pages / a CMS | Encapsulated inside the element | Portable and framework-agnostic; less ergonomic for complex UIs |
| Licensed template scene | Shipping fast with a proven, tuned architecture | Provided, editable | Fastest path; you adapt an existing structure rather than invent one |
A useful rule: if your product is already React, reach for React Three Fiber so the scene graph lives inside your component tree; if the 3D is a self-contained hero on an otherwise conventional page, a vanilla module with the contract above is smaller and simpler. When you want the tuned lifecycle without writing it, start from an AETumi library scene and edit the content layer.
Technical proof: the pattern in real repositories
Every pattern above ships in the open on the AETumiApp GitHub organisation, so you can read the exact implementation rather than take our word for it:
- The single-owner renderer/scene/camera factory, on-demand render loop and strict
dispose()are implemented in threejs-product-viewer / examples/product-viewer.html — including the loop that parks itself at rest and disposes controls, listeners, geometries, materials and the renderer onpagehide. - The scroll-driven single-loop pattern with a reduced-motion static frame and full teardown is in threejs-scroll-animation / examples/scroll-scene.html, where one normalised scroll value drives the scene and rendering is coalesced through
requestAnimationFrame. - The reusable scene-module shape — group-based graph, adaptive quality, and disposal of geometries, materials, environment map and renderer — is in aetumi-3d-components / examples/product-showcase.html, with usage notes in the repo GUIDE.md.
The article explains the why; the repositories are the runnable proof. Each example is a self-contained HTML file using Three.js r160 over a jsDelivr import map — the same setup as the live demo above.
How AETumi approaches Three.js architecture
Every scene in the AETumi library is built to the contract in this article: a single owner for the renderer, scene and camera; a group-based scene graph; one render loop that respects reduced-motion and visibility; and a disposal path that frees every GPU resource. That is what makes the scenes safe to drop into a production site and safe to unmount on navigation. You get the tuned architecture as a starting point and edit the content layer — copy, colours, models — without rewriting the lifecycle. Browse them on the AETumi Three.js hub or read the deeper WebGL and React Three Fiber guides.
FAQ
What is Three.js scene architecture? It is how you organise the three long-lived objects — WebGLRenderer, Scene, Camera — the scene graph of parented nodes, the boundary between scene modules and application code, a single render loop, and the disposal path that frees GPU memory. Good architecture makes a 3D site predictable to extend, performant, and safe to unmount.
Should I use vanilla Three.js or React Three Fiber? Use vanilla Three.js when you want full control of the render loop and a small dependency surface; use React Three Fiber when your app is already React and you want components and hooks to manage the scene graph. Both wrap the same renderer, scene and camera — the choice is about who owns the loop.
How do I prevent memory leaks in a Three.js website? Give each scene module one dispose() that cancels the animation frame, disconnects observers, removes listeners, and calls dispose() on every geometry, material, texture and render target it created, then on the renderer. Traverse the graph to reach nested resources — Three.js does not free GPU memory for you.
Where should the render loop live? In exactly one place. One cancellable requestAnimationFrame loop is the only site that should call renderer.render(), so animation stays in sync and the scene can be paused off-screen and stopped cleanly on unmount.
Does 3D hurt SEO? Only if the meaningful content lives inside the canvas. Keep headings and copy as real server-rendered HTML and treat the Three.js scene as an enhancement, and a 3D site indexes like any other page. Read the AETumi docs for the full pattern.
Conclusion & next step
Solid three.js architecture is not about clever shaders — it is about ownership. One factory for the renderer, scene and camera; a group-based scene graph; a clear init / update / resize / dispose boundary; a single render loop; and disciplined disposal. Get those five right and a 3D website stays fast, accessible and maintainable as it grows. Start from a scene that already follows them, adapt the content, and ship.
� All guides · Browse AETumi 3D components · See the Three.js library
More from the AETumi library
Real, production-ready scenes — preview the motion, grab the source.

