Three.js Performance Optimization — Rendering Tips and Best Practices
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
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