Loading...
Loading...
Measuring and optimizing three.js speed: render statistics, memory counters, fewer draw calls via merging, instancing, batching and LOD, fill rate, shadows, CPU work per frame, shader stalls and GPU compute for particles, WebGL or WebGPU with TSL. Use when a scene is slow, janks or exceeds memory.
npx skill4agent add cloudai-x/threejs-skills threejs-performanceimport * as THREE from "three";
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(window.devicePixelRatio);
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);
const scene = new THREE.Scene();
scene.add(new THREE.HemisphereLight(0xffffff, 0x445566, 1));
const sun = new THREE.DirectionalLight(0xffffff, 2);
sun.position.set(3, 6, 4);
scene.add(sun);
const camera = new THREE.PerspectiveCamera(50, window.innerWidth / window.innerHeight, 0.1, 100);
camera.position.set(0, 2, 8);
const mesh = new THREE.Mesh(new THREE.SphereGeometry(1, 32, 16), new THREE.MeshStandardMaterial({ color: 0x4488ff }));
scene.add(mesh);
function animate() {
mesh.rotation.y += 0.01;
renderer.render(scene, camera);
// the counters reset at the start of every render() call, so these cover the frame just drawn
console.log(`${renderer.info.render.calls} calls, ${renderer.info.render.triangles} triangles, ${renderer.info.memory.textures} textures`);
}
renderer.setAnimationLoop(animate);renderer.inforender()function animate() {
renderer.render(scene, camera);
console.table({
calls: renderer.info.render.calls, // draw calls submitted by this render
triangles: renderer.info.render.triangles,
lines: renderer.info.render.lines,
points: renderer.info.render.points,
geometries: renderer.info.memory.geometries, // geometry buffers live on the GPU
textures: renderer.info.memory.textures,
programs: renderer.info.programs.length, // compiled shader programs in the cache
});
}
renderer.setAnimationLoop(animate);render()info.autoResetrender()render()render()callstrianglescastShadowinfo.memory.geometriesinfo.memory.texturesdispose()render()renderer.info.autoReset = false; // render() stops clearing the counters
function animate() {
renderer.render(scene, camera);
console.log(renderer.info.render.calls); // every render of this frame, summed
renderer.info.reset(); // once per frame, after the last render
}
renderer.setAnimationLoop(animate);Timerperformance.now()const timer = new THREE.Timer();
timer.connect(document); // pauses while the tab is hidden
let interval = 0;
let cpu = 0;
let frames = 0;
function animate() {
timer.update();
interval += timer.getDelta(); // seconds since the previous frame, never less than one display refresh
const start = performance.now();
renderer.render(scene, camera);
cpu += performance.now() - start; // CPU time spent in render(); the GPU executes the commands later
frames++;
if (frames === 60) {
console.log(`${((interval / frames) * 1000).toFixed(2)} ms per frame, ${(cpu / frames).toFixed(2)} ms CPU in render()`);
interval = 0;
cpu = 0;
frames = 0;
}
}
renderer.setAnimationLoop(animate);stats-gltransparentside: THREE.DoubleSideforceSinglePass: truetransmissioninfo.render.callsimport * as BufferGeometryUtils from "three/addons/utils/BufferGeometryUtils.js";
const rockGeometry = new THREE.DodecahedronGeometry(1, 0);
const transform = new THREE.Matrix4();
const parts = [];
for (let i = 0; i < 200; i++) {
const baked = rockGeometry.clone();
transform.setPosition((Math.random() - 0.5) * 40, 0, (Math.random() - 0.5) * 40);
baked.applyMatrix4(transform); // bake the placement into the vertices
parts.push(baked);
}
const merged = BufferGeometryUtils.mergeGeometries(parts); // inputs need matching attributes and index state
scene.add(new THREE.Mesh(merged, new THREE.MeshNormalMaterial())); // 200 rocks, one call — but one bounding sphere for all of themconst tree = new THREE.ConeGeometry(0.5, 2, 6);
const trees = new THREE.InstancedMesh(tree, new THREE.MeshNormalMaterial(), 500);
const placement = new THREE.Object3D();
for (let i = 0; i < 500; i++) {
placement.position.set((Math.random() - 0.5) * 40, 1, (Math.random() - 0.5) * 40);
placement.rotation.y = Math.random() * 6.28;
placement.updateMatrix();
trees.setMatrixAt(i, placement.matrix);
}
// culling tests one sphere around all instances (computed lazily on the first cull test), so all 500 draw whenever it touches the view;
// after moving instances later, set trees.instanceMatrix.needsUpdate = true and call trees.computeBoundingSphere()
scene.add(trees);const material = new THREE.MeshNormalMaterial();
const batchedMesh = new THREE.BatchedMesh(500, 20000, 20000, material);
const coneId = batchedMesh.addGeometry(new THREE.ConeGeometry(0.5, 2, 6));
const boxId = batchedMesh.addGeometry(new THREE.BoxGeometry(1, 1, 1));
const placement = new THREE.Matrix4();
for (let i = 0; i < 500; i++) {
const instanceId = batchedMesh.addInstance(i % 2 ? coneId : boxId); // a geometry renders only through an instance
placement.makeTranslation((Math.random() - 0.5) * 40, 1, (Math.random() - 0.5) * 40);
batchedMesh.setMatrixAt(instanceId, placement);
}
scene.add(batchedMesh);perObjectFrustumCulledsortObjectsWEBGL_multi_drawthreejs-geometryLODrender()autoUpdateconst detailMaterial = new THREE.MeshNormalMaterial();
const lod = new THREE.LOD();
lod.addLevel(new THREE.Mesh(new THREE.SphereGeometry(1, 64, 32), detailMaterial), 0); // distances are camera-to-object
lod.addLevel(new THREE.Mesh(new THREE.SphereGeometry(1, 16, 8), detailMaterial), 12);
lod.addLevel(new THREE.Mesh(new THREE.SphereGeometry(1, 8, 4), detailMaterial), 30);
scene.add(lod);geometry.computeBoundingSphere()mesh.frustumCulled = falseinfo.memory.texturesimport { KTX2Loader } from "three/addons/loaders/KTX2Loader.js";
const ktx2Loader = new KTX2Loader().detectSupport(renderer);
const map = await ktx2Loader.loadAsync("color.ktx2"); // a CompressedTexture for a Basis Universal file
map.colorSpace = THREE.SRGBColorSpace; // KTX2Loader already sets it from the file's transfer function; this line only matters for a color file tagged linear
mesh.material.map = map;
mesh.material.needsUpdate = true; // the material rendered without a map beforegeometry.dispose()texture.dispose()texture.clone()scene.remove(mesh);
mesh.geometry.dispose(); // frees the GPU buffers now, even if another mesh still uses this geometry
// material.dispose() releases its shader programs (deleted once no material uses them), never its textures
for (const value of Object.values(mesh.material)) {
if (value?.isTexture) value.dispose();
}
mesh.material.dispose();
mesh.dispose(); // renderer-side state kept for this one object
console.log(renderer.info.memory.textures); // the count drops as each texture freesobject.dispose()memory.geometriesmemory.textures*Sizememory.totalwidth × ratioheight × ratiorenderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));renderer.infoconst sun = new THREE.DirectionalLight(0xffffff, 2);
sun.position.set(3, 6, 4);
sun.castShadow = true;
sun.shadow.mapSize.set(1024, 1024);
scene.add(sun);
renderer.shadowMap.enabled = true;
// static scenery: draw the shadow maps once instead of every frame
renderer.shadowMap.autoUpdate = false;
renderer.shadowMap.needsUpdate = true; // render them one more time, then freeze
// per light instead of globally: sun.shadow.autoUpdate = false; sun.shadow.needsUpdate = true;info.render.callscastShadowVSMShadowMapreceiveShadowsun.shadow.camera.leftrighttopbottomsun.shadow.camera.updateProjectionMatrix()render()scene.updateMatrixWorld()matrixAutoUpdatemesh.matrixAutoUpdate = false;
mesh.updateMatrix(); // bake position/rotation/scale into mesh.matrix before the first render
// later, when this object does move:
mesh.position.set(1, 2, 3);
mesh.updateMatrix(); // manual now: the renderer no longer recomposes itintersectObjects(objects, true)const raycaster = new THREE.Raycaster();
raycaster.layers.set(1); // only objects enabled on layer 1 are tested, before any geometry work
const pickables = [];
scene.traverse((child) => {
if (child.isMesh && child.userData.pickable) {
child.layers.enable(1); // adds layer 1 while keeping layer 0 membership
pickables.push(child);
}
});
const hits = raycaster.intersectObjects(pickables, false); // an explicit list, no recursionthree-mesh-bvhconst timer = new THREE.Timer();
timer.connect(document);
const step = new THREE.Vector3(); // allocated once, reused every frame
const target = new THREE.Vector3(0, 1, 0);
function animate() {
timer.update();
step.subVectors(target, mesh.position).multiplyScalar(Math.min(timer.getDelta() * 2, 1));
mesh.position.add(step);
renderer.render(scene, camera);
}
renderer.setAnimationLoop(animate);// during loading, before the first frame: compile everything the scene will render with
await renderer.compileAsync(scene, camera); // uses KHR_parallel_shader_compile where available
renderer.setAnimationLoop(() => {
renderer.render(scene, camera); // material programs are ready; shadow-depth programs still build on the first shadow render
});renderer.compile(scene, camera)transparentsideblendingflatShadingvertexColorsditheringpremultipliedAlphaalphaHashmaterial.needsUpdate = truethreejs-materialsimport * as THREE from "three/webgpu";
const renderer = new THREE.WebGPURenderer({ antialias: true });
await renderer.init(); // before any other use of the renderer
renderer.setPixelRatio(window.devicePixelRatio);
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x111111); // the WebGPU canvas is transparent by default
const camera = new THREE.PerspectiveCamera(50, window.innerWidth / window.innerHeight, 0.1, 100);
camera.position.set(0, 4, 18);
function animate() {
renderer.render(scene, camera);
// autoReset clears per-frame counters at each loop tick; render.calls counts render passes since the app started
console.log(`${renderer.info.render.drawCalls} draw calls, ${renderer.info.memory.total} bytes of GPU memory`);
}
renderer.setAnimationLoop(animate);import * as THREE from "three/webgpu";
import { Fn, cos, hash, instanceIndex, instancedArray, sin, time, TWO_PI, vec3 } from "three/tsl";
const count = 100000;
const positionBuffer = instancedArray(count, "vec3"); // a storage buffer living on the GPU
const simulate = Fn(() => {
const rand = hash(instanceIndex);
const angle = rand.mul(TWO_PI).add(time); // time updates itself every frame
positionBuffer.element(instanceIndex).assign(vec3(cos(angle).mul(8), sin(rand.mul(97).add(time.mul(2))).mul(2).add(2), sin(angle).mul(8)));
});
const simulateCompute = simulate().compute(count); // one compute thread per particle
const material = new THREE.SpriteNodeMaterial();
material.positionNode = positionBuffer.toAttribute(); // the material reads the storage buffer directly
material.colorNode = vec3(1, 0.7, 0.3);
const particles = new THREE.Sprite(material);
particles.count = count; // the sprite draws once per buffer element
particles.frustumCulled = false; // culling tests one small sphere at the sprite origin, not the particles
scene.add(particles);
function animate() {
renderer.compute(simulateCompute); // GPU updates the buffer, then the draw reads it
renderer.render(scene, camera);
}
renderer.setAnimationLoop(animate);info.render.drawCallsinfo.render.callsrender()info.callsinfo.compute.frameCallsinfo.memory.totalmemory.texturesSizememory.attributesSize*Sizeanimaterenderer.compute()await renderer.init()function animate() { requestAnimationFrame(animate); renderer.render(scene, camera); }renderer.setAnimationLoop(animate);const clock = new THREE.Clock(); const dt = clock.getDelta(); const t = clock.getElapsedTime();const timer = new THREE.Timer(); timer.update(); const dt = timer.getDelta(); const t = timer.getElapsed();console.log(renderer.info.render.calls); renderer.render(scene, camera);renderer.render(scene, camera); console.log(renderer.info.render.calls);renderer.info.autoReset = falserenderer.info.autoReset = false; function animate() { renderer.render(scene, camera); console.log(renderer.info.render.calls); renderer.info.reset(); }webgpuRenderer.info.render.callswebgpuRenderer.info.render.drawCallsrenderer.compile(scene, camera)await renderer.compileAsync(scene, camera)renderer.shadowMap.type = THREE.PCFSoftShadowMaprenderer.shadowMap.type = THREE.PCFShadowMapmesh.material.dispose()mesh.material.dispose(); for (const value of Object.values(mesh.material)) if (value?.isTexture) value.dispose();gltf.scene.dispose()gltf.scene.traverse((child) => { child.geometry?.dispose(); for (const material of [child.material ?? []].flat()) { for (const value of Object.values(material)) if (value?.isTexture) value.dispose(); material.dispose(); } child.dispose(); })const hits = raycaster.intersectObjects(scene.children, true)raycaster.layers.set(1); const hits = raycaster.intersectObjects(pickables, false);scene.traverse((child) => (child.frustumCulled = false))geometry.computeBoundingSphere(); // after moving vertices on the CPUfunction animate() { mesh.position.add(new THREE.Vector3(0, 0.01, 0)); }const offset = new THREE.Vector3(0, 0.01, 0); function animate() { mesh.position.add(offset); }const dt = uniform(0); /* fed from the timer each frame */const dt = deltaTime;threejs-fundamentalsthreejs-geometrythreejs-materialsthreejs-loadersthreejs-lightingthreejs-postprocessingthreejs-asset-pipeline