wikis / Three.js / wiki / syntheses / performance-and-optimization.md view as markdown report a mistake
Comparison
Four techniques from four different raw sources all trade something for frame-rate or battery life. None of them is free, and they interact:
| Technique | What it saves | What it costs | Primary page |
|---|---|---|---|
| Merge geometry / instancing | Draw-call count (~19,000 โ 1 in the globe example; 20fps โ 60fps) | Per-object independence โ merged objects can't move or be removed individually without re-splitting | performance |
| Render on demand | Continuous GPU work and battery on idle frames | Extra bookkeeping (reentrancy guards for damped controls, explicit renders after async loads) | performance |
| Responsive/HD-DPI sizing | GPU pixel-fill work (rendering at 1x instead of up to 9x on high-DPI mobile) | Visual crispness, if left at 1x | responsive rendering |
| Post-processing passes | โ (this one costs, never saves) | An extra full-screen render per pass, layered on top of everything else | post processing |
| HTML overlay occlusion (raycasting) | Correctness (hides labels behind objects) | Per-label raycast cost, worse than the naive "always show" version | performance |
Analysis
The four raw sources converge on one underlying idea: the fastest thing to render is the thing you don't render, or render less often. Concretely:
- Fewer draw calls beats fewer triangles. The globe example didn't get from 20fps to 60fps by simplifying box geometry โ it got there by merging ~19,000 separate
Meshdraw calls into one, usingBufferGeometryUtils.mergeGeometries. Per-object color survived the merge via vertex-color attributes rather than per-mesh materials. See performance for the full before/after code and the general principle (trees, rocks, fences, voxel chunks). - Frame rate you don't need is pure waste. performance's render-on-demand section and responsive rendering's HD-DPI section are the same argument at two different layers: don't compute frames nobody will see the difference in (render-on-demand), and don't compute pixels at a resolution the GPU can't afford (manual
devicePixelRatioscaling instead of blindly enablingrenderer.setPixelRatio). Both explicitly call out mobile GPUs and battery life as the reason this matters more than it looks. - Every "correctness" feature has a shadow cost. OrbitControls damping requires a render-on-demand app to add a reentrancy guard (
requestRenderIfNotRequested) or it infinite-loops. HTML label occlusion requires either a per-label raycast (slow) or, for the sphere-anchored special case, a cheap dot-product shortcut that only works because the geometry is a unit sphere. Post-processing is the most literal version of this: it is pure added cost (an extra full-screen pass per effect) purchased for visual quality, and it must be resized in lockstep with the renderer and camera or it silently renders at the wrong resolution. - The techniques compose, and composing them is where bugs live. The rendering-on-demand pattern is explicitly built on top of the responsive-design resize function (
resizeRendererToDisplaySize) from a separate article, and the merged-geometry globe example is explicitly built on top of the render-on-demand pattern (rendering once after data/texture load rather than continuously). Post-processing needs the same resize discipline as the base renderer, just at theEffectComposerlayer (composer.setSize) instead of the renderer layer. Each layer that's added โ merging, on-demand rendering, HD-DPI scaling, post-processing, HTML overlays โ needs its own resize/update hook wired into the same render loop, and skipping one hook while adding the next layer is the most common way these techniques silently stop working together.
Recommendations
- Start with responsive sizing correctly, before anything else. Every technique below assumes
resizeRendererToDisplaySize(from responsive rendering) is already wired into the render loop and gates the camera'saspect/updateProjectionMatrix()update. Decide deliberately whether to render atdevicePixelRatio(crisper, up to 9x the pixel cost on high-DPI mobile) or at 1x (cheaper, browser upscales) โ don't default torenderer.setPixelRatio()if the app will ever need its literal drawing-buffer size for post-processing,gl_FragCoordshaders, screenshots, or GPU picking. - Default to rendering continuously only if the scene is actually animating every frame. Otherwise, render once and wire re-renders to real triggers: OrbitControls'
changeevent,window'sresizeevent, async load completions, and GUI/input changes โ all funneled through a singlerequestRenderIfNotRequested-style guard so damped controls can't create an infinite loop. - When a scene has "lots of" anything (hundreds to tens of thousands of similar small objects), merge before optimizing anything else. Bake per-instance transforms into geometry with
applyMatrix4via a small reusable helper hierarchy, merge withBufferGeometryUtils.mergeGeometries, and recover per-instance color via vertex-color attributes rather than per-mesh materials. Only give up single-mesh merging for the subset of objects that genuinely need independent runtime movement or removal. - Add post-processing last, and budget it deliberately. Each
Passis an extra full-screen render โ decide which effects earn their cost, keepRenderPass/OutputPassas the fixed bookends, and remembercomposer.setSize()must be called anywhererenderer.setSize()/camera aspect already are. - For HTML-anchored labels, match the occlusion technique to the scene's shape, not the other way around. Use the cheap dot-product visibility test when anchors sit on a shared sphere/known surface; fall back to raycasting-based occlusion (accepting its cost) only when objects are arbitrary and overlapping; use a data-driven filter (e.g. area/importance threshold) to cap total label count before worrying about per-label occlusion cost at all.
Pages Compared
performance, responsive rendering, post processing, controls, raycasting and picking, threejs overview
