Ось факт, що дивує багатьох розробників: на ноутбуці середнього
класу виклик ctx.fillRect(x, y, 1, 1) 100 000 разів
на кадр приблизно у 40 разів повільніший за запис тих самих
100 000 пікселів безпосередньо у Uint8ClampedArray і
вивантаження результату одним викликом
ctx.putImageData(). Canvas 2D API чудово виразний,
але кожен виклик відмалювання несе суттєві накладні витрати на
виклик: валідацію стану, перевірку режиму композитування та
перехід через міст між движком JavaScript і GPU-компоузером.
Розуміння того, де ховаються ці накладні витрати, і як їх обійти —
найвпливовіша оптимізація, яку можна застосувати до симуляцій
частинок, клітинних автоматів або будь-якої візуалізації, що
оновлює багато пікселів на кадр.
1. ImageData — запис пікселів замість викликів відмалювання
Стандартний патерн для рендерингу n кольорових точок виглядає так:
// Slow: one draw call per particle
for (const p of particles) {
ctx.fillStyle = `rgb(${p.r},${p.g},${p.b})`;
ctx.fillRect(p.x | 0, p.y | 0, 1, 1);
}
Кожна ітерація змінює fillStyle (розбір рядка), а
потім виконує виклик відмалювання. Для 50 000 частинок при 60 fps
це 3 мільйони розборів рядків і викликів відмалювання за секунду —
набагато більше роботи, ніж сама математика пікселів.
Швидкий шлях створює буфер один раз, записує байти RGBA безпосередньо і вивантажує його одним викликом:
// Fast: one putImageData per frame
const W = canvas.width, H = canvas.height;
const imageData = ctx.createImageData(W, H);
const buf = imageData.data; // Uint8ClampedArray, length = W*H*4
function renderParticles(particles) {
// Clear to background colour (0,0,0,255 = opaque black)
buf.fill(0);
buf.fill(255, 3, buf.length); // set every alpha byte
for (const p of particles) {
const i = ((p.y | 0) * W + (p.x | 0)) * 4;
buf[i] = p.r;
buf[i + 1] = p.g;
buf[i + 2] = p.b;
buf[i + 3] = 255;
}
ctx.putImageData(imageData, 0, 0);
}
Чистіше очищення фону — це buf.fill(0), а потім
встановлення байтів альфа-каналу — або підтримка окремого
Uint32Array-подання над тим самим
ArrayBuffer для запису по 4 байти за раз:
const buf32 = new Uint32Array(imageData.data.buffer);
// Pack RGBA into a single uint32 (little-endian: ABGR in memory)
const colour = (255 << 24) | (b << 16) | (g << 8) | r;
buf32[(p.y | 0) * W + (p.x | 0)] = colour;
Прийом із Uint32Array скорочує тіло внутрішнього
циклу з чотирьох записів байтів до одного 32-бітного запису. У
наших симуляціях реакції-дифузії (полотно 1280×720, усі пікселі
активні) сама лише ця зміна скорочує час рендерингу з ~11 мс до
~3 мс на кадр у Chrome 124 / M2 MacBook Air.
2. Буфери частинок на Float32Array — уникнення накладних витрат об'єктів
Об'єкти JavaScript мають накладні витрати прихованих класів. Масив
зі 100 000 об'єктів-частинок — кожен із властивостями
x, y, vx, vy,
r, g, b, life
— змушує рушій JS виконувати пошук властивостей, який складно
оптимізувати навіть за допомогою прихованих класів V8, оскільки
властивості частинок оновлюються на місці, а форма кожного об'єкта
може розходитися.
Структура-масивів (SoA) на основі типізованих масивів повністю усуває ці накладні витрати:
const N = 100_000; // max particles
const px = new Float32Array(N); // x positions
const py = new Float32Array(N); // y positions
const pvx = new Float32Array(N); // x velocities
const pvy = new Float32Array(N); // y velocities
const pr = new Uint8Array(N); // red channel
const pg = new Uint8Array(N); // green channel
const pb = new Uint8Array(N); // blue channel
const pl = new Float32Array(N); // lifetime (seconds)
function updateParticles(dt, count) {
for (let i = 0; i < count; i++) {
pvx[i] += ax * dt;
pvy[i] += (ay + GRAVITY) * dt;
px[i] += pvx[i] * dt;
py[i] += pvy[i] * dt;
pl[i] -= dt;
}
}
Оскільки кожен масив — це суцільний блок пам'яті з фіксованим розміром елемента, префетчер CPU та автовекторизатор SIMD можуть обробляти послідовності елементів без переходів за вказівниками. У нашій симуляції boids перехід від масиву об'єктів до типізованих масивів SoA скоротив оновлення фізики з ~8 мс до ~1.4 мс для 80 000 агентів — приріст у 5.7× за незмінної логіки.
Математика не змінюється: оновлення позиції — це просто
p += v × Δt, прискорення —
a = F / m. З типізованими масивами ви лише
виражаєте це у форматі, який рушій може агресивно оптимізувати.
3. Часткове перемальовування dirty-rectangle — пропуск незмінних пікселів
Повнокадровий виклик putImageData() вивантажує
W × H × 4 байтів незалежно від того,
скільки пікселів насправді змінилося. Для полотна 1920×1080 це
~8 МБ на кадр через міст CPU → компоузер. Коли активна
лише невелика область полотна — наприклад, сплеск частинок у
лівому нижньому куті або локалізований фронт реакції — набагато
дешевше вивантажувати лише обмежувальний прямокутник змінених
пікселів.
Підхід полягає в тому, щоб відстежувати «брудний» прямокутник, що розширюється, охоплюючи кожен змінений піксель, а потім вивантажувати лише це під-зображення:
let dirtyX0 = W, dirtyY0 = H, dirtyX1 = 0, dirtyY1 = 0;
function markDirty(x, y) {
if (x < dirtyX0) dirtyX0 = x;
if (y < dirtyY0) dirtyY0 = y;
if (x > dirtyX1) dirtyX1 = x;
if (y > dirtyY1) dirtyY1 = y;
}
function flushDirtyRect() {
if (dirtyX1 < dirtyX0) return; // nothing changed
const dw = dirtyX1 - dirtyX0 + 1;
const dh = dirtyY1 - dirtyY0 + 1;
// Extract sub-image from full buffer
const sub = ctx.createImageData(dw, dh);
for (let row = 0; row < dh; row++) {
const srcOff = ((dirtyY0 + row) * W + dirtyX0) * 4;
sub.data.set(buf.subarray(srcOff, srcOff + dw * 4), row * dw * 4);
}
ctx.putImageData(sub, dirtyX0, dirtyY0);
// Reset dirty rect
dirtyX0 = W; dirtyY0 = H; dirtyX1 = 0; dirtyY1 = 0;
}
Коли активні лише 20% полотна, це зменшує обсяг вивантаження
приблизно на 80%. У нашому симуляторі патернів Тюрінга
(реакція-дифузія) на роздільності 1024×768 очищення через
dirty-rect скоротило витрати putImageData з 6.2 мс до
1.3 мс на кадр на ранній стабільній фазі, коли рухається лише
фронт хвилі.
Застереження: dirty rects допомагають лише тоді,
коли активність справді розріджена. Якщо кожен піксель
оновлюється щокадру (наприклад, повноекранна симуляція рідини),
підтримуйте єдиний повнокадровий ImageData і
викликайте putImageData(imageData, 0, 0) без
накладних витрат на вилучення під-зображення.
Групування змін стану контексту
Навіть при використанні putImageData для роботи з
пікселями ви часто поєднуєте це з викликами Canvas 2D API для
накладок інтерфейсу, текстових міток або векторних фігур. Щоразу,
коли ви змінюєте ctx.strokeStyle,
ctx.lineWidth, ctx.font або
ctx.globalAlpha, браузер може скинути незавершені
операції відмалювання. Групуйте виклики за спільним станом, щоб
мінімізувати такі скидання:
// Bad: state toggles between every path
for (const seg of segments) {
ctx.strokeStyle = seg.highlighted ? '#f00' : '#888';
ctx.lineWidth = seg.highlighted ? 2 : 1;
ctx.beginPath();
ctx.moveTo(seg.x0, seg.y0);
ctx.lineTo(seg.x1, seg.y1);
ctx.stroke();
}
// Good: sort by state, batch strokes
const normal = segments.filter(s => !s.highlighted);
const highlighted = segments.filter(s => s.highlighted);
ctx.strokeStyle = '#888';
ctx.lineWidth = 1;
ctx.beginPath();
for (const s of normal) { ctx.moveTo(s.x0, s.y0); ctx.lineTo(s.x1, s.y1); }
ctx.stroke();
ctx.strokeStyle = '#f00';
ctx.lineWidth = 2;
ctx.beginPath();
for (const s of highlighted) { ctx.moveTo(s.x0, s.y0); ctx.lineTo(s.x1, s.y1); }
ctx.stroke();
Для складних сцен ctx.save() / ctx.restore()
коштовні, оскільки вони роблять знімок усього графічного стану.
Натомість краще явно відстежувати стан у JavaScript — зберігати
поточний strokeStyle і викликати сеттер лише тоді,
коли він справді змінюється.
Підсумок бенчмарків
| Техніка | Сценарій | До | Після | Приріст |
|---|---|---|---|---|
| ImageData + Uint32Array | 100 тис. частинок, 1280×720 | ~41 мс/кадр | ~4 мс/кадр | 10× |
| SoA Float32Array | оновлення фізики 80 тис. boids | ~8 мс | ~1.4 мс | 5.7× |
| Очищення dirty-rect | фронт хвилі реакції-дифузії, 1024×768 | ~6.2 мс | ~1.3 мс | 4.8× |
| Групування змін стану | 5 тис. відрізків ліній, змішаний стан | ~3.8 мс | ~0.9 мс | 4.2× |
Усі цифри виміряні в Chrome 124 на M2 MacBook Air за допомогою
обрамлення performance.now(), усереднено за 300
кадрів. Результати залежатимуть від пристрою й браузера, але
відносне співвідношення залишається сталим.
Правило великого пальця: якщо ваша симуляція
оновлює понад ~1 000 пікселів або частинок на кадр, негайно
переходьте на ImageData + типізовані масиви. API
викликів відмалювання Canvas 2D створений для векторної графіки,
а не для растрової симуляції.
Спробуйте самі
Описані вище техніки можна побачити в дії на кількох симуляціях mysimulator.uk — відкрийте вкладку Performance у DevTools під час їх роботи, щоб побачити бюджет кадру в дії:
- 🌊 Симуляція рідини SPH — буфери SoA на Float32Array для 60 тис. частинок згладженої гідродинаміки частинок; запис пікселів через ImageData для візуалізації поля густини.
- 🧬 Реакція-дифузія (патерни Тюрінга) — повнокадровий ImageData з оптимізацією dirty-rect під час поширення фронту хвилі; пакування Uint32Array ABGR для кольорового відображення.
- 🐦 Зграя Boids — типізовані масиви SoA для зграї з 80 000 агентів; групування викликів шляхів Canvas 2D для слідів крил.
Підсумкова думка
Canvas 2D API не повільний — він просто розрахований на інший
сценарій використання, ніж високопродуктивна симуляція. Щойно ви
опиняєтеся в циклі, що викликає примітиви відмалювання для кожної
сутності, ви вийшли за межі його проєктного задуму.
ImageData, типізовані масиви, dirty rects і
групування стану — це не екзотичні хитрощі, а стандартний
інструментарій для будь-кого, хто створює живі візуалізації на
60 fps. Застосовуйте їх у такому порядку: спершу прямий доступ до
пікселів, потім розташування пам'яті, потім область вивантаження,
останнім — групування викликів відмалювання, — бо кожен наступний
приріст менший за попередній, але разом вони складаються в
симуляції, що відчуваються справді плавними навіть на скромному
залізі.
Коли ви вичавили з Canvas 2D усе можливе, наступний крок — WebGL або WebGPU з обчислювальними шейдерами, але багатьом симуляціям ніколи не доводиться заходити настільки далеко. Отримати 10-кратне прискорення в межах того самого API зазвичай є більш прагматичною перемогою.