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:
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.
What the presets actually change
- Particle count: reduces physics work quadratically (SPH is O(N²) without a grid).
- Shadow maps: disabled entirely on mobile — saves a full render pass every frame.
- Pixel ratio: capped at 1.5 on mid and 1.0 on low — halving pixel ratio cuts fragment work by 4×.
- Wave grid resolution: ocean and shallow-water sims use a 64×64 grid on low vs 256×256 on high.
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:
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:
-
The dynamic toolbar: scrolling changes viewport
height, causing canvas resize events in the middle of a gesture.
Fix: use
window.visualViewportheight, notwindow.innerHeight. -
Scroll bounce override: the default rubber-band
scroll cancels
touchmoveevents needed for orbital controls. Fix:touch-action: noneon the canvas element. - Safari WebGL texture size limit: some older iPhones cap textures at 4096×4096. Clamp all render target sizes.
-
Memory pressure events: iOS fires
memorywarning(via a polyfill bridge) when RAM is low. We respond by dropping to thelowpreset.
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:
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
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.