Tutorial · Three.js · WebGL · Performance
📅 March 2026 ⏱ ≈ 20 min 🎯 Intermediate – Advanced 🔧 Three.js r160

Three.js Performance Optimization — Render 100k Objects at 60 fps

The difference between a stuttering prototype and a buttery-smooth simulation usually comes down to a handful of API choices. This tutorial walks through the highest-impact optimizations: InstancedMesh, frustum culling, Level of Detail, Web Worker physics, and eliminating the most common memory leaks.

1. Profiling First

Never optimize blind. Open Chrome DevTools → Performance tab, record 3 seconds, and look for:

// Add to your animation loop during development
const info = renderer.info;
const debugEl = document.getElementById('debug');
if (debugEl) debugEl.textContent =
  `Draw calls: ${info.render.calls} | Tris: ${info.render.triangles
} | Textures: ${info.memory.textures}`;

Add Stats.js for live fps/ms/mb panel. Target: <5 ms CPU per frame on mid-range hardware.

2. InstancedMesh — One Draw Call for Thousands of Objects

The #1 performance bottleneck in Three.js scenes is too many draw calls. Each Mesh is a separate draw call. Modern GPUs can process millions of triangles per call but stall on CPU overhead at ~100–1000 separate draw calls per frame.

InstancedMesh collapses N identical objects into one draw call by putting per-instance transform matrices into a GPU buffer:

import * as THREE from 'three';

const COUNT = 100_000;
const geo  = new THREE.SphereGeometry(0.1, 6, 4); // low-poly sphere
const mat  = new THREE.MeshLambertMaterial({ color: 0x88aaff });
const mesh = new THREE.InstancedMesh(geo, mat, COUNT);
scene.add(mesh);

// Set initial transforms
const dummy = new THREE.Object3D();
for (let i = 0; i < COUNT; i++) {
  dummy.position.set(Math.random()*200-100, Math.random()*200-100, Math.random()*200-100);
  dummy.updateMatrix();
  mesh.setMatrixAt(i, dummy.matrix);
}
mesh.instanceMatrix.needsUpdate = true;

// Per-frame update (e.g. from physics):  
function updateInstances(positions) {
  for (let i = 0; i < COUNT; i++) {
    dummy.position.copy(positions[i]);
    dummy.updateMatrix();
    mesh.setMatrixAt(i, dummy.matrix);
  }
  mesh.instanceMatrix.needsUpdate = true; // upload to GPU
}
Approach 100k spheres Draw calls
Individual Meshes ~1 fps 100,000
Merged BufferGeometry 30–60 fps 1 (static only)
InstancedMesh 55–60 fps 1 (dynamic transforms)
Per-instance color: Set via mesh.setColorAt(i, color) and upload with mesh.instanceColor.needsUpdate = true. This adds a second GPU buffer but doesn't increase draw calls.

3. Frustum Culling and LOD

Frustum Culling

Three.js automatically frustum-culls individual Mesh objects — but not individual instances within an InstancedMesh. For large simulations where only part of the instances are visible, implement manual instance culling:

const frustum = new THREE.Frustum();
const matrix  = new THREE.Matrix4();
const sphere  = new THREE.Sphere();

function cullInstances(camera, positions, radius) {
  matrix.multiplyMatrices(camera.projectionMatrix, camera.matrixWorldInverse);
  frustum.setFromProjectionMatrix(matrix);

  let visibleCount = 0;
  for (let i = 0; i < positions.length; i++) {
    sphere.center.copy(positions[i]);
    sphere.radius = radius;
    if (frustum.intersectsSphere(sphere)) {
      dummy.position.copy(positions[i]);
      dummy.updateMatrix();
      mesh.setMatrixAt(visibleCount++, dummy.matrix);
    }
  }
  mesh.count = visibleCount; // render only visible instances
  mesh.instanceMatrix.needsUpdate = true;
}

Level of Detail (LOD)

const lod = new THREE.LOD();

const high   = new THREE.Mesh(new THREE.SphereGeometry(1, 32, 32), mat);
const medium = new THREE.Mesh(new THREE.SphereGeometry(1, 12, 8),  mat);
const low    = new THREE.Mesh(new THREE.SphereGeometry(1, 4,  3),  mat);

lod.addLevel(high,   0);   // 0–30 units from camera
lod.addLevel(medium, 30);  // 30–100 units
lod.addLevel(low,    100); // 100+ units
scene.add(lod);
// LOD.update(camera) is called automatically if added to scene

4. BufferGeometry and Attribute Streaming

When simulating deformable objects (cloth, fluid surface mesh), update geometry attributes directly instead of rebuilding geometry each frame:

// WRONG: rebuilding geometry every frame (allocates GPU memory constantly)
function badUpdate(vertices) {
  scene.remove(mesh);
  mesh.geometry.dispose();
  mesh.geometry = new THREE.BufferGeometry();
  mesh.geometry.setAttribute('position', new THREE.BufferAttribute(vertices, 3));
  scene.add(mesh);
}

// RIGHT: reuse buffer, set needsUpdate flag
const positions = new Float32Array(N * 3);
const attr = new THREE.BufferAttribute(positions, 3);
attr.usage = THREE.DynamicDrawUsage; // hint to GPU driver
geometry.setAttribute('position', attr);

function goodUpdate(newPositions) {
  positions.set(newPositions); // typed array copy — fast
  attr.needsUpdate = true;      // upload to GPU on next render
  geometry.computeBoundingSphere(); // for frustum culling to work
}

5. Web Workers for Physics

Physics simulation blocks the main thread. Move it to a Web Worker and communicate via SharedArrayBuffer (SAB) for zero-copy transfer:

// main.js
const N = 10_000;
const sharedBuf = new SharedArrayBuffer(N * 3 * 4); // xyz float32 per particle
const positions  = new Float32Array(sharedBuf);

const worker = new Worker('physics.worker.js');
worker.postMessage({ sharedBuf, N }); // share buffer — no copy

function animate() {
  requestAnimationFrame(animate);
  // positions[] is already updated by worker in background
  positionAttr.needsUpdate = true;
  renderer.render(scene, camera);
}

// physics.worker.js
let positions, N;
onmessage = ({ data }) => {
  positions = new Float32Array(data.sharedBuf);
  N = data.N;
  physicsLoop();
};
function physicsLoop() {
  setInterval(() => {
    for (let i = 0; i < N; i++) {
      positions[i*3+1] -= 0.01; // gravity step
    }
  }, 1000/60);
}
SharedArrayBuffer requires COOP/COEP headers: your server must send Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp. Without these headers, SharedArrayBuffer is disabled by browsers.

6. Texture and Material Optimization

7. Preventing Memory Leaks

Three.js GPU resources (geometry, materials, textures, render targets) are not automatically garbage collected. Always call .dispose():

function removeMesh(mesh) {
  scene.remove(mesh);
  mesh.geometry.dispose();         // free GPU vertex buffers
  if (mesh.material.map) mesh.material.map.dispose(); // texture
  mesh.material.dispose();          // free shader programs
}

// Render targets
renderTarget.dispose();

// When replacing renderer (e.g. in HMR / hot reload):
renderer.dispose();
renderer.forceContextLoss();

Use Chrome DevTools Memory tab — take heap snapshots before and after removing objects. If WebGLBuffer and WebGLTexture counts don't drop, you have a leak.

8. Performance Checklist