Mobile WebGL Optimization: Steps & Pitfalls
A simulation that hits 60fps on a desktop RTX card can drop to single digits — or thermal-throttle itself into a slideshow after two minutes — on a mid-range phone. Mobile GPUs aren't smaller desktop GPUs; they're a genuinely different architecture with different bottlenecks. This tutorial covers the concrete steps that actually move the needle, and the pitfalls that only show up once real users open your simulation on real phones.
1. A different regime, not a smaller desktop GPU
Nearly every mobile GPU (Apple's, Arm Mali, Qualcomm Adreno, Imagination PowerVR) uses tile-based deferred rendering (TBDR): the screen is split into small tiles, and each tile's geometry and shading is resolved entirely in fast on-chip memory before writing to main memory once. This is why some desktop-honed habits actively hurt mobile performance:
- Reading back from the framebuffer mid-frame (e.g. a manual depth pre-pass followed by a full-screen read) forces an early tile flush, which is far more expensive on TBDR hardware than on a desktop immediate-mode renderer.
- Unified memory means the GPU and CPU share the same physical RAM — there's no separate VRAM budget to lean on, and large textures compete directly with the rest of the page's memory.
- No sustained clock headroom. Mobile SoCs are built for a much lower thermal envelope than a desktop GPU with active cooling — see section 5.
2. Cap devicePixelRatio
A modern phone reports devicePixelRatio of 2.6-3+.
Rendering a WebGL canvas at that full ratio means every fragment
shader runs roughly 9x as many times as at 1x — for sharpness
improvements that are barely perceptible on a small screen held
at arm's length.
const pixelRatio = Math.min(window.devicePixelRatio, 1.5); // 1.5-2 is a good mobile default
renderer.setPixelRatio(pixelRatio);
renderer.setSize(window.innerWidth, window.innerHeight);
For fill-rate-heavy simulations (particle systems, fluid sims, anything with large full-screen shader passes), consider going further: render internally at a fraction of the CSS size and let the canvas upscale via CSS, which costs almost nothing compared to shading every pixel at native resolution.
// Internal render resolution at 0.75x, upscaled by the browser via CSS
const renderScale = 0.75;
renderer.setSize(
window.innerWidth * renderScale,
window.innerHeight * renderScale,
false // false = don't touch the CSS size, only the drawing buffer
);
renderer.domElement.style.width = '100%';
renderer.domElement.style.height = '100%';
3. Shader precision: avoid highp overflow
GLSL ES precision qualifiers (lowp,
mediump, highp) aren't just
suggestions on mobile — many mobile GPUs execute
mediump fragment shader math on genuinely narrower
hardware paths than highp, and some older mobile
GPUs don't support fragment-shader highp at all
(the spec allows falling back to mediump silently).
| Qualifier | Typical range | Use for |
|---|---|---|
lowp |
~256 distinct values, ±2 | Normalised colours, simple 0-1 factors |
mediump |
~16-bit float range | UVs, most lighting math, default fragment precision on many GPUs |
highp |
~32-bit float range | World-space positions far from origin, precise time accumulation |
The most common silent mobile-only bug: a simulation that
accumulates elapsed time in a mediump (or
fallback-from-highp) uniform starts showing visible
jitter or banding after a few minutes, once the accumulated value
exceeds mediump's precision at that magnitude — it
runs fine on desktop's full highp support and only
breaks on the phone in your users' hands.
// Reset time-based uniforms periodically instead of accumulating forever
const t = (performance.now() % 100000) / 1000; // wraps every ~100s
material.uniforms.uTime.value = t;
gl.getShaderPrecisionFormat(gl.FRAGMENT_SHADER, gl.HIGH_FLOAT)
— if .precision is 0, the GPU has no true
highp fragment support and is silently running your
"highp" math at mediump.
4. Draw call & texture memory budgets
Rough, conservative starting budgets for a mid-range phone (2-3 year old Android mid-tier or an older iPhone) versus a typical desktop:
| Budget | Desktop (typical) | Mid-range mobile |
|---|---|---|
| Draw calls / frame at 60fps | ~1,000-3,000+ | ~150-400 |
| Triangles / frame | Several million | ~200k-500k |
| Total texture memory | Several GB | ~150-400 MB (shared with the OS and browser) |
- Batch geometry / use InstancedMesh — merging static meshes or instancing repeated objects (particles, trees, grid cells) directly cuts draw calls, mobile's tightest budget.
-
Compressed textures — ship
KTX2/Basis Universal textures (transcoded to ASTC on modern mobile GPUs, ETC2 as a fallback) instead of raw PNG/JPEG decoded to full RGBA8 in GPU memory; this alone can cut texture VRAM by 4-8x. - Mipmaps, always — minified textures without mipmaps thrash mobile texture cache bandwidth badly; always generate mipmaps for anything viewed at varying distance.
5. Thermal throttling
A phone has no active cooling. Sustained high GPU utilisation heats the SoC until the OS's thermal management clocks the GPU (and often the CPU) down — sometimes 30-50% below its peak clock — to stay within safe temperatures. This means:
- A simulation that benchmarks at a smooth 60fps in the first 30 seconds can drop to 35-40fps after 3-5 minutes of continuous play, with no code change and no user action.
- Throttling is non-linear and device-specific — a flagship phone with a vapour chamber tolerates far more sustained load than a budget phone in a thin plastic shell, even at similar peak benchmark scores.
// A minimal adaptive-quality governor
let quality = 1.0;
function animate(now) {
const dt = (now - lastTime) / 1000;
lastTime = now;
if (dt > 1 / 45) quality = Math.max(0.5, quality - 0.02); // falling behind → cut back
else if (dt < 1 / 58) quality = Math.min(1.0, quality + 0.005); // headroom → ease back up
particleSystem.setActiveCount(Math.floor(maxParticles * quality));
requestAnimationFrame(animate);
}
6. Touch input instead of mouse/keyboard
Every desktop-first interaction pattern needs a touch-friendly equivalent — there is no hover, no right-click, and often no keyboard at all:
| Desktop pattern | Mobile-friendly equivalent |
|---|---|
| Hover to preview/highlight | Tap to select, with a visible "selected" state (no hover exists) |
| Right-click context menu | Long-press gesture, or a persistent on-screen button |
| Mouse drag to orbit camera | Single-finger drag to orbit, two-finger pinch to zoom, two-finger drag to pan |
| Scroll wheel to zoom | Pinch gesture (touchmove with two active touch points) |
| Keyboard WASD/arrow controls | On-screen virtual joystick or directional buttons |
// Minimal pinch-to-zoom distance tracking
let lastPinchDist = null;
canvas.addEventListener('touchmove', (e) => {
if (e.touches.length === 2) {
const [a, b] = e.touches;
const dist = Math.hypot(a.clientX - b.clientX, a.clientY - b.clientY);
if (lastPinchDist !== null) {
const delta = dist - lastPinchDist;
camera.position.multiplyScalar(1 - delta * 0.003); // zoom by scaling distance
}
lastPinchDist = dist;
e.preventDefault(); // stop the page from scrolling/zooming
}
}, { passive: false });
canvas.addEventListener('touchend', () => { lastPinchDist = null; });
touch-action: none on the
canvas element in CSS alongside { passive: false }
listeners — otherwise the browser's own pinch-zoom/scroll
gestures fight with your own touch handling.
7. Testing on real devices
Chrome DevTools' emulated "mobile" mode (device toolbar) only simulates screen size and touch events — it still runs your shaders on the desktop GPU driver, which gives no useful signal about real mobile GPU performance, precision fallbacks, or thermal behaviour.
-
Connect an Android phone via USB with USB debugging enabled,
then open
chrome://inspecton the desktop — it lists the phone's open tabs with a live "inspect" link into real DevTools running against the actual device. - On iOS, connect an iPhone/iPad via cable, enable Web Inspector in Settings → Safari → Advanced, then open Safari's Develop menu on a Mac to attach to the page running on-device.
- Test on the oldest device your analytics show meaningful traffic from, not your own newest phone — a 60fps result on a current flagship says nothing about the 3-year-old mid-range Android devices that are usually the real long tail.
chrome://gpu on the
connected device itself, or the
3D optimisation
tutorial for LOD and culling techniques that reduce the same
draw-call and triangle budgets discussed in section 4.
Frequently Asked Questions
What will I learn in this tutorial?
Ship a WebGL simulation that actually runs well on phones: pixel ratio capping, shader precision, draw call and texture memory budgets, thermal throttling, touch input, and the mobile-specific bugs that don't show up on desktop.
What topics are covered in this tutorial?
This tutorial covers: A different regime, not a smaller desktop GPU, Cap devicePixelRatio, Shader precision: avoid highp overflow, Draw call & texture memory budgets, Thermal throttling, Touch input instead of mouse/keyboard, Testing on real devices.
How long does this tutorial take?
This tutorial takes approximately 25 minutes to complete.
What prerequisites do I need before starting?
This is a Intermediate-level tutorial — no special preparation beyond basic JavaScript is assumed.