Skip to main content

Three.js Performance: How to Optimize 3D Models for 60fps

Your Three.js scene looks incredible in development. Then you test on a laptop and watch the framerate drop to 15fps. On mobile, it's a slideshow. The client demo is tomorrow.

This is the reality of WebGL development. The gap between "works on my machine" and "works everywhere" is filled with optimization work that most tutorials skip over.

Here's how to diagnose performance issues and fix them systematically.

Diagnosing the Problem

Before optimizing, you need to know what's actually slow. Add stats.js to see real-time performance metrics:

import Stats from 'three/examples/jsm/libs/stats.module.js';

const stats = new Stats();
document.body.appendChild(stats.dom);

function animate() {
    stats.begin();
    // your render loop
    renderer.render(scene, camera);
    stats.end();
    requestAnimationFrame(animate);
}

This shows you three critical numbers:

  • FPS - Frames per second. Target: 60. Minimum acceptable: 30.
  • MS - Milliseconds per frame. Under 16ms = 60fps. Under 33ms = 30fps.
  • MB - Memory usage. Watch for leaks (constantly increasing).

Chrome DevTools Performance Tab

For deeper analysis, use Chrome DevTools:

  1. Open DevTools (F12)
  2. Go to Performance tab
  3. Click Record, interact with your scene, click Stop
  4. Look for long frames in the timeline

This reveals whether you're CPU-bound (JavaScript taking too long) or GPU-bound (too much geometry/shaders).

Three.js Renderer Info

Check what's actually being rendered:

console.log(renderer.info);
// Shows:
// - render.calls (draw calls per frame)
// - render.triangles (triangles per frame)
// - memory.geometries
// - memory.textures

High draw calls (over 100) or triangle counts (over 1 million) are red flags.

The Four Performance Killers

In order of impact, these are what actually slow down Three.js scenes:

1. Draw Calls

Every mesh with a unique material requires a separate "draw call" to the GPU. Each draw call has overhead - context switching, state changes, buffer binding.

Draw Calls Performance Target Device
< 50 Excellent All devices
50-100 Good Desktop + modern mobile
100-200 Acceptable Desktop only
> 200 Problematic High-end desktop only

Fix: Merge meshes that share materials. Use instancing for repeated objects. Combine textures into atlases.

import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js';

// Before: 100 draw calls
const meshes = objects.map(obj => obj.geometry);

// After: 1 draw call
const mergedGeometry = mergeGeometries(meshes);
const mergedMesh = new THREE.Mesh(mergedGeometry, sharedMaterial);

2. Polygon Count

Every triangle needs to be transformed, lit, and rasterized. More triangles = more work.

Total Triangles Performance Use Case
< 100K Fast everywhere Mobile-first, AR
100K-500K Good on desktop Product viewers, games
500K-1M Desktop only Architectural viz
> 1M Needs LOD system Film, high-end

Fix: Decimate models before loading. Use Level of Detail (LOD) to swap in simpler models at distance.

const lod = new THREE.LOD();

// High detail - close up
lod.addLevel(highDetailMesh, 0);

// Medium detail - mid range
lod.addLevel(mediumDetailMesh, 50);

// Low detail - far away
lod.addLevel(lowDetailMesh, 200);

scene.add(lod);

3. Texture Memory

Textures consume GPU memory. Exceed the GPU's VRAM and you'll see stuttering as textures swap in and out.

Texture Size Memory (RGBA) Recommendation
512 x 512 1 MB Props, distant objects
1024 x 1024 4 MB Standard for web
2048 x 2048 16 MB Hero objects only
4096 x 4096 64 MB Avoid for web

Fix: Resize textures to appropriate sizes. Use compressed texture formats (KTX2 with Basis Universal). Dispose textures when no longer needed.

// Use KTX2 loader for compressed textures
import { KTX2Loader } from 'three/examples/jsm/loaders/KTX2Loader.js';

const ktx2Loader = new KTX2Loader()
    .setTranscoderPath('/basis/')
    .detectSupport(renderer);

ktx2Loader.load('texture.ktx2', (texture) => {
    material.map = texture;
});

4. Shader Complexity

Complex materials with many texture lookups, lighting calculations, or custom shaders can bottleneck the GPU.

Fix: Use simpler materials for distant objects. Bake lighting into textures. Reduce light count (each light multiplies fragment shader work).

// Instead of MeshStandardMaterial (PBR, expensive)
const expensiveMaterial = new THREE.MeshStandardMaterial({
    map: diffuse,
    normalMap: normal,
    roughnessMap: roughness,
    metalnessMap: metalness,
    aoMap: ao
});

// Use MeshBasicMaterial for distant/simple objects
const cheapMaterial = new THREE.MeshBasicMaterial({
    map: bakedTexture // baked lighting into diffuse
});

Optimization Techniques

Frustum Culling (Free Performance)

