Skip to content

Three.js Performance Optimization — Rendering Tips and Best Practices

DodaTech Updated 2026-06-28 8 min read

In this tutorial, you will learn about Three.js Performance Optimization. We cover key concepts, practical examples, and best practices to help you master this topic.

Three.js performance optimization techniques reduce GPU and CPU load through draw call batching, geometry instancing, level-of-detail systems, and efficient resource management for smooth rendering.

What You'll Learn

By the end of this guide, you will profile Three.js performance, reduce draw calls with instancing and merged geometry, implement LOD for distant objects, optimize textures and materials, use frustum culling effectively, and build a high-performance scene.

Why Performance Matters

A scene that runs at 20fps feels sluggish and unresponsive. In Durga Antivirus Pro, the 3D threat visualization must render hundreds of network nodes at 60fps while the user pans and zooms. Performance optimization transforms a choppy demo into a polished product.

flowchart LR
    A[Profile Scene] --> B{Issue}
    B -->|Too Many Draw Calls| C[Instancing / Merge]
    B -->|Too Many Triangles| D[LOD / Simplify]
    B -->|Texture Memory| E[Atlas / Compress]
    B -->|CPU Overhead| F[Frustum Culling]
    C --> G[Re-profile]
    D --> G
    E --> G
    F --> G
    G --> H[60 FPS Target]

Profiling Performance

Always measure before optimizing.

// Built-in stats
var stats = new Stats();
stats.showPanel(0);
document.body.appendChild(stats.dom);

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

// Renderer info
console.log('Draw calls:', renderer.info.render.calls);
console.log('Triangles:', renderer.info.render.triangles);
console.log('Geometries:', renderer.info.memory.geometries);
console.log('Textures:', renderer.info.memory.textures);

Expected output: Real-time FPS display and console data showing current rendering load.

Instancing — One Draw Call for Many Objects

InstancedMesh renders many copies of the same geometry in a single draw call.

var geometry = new THREE.BoxGeometry(0.5, 0.5, 0.5);
var material = new THREE.MeshStandardMaterial({ color: 0x44aa88 });

var count = 10000;
var instancedMesh = new THREE.InstancedMesh(geometry, material, count);

var dummy = new THREE.Object3D();
var color = new THREE.Color();

for (var i = 0; i < count; i++) {
    dummy.position.set(
        (Math.random() - 0.5) * 100,
        (Math.random() - 0.5) * 100,
        (Math.random() - 0.5) * 100
    );
    dummy.rotation.set(Math.random() * Math.PI, Math.random() * Math.PI, 0);
    dummy.scale.setScalar(0.5 + Math.random() * 1.5);
    dummy.updateMatrix();
    instancedMesh.setMatrixAt(i, dummy.matrix);

    color.setHSL(Math.random(), 0.7, 0.5);
    instancedMesh.setColorAt(i, color);
}

instancedMesh.instanceMatrix.needsUpdate = true;
instancedMesh.instanceColor.needsUpdate = true;
scene.add(instancedMesh);

Expected output: 10,000 cubes rendered as a single draw call. Without instancing, this would be 10,000 separate draw calls.

Why instancing works: Instead of sending 10,000 individual meshes to the GPU, InstancedMesh sends one geometry and one material with 10,000 transformation matrices.

Level of Detail (LOD)

var lod = new THREE.LOD();

// High detail
lod.addLevel(
    new THREE.Mesh(
        new THREE.SphereGeometry(1, 64, 64),
        material
    ),
    20
);

// Medium detail
lod.addLevel(
    new THREE.Mesh(
        new THREE.SphereGeometry(1, 32, 32),
        material
    ),
    50
);

// Low detail
lod.addLevel(
    new THREE.Mesh(
        new THREE.SphereGeometry(1, 8, 8),
        material
    ),
    100
);

scene.add(lod);

Expected output: Objects farther than 20 units use the high-detail mesh. Between 20 and 50 units, medium detail. Beyond 50 units, low detail.

Geometry Merging

For static objects that share a material, merge geometries into one.

var mergedGeo = BufferGeometryUtils.mergeGeometries([
    boxGeo,
    sphereGeo.translate(2, 0, 0),
    cylinderGeo.translate(-2, 0, 0)
]);

var mergedMesh = new THREE.Mesh(mergedGeo, sharedMaterial);
scene.add(mergedMesh);

