Skip to main content
graphwiz.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);
}

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.