Three.js automatically skips rendering objects outside the camera's view. Make sure it's enabled (it is by default):

mesh.frustumCulled = true; // default

For this to work efficiently, set proper bounding boxes:

geometry.computeBoundingBox();
geometry.computeBoundingSphere();

Instancing for Repeated Objects

If you have many copies of the same geometry (trees, particles, buildings), use instancing:

const geometry = new THREE.BoxGeometry(1, 1, 1);
const material = new THREE.MeshStandardMaterial();

// 1000 boxes, 1 draw call
const instancedMesh = new THREE.InstancedMesh(geometry, material, 1000);

const dummy = new THREE.Object3D();
for (let i = 0; i < 1000; i++) {
    dummy.position.set(
        Math.random() * 100,
        Math.random() * 100,
        Math.random() * 100
    );
    dummy.updateMatrix();
    instancedMesh.setMatrixAt(i, dummy.matrix);
}

scene.add(instancedMesh);

This renders 1000 boxes in a single draw call.

Draco Compression for Faster Loading

Draco compresses geometry data by 80-90%, dramatically reducing download time:

import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
import { DRACOLoader } from 'three/examples/jsm/loaders/DRACOLoader.js';

const dracoLoader = new DRACOLoader();
dracoLoader.setDecoderPath('/draco/');

const gltfLoader = new GLTFLoader();
gltfLoader.setDRACOLoader(dracoLoader);

gltfLoader.load('model.glb', (gltf) => {
    scene.add(gltf.scene);
});

Dispose Resources Properly

Memory leaks kill performance over time. Always dispose when removing objects:

function disposeObject(obj) {
    if (obj.geometry) {
        obj.geometry.dispose();
    }

    if (obj.material) {
        if (Array.isArray(obj.material)) {
            obj.material.forEach(mat => disposeMaterial(mat));
        } else {
            disposeMaterial(obj.material);
        }
    }
}

function disposeMaterial(material) {
    Object.keys(material).forEach(key => {
        if (material[key] && material[key].isTexture) {
            material[key].dispose();
        }
    });
    material.dispose();
}

Model Optimization: Manual vs Automated

The techniques above help at runtime, but the biggest gains come from optimizing models before they reach Three.js.

Manual Approach (Blender)

  1. Import model
  2. Apply Decimate modifier to reduce polygons
  3. Resize textures in image editor
  4. Export with Draco compression
  5. Test in scene
  6. Repeat until performance targets met

Time: 30-60 minutes per model.

Automated Approach (API)

curl -X POST https://webdeliveryengine.com/optimize \
  -H "Authorization: Bearer sk_your_key" \
  -F "file=@model.glb" \
  -F "mode=decimate" \
  -F "ratio=0.5" \
  --output model-optimized.glb

Time: Seconds to minutes per model (varies by file size). Includes polygon reduction, texture optimization, and Draco compression.

Integration with Build Pipeline

For projects with many models, integrate optimization into your build:

// optimize-models.js
const fs = require('fs');
const path = require('path');
const FormData = require('form-data');

async function optimizeModel(inputPath, outputPath) {
    const form = new FormData();
    form.append('file', fs.createReadStream(inputPath));
    form.append('mode', 'decimate');
    form.append('quality', '50');

    const response = await fetch('https://webdeliveryengine.com/optimize', {
        method: 'POST',
        headers: {
            'Authorization': `Bearer ${process.env.MESHOPT_API_KEY}`
        },
        body: form
    });

    const buffer = await response.arrayBuffer();
    fs.writeFileSync(outputPath, Buffer.from(buffer));
}

// Process all models in directory
const modelsDir = './src/models';
const outputDir = './public/models';

fs.readdirSync(modelsDir)
    .filter(f => f.endsWith('.glb'))
    .forEach(file => {
        optimizeModel(
            path.join(modelsDir, file),
            path.join(outputDir, file)
        );
    });

Performance Checklist

Before deploying, verify:

  • Draw calls under 100 - Check renderer.info.render.calls
  • Total triangles under 500K - Use our GLB Inspector
  • No texture over 2048px - Check Texture Calculator
  • 60fps on target devices - Test on real hardware, not just your dev machine
  • Under 3 seconds load time - Compress, use Draco, lazy load
  • No memory leaks - Monitor MB counter in stats.js over time

Summary

Three.js performance problems almost always trace back to:

  1. Too many draw calls (merge meshes, use instancing)
  2. Too many polygons (decimate models)
  3. Textures too large (resize, compress)
  4. Shaders too complex (simplify materials)

Fix the models before they reach your scene, and runtime optimization becomes much easier. A 50MB unoptimized GLB will never hit 60fps no matter how clever your code is.

The best Three.js performance optimization happens before scene.add().

Optimize Models Before Loading

Get free credits to reduce polygon counts and compress textures. Your Three.js scenes will thank you.

Start Optimizing