Oto fakt, który zaskakuje wielu deweloperów: na laptopie średniej
klasy wywołanie ctx.fillRect(x, y, 1, 1) 100 000 razy
na klatkę jest z grubsza 40 razy wolniejsze niż zapisanie tych
samych 100 000 pikseli bezpośrednio do
Uint8ClampedArray i przesłanie ich jednym wywołaniem
ctx.putImageData(). API Canvas 2D jest pięknie
ekspresywne, ale każde wywołanie rysowania niesie ze sobą znaczący
narzut na wywołanie: walidację stanu, sprawdzanie trybu
kompozycji i przejścia między silnikiem JavaScript a
kompozytorem GPU. Zrozumienie, gdzie ten narzut się kryje — i jak
go ominąć — to pojedyncza najbardziej wpływowa optymalizacja, jaką
można zastosować w symulacjach cząstek, automatach komórkowych czy
jakiejkolwiek wizualizacji aktualizującej wiele pikseli na klatkę.
1. ImageData — pisz piksele, nie wywołania rysowania
Standardowy wzorzec renderowania n kolorowych kropek wygląda tak:
// Wolno: jedno wywołanie rysowania na cząstkę
for (const p of particles) {
ctx.fillStyle = `rgb(${p.r},${p.g},${p.b})`;
ctx.fillRect(p.x | 0, p.y | 0, 1, 1);
}
Każda iteracja zmienia fillStyle (parsowanie
łańcucha), a następnie wykonuje wywołanie rysowania. Dla 50 000
cząstek przy 60 kl./s daje to 3 miliony parsowań łańcuchów i
wywołań rysowania na sekundę — znacznie więcej pracy niż same
obliczenia na pikselach.
Szybka ścieżka tworzy bufor bazowy raz, zapisuje bajty RGBA bezpośrednio i przesyła je jednym wywołaniem:
// Szybko: jedno putImageData na klatkę
const W = canvas.width, H = canvas.height;
const imageData = ctx.createImageData(W, H);
const buf = imageData.data; // Uint8ClampedArray, długość = W*H*4
function renderParticles(particles) {
// Wyczyść do koloru tła (0,0,0,255 = nieprzezroczysta czerń)
buf.fill(0);
buf.fill(255, 3, buf.length); // ustaw każdy bajt alfa
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);
}
Czystsze czyszczenie tła to buf.fill(0), po którym
ustawiane są bajty alfa — albo utrzymanie osobnego widoku
Uint32Array nad tym samym ArrayBuffer do
zapisów po 4 bajty naraz:
const buf32 = new Uint32Array(imageData.data.buffer);
// Spakuj RGBA do jednego uint32 (little-endian: ABGR w pamięci)
const colour = (255 << 24) | (b << 16) | (g << 8) | r;
buf32[(p.y | 0) * W + (p.x | 0)] = colour;
Sztuczka z Uint32Array zmniejsza ciało wewnętrznej
pętli z czterech zapisów bajtowych do jednego zapisu 32-bitowego.
W naszych symulacjach reakcji-dyfuzji (canvas 1280×720, wszystkie
piksele aktywne) samo to skraca czas renderowania z ~11 ms do
~3 ms na klatkę na Chrome 124 / M2 MacBook Air.
2. Bufory cząstek Float32Array — unikaj narzutu obiektów
Obiekty JavaScript niosą ukryty narzut klas. Tablica 100 000
obiektów cząstek — każdy z właściwościami x,
y, vx, vy, r,
g, b, life — zmusza silnik
JS do wykonywania odczytów właściwości, które trudno
zoptymalizować nawet przy ukrytych klasach V8, ponieważ
właściwości cząstek są aktualizowane w miejscu, a kształt każdego
obiektu może się rozjeżdżać.
Struktura tablic (Structure-of-Arrays, SoA) z użyciem tablic typowanych całkowicie eliminuje ten narzut:
const N = 100_000; // maks. liczba cząstek
const px = new Float32Array(N); // pozycje x
const py = new Float32Array(N); // pozycje y
const pvx = new Float32Array(N); // prędkości x
const pvy = new Float32Array(N); // prędkości y
const pr = new Uint8Array(N); // kanał czerwony
const pg = new Uint8Array(N); // kanał zielony
const pb = new Uint8Array(N); // kanał niebieski
const pl = new Float32Array(N); // czas życia (sekundy)
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;
}
}
Ponieważ każda tablica to spójny blok pamięci o stałym rozmiarze elementu, prefetcher CPU i auto-wektoryzator SIMD mogą przetwarzać serie elementów bez podążania za wskaźnikami. W naszej symulacji boids przejście z tablicy obiektów na typowane tablice SoA zredukowało aktualizację fizyki z ~8 ms do ~1,4 ms dla 80 000 agentów — przyspieszenie 5,7× przy identycznej logice.
Matematyka pozostaje niezmieniona: aktualizacja pozycji to po
prostu p += v × Δt, przyspieszenie to
a = F / m. Przy tablicach typowanych po prostu
wyrażasz to w układzie, który silnik może agresywnie
zoptymalizować.
3. Częściowe przemalowywanie dirty-rect — pomijaj niezmienione piksele
Pełne wywołanie putImageData() na canvasie przesyła
W × H × 4 bajtów niezależnie od tego,
ile pikseli się zmieniło. Dla canvasu 1920×1080 to ~8 MB na
klatkę przez mostek CPU → kompozytor. Gdy tylko mały
obszar canvasu jest aktywny — na przykład wybuch cząstek w lewym
dolnym rogu albo zlokalizowany front reakcji — znacznie taniej
jest przesłać tylko prostokąt otaczający zmienione piksele.
Podejście polega na śledzeniu dirty rectangle, który rozszerza się, by objąć każdy zmodyfikowany piksel, a następnie przesłaniu tylko tego pod-obrazu:
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; // nic się nie zmieniło
const dw = dirtyX1 - dirtyX0 + 1;
const dh = dirtyY1 - dirtyY0 + 1;
// Wyodrębnij pod-obraz z pełnego bufora
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);
// Zresetuj dirty rect
dirtyX0 = W; dirtyY0 = H; dirtyX1 = 0; dirtyY1 = 0;
}
Gdy tylko 20% canvasu jest aktywne, redukuje to przepustowość
przesyłania o mniej więcej 80%. W naszym symulatorze wzorów
Turinga (reakcja-dyfuzja) w rozdzielczości 1024×768 przemalowanie
dirty-rect zredukowało koszt putImageData z 6,2 ms do
1,3 ms na klatkę w początkowej stabilnej fazie, gdy porusza się
tylko front fali.
Zastrzeżenie: dirty rects pomagają tylko wtedy,
gdy aktywność jest rzeczywiście rzadka. Jeśli każdy piksel
aktualizuje się co klatkę (np. symulacja płynu na cały ekran),
utrzymuj pojedynczy pełny ImageData i wywołuj
putImageData(imageData, 0, 0) bez narzutu
wyodrębniania pod-obrazu.
Grupowanie zmian stanu kontekstu
Nawet używając putImageData do pracy na pikselach,
często mieszasz to z wywołaniami API Canvas 2D dla nakładek UI,
etykiet tekstowych czy kształtów wektorowych. Za każdym razem, gdy
zmieniasz ctx.strokeStyle, ctx.lineWidth,
ctx.font lub ctx.globalAlpha,
przeglądarka może opróżnić oczekujące operacje rysowania. Grupuj
wywołania o wspólnym stanie, by zminimalizować takie opróżnienia:
// Źle: przełączanie stanu między każdą ścieżką
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();
}
// Dobrze: sortuj wg stanu, grupuj obrysy
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();
Dla złożonych scen ctx.save() /
ctx.restore() są kosztowne, bo tworzą zrzut całego
stanu grafiki. Preferuj jawne śledzenie stanu w JavaScripcie —
zapamiętuj bieżący strokeStyle i wywołuj setter tylko
wtedy, gdy faktycznie się zmienia.
Podsumowanie benchmarków
| Technika | Scenariusz | Przed | Po | Przyspieszenie |
|---|---|---|---|---|
| ImageData + Uint32Array | 100k cząstek, 1280×720 | ~41 ms/klatkę | ~4 ms/klatkę | 10× |
| SoA Float32Array | Aktualizacja fizyki 80k boids | ~8 ms | ~1,4 ms | 5,7× |
| Przemalowanie dirty-rect | Front fali reakcji-dyfuzji, 1024×768 | ~6,2 ms | ~1,3 ms | 4,8× |
| Grupowane zmiany stanu | 5k segmentów linii, mieszany stan | ~3,8 ms | ~0,9 ms | 4,2× |
Wszystkie liczby zmierzono w Chrome 124 na M2 MacBook Air przy
użyciu nawiasowania performance.now(), uśredniając
po 300 klatkach. Wyniki będą się różnić w zależności od
urządzenia i przeglądarki, ale względna kolejność jest spójna.
Reguła kciuka: jeśli twoja symulacja
aktualizuje więcej niż ~1000 pikseli lub cząstek na klatkę,
przechodź natychmiast na ImageData + tablice
typowane. API wywołań rysowania Canvas 2D zostało zaprojektowane
do grafiki wektorowej, nie do rastrowej symulacji.
Wypróbuj to sam
Powyższe techniki są widoczne w akcji w wielu symulacjach mysimulator.uk — otwórz zakładkę Performance w DevTools podczas ich działania, by zobaczyć budżet klatki w praktyce:
- 🌊 Symulacja płynu SPH — bufory SoA na Float32Array dla 60 tys. cząstek hydrodynamiki wygładzonych cząstek; zapis pikseli przez ImageData dla wizualizacji pola gęstości.
- 🧬 Reakcja-dyfuzja (wzory Turinga) — pełny ImageData + optymalizacja dirty-rect podczas propagacji frontu fali; pakowanie kolorów Uint32Array ABGR.
- 🐦 Stado boids — typowane tablice SoA dla stada 80 000 agentów; grupowane wywołania ścieżek Canvas 2D dla śladów skrzydeł.
Myśl na zakończenie
API Canvas 2D nie jest wolne — po prostu jest zaprojektowane do
innego przypadku użycia niż symulacja o wysokiej przepustowości.
W momencie, gdy znajdujesz się w pętli wywołującej prymitywy
rysowania na każdą encję, opuściłeś jego zamierzony zakres
projektowy. ImageData, tablice typowane, dirty rects
i grupowany stan nie są egzotycznymi trikami; to standardowy
zestaw narzędzi dla każdego, kto buduje wizualizacje na żywo przy
60 kl./s. Stosuj je w tej kolejności: najpierw surowy dostęp do
pikseli, potem układ pamięci, następnie obszar przesyłania, na
końcu grupowanie wywołań rysowania — bo każdy kolejny zysk jest
mniejszy od poprzedniego, ale razem składają się na symulacje,
które są naprawdę płynne nawet na skromnym sprzęcie.
Gdy wyciśniesz już wszystko, co możliwe z Canvas 2D, kolejnym krokiem są shadery obliczeniowe WebGL lub WebGPU — ale wiele symulacji nigdy nie musi się tam posunąć. Uzyskanie 10× większej szybkości w ramach tego samego API to zwykle bardziej pragmatyczne zwycięstwo.