Девлог #15 — Як змусити симуляції працювати на мобільних: дотик, продуктивність і батарея

Третина наших відвідувачів заходить із телефонів. Утримувати частоту кадрів вище 30 FPS на Android середнього класу за наявності 225 різних симуляцій виявилося окремим інженерним викликом.

33 %
частка мобільного трафіку
приріст FPS (симуляції з частинками)
–42 %
початковий бандл Three.js
225
адаптованих симуляцій

Проблема

Фізичні симуляції — це дорого: кожен кадр виконує крок фізики, оновлює дані буфера, малює геометрію та виконує фрагментні шейдери. На десктопі з дискретним GPU це тривіально. На середньому телефоні 2020 року з спільним тепловим бюджетом і 60-герцевим AMOLED це спричиняє помітні ривки, перегрів і швидке розряджання батареї.

Базове профілювання на Samsung Galaxy A52 показало, що симуляція рідини SPH просідала до 11 FPS при повній кількості частинок. Це було неприйнятно.

Крок 1: уніфіковані Pointer Events (більше не дотик проти миші)

Ранні симуляції використовували окремі слухачі touchstart / mousedown. Це означало дублювання коду, непослідовну поведінку та пропущені взаємодії, коли браузер перетворював події дотику на події миші.

Рішенням стало перейти повністю на Pointer Events API, який об'єднує мишу, дотик і стилус в одну модель подій:

// До: два окремі обробники canvas.addEventListener('mousedown', onMouseDown); canvas.addEventListener('touchstart', onTouchStart, { passive: false }); // Після: один обробник для всіх типів вводу canvas.addEventListener('pointerdown', onPointerDown); canvas.addEventListener('pointermove', onPointerMove); canvas.addEventListener('pointerup', onPointerUp);

На сенсорних пристроях e.pointerType === 'touch'. Мультитач реалізується через setPointerCapture та відстеження кількох ідентифікаторів вказівника — той самий API, що й для графічних планшетів.

Крок 2: адаптивні рівні якості

Ми визначаємо клас пристрою при запуску та встановлюємо пресет якості. Жодних дій користувача не потрібно.

function detectTier() { const isMobile = /Mobi|Android/.test(navigator.userAgent); const cores = navigator.hardwareConcurrency || 2; const mem = navigator.deviceMemory || 1; // ГБ (приблизно) 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 }, };

Що насправді змінюють пресети

Крок 3: відкладене завантаження Three.js

Three.js r160 важить 624 КБ у мінімізованому вигляді. Завантажувати його при кожному відвідуванні сторінки — навіть тих, де користувач жодного разу не взаємодіє з симуляцією, — було марнотратно. Ми перейшли на динамічний імпорт, що запускається при першій взаємодії з вказівником:

// Не імпортуємо на рівні модуля — чекаємо на взаємодію 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 });

Для симуляцій, які мають запускатися автоматично (як на головній сторінці), імпорт запускається після спрацювання IntersectionObserver — коли симуляція потрапляє в область видимості.

Крок 4: особливості viewport в iOS

Safari на iPhone має кілька особливостей поведінки, які ламають фізичні симуляції, якщо їх не обробити:

Крок 5: тротлінг з урахуванням заряду батареї

Battery Status API дозволяє знижувати якість симуляції, коли телефон не заряджається, а рівень батареї низький — це запобігає витрачанню симуляцією останніх відсотків заряду:

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

Ми також реагуємо на подію visibilitychange: коли вкладка прихована, requestAnimationFrame автоматично зупиняється, але ми також призупиняємо цикл кроку фізики, щоб не витрачати процесор у фоновому режимі даремно.

Результати

До
Рідина SPH — 11 FPS на A52
Симуляція тканини — 18 FPS на A52
Початковий бандл — 624 КБ (Three.js)
Дотик — непослідовне перетягування
Після
Рідина SPH — 34 FPS (середній пресет)
Симуляція тканини — 58 FPS (середній пресет)
Початковий бандл — 0 КБ (відкладено)
Дотик — pointer events, уніфіковано

Чого ми не робили: фізичні рушії на WASM. Ми оцінили Rapier (Rust/WASM), але накладні витрати на серіалізацію масивів позицій через межу WASM щокадру перевищили виграш у обчисленнях для розмірів наших симуляцій (менше 2000 тіл). JavaScript-рушій Cannon-es, налаштований під мобільні пристрої, на практиці виявився швидшим.

Що далі

Наступний рубіж продуктивності — фізика у фоновому потоці (Web Workers + SharedArrayBuffer). Ми розповімо про архітектуру в нашому наступному пості з порадами.