Three.js Performance Optimization: 60fps on Any Device
XRThree.js
threejsperformanceoptimizationwebgl3d
Three.js Performance Optimization: 60fps on Any Device
Performance makes or breaks XR experiences. A dropped frame in VR causes motion sickness. Here's how to optimize Three.js for consistent 60fps.
Measure First
Stats.js
import Stats from 'three/examples/jsm/libs/stats.module.js';
const stats = new Stats();
document.body.appendChild(stats.dom);
function animate() {
stats.begin();
// render
stats.end();
requestAnimationFrame(animate);
}
Render Info
console.log(renderer.info);
// {
// memory: { geometries: 12, textures: 8 },
// render: { calls: 45, triangles: 15000, points: 0, lines: 0 }
// }
Geometry Optimization
Merge Geometries
import { mergeBufferGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js';
const geometries = [];
for (let i = 0; i < 1000; i++) {
const geo = new THREE.BoxGeometry(1, 1, 1);
geo.translate(Math.random() * 100, 0, Math.random() * 100);
geometries.push(geo);
}
const merged = mergeBufferGeometries(geometries);
const mesh = new THREE.Mesh(merged, material);
// 1000 draw calls → 1 draw call
InstancedMesh
For repeated identical objects:
const geometry = new THREE.BoxGeometry(1, 1, 1);
const material = new THREE.MeshStandardMaterial();
const count = 10000;
const mesh = new THREE.InstancedMesh(geometry, material, count);
const matrix = new THREE.Matrix4();
for (let i = 0; i < count; i++) {
matrix.setPosition(
Math.random() * 100 - 50,
Math.random() * 50,
Math.random() * 100 - 50
);
mesh.setMatrixAt(i, matrix);
}
mesh.instanceMatrix.needsUpdate = true;
scene.add(mesh);
Material Optimization
Share Materials
// Bad: 1000 materials
for (let i = 0; i < 1000; i++) {
const mat = new THREE.MeshStandardMaterial({ color: 0xff0000 });
}
// Good: 1 shared material
const sharedMat = new THREE.MeshStandardMaterial({ color: 0xff0000 });
Use Cheaper Materials
// Most expensive
MeshPhysicalMaterial
// Medium
MeshStandardMaterial
// Cheaper
MeshLambertMaterial
// Cheapest
MeshBasicMaterial
Texture Optimization
Texture Size
// Power of 2 sizes for GPU efficiency
const loader = new THREE.TextureLoader();
const texture = loader.load('texture.jpg');
texture.encoding = THREE.sRGBEncoding;
// Resize large textures
if (texture.image.width > 1024) {
// Use texture compression (KTX2, Basis)
}
Texture Atlasing
Combine multiple textures into one:
// Instead of 10 materials with 10 textures
// Use 1 material with 1 atlas texture
// Adjust UVs per object
Level of Detail (LOD)
const lod = new THREE.LOD();
const highPoly = createHighPolyMesh(); // 10,000 triangles
const mediumPoly = createMediumPolyMesh(); // 1,000 triangles
const lowPoly = createLowPolyMesh(); // 100 triangles
lod.addLevel(highPoly, 0); // Show at 0-10 distance
lod.addLevel(mediumPoly, 10); // Show at 10-50 distance
lod.addLevel(lowPoly, 50); // Show at 50+ distance
scene.add(lod);
Frustum Culling
Three.js does this automatically, but verify:
// Check if object is visible
const frustum = new THREE.Frustum();
const matrix = new THREE.Matrix4().multiplyMatrices(
camera.projectionMatrix,
camera.matrixWorldInverse
);
frustum.setFromProjectionMatrix(matrix);
if (frustum.intersectsObject(mesh)) {
// Object is visible
}
Memory Management
Dispose Pattern
function disposeObject(obj) {
if (obj.geometry) {
obj.geometry.dispose();
}
if (obj.material) {
if (Array.isArray(obj.material)) {
obj.material.forEach(m => disposeMaterial(m));
} else {
disposeMaterial(obj.material);
}
}
}
function disposeMaterial(mat) {
if (mat.map) mat.map.dispose();
if (mat.normalMap) mat.normalMap.dispose();
if (mat.roughnessMap) mat.roughnessMap.dispose();
mat.dispose();
}
Object Pooling
class ObjectPool {
constructor(createFn, maxSize = 100) {
this.pool = [];
this.createFn = createFn;
this.maxSize = maxSize;
}
get() {
return this.pool.pop() || this.createFn();
}
release(obj) {
if (this.pool.length < this.maxSize) {
obj.visible = false;
this.pool.push(obj);
} else {
disposeObject(obj);
}
}
}
Web Worker Offloading
Move heavy computation off the main thread:
// worker.js
self.onmessage = (e) => {
const { positions, velocities } = e.data;
for (let i = 0; i < positions.length; i++) {
positions[i] += velocities[i];
}
self.postMessage({ positions });
};
// main.js
const worker = new Worker('worker.js');
worker.postMessage({ positions, velocities });
worker.onmessage = (e) => {
geometry.attributes.position.array = e.data.positions;
geometry.attributes.position.needsUpdate = true;
};
Performance Targets
| Platform | Target FPS | Draw Calls | Triangles |
|---|---|---|---|
| Desktop | 60 | < 2000 | < 10M |
| Mobile | 60 | < 500 | < 1M |
| VR Standalone | 72 | < 300 | < 500K |
| Mobile VR | 72 | < 100 | < 200K |
Conclusion
Optimization is iterative. Profile, identify bottlenecks, optimize the biggest gains first. Geometry merging and instancing give the biggest improvements for typical scenes.