Tutorial
⏱️ ~45 minutes 🎓 Intermediate 🛠️ Three.js · JavaScript · GLSL

Simulation Performance Optimization

Browser simulations often start fast—then slow down as objects multiply. This tutorial covers practical techniques to sustain 60 fps: InstancedMesh for thousands of objects, TypedArrays to reduce GC pressure, spatial hashing for broad-phase collision, Web Workers to move physics off the main thread, and GPU-side tricks.

Know Your Bottleneck First

Before optimising, measure. Chrome DevTools Performance panel and the three.js Stats helper tell you whether you are CPU-bound or GPU-bound:

import Stats from 'https://cdn.jsdelivr.net/npm/three@0.168/examples/jsm/libs/stats.module.js';
const stats = new Stats();
stats.showPanel(0); // 0 = fps, 1 = ms, 2 = mb
document.body.appendChild(stats.dom);

// In animate():
stats.begin();
renderer.render(scene, camera);
stats.end();
Symptom Likely bottleneck Fix direction
JS frame time > 10 ms CPU — JS physics / updates TypedArrays, WASM, Web Workers
Many draw calls (>500) CPU — render thread batching InstancedMesh, merge geometry
High GPU usage, low CPU GPU — fragment complexity Reduce shader cost, lower resolution
GC spikes (ms chart jitters) JS heap allocations Object pooling, TypedArrays

InstancedMesh — One Draw Call per Object Type

Rendering 10,000 separate Mesh objects = 10,000 draw calls. InstancedMesh renders all of them in one draw call:

const COUNT = 10_000;
const geo  = new THREE.SphereGeometry(0.1, 8, 8);
const mat  = new THREE.MeshStandardMaterial({ color: 0x2299ff });
const iMesh = new THREE.InstancedMesh(geo, mat, COUNT);
scene.add(iMesh);

const dummy = new THREE.Object3D();
const positions = new Float32Array(COUNT * 3); // x,y,z per instance

// Initialize positions
for (let i = 0; i < COUNT; i++) {
  positions[i*3+0] = (Math.random() - 0.5) * 20;
  positions[i*3+1] = (Math.random() - 0.5) * 20;
  positions[i*3+2] = (Math.random() - 0.5) * 20;
}

function updateInstances() {
  for (let i = 0; i < COUNT; i++) {
    dummy.position.set(positions[i*3], positions[i*3+1], positions[i*3+2]);
    dummy.updateMatrix();
    iMesh.setMatrixAt(i, dummy.matrix);
  }
  iMesh.instanceMatrix.needsUpdate = true; // ← required!
}

Only set needsUpdate = true when the data actually changed. Unnecessary updates cause a CPU→GPU buffer upload every frame.

TypedArrays — Eliminate GC Pauses

Regular JavaScript objects ({ x, y, z }) per particle = many heap allocations = GC pauses at the worst moment. Use Float32Array (or Float64Array) instead — the memory is contiguous and never garbage collected:

// ❌ Object array — GC pressure
const particles = Array.from({ length: 10000 }, () => ({
  x: Math.random(), y: Math.random(), z: Math.random(),
  vx: 0, vy: 0, vz: 0,
}));

// ✅ TypedArray SoA (Structure of Arrays) — no GC, cache-friendly
const N = 10_000;
const px = new Float32Array(N), py = new Float32Array(N), pz = new Float32Array(N);
const vx = new Float32Array(N), vy = new Float32Array(N), vz = new Float32Array(N);

// Physics update — no object allocation
for (let i = 0; i < N; i++) {
  vx[i] += 0; // gravity, forces...
  vy[i] -= 9.8 * dt;
  px[i] += vx[i] * dt;
  py[i] += vy[i] * dt;
  pz[i] += vz[i] * dt;
}

SoA (Structure of Arrays) is more cache-friendly than AoS (Array of Structures) because the loop processes one property of all particles at a time, which matches how CPU cache lines work.

Spatial Hashing for O(1) Neighbour Lookup

Naïve collision detection is O(N²) — every particle checks every other. Spatial hashing reduces it to ~O(N) for uniform particle distributions:

class SpatialHash {
  constructor(cellSize) {
    this.cellSize = cellSize;
    this.table = new Map();
  }
  _key(x, y, z) {
    const cx = Math.floor(x / this.cellSize);
    const cy = Math.floor(y / this.cellSize);
    const cz = Math.floor(z / this.cellSize);
    return `${cx},${cy},${cz}`;
  }
  clear() { this.table.clear(); }
  insert(i, x, y, z) {
    const k = this._key(x, y, z);
    if (!this.table.has(k)) this.table.set(k, []);
    this.table.get(k).push(i);
  }
  query(x, y, z) {
    // Returns indices of particles in same and adjacent cells
    const result = [];
    const cx = Math.floor(x / this.cellSize);
    const cy = Math.floor(y / this.cellSize);
    const cz = Math.floor(z / this.cellSize);
    for (let dx = -1; dx <= 1; dx++)
    for (let dy = -1; dy <= 1; dy++)
    for (let dz = -1; dz <= 1; dz++) {
      const k = `${cx+dx},${cy+dy},${cz+dz}`;
      const cell = this.table.get(k);
      if (cell) result.push(...cell);
    }
    return result;
  }
}

