Devlog #15 — Jak sprawiliśmy, że symulacje działają na urządzeniach mobilnych: dotyk, wydajność i bateria

Jedna trzecia naszych odwiedzających wchodzi z telefonów. Utrzymanie liczby klatek powyżej 30 FPS na Androidzie średniej klasy przy 225 różnych symulacjach okazało się osobnym wyzwaniem inżynierskim.

33%
udział ruchu mobilnego
poprawa FPS (symulacje cząstek)
–42%
początkowy pakiet Three.js
225
dostosowanych symulacji

Problem

Symulacje fizyczne są kosztowne: każda klatka wykonuje krok fizyki, aktualizuje dane buforów, rysuje geometrię i wykonuje shadery fragmentów. Na desktopie z dedykowanym GPU to trywialne. Na telefonie średniej klasy z 2020 roku, dzielącym budżet termiczny i wyświetlaczem AMOLED 60 Hz, powoduje to widoczne zacinanie, przegrzewanie i drenaż baterii.

Nasze bazowe profilowanie na Samsungu Galaxy A52 pokazało, że płyn SPH spadał do 11 FPS przy pełnej liczbie cząstek. To było nie do przyjęcia.

Krok 1: ujednolicone zdarzenia wskaźnika (koniec touch vs mouse)

Wczesne symulacje używały osobnych nasłuchiwaczy touchstart / mousedown. Oznaczało to zduplikowany kod, niespójne zachowanie i pominięte interakcje, gdy przeglądarka konwertowała zdarzenia dotykowe na zdarzenia myszy.

Rozwiązaniem było przełączenie wszystkiego na Pointer Events API, które ujednolica mysz, dotyk i rysik w jednym modelu zdarzeń:

// Przed: dwa osobne handlery canvas.addEventListener('mousedown', onMouseDown); canvas.addEventListener('touchstart', onTouchStart, { passive: false }); // Po: jeden handler dla wszystkich typów wejścia canvas.addEventListener('pointerdown', onPointerDown); canvas.addEventListener('pointermove', onPointerMove); canvas.addEventListener('pointerup', onPointerUp);

Na urządzeniach dotykowych e.pointerType === 'touch'. Multi-touch jest dostępny przez setPointerCapture i śledzenie wielu identyfikatorów wskaźników — to samo API co dla tabletów piórkowych.

Krok 2: adaptacyjne poziomy jakości

Wykrywamy klasę urządzenia przy starcie i ustawiamy preset jakości. Bez żadnej akcji użytkownika.

function detectTier() { const isMobile = /Mobi|Android/.test(navigator.userAgent); const cores = navigator.hardwareConcurrency || 2; const mem = navigator.deviceMemory || 1; // GB (przybliżone) 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 }, };

Co faktycznie zmieniają presety

Krok 3: leniwe ładowanie Three.js

Three.js r160 waży 624 KB po minifikacji. Ładowanie go przy każdej wizycie na stronie — w tym na stronach, gdzie użytkownik nigdy nie wchodzi w interakcję z symulacją — było marnotrawstwem. Przełączyliśmy się na dynamiczny import wyzwalany pierwszą interakcją wskaźnika:

// Nie importuj na poziomie modułu — poczekaj na interakcję 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 });

Dla symulacji, które muszą uruchamiać się automatycznie (jak strona główna), import jest wyzwalany po tym, jak IntersectionObserver się uruchomi — gdy symulacja wchodzi w obszar widoczny.

Krok 4: dziwactwa viewportu iOS

Safari na iPhonie ma kilka zachowań, które psują symulacje fizyczne, jeśli nie są obsłużone:

Krok 5: throttling świadomy baterii

Battery Status API pozwala zmniejszyć jakość symulacji, gdy telefon nie jest ładowany, a poziom baterii jest niski — zapobiegając drenowaniu przez symulację ostatnich kilku procent naładowania:

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(); }); }

Reagujemy też na zdarzenie visibilitychange: gdy karta jest ukryta, requestAnimationFrame automatycznie się zatrzymuje, ale wstrzymujemy też pętlę kroku fizyki, by nie marnować CPU w tle.

Wyniki

Przed
Płyn SPH — 11 FPS na A52
Symulacja tkaniny — 18 FPS na A52
Początkowy pakiet — 624 KB (Three.js)
Dotyk — niespójne przeciąganie
Po
Płyn SPH — 34 FPS (preset mid)
Symulacja tkaniny — 58 FPS (preset mid)
Początkowy pakiet — 0 KB (odroczony)
Dotyk — zdarzenia wskaźnika, ujednolicone

Czego nie zrobiliśmy: silników fizyki WASM. Oceniliśmy Rapier (Rust/WASM), ale narzut serializacji tablic pozycji przez granicę WASM w każdej klatce przewyższał oszczędności obliczeniowe dla rozmiarów naszych symulacji (<2000 ciał). JavaScriptowy Cannon-es, dostrojony pod mobile, okazał się w praktyce szybszy.

Co dalej

Kolejnym frontem wydajności jest fizyka w wątku w tle (Web Workery + SharedArrayBuffer). Omówimy architekturę w nadchodzącym wpisie ze wskazówkami.