Skip to main content
graphwiz.aigraphwiz.ai
← Back to Posts

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);
}
```text

### Render Info

```javascript
console.log(renderer.info);
// {
//   memory: { geometries: 12, textures: 8 },
//   render: { calls: 45, triangles: 15000, points: 0, lines: 0 }
// }
```text

## Geometry Optimization

### Merge Geometries

```javascript
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
```text

### InstancedMesh

For repeated identical objects:

```javascript
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);
```text

## Material Optimization

### Share Materials

```javascript
// 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 });
```text

### Use Cheaper Materials

```javascript
// Most expensive
MeshPhysicalMaterial

// Medium
MeshStandardMaterial

// Cheaper
MeshLambertMaterial

// Cheapest
MeshBasicMaterial
```text

## Texture Optimization

### Texture Size

```javascript
// 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)
}
```text

### Texture Atlasing

Combine multiple textures into one:

```javascript
// Instead of 10 materials with 10 textures
// Use 1 material with 1 atlas texture
// Adjust UVs per object
```text

## Level of Detail (LOD)

```javascript
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);
```text

## Frustum Culling

Three.js does this automatically, but verify:

```javascript
// 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
}
```text

## Memory Management

### Dispose Pattern

```javascript
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();
}
```text

### Object Pooling

```javascript
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);
    }
  }
}
```text

## Web Worker Offloading

Move heavy computation off the main thread:

```javascript
// 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;
};
```text

## 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.