// Usage: cell size = 2× particle radius
const hash = new SpatialHash(0.2);
// Each frame: 1) clear 2) insert all 3) query neighbours
hash.clear();
for (let i = 0; i < N; i++) hash.insert(i, px[i], py[i], pz[i]);
for (let i = 0; i < N; i++) {
  const neighbours = hash.query(px[i], py[i], pz[i]);
  // check collision only with neighbours (small set)
}

Fixed Timestep + Web Worker Physics

Physics should run at a fixed step (e.g. 1/120 s) independent of rendering frame rate. Offload to a Web Worker so physics doesn't block the render:

// main.js
const PHYSICS_STEP = 1 / 120;
let accumulator = 0;

// Worker for physics
const worker = new Worker('./physics-worker.js');
const posBuffer = new SharedArrayBuffer(N * 3 * 4); // Float32
const positions = new Float32Array(posBuffer);

worker.postMessage({ type: 'init', buffer: posBuffer, count: N });

// Render loop — just reads the shared buffer
function animate(t) {
  requestAnimationFrame(animate);
  // Only reads — no locking needed for loose sync
  updateInstancesFromBuffer(positions);
  renderer.render(scene, camera);
}

// physics-worker.js
self.onmessage = ({ data }) => {
  if (data.type !== 'init') return;
  const pos = new Float32Array(data.buffer);
  const vel = new Float32Array(data.count * 3);
  const dt = 1 / 120;
  setInterval(() => {
    for (let i = 0; i < data.count; i++) {
      vel[i*3+1] -= 9.8 * dt;
      pos[i*3+0] += vel[i*3+0] * dt;
      pos[i*3+1] += vel[i*3+1] * dt;
      pos[i*3+2] += vel[i*3+2] * dt;
      if (pos[i*3+1] < 0) { pos[i*3+1] = 0; vel[i*3+1] *= -0.7; }
    }
  }, dt * 1000);
};

SharedArrayBuffer requires Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp headers. For simpler cases use postMessage with a regular ArrayBuffer transferable (zero-copy).

Frustum Culling and LOD

Three.js does frustum culling automatically for individual Mesh objects. For instanced meshes, culling is per-draw-call (either all or nothing). Manual per-instance culling:

const frustum = new THREE.Frustum();
const projScreen = new THREE.Matrix4();

function cullInstances(iMesh, positions, count) {
  projScreen.multiplyMatrices(camera.projectionMatrix, camera.matrixWorldInverse);
  frustum.setFromProjectionMatrix(projScreen);

  const sphere = new THREE.Sphere();
  let visibleCount = 0;

  for (let i = 0; i < count; i++) {
    sphere.center.set(positions[i*3], positions[i*3+1], positions[i*3+2]);
    sphere.radius = 0.1; // bounding radius
    if (frustum.intersectsSphere(sphere)) {
      // Copy matrix to visible slot
      iMesh.getMatrixAt(i, dummy.matrix);
      iMesh.setMatrixAt(visibleCount++, dummy.matrix);
    }
  }
  iMesh.count = visibleCount; // only render visible instances
  iMesh.instanceMatrix.needsUpdate = true;
}

For complex scenes, Three.js has built-in LOD (Level of Detail) — swap to simpler geometry when far from the camera:

const lod = new THREE.LOD();
lod.addLevel(new THREE.Mesh(highPoly, mat), 0);    // <10 units away
lod.addLevel(new THREE.Mesh(midPoly,  mat), 10);   // 10–50 units
lod.addLevel(new THREE.Mesh(lowPoly,  mat), 50);   // >50 units
scene.add(lod);

Things to Avoid

Continue Learning

Frequently Asked Questions

What will I learn in this tutorial?

Techniques to hit 60 fps in browser simulations: InstancedMesh, spatial hashing, TypedArrays, frustum culling, web workers and GPU readback avoidance.

What topics are covered in this tutorial?

This tutorial covers: Know your bottleneck first, InstancedMesh — one draw call per object type 3. TypedArrays — eliminate GC pauses, Spatial hashing for O(1) neighbour lookup, Fixed timestep + Web Worker physics, Frustum culling and LOD, Things to avoid.

What tools and technologies does this tutorial use?

This tutorial uses Three.js, JavaScript, GLSL.

How long does this tutorial take?

This tutorial takes approximately 45 minutes to complete.

What prerequisites do I need before starting?

This is a Intermediate-level tutorial — no special preparation beyond basic JavaScript is assumed.

🛠

Experiment in Playground

Benchmark your optimisations — write and run Three.js code directly in the browser.

Open Playground → View Simulation ↗