AETumi — AI-native 3D web platform for Three.js, WebGL and interactive websites
News & Guides

Three.js scene architecture for production websites

September 9, 2026 · AETumi

Live · Three.js r160 · scene graph
Your browser could not start WebGL, so this interactive scene is showing its static description instead: labelled node boxes connected by lines into a scene-graph tree — a rotating root group whose world, camera, rig, mesh, light, core and satellite nodes all inherit its transform, with one branch highlighted at a time.
Live hero — a Three.js scene graph as labelled node boxes wired into a parent → child tree; the root group rotates and every descendant node and connector moves with it. Hover a node to light its subtree.

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.

The scene graph as a transform hierarchyAETumi technical diagram — a tree rooted at Scene, branching to Camera, a World group and a Rig group; World holds a Mesh and a Light, Rig holds a Core that holds a Satellite, showing that transforms inherit down the tree.Scene (root)CameraWorld (Group)Rig (Group)MeshLightCore (Group)SatelliteRotate the root and everydescendant inherits the transform.
The scene graph is a transform hierarchy: parent a node and it inherits its parent’s position, rotation and scale.

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.

AETumi Expert Note — cap the pixel ratio before you optimise anything elseOn a phone with 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.
AETumi Expert Note — render on demand when the scene can be stillA hero that idles until the user interacts does not need 60 frames a second of identical output. Gate 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.

The module lifecycle loopAETumi technical diagram — a four-stage cycle: init creates the stage and graph, update runs one render per frame, resize updates camera and renderer, and dispose frees every resource, with update and resize looping while the scene is mounted.init()stage + graphupdate(t)one render / frameresize(w,h)camera + bufferdispose()free everythingloop while mounted
The lifecycle a scene module repeats: init once, update and resize while mounted, dispose on unmount.

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.

Choosing a Three.js architectureAETumi decision tree — starting from whether the app is already React, branching through whether the scene is a standalone hero and whether speed matters most, ending at React Three Fiber, a vanilla module, a wrapped web component, or a licensed template scene.yesnoyesnoyesnoApp already in React?React Three FiberSpeed to ship the priority?Licensed template sceneMany CMS pages?Wrapped web componentVanilla module
A two-question decision tree for choosing a Three.js architecture before you write any scene code.
ApproachBest forLoop ownershipTrade-off
Vanilla Three.js moduleStandalone scenes, landing heroes, static-exported sitesYou own a single rAFFull control; you write the lifecycle plumbing yourself
React Three Fiber (R3F)React apps needing components, hooks and state-driven scenesR3F owns the loop; you write componentsDeclarative and composable; adds a reconciler and React coupling
Wrapped Web ComponentDropping one scene into many non-React pages / a CMSEncapsulated inside the elementPortable and framework-agnostic; less ergonomic for complex UIs
Licensed template sceneShipping fast with a proven, tuned architectureProvided, editableFastest 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 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.

Chroma Lattice
Chroma Lattice — a structured, group-based scene from the AETumi Three.js library, built on the architecture in this guide.

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.

Browse all Three.js assets →