Reference

Three.js Design Patterns

Proven patterns and idioms for production Three.js applications: scene structure, memory management, responsive resize, object pooling, and animation loop organisation.

Boilerplate Setup

import * as THREE from 'https://cdn.jsdelivr.net/npm/three@0.160/build/three.module.js';

// Renderer
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); // cap at 2x
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.outputColorSpace = THREE.SRGBColorSpace;
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
document.body.appendChild(renderer.domElement);

// Scene
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x0a0a0a);

// Camera
const camera = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 0.1, 1000);
camera.position.set(0, 5, 10);

// Lights
const ambient = new THREE.AmbientLight(0xffffff, 0.5);
const sun = new THREE.DirectionalLight(0xffffff, 2);
sun.position.set(5, 10, 5);
scene.add(ambient, sun);

Responsive Resize

Use ResizeObserver on the canvas container for accurate size tracking; avoid window.onresize which fires on scroll on mobile.

// ResizeObserver approach — fires only on actual size change
const ro = new ResizeObserver(entries => {
  const { width, height } = entries[0].contentRect;
  camera.aspect = width / height;
  camera.updateProjectionMatrix();
  renderer.setSize(width, height, false); // false = don't override canvas CSS size
});
ro.observe(renderer.domElement.parentElement ?? document.body);

// Alternative: resize on canvas element itself
const ro2 = new ResizeObserver(() => {
  const w = renderer.domElement.clientWidth;
  const h = renderer.domElement.clientHeight;
  if (renderer.domElement.width !== w || renderer.domElement.height !== h) {
    renderer.setSize(w, h, false);
    camera.aspect = w / h;
    camera.updateProjectionMatrix();
  }
});
ro2.observe(renderer.domElement);

Memory Disposal

Three.js objects allocate GPU memory. Always dispose when removing objects from the scene to prevent memory leaks.

// Dispose a single mesh completely
function disposeMesh(mesh) {
  mesh.geometry.dispose();

  // Material can be shared — only dispose if not reused elsewhere
  if (Array.isArray(mesh.material)) {
    mesh.material.forEach(m => disposeMaterial(m));
  } else {
    disposeMaterial(mesh.material);
  }

  mesh.removeFromParent();
}

function disposeMaterial(mat) {
  // Dispose all texture maps
  for (const key of Object.keys(mat)) {
    const val = mat[key];
    if (val?.isTexture) val.dispose();
  }
  mat.dispose();
}

// Dispose entire scene recursively
function disposeScene(scene) {
  scene.traverse(obj => {
    if (obj.isMesh) disposeMesh(obj);
  });
}

// Dispose the renderer itself (call when unmounting)
renderer.dispose();
renderer.forceContextLoss();
Call renderer.info.memory before and after disposal to verify objects are being freed. geometries and textures counts should go down.

Object Pooling

Creating and destroying Vector3, Matrix4, and Quaternion inside hot loops triggers GC. Reuse them instead.

// Pre-allocate temporaries outside loops
const _v  = new THREE.Vector3();
const _q  = new THREE.Quaternion();
const _m  = new THREE.Matrix4();
const _box = new THREE.Box3();

function updateObjects(objects) {
  for (const obj of objects) {
    // Use _ prefixed temporaries — no allocation inside loop
    _v.set(obj.x, obj.y, obj.z);
    _box.setFromCenterAndSize(_v, _v.setScalar(obj.size));
    obj.mesh.position.copy(_v.set(obj.x, obj.y, obj.z));
  }
}

// Pool pattern for mesh instances
class MeshPool {
  constructor(geo, mat, maxSize) {
    this.im = new THREE.InstancedMesh(geo, mat, maxSize);
    this.im.count = 0;
    this._free = Array.from({length: maxSize}, (_, i) => i).reverse();
    this._active = new Set();
  }
  acquire() {
    const id = this._free.pop();
    this._active.add(id);
    this.im.count = Math.max(this.im.count, id + 1);
    return id;
  }
  release(id) {
    this._active.delete(id);
    this._free.push(id);
    // Hide the instance by sending it off-screen
    _m.makeTranslation(99999, 0, 0);
    this.im.setMatrixAt(id, _m);
    this.im.instanceMatrix.needsUpdate = true;
  }
}

Animation Loop

let lastTime = 0;

function tick(now) {
  const dt = Math.min((now - lastTime) / 1000, 0.1); // seconds, capped at 100ms
  lastTime = now;

  // 1. Update systems (physics, particles, animations)
  updatePhysics(dt);
  updateParticles(dt);
  controls.update();   // OrbitControls damping

  // 2. Render
  renderer.render(scene, camera);

  // 3. Stats (dev mode only)
  // stats.update();
}

// Three.js animation loop (handles XR too)
renderer.setAnimationLoop(tick);

// To stop:
// renderer.setAnimationLoop(null);
Prefer renderer.setAnimationLoop() over requestAnimationFrame() — it is WebXR-compatible and automatically calls the callback at the display refresh rate.

Performance Idioms

Mark buffers dirty only when changed
Three.js does not know when you mutate attribute arrays. Tell it explicitly.
geo.attributes.position.needsUpdate = true;
geo.attributes.color.needsUpdate = true;
geo.computeBoundingSphere();   // needed for correct frustum culling
Share geometries and materials
Multiple meshes can share the same BufferGeometry and Material instance — Three.js handles instanced uniforms. Sharing reduces GPU state switches.
const geo = new THREE.BoxGeometry();
const mat = new THREE.MeshStandardMaterial({ color: 0xff0000 });
for (let i = 0; i < 1000; i++) {
  scene.add(new THREE.Mesh(geo, mat)); // shared geo + mat
}
frustumCulled flag for particle systems
Large particle Points objects may be incorrectly culled if the bounding sphere is stale. Set mesh.frustumCulled = false or always call computeBoundingSphere() after updates.
points.frustumCulled = false; // disable culling entirely for dynamic particles
Texture power-of-2 and mipmap generation
Non-power-of-2 textures cannot use mipmapping in WebGL 1. In WebGL 2 they work but still generate power-of-2 allocation internally. Set generateMipmaps = false for data textures.
const dataTexture = new THREE.DataTexture(buffer, 200, 100);
dataTexture.type = THREE.FloatType;
dataTexture.format = THREE.RGBAFormat;
dataTexture.minFilter = THREE.LinearFilter;
dataTexture.generateMipmaps = false; // not needed for data / simulation textures
dataTexture.needsUpdate = true;