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
Frequently Asked Questions
What will I learn in this tutorial?
Practical Three.js performance optimization: InstancedMesh for 100k+ objects, frustum culling, LOD, BufferGeometry, Web Workers for physics, and GPU picking.
What topics are covered in this tutorial?
This tutorial covers: Profiling First: Chrome DevTools and Stats.js InstancedMesh — Draw 100k Objects in One Call Frustum Culling and Manual LOD, BufferGeometry and Attribute Streaming, Web Workers for Physics, Texture and Material Optimization, Preventing Memory Leaks, Performance Checklist.
How long does this tutorial take?
This tutorial takes approximately 20 minutes to complete.
What prerequisites do I need before starting?
This is a Intermediate – Advanced-level tutorial — no special preparation beyond basic JavaScript is assumed.