Expected output: Three separate shapes rendered as a single geometry with one draw call.

Texture Optimization

// Use texture atlasing — one large texture with many smaller images
// Each mesh uses different UV coordinates to select its tile

// Limit texture resolution
var texture = loader.load('texture.jpg');
texture.generateMipmaps = true;
texture.minFilter = THREE.LinearMipmapLinearFilter;
texture.anisotropy = Math.min(renderer.capabilities.getMaxAnisotropy(), 4);

Why atlas: One texture = one texture unit = less GPU state change. GPU texture units are limited (typically 8-16).

Common Mistakes

1. Creating New Materials per Object

Each material is a separate draw call batch. Share materials across objects that look the same.

2. Not Using BufferGeometry

Three.js still supports Geometry (deprecated). Always use BufferGeometry for GPU-friendly data layout.

3. Excessive Shadow Maps

Every shadow-casting light requires an expensive shadow map render pass. Use one directional light with shadows, and disable shadows on distant objects.

4. Leaving Animations Running for Off-screen Objects

Pause animations and updates for objects outside the camera frustum. Use object.frustumCulled = true (default) to skip render for invisible objects.

5. Overdraw — Transparent Objects on Top of Each Other

Transparent objects disable early-Z optimizations. Minimize overlapping transparent geometry.

6. High Polygon Count for Distant Objects

Use LOD for any object that appears at varying distances. A 64-segment sphere at 100 meters away wastes GPU time.

Practice Questions

Q1: What is a draw call and why does it matter? A: A draw call is a GPU command to render geometry. Each call has CPU overhead. Reducing draw calls improves performance, especially on CPU-limited scenes.

Q2: How does InstancedMesh reduce draw calls? A: It renders many instances of the same geometry in a single draw call by uploading all transformation matrices to the GPU at once.

Q3: What does LOD stand for and when should you use it? A: Level of Detail. Use it for any object that appears at varying distances from the camera — switching to lower-poly versions at distance.

Q4: What is the purpose of frustum culling? A: It prevents rendering objects outside the camera's view frustum. Three.js enables this by default on all Object3D instances.

Q5: How do you measure renderer performance? A: Use renderer.info.render.calls and renderer.info.render.triangles. Use Stats.js for FPS monitoring.

Challenge: Create a forest scene with 5000 trees. Use InstancedMesh for the trunks, merged geometry for the ground, LOD for distant trees, and profile the draw call count before and after optimizations.

FAQ

What is the maximum number of instances in InstancedMesh?

The count parameter is limited by gl.drawElementsInstanced which typically supports millions of instances. The practical limit is determined by GPU memory for matrices.

Does Three.js automatically cull invisible objects?

Yes. Each Object3D has frustumCulled = true by default. It checks the object's bounding sphere against the camera frustum each frame.

What is overdraw?

Overdraw occurs when multiple transparent fragments are drawn on the same pixel. Each layer costs GPU time. Minimize overlapping transparent geometry.

How many triangles is too many?

Modern GPUs handle 1-5 million triangles per frame at 60fps. Mobile GPUs are 5-10x slower. Profile on your target device.

Does using many textures hurt performance?

Each unique texture requires a texture unit and state change. Use texture atlases to combine many small textures into one large texture.

When should I use mergeGeometries vs InstancedMesh?

Use mergeGeometries for static, visually connected geometry. Use InstancedMesh for many copies of the same object that may need individual transforms or colors.

Try It Yourself — Performance Comparison

Build a scene that compares 10,000 individual meshes against 10,000 instanced meshes. Show FPS and draw call counts for each mode.

<!DOCTYPE html>
<html>
<head>
    <title>Performance Comparison</title>
    <style>
        body { margin: 0; overflow: hidden; font-family: sans-serif; }
        #ui { position: absolute; top: 10px; left: 10px; background: rgba(0,0,0,0.8); color: white; padding: 15px; border-radius: 8px; font-size: 13px; }
        #ui button { padding: 6px 12px; margin: 4px; cursor: pointer; }
        #stats { margin-top: 8px; font-size: 11px; color: #aaa; line-height: 1.5; }
    </style>
</head>
<body>
<div id="ui">
    <button onclick="switchMode('individual')">Individual</button>
    <button onclick="switchMode('instanced')">Instanced</button>
    <div id="stats">Mode: None | Draw Calls: 0 | Tris: 0 | FPS: 0</div>
