A scene that hits 144 fps on a gaming laptop can collapse to 8 fps on a three-year-old Android. The gap isn't usually about raw GPU speed — it's pixel-fill rate, memory bandwidth, shader complexity, and CPU draw-call overhead. This post is the practical checklist for shipping Three.js to phones, low-end laptops, and integrated GPUs: pixel-ratio capping, budgets for triangles + textures + draw calls, on-demand rendering, shader cost awareness, and the diagnostic flow for any "why is this so slow on my phone" investigation.

Series map: 00 Foundations → 01 Geometry & Materials → 02 Cameras & Controls → 03 Lighting & Shadows → 04 Textures & PBR → 05 Asset Pipeline → 06 Animation → 07 Instancing & Batching → 08 Low-End Performance (this post) → 09 Shaders → 10 Post-Processing → 11 Physics → 12 R3F → 13 AI 3D.


Learning Outcome

  • Cap devicePixelRatio correctly (this alone often doubles framerate).
  • Apply triangle / texture / draw-call budgets for mobile.
  • Use on-demand rendering when the scene isn't always animating.
  • Pick cheaper material families and shadow settings on low-end devices.
  • Diagnose CPU vs GPU bottlenecks systematically.
  • Detect device capability and adapt at runtime.

Chapter 1 — Pixel ratio (the biggest free win)

A modern phone may report window.devicePixelRatio = 3 — meaning a 400×800 logical viewport renders at 1200×2400 actual pixels. That's 9× the pixels of a desktop running the same canvas at DPR=1.

1
2
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));

Capping at 2 is the standard. The visual difference between DPR=2 and DPR=3 is essentially invisible; the performance difference is dramatic.

For aggressive optimisation, cap at 1.5 or 1 on detected low-end devices. Combine with a quality slider in your UI.

The same canvas at pixel ratio 1, 1.5, 2 and 3, showing how the pixel count grows with the square of the ratio


Chapter 2 — Budgets

Rough budgets for 60 fps on a mid-2020s phone (Snapdragon 7-gen, A15):

Resource Mobile budget Desktop budget
Draw calls per frame ≤ 100-200 ≤ 1,000
Triangles per frame ≤ 250-500 k ≤ 5 M
Active textures ≤ 30 ≤ 100+
Texture VRAM total ≤ 100 MB ≤ 500 MB
Shader programs keep under 30 keep under 200
Shadow maps active 1 × 1024 1-3 × 2048
Postprocess passes 1-2 (bloom only) many

Low-end / older mobile is 2-4× tighter. Higher-end mobile (latest iPhone Pro) approaches desktop. Always test on the lowest device you intend to support, not your dev machine.

Measuring

1
2
3
4
console.log(renderer.info.render);   // calls, triangles, points, lines
console.log(renderer.info.memory); // geometries, textures
console.log(renderer.info.programs); // shader programs

In R3F (Post 12), r3f-perf gives the same overlayed in the canvas.


Chapter 3 — On-demand rendering

If the scene doesn't change every frame (static showroom, paused state), don't render every frame.

1
2
3
4
5
6
7
8
9
10
let needsRender = true;
controls.addEventListener('change', () => { needsRender = true; });

renderer.setAnimationLoop(() => {
if (needsRender) {
renderer.render(scene, camera);
needsRender = false;
}
});

In R3F: <Canvas frameloop="demand" />. The renderer goes idle when nothing's happening, dropping power consumption to near zero — invaluable on mobile (battery, thermal throttling).

For mixed scenes (some always-animating elements, some not), trigger render on relevant property changes only.


Chapter 4 — Material cost ranking

Cheaper materials → less per-pixel shader work → less heat → more battery.

Material Relative GPU cost When OK to downgrade
MeshBasicMaterial 1× UI, hero text, sprites
MeshLambertMaterial 1.5× matte non-shiny
MeshPhongMaterial 2× shiny but non-PBR
MeshStandardMaterial 4-6× default for hero objects
MeshPhysicalMaterial 6-10× only hero objects (glass, clearcoat)

For low-end builds, swap Standard → Lambert for background props. The difference at small sizes / dim lighting is rarely noticeable.

1
2
3
4
5
6
// quality-based material selection
const HIGH_QUALITY = !isMobile && !isLowEnd;
const propMaterial = HIGH_QUALITY
? new THREE.MeshStandardMaterial({ color: 0xaaaaaa, roughness: 0.6 })
: new THREE.MeshLambertMaterial({ color: 0xaaaaaa });

Chapter 5 — Shadow settings for mobile

From Post 03, shadows are often the most expensive piece. For mobile:

