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:
- Long frames (>16.7 ms) — expand to see JavaScript vs GPU time
- Renderer.info — print this every frame during development
// 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) |
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);
}
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
- Use power-of-2 textures (512×512, 1024×1024) — non-PoT textures disable mipmapping and waste memory
-
Set
texture.generateMipmaps = falsefor render targets or textures only shown at one size -
Use KTX2 / ETC1S / UASTC compressed textures via
KTX2Loader— 4–8× smaller on GPU (uses GPU texture compression, not lossy JPEG — stays compressed in VRAM) -
Use
MeshLambertMaterialinstead ofMeshStandardMaterialfor non-raytrace-quality scenes — Lambert skips the expensive PBR BRDF computation (2× faster lighting pass) -
Share materials:
const mat = new MeshLambertMaterial()once; assign to all instances — materials are uploaded once to GPU
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
- ☐ Profile with Chrome DevTools before optimizing
-
☐ Replace N individual
Meshes with oneInstancedMesh -
☐ Use
MeshLambertMaterialinstead ofMeshStandardMaterialwhere PBR not needed -
☐ Reuse
BufferGeometrywithneedsUpdate = trueinstead of recreating -
☐ Add
DynamicDrawUsagehint for frequently updated attributes - ☐ Implement LOD for scene objects beyond 50 units
- ☐ Frustum-cull instances manually for large instanced meshes
- ☐ Move physics/particle updates to Web Worker + SharedArrayBuffer
- ☐ Dispose geometry, material, texture on object removal
- ☐ Use power-of-2 texture dimensions; consider KTX2 compression
-
☐ Keep draw calls <100 per frame (check
renderer.info) - ☐ Target <5 ms CPU + <8 ms GPU per frame on mid-range hardware