name: threejs-performance
description: Optimizes Three.js performance — instancing, draw call reduction, LOD, frustum culling, geometry merging, texture compression, Stats.js profiling, and GPU debugging. Use when the user asks about performance, FPS, draw calls, instancing, optimization, lag, stuttering, or profiling a Three.js scene. Trigger keywords: performance, fps, draw calls, instancing, InstancedMesh, LOD, profiling, optimization, Stats.
Three.js Performance
Profiling First
import Stats from "three/addons/libs/stats.module.js";
const stats = new Stats();
stats.showPanel(0);
document.body.appendChild(stats.dom);
function animate() {
stats.begin();
stats.end();
}
Open Chrome DevTools → Performance or install Spector.js for GPU frame capture.
renderer.info exposes draw call counts:
console.log(renderer.info.render);
console.log(renderer.info.memory);
Instancing (biggest win)
const count = 10000;
const geo = new THREE.SphereGeometry(0.1, 8, 4);
const mat = new THREE.MeshStandardMaterial({ color: 0xff8800 });
const mesh = new THREE.InstancedMesh(geo, mat, count);
mesh.castShadow = true;
scene.add(mesh);
const dummy = new THREE.Object3D();
for (let i = 0; i < count; i++) {
dummy.position.set(
(Math.random() - 0.5) * 20,
Math.random() * 5,
(Math.random() - 0.5) * 20,
);
dummy.rotation.set(Math.random() * Math.PI, Math.random() * Math.PI, 0);
dummy.scale.setScalar(0.5 + Math.random());
dummy.updateMatrix();
mesh.setMatrixAt(i, dummy.matrix);
}
mesh.instanceMatrix.needsUpdate = true;
mesh.setColorAt(i, new THREE.Color(Math.random(), 0.5, 0.5));
mesh.instanceColor.needsUpdate = true;
function animate() {
mesh.getMatrixAt(i, dummy.matrix);
dummy.matrix.decompose(dummy.position, dummy.quaternion, dummy.scale);
dummy.rotation.y += delta;
dummy.updateMatrix();
mesh.setMatrixAt(i, dummy.matrix);
mesh.instanceMatrix.needsUpdate = true;
}
Geometry Merging (static scenes)
import { mergeGeometries } from "three/addons/utils/BufferGeometryUtils.js";
const geos = objects.map((obj) => {
const g = obj.geometry.clone();
g.applyMatrix4(obj.matrixWorld);
return g;
});
const merged = mergeGeometries(geos, true);
const mesh = new THREE.Mesh(merged, materials);
scene.add(mesh);
LOD (Level of Detail)
const lod = new THREE.LOD();
const highGeo = new THREE.SphereGeometry(1, 64, 32);
const midGeo = new THREE.SphereGeometry(1, 16, 8);
const lowGeo = new THREE.SphereGeometry(1, 6, 3);
const mat = new THREE.MeshStandardMaterial();
lod.addLevel(new THREE.Mesh(highGeo, mat), 0);
lod.addLevel(new THREE.Mesh(midGeo, mat), 15);
lod.addLevel(new THREE.Mesh(lowGeo, mat), 50);
scene.add(lod);
function animate() {
lod.update(camera);
renderer.render(scene, camera);
}
Frustum Culling
Three.js does frustum culling automatically. Make sure:
mesh.frustumCulled = true;
geo.computeBoundingSphere();
geo.computeBoundingBox();
For InstancedMesh, culling applies to the whole mesh. Use custom culling per-instance for large sparse sets.
Texture Optimization
import { KTX2Loader } from "three/addons/loaders/KTX2Loader.js";
const ktx2Loader = new KTX2Loader()
.setTranscoderPath("/basis/")
.detectSupport(renderer);
texture.generateMipmaps = true;
texture.minFilter = THREE.LinearMipmapLinearFilter;
texture.anisotropy = renderer.capabilities.getMaxAnisotropy();
texture.dispose();
Material & Draw Call Tips
const sharedMat = new THREE.MeshStandardMaterial({ color: 0xff0000 });
mesh1.material = sharedMat;
mesh2.material = sharedMat;
const uiMat = new THREE.MeshBasicMaterial({ map: texture, transparent: true });
mat.alphaTest = 0.5;
mat.transparent = false;
Renderer Settings for Performance
renderer.shadowMap.enabled = false;
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 1.5));
renderer.dispose();
renderer.forceContextLoss();
Common Gotchas
- Each unique material = one shader program = overhead at first render
mesh.visible = false still costs a draw call check — use scene.remove(mesh) for long-lived invisible objects
- Avoid updating
instanceMatrix every frame if only some instances change
- Skinned meshes (with bones) can't be instanced with standard InstancedMesh — use custom approach
renderer.info.render.calls resets each frame — read it after render, not before