threejs-performance
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.
Install
npx skills add https://github.com/cloudai-x/threejs-skills --skill threejs-performanceThree.js Performance
Targets three.js r186. Examples use WebGLRenderer; the WebGPU and TSL section uses WebGPURenderer.
Quick Start
Measure First
Optimize what the numbers name, not what a hunch suspects. WebGLRenderer's renderer.info object is read after render():
Details worth knowing when reading these:
- The counters reset at the start of every
render()call whileinfo.autoResetis true (the default). Reading them beforerender()reports the previous frame, and a frame with severalrender()calls (picking, reflections, post passes) shows only the last one's numbers. - Shadow-map drawing runs through the same counters: each shadow-casting light re-draws its casters from the light's view inside
render(), and those draws appear incallsandtriangles. Toggling a light'scastShadowis directly visible here. info.memory.geometriesandinfo.memory.texturescount uploads, not bytes; they drop when the matchingdispose()runs. Geometries and textures upload once and are shared by every mesh that references them.
When a frame really has several render() calls, accumulate over the whole frame yourself:
Timer gives the frame interval; average it over a second of frames, single frames jitter too much to compare. The interval never drops below one display refresh (16.7 ms at 60 Hz), so it shows dropped frames but not headroom; time the CPU side of the frame with performance.now() around the work:
For anything deeper than frame time, use the browser profiler — the Chrome DevTools Performance panel shows long main-thread tasks, scripting versus rendering time, and GPU work on its GPU track. The stats-gl package shows a live per-frame statistics overlay when you want the numbers always visible.
Draw Calls
Each visible mesh queues one draw call, or one per geometry group when the material is an array, and two settings multiply that: a transparent material with side: THREE.DoubleSide draws each mesh twice, back faces then front faces (forceSinglePass: true makes it one), and while any MeshPhysicalMaterial with transmission above 0 is in view, every opaque object is drawn a second time into the transmission buffer. Without those two, a thousand single-material meshes is a thousand draw calls; info.render.calls is the number to watch. The classic reducers:
Merge static geometry. Many meshes that never move and share one material become one mesh and one call:
Merging trades culling granularity for the call count: the merged mesh has a single bounding sphere, so frustum culling is all-or-nothing. Keep world-sized clusters merged per cluster, not one merge for a whole level.
Instance repeats. One geometry drawn many times with per-instance transforms stays one call:
Batch different geometries. Many geometries sharing one material pack into shared buffers:
Unlike InstancedMesh, it culls and sorts each instance on its own (perObjectFrustumCulled and sortObjects, both on by default). WebGLRenderer multi-draws these in one call where the browser supports WEBGL_multi_draw, one draw per instance otherwise; the WebGPU backend issues one draw per instance. threejs-geometry covers both APIs in depth.
Drop detail with distance. LOD keeps every level as a child and, inside every render() (its autoUpdate is on by default), shows only the level for the current camera distance, so far objects submit fewer triangles — the call count stays the same:
Let culling skip whole calls. Frustum culling is on by default: before a mesh is queued, its geometry's bounding sphere is tested against the camera frustum, and a failed test removes the draw entirely. Two ways to lose it silently: editing vertices on the CPU without geometry.computeBoundingSphere() leaves a stale sphere — one left smaller than the moved vertices hides the mesh while it is still in view — and moving vertices in a shader leaves the CPU sphere untouched, so set mesh.frustumCulled = false on that one mesh, not scene-wide.
GPU Memory
info.memory.textures counts textures; the byte math is yours. An RGBA8 texture costs 4 bytes per pixel, and with the default mip chain WebGPURenderer's byte estimate multiplies by 1.333 — a 2048 by 2048 color map is about 22 MB of VRAM. Three levers:
- Compressed KTX2. A Basis Universal KTX2 texture (ETC1S or UASTC) is transcoded to a block-compressed format the GPU supports (ASTC, BC7, BC1 or BC3, ETC2, ETC1 or PVRTC) and stays compressed in VRAM, instead of expanding to 4 bytes per pixel like a PNG or JPEG does; when none of them fits the file (ASTC takes UASTC files only, ETC1 has no alpha, PVRTC needs power-of-two sizes), KTX2Loader falls back to uncompressed RGBA. Load it with KTX2Loader:
- Size. A 1024 texture is a quarter of a 2048 one. Author color maps at the size they appear on screen, and let mipmapping handle distance.
- Disposal.
geometry.dispose()frees its GPU buffers at once;texture.dispose()frees the GPU texture once no other undisposed texture shares it (atexture.clone()shares the image, and its GPU copy while wrap, filter, format and color space stay the same). Nothing counts meshes, so dispose only what nothing still renders — a mesh that still uses a disposed geometry or texture uploads it again on its next render:
To free a whole subtree, traverse it — object.dispose() does not visit children. Under WebGPURenderer memory.geometries and memory.textures stay counts, and the *Size fields and memory.total add byte estimates; see the WebGPU section.
Fill Rate
Fill rate is fragment work: pixels shaded per frame. The drawing buffer is width × ratio by height × ratio pixels, so shading cost scales with the pixel ratio squared — a 3× display shades 9× the pixels of 1×. Cap it:
Overdraw and transparency. Opaque geometry draws first (sorted by group, render order, material, then distance), transmissive next, transparent last — transparent back-to-front so blending composes correctly. Stacked transparent layers cannot reject each other: every layer behind another transparent surface still shades its pixels, and a full-screen transparent overlay shades every pixel of the screen once more on top of the scene. Prefer opaque materials where the look allows, and keep transparent quads few and small. renderer.info will not show overdraw — calls and triangles stay identical — only frame time moves.
Shadow maps. Each shadow-casting light renders its casters into a depth texture every frame while shadow updates are on. The defaults are small (512 by 512); raise them knowingly, and freeze them for static scenery:
Each shadow-casting light adds its own pass, and those draws appear in info.render.calls: it re-draws every castShadow object inside its shadow camera's frustum, a PointLight once per cube face the object falls in (up to six), and with VSMShadowMap the receiveShadow objects too, plus two blur draws. Tight shadow camera bounds (sun.shadow.camera.left, right, top, bottom, then sun.shadow.camera.updateProjectionMatrix()) cut the casters drawn; the map size sets each pass's fill cost.
CPU Work in the Loop
Static transforms. Every render() calls scene.updateMatrixWorld(), which visits every object and recomposes its local matrix from position, rotation and scale when matrixAutoUpdate is true (the default). For scenery that never moves, compose once:
Raycasting. intersectObjects(objects, true) walks every descendant of every object and tests each one: a layers test first, then the geometry's bounding sphere, then triangles. Narrow the input instead of filtering the output:
A pointermove handler that raycasts runs on every event, so this runs hot on scenes with picking. For large static geometry, the three-mesh-bvh package accelerates the triangle tests.
No allocations in the loop. Objects created per frame become garbage, and collection pauses show up as periodic frame spikes. Reuse vectors and matrices declared outside the loop:
Shader Compile Stalls
Programs build lazily: the first frame a material renders under conditions it has not seen before — a different light count, fog switched on for it — stalls while its shader compiles. Build them during loading instead:
renderer.compile(scene, camera) issues the same builds but returns at once, so the first render still waits for any program that has not finished; prefer the async form. Changing a setting that is compiled into the shader — transparent, side, blending, flatShading, vertexColors, dithering, premultipliedAlpha, alphaHash, or adding or removing a map — needs material.needsUpdate = true and builds a new program, so avoid flipping these at runtime (threejs-materials lists them all).
WebGPU and TSL
Compute moves particle work off the CPU: no per-particle loop, no buffer re-upload per frame, one dispatch updates every particle:
- The WebGPU counters have their own names:
info.render.drawCallsper frame,info.render.calls(render passes: eachrender()and internal ones such as the output pass) andinfo.calls(render and compute calls) cumulative since the app started,info.compute.frameCallsper frame, andinfo.memory.total— a byte estimate over vertex, index and storage buffers, textures, uniform buffers and programs (memory.texturesSize,memory.attributesSizeand the other*Sizefields hold the parts). A compressed texture counts as 1 byte there, a fallback estimate, so KTX2 memory is under-reported. - The second block's loop replaces the first block's
animate;renderer.compute()submits directly becauseawait renderer.init()has already run.
Common Mistakes
function animate() { requestAnimationFrame(animate); renderer.render(scene, camera); }→renderer.setAnimationLoop(animate);: a WebXR session runs only the callback given to setAnimationLoop, through the session's own frame loop, and under WebGPURenderer setAnimationLoop also awaits init() before the first frame.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();: Clock is deprecated in r183, and getElapsedTime() calls getDelta() internally, so a later getDelta() in the same frame returns about zero.console.log(renderer.info.render.calls); renderer.render(scene, camera);→renderer.render(scene, camera); console.log(renderer.info.render.calls);: the counters reset at the start of render(), so reading before it reports the previous frame.renderer.info.autoReset = false→renderer.info.autoReset = false; function animate() { renderer.render(scene, camera); console.log(renderer.info.render.calls); renderer.info.reset(); }: with autoReset off, nothing clears the counters unless reset() runs once per frame after the last render(), so they accumulate across frames.webgpuRenderer.info.render.calls→webgpuRenderer.info.render.drawCalls: under WebGPURenderer, render.calls counts render passes since the app started, while drawCalls counts this frame's draw calls; WebGLRenderer has no drawCalls, and its render.calls is the per-render() draw count.renderer.compile(scene, camera)→await renderer.compileAsync(scene, camera): compile() queues the builds and returns, so a render right after it still blocks on unfinished programs, while compileAsync() resolves only once KHR_parallel_shader_compile reports every program complete, where the browser exposes that extension.renderer.shadowMap.type = THREE.PCFSoftShadowMap→renderer.shadowMap.type = THREE.PCFShadowMap: PCFSoftShadowMap is deprecated in r182 and falls back to PCF with a console warning.mesh.material.dispose()→mesh.material.dispose(); for (const value of Object.values(mesh.material)) if (value?.isTexture) value.dispose();: material.dispose() releases its shader programs, never the material's textures, so info.memory.textures only drops when each texture.dispose() runs.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(); }): Object3D.dispose() frees renderer state for that one object only, never its children, geometry, materials or textures.const hits = raycaster.intersectObjects(scene.children, true)→raycaster.layers.set(1); const hits = raycaster.intersectObjects(pickables, false);: the recursive call walks and tests every descendant on every event, while the layers check runs before each object's geometry test and an explicit list never visits the rest of the scene.scene.traverse((child) => (child.frustumCulled = false))→geometry.computeBoundingSphere(); // after moving vertices on the CPU: culling skips whole draw calls through each geometry's bounding sphere, so disabling it scene-wide draws everything every frame.function 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); }: every per-frame allocation becomes garbage that the collector must pause to reclaim, which shows up as periodic frame spikes.const dt = uniform(0); /* fed from the timer each frame */→const dt = deltaTime;: the built-in time and deltaTime nodes are uniforms the renderer already updates on every render, so a hand-fed copy duplicates them and needs its own Timer.
See Also
threejs-fundamentals- renderer construction, the render loop and resize handling around everything measured here.threejs-geometry- the full instancing, batching, merging and attribute APIs behind the draw-call cuts.threejs-materials- which material settings compile into the shader and force a rebuild.threejs-loaders- the KTX2, Draco and meshopt loader wiring for the compressed files these levers produce.threejs-lighting- light types, units and shadow setup whose costs this skill measures.threejs-postprocessing- the composer's own buffers, MSAA and pixel-ratio settings.threejs-asset-pipeline- the gltf-transform commands that produce KTX2 textures and compressed, instanced or joined glTF files.