Targets and mindset
- Target at least 60fps; gamer setups run higher refresh rates and users notice. The two bottlenecks are the CPU and the GPU, and the fix differs depending on which one is saturated
- Test on multiple devices, including mobile, from the start of the project. Fix strange behavior before building further, it only gets harder later
- Also watch total asset weight: local dev loads instantly, real users have slow connections
- Measure, never eyeball. The monitoring tools below come first
Monitoring
FPS meter: stats.js
// npm install stats.js
import Stats from 'stats.js'
const stats = new Stats()
stats.showPanel(0) // 0: fps, 1: ms, 2: mb, 3+: custom
document.body.appendChild(stats.dom)
const tick = () => {
stats.begin()
// ... render
stats.end()
}
Unlock the Chrome FPS cap
- A good machine showing a locked 60fps hides headroom problems: the scene might really run at 70fps and drop below 60 on weaker hardware. Unlocked, you want something like 150 to 200fps on a good machine to be safe
- Launch Chrome fully closed via the command in Bruno Simon's gist (Mac and Windows variants): https://gist.github.com/brunosimon/c15e7451a802fa8e34c0678620022f7d
- Warning: this draws much more power and can crash Chrome
Draw calls: Spector.js
- A draw call is one GPU triangle-drawing action. Fewer is better; complex scenes with many objects, geometries, and materials multiply them
- The Spector.js Chrome extension records one frame and shows every command. Blue steps are draw calls, the rest is data upload (matrices, attributes, uniforms)
renderer.info
console.log(renderer.info)
Reports what is in the scene and what is being drawn: geometries, textures, draw calls, triangles.
General
- Keep the
tickfunction lean. It runs every frame, so any sloppy JavaScript there is paid 60+ times per second - Dispose of resources you no longer need (level changes, removed objects). Not disposing leaks GPU memory:
scene.remove(cube)
cube.geometry.dispose()
cube.material.dispose()
Full guide: https://threejs.org/manual/#en/how-to-dispose-of-objects
Lights
- Avoid Three.js lights when possible; bake lighting into textures instead. If unavoidable, use as few as possible and prefer the cheap ones: AmbientLight and DirectionalLight
- Never add or remove lights at runtime. Every material that supports lights recompiles, which can freeze the screen in a complex scene
Shadows
- Avoid real-time shadows; baked shadows in textures are far cheaper
- If you need them, shrink the shadow camera frustum to the smallest box that covers the scene, verified with a CameraHelper:
directionalLight.shadow.camera.top = 3
directionalLight.shadow.camera.right = 6
directionalLight.shadow.camera.left = -6
directionalLight.shadow.camera.bottom = -3
directionalLight.shadow.camera.far = 10
directionalLight.shadow.mapSize.set(1024, 1024)
scene.add(new THREE.CameraHelper(directionalLight.shadow.camera))
- Use the smallest
mapSizethat still looks acceptable - Set
castShadowandreceiveShadowper object, on as few objects as possible. A floor receives but does not cast; most props cast but do not receive - If shadows are static, stop re-rendering them every frame:
renderer.shadowMap.autoUpdate = false
renderer.shadowMap.needsUpdate = true // set again only when shadows must refresh
Textures
- GPU memory cost depends only on resolution, never on file weight. Mipmaps make it worse. Reduce resolution to the minimum that still looks decent
- Keep power-of-2 resolutions (width and height independently, no need for square). Otherwise Three.js resizes to the nearest power of 2 at load time, costing resources and quality
- File format affects loading time, not GPU memory. Choose
.jpgvs.pngby content and alpha needs, compress with tools like TinyPNG, and consider.basis(GPU-readable, powerful compression, but hard to generate)
Geometries
- All geometries are BufferGeometries since r125. Only worry about "non buffer" geometries on ancient versions
- Never update vertices in the
tickfunction; it is terrible for performance. Vertex animation belongs in a vertex shader - Mutualize: one geometry instance shared across many Meshes, transforming each mesh, not each geometry:
const geometry = new THREE.BoxGeometry(0.5, 0.5, 0.5)
for (let i = 0; i < 50; i++) {
const mesh = new THREE.Mesh(geometry, material)
mesh.position.x = (Math.random() - 0.5) * 10
scene.add(mesh)
}
Merge geometries (static objects, one draw call)
If the objects never move independently, merge them into one geometry and one Mesh. Transformations move into the geometries themselves before merging:
import * as BufferGeometryUtils from 'three/examples/jsm/utils/BufferGeometryUtils.js'
const geometries = []
for (let i = 0; i < 50; i++) {
const geometry = new THREE.BoxGeometry(0.5, 0.5, 0.5)
geometry.rotateX((Math.random() - 0.5) * Math.PI * 2)
geometry.translate(
(Math.random() - 0.5) * 10,
(Math.random() - 0.5) * 10,
(Math.random() - 0.5) * 10
)
geometries.push(geometry)
}
const mergedGeometry = BufferGeometryUtils.mergeGeometries(geometries)
const mesh = new THREE.Mesh(mergedGeometry, new THREE.MeshNormalMaterial())
scene.add(mesh)
The payoff is a single draw call for the whole batch.
InstancedMesh (independent movement, still one draw call)
When objects share geometry and material but must move independently, use InstancedMesh: one object, one matrix per instance:
const mesh = new THREE.InstancedMesh(geometry, material, 50)
scene.add(mesh)
for (let i = 0; i < 50; i++) {
const quaternion = new THREE.Quaternion()
quaternion.setFromEuler(new THREE.Euler(rx, ry, 0))
const matrix = new THREE.Matrix4()
matrix.makeRotationFromQuaternion(quaternion)
matrix.setPosition(new THREE.Vector3(x, y, z))
mesh.setMatrixAt(i, matrix)
}
- Nearly as fast as merged geometry, but instances stay movable via
setMatrixAt - If you update matrices in
tick, declare it:mesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage)
Materials
- Mutualize: one material instance shared across all meshes that look the same, created once outside the loop
- Use the cheapest material that does the job. MeshBasicMaterial, MeshLambertMaterial, and MeshPhongMaterial are much cheaper than MeshStandardMaterial or MeshPhysicalMaterial
Models
- Use low-poly models; fake detail with normal maps, which cost only a texture
- Draco compression drastically shrinks complex geometry, at the cost of a possible decompression freeze plus loading the Draco decoder libraries
- Enable gzip on the server for model files. Most servers do not gzip
.glb,.gltf,.objby default
Camera
- Frustum culling is free: objects outside the field of view are not rendered. A narrower
fovmeans fewer objects on screen - Tighten
nearandfar. In a vast world, distant objects hidden behind terrain still attempt to render untilfarexcludes them
Renderer
- Cap pixel ratio at 2; higher ratios are marketing, and each extra pixel costs frame rate:
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
- Hint at the GPU tier only when you actually have performance issues:
const renderer = new THREE.WebGLRenderer({
canvas,
powerPreference: 'high-performance' // otherwise 'default'
})
- Antialias is relatively cheap but not free. Enable it only when aliasing is visible and performance allows
Postprocessing
- Every pass re-renders the full resolution times the pixel ratio. At 1920x1080 with pixel ratio 2 and 4 passes, that is 33 million pixels per frame. Regroup custom passes into one
Shaders
- Force lower precision in ShaderMaterial and check for glitches:
new THREE.ShaderMaterial({ precision: 'lowp' }). RawShaderMaterial ignores this; declare precision in the shader source yourself - Keep shader code simple. Avoid
ifstatements; useclamp,mix,step, swizzles, and other built-ins instead:
modelPosition.y += clamp(elevation, 0.5, 1.0) * uDisplacementStrength;
vec3 finalColor = mix(depthColor, surfaceColor, elevation);
- Perlin noise functions are expensive. A noise texture read with
texture2D()is far cheaper and easy to author in an image editor - Uniforms have a per-frame cost. If a value never changes, use a define instead, either in GLSL (
#define uDisplacementStrength 1.5) or via the material, which injects it automatically:
const shaderMaterial = new THREE.ShaderMaterial({
defines: { uDisplacementStrength: 1.5 }
})
- Do calculations in the vertex shader when possible and pass results to the fragment shader as varyings. Vertices are far fewer than fragments, so per-vertex work is cheaper than per-fragment work
Checklist
- Monitor: stats.js FPS, Spector.js draw calls,
renderer.info - Dispose unused geometries and materials
- Few cheap lights, never added or removed at runtime
- Baked or tightly-fitted low-res shadow maps,
autoUpdateoff when static - Power-of-2 textures, resolution minimized, files compressed and gzipped
- One geometry and one material shared; merge static objects; instance dynamic ones
- No vertex updates in
tick; animate in the vertex shader - Low poly plus normal maps; Draco for heavy geometry
- Tight
fov,near,far; pixel ratio capped at 2 - Few postprocessing passes; simple shaders with defines, textures for noise, work in the vertex shader
- More tips: https://discoverthreejs.com/tips-and-tricks/