Porada: Wydajność Canvas 2D — ImageData, tablice typowane i dirty rects

Większość wąskich gardeł Canvas 2D nie tkwi w JavaScripcie — tkwi w liczbie wywołań rysowania. Zastąpienie pętli fillRect bezpośrednim zapisem pikseli i częściowym przemalowywaniem może dać 4–10× wzrostu przepustowości bez jakichkolwiek zmian w algorytmie.

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.