1
2
3
4
5
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFShadowMap; // not PCFSoft on mobile
sun.shadow.mapSize.set(1024, 1024); // not 2048
sun.shadow.camera.far = 30; // tighter frustum

Or skip real-time shadows entirely and bake:

  • Static scenery → bake AO + lightmap into textures (Post 03/04). Zero per-frame shadow cost.
  • One key character → real-time shadow only for it.
  • Use Drei's <AccumulativeShadows> or a ShadowMaterial plane for cheap "contact shadows".

Chapter 6 — Texture budget

GPU memory is precious on mobile. Each 1024² uncompressed texture = 4 MB. 30 textures = 120 MB — half the VRAM on many phones.

Discipline:

  • Resize source textures to power-of-two and to the actual on-screen size needed (1024² for hero, 512² for medium, 256² for background props).
  • Use KTX2 / Basis Universal (Post 04) — ~10× GPU memory savings.
  • Pack channels (ORM in one RGB image — Post 04).
  • Avoid wrapS/T = ClampToEdge if you can tile — repeating textures need lower resolution.
  • For decorative repeated detail, use a trim sheet (small atlas tiled across many shapes).

Detecting mobile / low-end

1
2
3
const isMobile = /Mobi|Android/i.test(navigator.userAgent);
const lowEnd = navigator.hardwareConcurrency < 4 || (navigator.deviceMemory ?? 4) < 4;

Not perfect (web hides specs by design); good enough to make a tier decision at startup.


Chapter 7 — Geometry budget

Triangle count is rarely the bottleneck for modern GPUs; fill rate (pixels × shader cost) is. Still, mobile rasterisers slow on huge meshes:

  • High-detail hero models: ~10-30k triangles.
  • Background props: ≤ 1k each.
  • Total scene triangles: ≤ 500k on mid-mobile, ≤ 100k on low.

Reduce via:

  • gltf-transform simplify (CLI).
  • LOD swaps (Post 07).
  • Decimate / retopo in Blender.

Trick: check if your model is over-tessellated for its on-screen size. A 50k-tri tree displayed at 30 pixels tall is wasted work; a 500-tri impostor billboard would look identical.


Chapter 8 — Postprocess discipline on mobile

Each postprocess pass is a full-screen render at the canvas resolution. On a 1200×2400 phone display (DPR=3 capped to 2 → 800×1600), every pass is 1.28 MP of fragment work. Three passes triple that.

Mobile rules:

  • 0-1 postprocess passes maximum.
  • Run post passes at half resolution where possible (composer.setSize(w/2, h/2)).
  • Skip bloom + SSAO + DOF together — pick one if any.
  • Tonemapping in the main render pass is free (renderer.toneMapping = THREE.ACESFilmicToneMapping) — keep that.

Post 10 covers postprocess in detail.


Chapter 9 — Render targets + memory

Each render target (custom framebuffer used for post passes, reflections, planar mirrors, refraction) = a full-resolution colour + depth texture. On mobile, two render targets at full resolution might already eat 30 MB.

Tips:

  • Half-resolution where the artifact won't be noticed.
  • Reuse the same render target across passes (ping-pong) instead of allocating new each frame.
  • Free render targets you no longer use: rt.dispose().

Chapter 10 — CPU vs GPU bottleneck — diagnosis

Three classes of bottleneck. Different fixes for each.

Bottleneck Symptom Fix
CPU (JS + draw calls) high JS time in Performance panel; renderer.info.calls high reduce draw calls (Post 07); move per-frame work out of JS; throttle updates
GPU fragment (pixel shader / fill rate) lowering pixel ratio drastically improves; high GPU ms cheaper materials; smaller postprocess; smaller canvas
GPU vertex (geometry) many high-tri meshes; OK on small canvas, slow on big LOD; geometry simplification; instancing

Quick test: halve the canvas size (renderer.setSize(w/2, h/2)). If FPS doubles or more, you're GPU-bound. If little change, you're CPU-bound.

Quick test 2: disable shadows + post. If FPS doubles, those were the cost. Tune them.

Use stats-gl (Post 00) — separate CPU ms and GPU ms readouts make this diagnosis straightforward.


Chapter 11 — Adaptive quality (the production pattern)

Detect FPS over a window; downgrade quality automatically when it drops.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
let avgFps = 60;
let quality = 1.0; // 0..1 multiplier

