60 кадрів/с на кожному пристрої — інженерія продуктивності для браузерних симуляцій

Бюджетний смартфон на Snapdragon 695 має відтворювати наші симуляції частинок так само плавно, як настільний RTX 4090 — просто з меншою кількістю частинок. Цей девлог детально описує ривок продуктивності 4 кварталу 2027 року: дисципліну фіксованого кроку часу, просторове хешування для відсіювання частинок, винесення фізики у Web Worker та адаптивне обмеження за батареєю, яке тримає вентилятори тихими, а батареї — живими.

Показники платформи, 4 квартал 2027

345+
Активних симуляцій
80+
Категорій
68
Дописів у блозі

Контент хвилі 10

Цей девлог завершує хвилю 10, у межах якої вийшло ще два ґрунтовні дописи поряд із цим:

Допис Тема Дата
🔥 Spotlight #20 Термодинаміка та теплопередача Лис 2027
🧮 Learning #19 Алгоритми та обчислювальна складність Лис 2027
⚙️ Девлог #29 60 кадрів/с на кожному пристрої (цей допис) Гру 2027

Проблема: реальність бюджету кадру

Браузерна симуляція працює всередині requestAnimationFrame, який спрацьовує з частотою 60 Гц на більшості дисплеїв — даючи рівно 16,67 мс на кадр. Кожен кадр має встигнути обробити фізику, виявлення зіткнень і рендеринг. На сучасному настільному комп'ютері з дискретною відеокартою цей бюджет цілком комфортний. На середньому телефоні зразка 2019 року він уже тісний. А на бюджетному пристрої з повільним JavaScript-двигуном це обмеження, яке безжально карає будь-яке алгоритмічне марнотратство.

До спринту оптимізації 4 кварталу 2027 року 47 наших симуляцій опускалися нижче 55 кадрів/с на нашому еталонному бюджетному пристрої (Snapdragon 695, 4 ГБ ОЗП, Chrome 119). Після спринту всі 345 симуляцій утримують щонайменше 59 кадрів/с за стандартної кількості частинок, з автоматичним зниженням якості для повільніших пристроїв.

Техніка 1: фіксований крок часу з інтерполяцією

Пастка змінного dt

Більшість навчальних матеріалів із симуляцій інтегрують фізику за фактичною дельтою кадру: position += velocity * dt. Це здається логічним — якщо останній кадр тривав 33 мс замість 16 мс, фізика просуває рух на ці 33 мс, зберігаючи симуляцію «реальним часом». Але змінний dt руйнує детермінованість, спричиняє дрейф енергії в симплектичних інтеграторах і змушує виявлення зіткнень пропускати швидкі об'єкти (ефект тунелювання).

Патерн ігрового циклу з фіксованим кроком часу

const FIXED_DT = 1 / 60;   // крок фізики 16,67 мс

let accumulator = 0;
let prevTime = performance.now();

function loop(now) {
  const frameTime = Math.min((now - prevTime) / 1000, 0.25);
  prevTime = now;
  accumulator += frameTime;

  while (accumulator >= FIXED_DT) {
    physicsStep(FIXED_DT);   // детерміновано, обмежено
    accumulator -= FIXED_DT;
  }

  const alpha = accumulator / FIXED_DT;  // коефіцієнт інтерполяції
  render(alpha);             // інтерполяція між попереднім і поточним станом
  requestAnimationFrame(loop);
}

Переваги фіксованого dt:
  - Детермінований повтор (той самий вхід → той самий вихід)
  - Стабільне збереження енергії (симплектичний Ейлер, Верле)
  - Безпечне CCD: перевірка тунелювання на кожному інтервалі FIXED_DT
  - Обмежений бюджет фізики навіть на повільних кадрах (максимум 0,25 с)

Вартість:
  - Стан потрібно зберігати двічі (попередній + поточний) для інтерполяції
  - Додаткова пам'ять: 2× масиви частинок ≈ незначно при типовому n

Обмеження frameTime у 0,25 с — критично важливе: якщо вкладка згорнута у фон або пристрій перевантажений, ми не дозволяємо акумулятору вибухнути у «спіраль смерті», яка ставить у чергу сотні кроків фізики й заморожує сторінку.

Техніка 2: просторове хешування для зіткнень частинок

Від O(n²) до O(n)

