Понад половина симуляцій на цьому сайті використовують звичайний
Canvas 2D. Клітинні автомати, агентні моделі, графіки обробки
сигналів і симуляції дорожнього руху — усі вони працюють у
<canvas> без жодного натяку на WebGL. Більшість
стабільно тримають 60 FPS на середньому ноутбуці 2019 року — але це
сталося не випадково. Ось що робить різницю.
Вузькі місця, які варто знати
Проблеми продуктивності Canvas 2D зводяться до трьох категорій: забагато викликів малювання, забагато роботи з пікселями за кадр і забагато навантаження на збирач сміття. Виправте ці три речі — і більшість симуляцій стануть плавними.
Використовуйте типізовані масиви для стану частинок
Найбільший виграш для систем частинок — заміна масиву об'єктів
паралельними типізованими масивами. Властивості об'єктів живуть у
купі пам'яті; звернення до них щокадру змушує «блукати» за
вказівниками і створює навантаження на збирач сміття.
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
Об'єднуйте виклики малювання в один шлях
Кожна пара 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 кіл
Використовуйте 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 разів.
Фіксований крок часу з акумулятором дельта-часу
Оновлення фізики має бути незалежним від частоти кадрів рендерингу. Використовуйте акумулятор з фіксованим кроком часу, щоб симуляції були детермінованими і не «вибухали» при високій частоті кадрів:
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);
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); // змінюються лише частинки
Уникайте виділень пам'яті всередині циклу
Будь-який 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); // скидання, без виділення пам'яті
}
Просторове хешування для 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;
}
Спочатку профілюйте, потім оптимізуйте
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. Різницю створили саме наведені вище оптимізації — а не рендерер.