renderer.setAnimationLoop(() => {
const dt = clock.getDelta();
avgFps = avgFps * 0.95 + (1 / dt) * 0.05;

if (avgFps < 40 && quality > 0.5) {
quality -= 0.05;
renderer.setPixelRatio(window.devicePixelRatio * quality);
composer.setSize(innerWidth * quality, innerHeight * quality);
} else if (avgFps > 55 && quality < 1.0) {
quality += 0.02;
renderer.setPixelRatio(window.devicePixelRatio * quality);
}

renderer.render(scene, camera);
});

Same pattern works for: disabling shadows below threshold, swapping to simpler materials, dropping postprocess.

Trick: Three.js has built-in helpers for adaptive scaling — see examples/jsm/utils/AdaptiveDpr.js. Saves you writing the loop.


Chapter 12 — Bundle size + load time

Slow first paint kills mobile users on cellular. Reduce JS bundle:

  • Tree-shake — Vite + ESM does this automatically; avoid importing three namespaces from anything but three/addons/*.
  • Lazy-load decoders (Post 05) — DRACOLoader + KTX2Loader pulled only when needed.
  • Lazy-load Three.js itself — dynamic import('three') inside the first interaction handler if 3D isn't the main page content.
  • Show a loading indicator during decode (10-100 MB models can take seconds even after download).
1
2
3
4
5
6
7
8
import { LoadingManager } from 'three';

const manager = new THREE.LoadingManager();
manager.onProgress = (url, loaded, total) => {
progressBar.value = (loaded / total) * 100;
};
const loader = new GLTFLoader(manager);

Chapter 13 — The mobile audit checklist

Before shipping any Three.js page to mobile:

  • [ ] renderer.setPixelRatio(Math.min(devicePixelRatio, 2)).
  • [ ] Draw calls measured + ≤ 100-200.
  • [ ] Triangles ≤ 500k.
  • [ ] Textures KTX2-compressed; total ≤ 100 MB GPU.
  • [ ] Shadows: 1 × 1024 PCF (or baked AO only).
  • [ ] Postprocess: 0-1 passes max.
  • [ ] On-demand rendering if scene isn't always animating.
  • [ ] Tested on the lowest-end target device, not just dev machine.
  • [ ] Loading indicator + progress.
  • [ ] Lazy-loaded decoders, lazy-loaded Three.js if 3D is secondary.
  • [ ] Adaptive quality loop in place.
  • [ ] Battery + thermal tested for sessions > 1 minute.

Practice Exercises

Exercise 1 — Pixel ratio swap

Build a scene with 1000 cubes. Render with DPR uncapped vs DPR=2 vs DPR=1. Measure FPS on a phone.

Exercise 2 — Audit a real model

Load any free Sketchfab model. Print renderer.info.render + renderer.info.memory. Note draw calls, textures, triangles. Decide what to optimise.

Exercise 3 — On-demand rendering

Convert your default render loop to on-demand. Trigger render on OrbitControls change. Watch CPU usage drop to 0 in idle state.

Exercise 4 — Quality tiers

Build a quality selector (Low / Medium / High) that switches: pixel ratio, shadow map size, shadow type, material families. Switch live without re-loading.

Exercise 5 — Adaptive loop

Implement Chapter 11's adaptive quality loop. Simulate slow GPU with for (let i = 0; i < 1e6; i++) {} inside the render loop. Observe quality scaling down.

Exercise 6 — Bottleneck diagnosis

A scene seemingly lagging on mobile. Halve canvas size — measure. Disable shadows — measure. Disable post — measure. Identify the dominant cost.


Cheat-sheet

What How
Pixel ratio cap renderer.setPixelRatio(Math.min(devicePixelRatio, 2))
Mobile draw-call budget < 100-200
Mobile triangle budget < 500k
Mobile texture VRAM < 100 MB
On-demand render only render when needsRender flag set
Cheap material Lambert > Phong > Standard for background props
Shadow on mobile 1024 PCF (not 2048 PCFSoft)
Postprocess on mobile 0-1 passes
Diagnose CPU vs GPU halve canvas; if FPS doubles → GPU-bound
Disable shadows test confirms shadow cost
Adaptive quality downgrade pixel ratio + shadow size on low FPS
Mobile detection `/Mobi
Capability detect navigator.hardwareConcurrency, navigator.deviceMemory
Loading manager progress for users; lazy-load decoders
Free render target rt.dispose() when unused

What's next

Post 09 dives into shaders — both classical GLSL ShaderMaterial and the new TSL (Three.js Shading Language) NodeMaterial that compiles to both WebGL2 and WebGPU. Custom shaders unlock effects the built-in materials can't do.


References

Note: this post is in progress. Planned additions: thermal-throttling benchmarks; WebGL vs WebGPU mobile comparison; first-paint optimisation per Lighthouse.