Наївне виявлення зіткнень перевіряє кожну частинку проти кожної іншої: O(n²). При 500 частинках це 125 000 перевірок на крок; при 5 000 частинках — 12,5 мільйона. Стандартне рішення — просторове хешування: розділити простір на сітку комірок, призначити кожній частинці свою комірку, а потім перевіряти лише частинки в тій самій або сусідніх комірках.

Просторове хешування — побудова та запит

Розмір комірки: обирайте ≈ 2r (діаметр частинки) для щільного пакування

Хеш-функція:
  cellX = Math.floor(px / cellSize)
  cellY = Math.floor(py / cellSize)
  hash  = ((cellX * 73856093) ^ (cellY * 19349663)) % tableSize

Фаза побудови (кожен кадр):
  O(n): перебрати всі частинки, вставити в хеш-таблицю
  Використовуйте плаский Uint32Array (уникайте навантаження на GC від об'єктних мап)

Фаза запиту (для кожної частинки):
  Перевірити 9 сусідніх комірок (околиця 3×3)
  Середня кількість сусідів: 4-9 частинок (щільно) проти 0-1 (розріджено)
  Найгірший випадок: O(n), лише якщо всі частинки в одній комірці

Очікувана складність: O(n) побудова + O(n·k) запит, k = середня кількість сусідів
  k ≈ π·(2r)²·ρ = константа для фіксованої густини → загалом O(n)

Розташування в пам'яті (дружнє до кешу):
  pos[0..n]: x₀,y₀,x₁,y₁,…  (перемежовано, Float32Array)
  vel[0..n]: vx₀,vy₀,vx₁,vy₁,… (перемежовано, Float32Array)
  Уникає "гонитви за вказівниками", тримає L1/L2 кеш гарячим під час ітерації

У наших симуляціях частинок (Бульбашки, Дим, Броунівський рух, Молекулярна динаміка) ця зміна скоротила час виявлення зіткнень з ~8 мс до ~0,4 мс для 2000 частинок — прискорення у 20 разів, яке звільнило бюджет кадру для більшої кількості частинок і якіснішого рендерингу.

Техніка 3: винесення фізики у Web Worker

Звільнення основного потоку

Основний потік браузера обробляє JavaScript, розмітку, відмальовування, події вводу та скидання буфера Canvas/WebGL. Конкуренція з усім цим за бюджет фізики спричиняє «смикання» — помітні ривки навіть тоді, коли середній час кадру вкладається в бюджет, бо раптова пауза збирача сміття або подія DOM може вкрасти 2–5 мс. Рішення — виконувати фізику в окремому Web Worker, обмінюючись станом через SharedArrayBuffer.

Архітектура Web Worker + SharedArrayBuffer

Основний потік                   Воркер фізики
──────────────────────────────────────────────────────
SharedArrayBuffer (SAB):
  positionsBuf: Float32Array(n*2)
  velocitiesBuf: Float32Array(n*2)
  controlBuf:   Int32Array(4)   [крок, готово, пауза, n]

// Основний потік: запуск воркера
const worker = new Worker('physics.js');
worker.postMessage({ sab: positionsBuf.buffer, ... });

// requestAnimationFrame: рендер останнього стану
function render(alpha) {
  Atomics.wait(controlBuf, 1, 0);  // чекати прапорець "готово"
  drawParticles(positionsBuf);
  Atomics.store(controlBuf, 0, 1); // сигнал: наступний крок
}

// physics.js Worker: цикл фізики
self.onmessage = ({ data }) => {
  const pos = new Float32Array(data.sab);
  while (true) {
    Atomics.wait(data.ctrl, 0, 0); // чекати сигнал кроку
    physicsStep(pos, vel, FIXED_DT);
    Atomics.store(data.ctrl, 1, 1); // сигнал: готово
  }
};

Переваги:
  - Фізика ніколи не блокує ввід чи відмальовування
  - Використовує друге ядро процесора на всіх сучасних телефонах
  - Паузи GC в основному потоці не гальмують фізику

Застереження:
  - SharedArrayBuffer вимагає заголовків COOP/COEP
  - Atomics.wait() блокує потік, що викликає — використовуйте обережно
  - Без серіалізації: без витрат на структуроване клонування

Вимога безпеки: SharedArrayBuffer потребує HTTP-заголовків Cross-Origin-Opener-Policy: same-origin та Cross-Origin-Embedder-Policy: require-corp. Вони налаштовані в конфігурації Cloudflare Pages для всіх маршрутів симуляцій.

Техніка 4: адаптивна якість залежно від батареї

Тримаємо телефони прохолодними

Фізика частинок на 60 кадрів/с швидко виснажує батарею телефону і провокує термічне дроселювання — тактова частота CPU/GPU пристрою падає, щоб розсіяти тепло, спричиняючи різкий обвал FPS, який гірший за стабільно нижчу продуктивність. Наша система, що враховує стан батареї, визначає рівень заряду та статус підключення до мережі через Battery Status API і автоматично регулює кількість частинок та якість симуляції.

Адаптивна якість — інтеграція з Battery API

// Рівні якості
const QUALITY = {
  high:   { particles: 1.0, substeps: 4, shadows: true  },
  medium: { particles: 0.6, substeps: 2, shadows: false },
  low:    { particles: 0.3, substeps: 1, shadows: false },
};

async function selectQuality() {
  if (!navigator.getBattery) return 'high';
  const bat = await navigator.getBattery();

  if (bat.charging)          return 'high';
  if (bat.level > 0.5)       return 'high';
  if (bat.level > 0.2)       return 'medium';
  return 'low';
}

// Резервний варіант за часом кадру (немає Battery API на десктопі)
let slowFrames = 0;
function checkPerformance(dt) {
  if (dt > 20) slowFrames++;        // 20мс = 50 кадрів/с
  else         slowFrames = Math.max(0, slowFrames - 1);
  if (slowFrames > 60) downscale(); // 1 секунда повільних кадрів
}

// Результат на еталонному пристрої (Snapdragon 695):
//   За замовчуванням (high):  ~58 кадрів/с, розряд батареї 12%/год
//   Адаптивно (medium):       ~60 кадрів/с, розряд батареї  7%/год
//   Адаптивно (low):          ~60 кадрів/с, розряд батареї  4%/год

Техніка 5: оптимізації на стороні рендерингу

Фізика — лише половина бюджету кадру. Виклики відмальовування WebGL та накладні витрати Three.js становлять другу половину у складних сценах. Найбільший вплив мали чотири зміни:

Результати бенчмарків

До / після — еталонний пристрій (Snapdragon 695)

Симуляція                   До (FPS)     Після (FPS)  Застосована техніка
──────────────────────────────────────────────────────────────────────
Зграї птахів (500 агентів)     38           60        Просторове хеш + Worker
N-тіл (200 тіл)                44           60        Барнс-Гат + Worker
Броунівський рух (2000)        29           60        Просторове хеш + SAB
Бульбашки (1000)                41           60        Інстансований меш + хеш
Леннард-Джонс МД (800)          35           60        Фіксований dt + Worker
SPH-рідина (600 точок)          22           58        Хеш + Worker + SAB
Клітинний автомат (256²)        60           60        Вже оптимально
Сортування (1000 стовпців)      60           60        Чистий JS, без змін

Загалом: 47 симуляцій нижче 55 кадрів/с → 0 симуляцій нижче 55 кадрів/с
Середній приріст FPS на бюджетному пристрої: +19 кадрів/с

Основні матеріали хвилі 10

Spotlight #20 — Термодинаміка

Spotlight #20 охоплює наші шість симуляцій термодинаміки з акцентом на те, чому ентропія і потік тепла — найглибші закони фізики. Гід проводить через закон охолодження Ньютона, межу ефективності Карно, розподіли швидкостей Максвелла-Больцмана, квантове виправлення Планка для ультрафіолетової катастрофи, формування конвективних структур Бенара та фазові діаграми бінарних сплавів. Кожна симуляція пояснена з перших принципів.

Learning #19 — Алгоритми та складність

Learning #19 об'єднує всі симуляції алгоритмів під кутом обчислювальної складності. Починаючи з нотації Big-O, гід охоплює сортування (наочна різниця O(n²) проти O(n log n)), пошук шляху A* проти Дейкстри (порівняння відвіданих вузлів), пошук із поверненням для задачі N ферзів з поширенням обмежень, NP-складну задачу комівояжера з локальним пошуком 2-opt та генетичні алгоритми, що використовують TSP як ландшафт пристосованості.

Що далі — анонс хвилі 11

1 квартал 2028 року буде зосереджений на двох недостатньо висвітлених розділах каталогу симуляцій: колекції з хімії та колекції із соціальних наук/економіки, які значно розрослися після поштовху прикладних категорій із девлогу #26. Дописи хвилі 11: