Devlog #15 — Making Simulations Work on Mobile: Touch, Performance, and Battery

A third of our visitors arrive on phones. Keeping frame rates above 30 FPS on a mid-range Android with 225 different simulations turned out to be its own engineering challenge.

33 %
mobile traffic share
FPS improvement (particle sims)
–42 %
Three.js initial bundle
225
sims adapted

The Problem

Physics simulations are expensive: each frame runs a physics step, updates buffer data, draws geometry and executes fragment shaders. On a desktop with a discrete GPU, this is trivial. On a 2020 mid-range phone with a shared thermal budget and 60 Hz AMOLED, it causes visible stutter, overheating, and battery drain.

Our baseline profiling on a Samsung Galaxy A52 showed SPH fluid dropping to 11 FPS at full particle count. That was unacceptable.

Step 1: Unified Pointer Events (No More Touch vs Mouse)

Early sims used separate touchstart / mousedown listeners. This meant duplicated code, inconsistent behaviour and missed interactions when the browser converted touch events to mouse events.

The fix was to switch everything to the Pointer Events API, which unifies mouse, touch and stylus into one event model:

// Before: two separate handlers canvas.addEventListener('mousedown', onMouseDown); canvas.addEventListener('touchstart', onTouchStart, { passive: false }); // After: one handler for all input types canvas.addEventListener('pointerdown', onPointerDown); canvas.addEventListener('pointermove', onPointerMove); canvas.addEventListener('pointerup', onPointerUp);

On touch devices, e.pointerType === 'touch'. Multi-touch is accessed via setPointerCapture and tracking multiple pointer IDs — the same API as for pen tablets.

Step 2: Adaptive Quality Levels

We detect device tier at startup and set a quality preset. No user action required.

function detectTier() { const isMobile = /Mobi|Android/.test(navigator.userAgent); const cores = navigator.hardwareConcurrency || 2; const mem = navigator.deviceMemory || 1; // GB (approximate) if (!isMobile) return 'high'; if (cores >= 6 && mem >= 4) return 'mid'; return 'low'; } const PRESETS = { high: { particles: 2000, shadowMap: true, pixelRatio: window.devicePixelRatio }, mid: { particles: 800, shadowMap: false, pixelRatio: 1.5 }, low: { particles: 300, shadowMap: false, pixelRatio: 1.0 }, };

What the presets actually change

Step 3: Lazy-Loading Three.js

Three.js r160 is 624 KB minified. Loading it for every page visit — including pages where the user never interacts with a sim — was wasteful. We switched to dynamic import triggered on the first pointer interaction:

// Don't import at module level — wait for interaction let threeLoaded = false; canvas.addEventListener('pointerdown', async () => { if (threeLoaded) return; threeLoaded = true; const THREE = await import('https://cdn.jsdelivr.net/npm/three@0.160/build/three.module.js'); initScene(THREE); }, { once: true });

For simulations that need to autoplay (like the landing page), the import is triggered after the IntersectionObserver fires — when the sim enters the viewport.

Step 4: iOS Viewport Quirks

Safari on iPhone has several behaviours that break physics simulations if not handled:

Step 5: Battery-Aware Throttling

The Battery Status API lets you reduce sim quality when a phone is not charging and battery level is low — preventing the sim from draining the last few percent of charge:

if ('getBattery' in navigator) { navigator.getBattery().then(battery => { function check() { if (!battery.charging && battery.level < 0.15) { setQualityPreset('low'); } } battery.addEventListener('levelchange', check); battery.addEventListener('chargingchange', check); check(); }); }

We also respond to the visibilitychange event: when the tab is hidden, requestAnimationFrame stops automatically, but we also pause the physics step loop to avoid wasting background CPU.

Results

Before
SPH fluid — 11 FPS on A52
Cloth sim — 18 FPS on A52
Initial bundle — 624 KB (Three.js)
Touch — inconsistent drag
After
SPH fluid — 34 FPS (mid preset)
Cloth sim — 58 FPS (mid preset)
Initial bundle — 0 KB (deferred)
Touch — pointer events, unified

What we didn't do: WASM physics engines. We evaluated Rapier (Rust/WASM) but the overhead of serialising position arrays across the WASM boundary each frame exceeded the computation savings for our sim sizes (<2000 bodies). JavaScript Cannon-es, when tuned for mobile, proved faster in practice.

What's Next

The next performance frontier is physics on a background thread (Web Workers + SharedArrayBuffer). We will cover the architecture in our upcoming tips post.