60 FPS у фізиці на Canvas 2D

Canvas 2D достатньо швидкий для більшості фізичних симуляцій — якщо знати, яких вузьких місць уникати. Ось вісім конкретних технік, що утримують симуляції цього проєкту напрочуд плавними навіть на середньому обладнанні.

Понад половина симуляцій на цьому сайті використовують звичайний Canvas 2D. Клітинні автомати, агентні моделі, графіки обробки сигналів і симуляції дорожнього руху — усі вони працюють у <canvas> без жодного натяку на WebGL. Більшість стабільно тримають 60 FPS на середньому ноутбуці 2019 року — але це сталося не випадково. Ось що робить різницю.

Вузькі місця, які варто знати

Проблеми продуктивності Canvas 2D зводяться до трьох категорій: забагато викликів малювання, забагато роботи з пікселями за кадр і забагато навантаження на збирач сміття. Виправте ці три речі — і більшість симуляцій стануть плавними.

Типова стеля
5 000
викликів fillCircle() при 60 FPS (мобільні)
З ImageData
160×160
піксельна сітка при 60 FPS, лише CPU
1

Використовуйте типізовані масиви для стану частинок

Найбільший виграш для систем частинок — заміна масиву об'єктів паралельними типізованими масивами. Властивості об'єктів живуть у купі пам'яті; звернення до них щокадру змушує «блукати» за вказівниками і створює навантаження на збирач сміття. Float32Array розташований у пам'яті суцільно і чудово кешується процесором.

// ❌ Повільно — купа об'єктів, навантаження на GC
const particles = Array.from({ length: 5000 }, () => ({ x: 0, y: 0, vx: 0, vy: 0 }));

// ✅ Швидко — суцільний Float32Array, нуль GC
const N = 5000;
const px = new Float32Array(N);  // позиції x
const py = new Float32Array(N);  // позиції y
const vx = new Float32Array(N);  // швидкості x
const vy = new Float32Array(N);  // швидкості y
2

Об'єднуйте виклики малювання в один шлях

Кожна пара beginPath() / fill() — це скидання стану до GPU-компоузера. Для N однакових кіл використовуйте єдиний складений шлях:

ctx.beginPath();
for (let i = 0; i < N; i++) {
  ctx.moveTo(px[i] + r, py[i]);
  ctx.arc(px[i], py[i], r, 0, Math.PI * 2);
}
ctx.fillStyle = '#22d3ee';
ctx.fill();  // один виклик GPU для всіх N кіл
3

Використовуйте ImageData для сіткових симуляцій

Клітинні автомати та патерни реакції-дифузії оновлюють кожну клітинку щокадру. Виклик fillRect() для кожної клітинки — це катастрофа: тисячі викликів малювання за кадр. Натомість пишіть напряму в Uint8ClampedArray і передавайте його одним викликом putImageData():

const W = 320, H = 240;
const img = ctx.createImageData(W, H);
const px = img.data;  // Uint8ClampedArray, RGBA

function render(grid) {
  for (let i = 0; i < W * H; i++) {
    const v = grid[i] * 255 | 0;
    px[i * 4]     = v;     // R
    px[i * 4 + 1] = v;     // G
    px[i * 4 + 2] = v;     // B
    px[i * 4 + 3] = 255;   // A (непрозорий)
  }
  ctx.putImageData(img, 0, 0);  // одне завантаження на GPU
}

Порада: Працюйте зі зменшеною роздільною здатністю (наприклад, 160×160) і масштабуйте за допомогою ctx.scale() або CSS image-rendering: pixelated. 4-кратне масштабування виглядає чітко і зменшує кількість записів пікселів у 16 разів.

4

Фіксований крок часу з акумулятором дельта-часу

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

const DT = 1 / 60;  // фіксований крок фізики (секунди)
let acc = 0;
let prev = performance.now();

function loop(now) {
  acc += Math.min((now - prev) / 1000, 0.1);  // обмеження 100 мс, щоб пережити перемикання вкладки
  prev = now;
  while (acc >= DT) {
    updatePhysics(DT);
    acc -= DT;
  }
  render();
  requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
5

Offscreen canvas для статичних шарів

Якщо ваша симуляція має статичний фон (сітку, осі чи базове зображення), намалюйте його один раз на offscreen-полотні й викликайте drawImage(), щоб перенести його на основне полотно щокадру. Це набагато дешевше, ніж перемальовувати фон заново.

// Налаштування (одноразово)
const bg = document.createElement('canvas');
bg.width = canvas.width;
bg.height = canvas.height;
const bgCtx = bg.getContext('2d');
drawGrid(bgCtx);  // затратно — виконується один раз

// Цикл рендерингу (щокадру)
ctx.drawImage(bg, 0, 0);  // швидкий блит
drawParticles(ctx);        // змінюються лише частинки
6

Уникайте виділень пам'яті всередині циклу

Будь-який new Array(), {} або деструктуризація всередині циклу рендерингу виділяє пам'ять у купі, яку зрештою збере збирач сміття. Пауза GC спричиняє помітні затримки. Виділяйте все заздалегідь, поза циклом:

// ❌ Виділяє пам'ять щокадру
function update() {
  const forces = particles.map(p => ({ fx: 0, fy: gravity }));
}

// ✅ Повторне використання заздалегідь виділених масивів
const fx = new Float32Array(N);
const fy = new Float32Array(N);
function update() {
  fy.fill(gravity);  // скидання, без виділення пам'яті
}
7

Просторове хешування для N-тіл або зіткнень

Груба перевірка сусідів за O(N²) вбиває продуктивність вже понад ~500 частинок. Просте просторове хешування скорочує це до середнього O(N):

const CELL = 20;  // розмір клітинки сітки в пікселях
const grid = new Map();

function hashCell(x, y) { return `${x / CELL | 0},${y / CELL | 0}`; }

// Вставка
grid.clear();
for (let i = 0; i < N; i++) {
  const key = hashCell(px[i], py[i]);
  if (!grid.has(key)) grid.set(key, []);
  grid.get(key).push(i);
}

// Запит сусідів частинки i (перевіряється лише 9 клітинок)
function neighbours(i) {
  const cx = px[i] / CELL | 0, cy = py[i] / CELL | 0;
  const result = [];
  for (let dx = -1; dx <= 1; dx++)
    for (let dy = -1; dy <= 1; dy++)
      (grid.get(`${cx+dx},${cy+dy}`) || []).forEach(j => result.push(j));
  return result;
}
8

Спочатку профілюйте, потім оптимізуйте

Chrome DevTools → вкладка Performance → запишіть 5 секунд роботи симуляції. Шукайте довгі жовті JS-задачі на «полум'яному» графіку. Найвищі стовпчики точно вкажуть, яку функцію оптимізувати. Не вгадуйте — профайлер показує правду.

Поширена помилка: оптимізувати цикл фізики, коли вузьким місцем насправді є виклики малювання (або навпаки). Профілюйте перед тим, як витрачати годину на рефакторинг не того, що треба.

Складаємо все разом

Симуляція «Реакція-дифузія» на цьому сайті виконує сітку Ґрея-Скотта 160×160 при 60 FPS, використовуючи техніки 3, 4 і 6 вище. Симуляція Boids 2D обробляє 1000 агентів за допомогою технік 1, 2, 4 і 7. Модель дорожнього руху (NaSch) оновлює кільцеву дорогу зі 200 клітинок за допомогою техніки 3 і відображає просторово-часову діаграму з 80 рядків через єдиний виклик putImageData за кадр.

Жодна з них не потребувала Three.js чи WebGL. Різницю створили саме наведені вище оптимізації — а не рендерер.