</div>
<script type="importmap">
{
    "imports": {
        "three": "https://cdn.jsdelivr.net/npm/three@0.170.0/build/three.module.js",
        "three/addons/": "https://cdn.jsdelivr.net/npm/three@0.170.0/examples/jsm/"
    }
}
</script>
<script type="module">
import * as THREE from "three";
import { OrbitControls } from "three/addons/controls/OrbitControls.js";
import Stats from "three/addons/libs/stats.module.js";

var scene = new THREE.Scene();
scene.background = new THREE.Color(0x111122);
var camera = new THREE.PerspectiveCamera(60, innerWidth / innerHeight, 0.1, 500);
camera.position.set(20, 20, 20);
var renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(innerWidth, innerHeight);
renderer.setPixelRatio(devicePixelRatio);
document.body.appendChild(renderer.domElement);
var controls = new OrbitControls(camera, renderer.domElement);

var stats = new Stats();
stats.showPanel(0);
document.body.appendChild(stats.dom);

var ambient = new THREE.AmbientLight(0x404060);
scene.add(ambient);
var dirLight = new THREE.DirectionalLight(0xffffff, 1);
dirLight.position.set(10, 20, 10);
scene.add(dirLight);

var COUNT = 10000;
var currentGroup = null;

function createIndividual() {
    var group = new THREE.Group();
    var geo = new THREE.BoxGeometry(0.3, 0.3, 0.3);
    for (var i = 0; i < COUNT; i++) {
        var mat = new THREE.MeshStandardMaterial({ color: Math.random() * 0xffffff });
        var mesh = new THREE.Mesh(geo, mat);
        mesh.position.set(
            (Math.random() - 0.5) * 50,
            (Math.random() - 0.5) * 50,
            (Math.random() - 0.5) * 50
        );
        mesh.rotation.set(Math.random() * Math.PI, Math.random() * Math.PI, 0);
        group.add(mesh);
    }
    return group;
}

function createInstanced() {
    var geo = new THREE.BoxGeometry(0.3, 0.3, 0.3);
    var mat = new THREE.MeshStandardMaterial({ color: 0x44aa88 });
    var instanced = new THREE.InstancedMesh(geo, mat, COUNT);
    var dummy = new THREE.Object3D();
    var color = new THREE.Color();
    for (var i = 0; i < COUNT; i++) {
        dummy.position.set(
            (Math.random() - 0.5) * 50,
            (Math.random() - 0.5) * 50,
            (Math.random() - 0.5) * 50
        );
        dummy.rotation.set(Math.random() * Math.PI, Math.random() * Math.PI, 0);
        dummy.updateMatrix();
        instanced.setMatrixAt(i, dummy.matrix);
        color.setHex(Math.random() * 0xffffff);
        instanced.setColorAt(i, color);
    }
    instanced.instanceMatrix.needsUpdate = true;
    instanced.instanceColor.needsUpdate = true;
    return instanced;
}

window.switchMode = function(mode) {
    if (currentGroup) {
        scene.remove(currentGroup);
        if (currentGroup.isInstancedMesh) {
            currentGroup.geometry.dispose();
            currentGroup.material.dispose();
        } else {
            currentGroup.traverse(function(child) {
                if (child.isMesh) {
                    child.geometry.dispose();
                    child.material.dispose();
                }
            });
        }
    }
    currentGroup = mode === 'individual' ? createIndividual() : createInstanced();
    scene.add(currentGroup);
};

window.switchMode('instanced');

var statsDiv = document.getElementById('stats');
function animate() {
    stats.begin();
    var info = renderer.info;
    statsDiv.innerHTML = 'Draw Calls: ' + info.render.calls +
        ' | Tris: ' + info.render.triangles +
        ' | Geometries: ' + info.memory.geometries;
    controls.update();
    renderer.render(scene, camera);
    stats.end();
    requestAnimationFrame(animate);
}
animate();

window.addEventListener("resize", function() {
    camera.aspect = innerWidth / innerHeight;
    camera.updateProjectionMatrix();
    renderer.setSize(innerWidth, innerHeight);
});
</script>
</body>
</html>

What's Next

Learn to export Three.js scenes for sharing and deployment.

Exporting — glTF export and scene Serialization. Loading Manager — Asset loading management and progress tracking.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro