60 FPS w fizyce na Canvas 2D

Canvas 2D jest wystarczająco szybki dla większości symulacji fizycznych — jeśli wiesz, jakich wąskich gardeł unikać. Oto osiem konkretnych technik, dzięki którym symulacje tego projektu działają aksamitnie płynnie nawet na sprzęcie średniej klasy.

Ponad połowa symulacji na tej stronie korzysta ze zwykłego Canvas 2D. Automaty komórkowe, modele agentowe, wykresy przetwarzania sygnałów i symulacje ruchu drogowego — wszystkie działają w <canvas> bez odrobiny WebGL. Większość utrzymuje stabilne 60 FPS na laptopie średniej klasy z 2019 roku — ale to nie stało się przypadkiem. Oto co robi różnicę.

Wąskie gardła, które warto znać

Problemy z wydajnością Canvas 2D dzielą się na trzy kategorie: zbyt wiele wywołań rysowania, zbyt dużo pracy na pikselach w każdej klatce oraz zbyt duże obciążenie garbage collectora. Napraw te trzy rzeczy, a większość symulacji będzie płynna.

Typowy sufit
5 000
wywołań fillCircle() przy 60 FPS (mobile)
Z ImageData
160×160
siatka pikseli przy 60 FPS, tylko CPU
1

Używaj tablic typowanych dla stanu cząstek

Największy pojedynczy zysk dla systemów cząstek to zastąpienie tablicy obiektów równoległymi tablicami typowanymi. Właściwości obiektów żyją w pamięci sterty; dostęp do nich w każdej klatce wymusza „gonienie za wskaźnikami" i wywołuje obciążenie GC. Float32Array jest ciągły w pamięci i przyjazny dla pamięci podręcznej procesora.

// ❌ Wolno — sterta obiektów, obciążenie GC
const particles = Array.from({ length: 5000 }, () => ({ x: 0, y: 0, vx: 0, vy: 0 }));

// ✅ Szybko — ciągły Float32Array, zero GC
const N = 5000;
const px = new Float32Array(N);  // pozycje x
const py = new Float32Array(N);  // pozycje y
const vx = new Float32Array(N);  // prędkości x
const vy = new Float32Array(N);  // prędkości y
2

Grupuj wywołania rysowania w jedną ścieżkę

Każda para beginPath() / fill() to zrzut stanu do kompozytora GPU. Dla N identycznych okręgów użyj jednej złożonej ścieżki:

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();  // jedno wywołanie GPU dla wszystkich N okręgów
3

Używaj ImageData dla symulacji siatkowych

Automaty komórkowe i wzorce reakcji-dyfuzji aktualizują każdą komórkę w każdej klatce. Wywoływanie fillRect() dla każdej komórki to katastrofa — tysiące wywołań rysowania na klatkę. Zamiast tego pisz bezpośrednio do Uint8ClampedArray i przekaż go jednym wywołaniem 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 (nieprzezroczysty)
  }
  ctx.putImageData(img, 0, 0);  // jeden upload do GPU
}

Porada: Pracuj w zmniejszonej rozdzielczości (np. 160×160) i skaluj w górę za pomocą ctx.scale() lub CSS image-rendering: pixelated. 4-krotne skalowanie wygląda ostro i redukuje liczbę zapisów pikseli 16-krotnie.

4

Stały krok czasowy z akumulatorem delta-time

Aktualizacja fizyki musi być niezależna od częstotliwości renderowania. Użyj akumulatora ze stałym krokiem czasowym, aby symulacje były deterministyczne i nie „eksplodowały" przy wysokiej liczbie klatek na sekundę:

const DT = 1 / 60;  // stały krok fizyki (sekundy)
let acc = 0;
let prev = performance.now();

function loop(now) {
  acc += Math.min((now - prev) / 1000, 0.1);  // limit 100 ms, by przetrwać zmianę karty
  prev = now;
  while (acc >= DT) {
    updatePhysics(DT);
    acc -= DT;
  }
  render();
  requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
5

Offscreen canvas dla warstw statycznych

Jeśli twoja symulacja ma statyczne tło (siatkę, osie lub obraz bazowy), narysuj je raz na offscreen canvasie i wywołuj drawImage(), aby przenieść je na główny canvas w każdej klatce. To znacznie tańsze niż ponowne rysowanie tła.

// Konfiguracja (jednorazowo)
const bg = document.createElement('canvas');
bg.width = canvas.width;
bg.height = canvas.height;
const bgCtx = bg.getContext('2d');
drawGrid(bgCtx);  // kosztowne — uruchamiane raz

// Pętla renderowania (co klatkę)
ctx.drawImage(bg, 0, 0);  // szybki blit
drawParticles(ctx);        // zmieniają się tylko cząstki
6

Unikaj alokacji w środku pętli

Każde new Array(), {} lub destrukturyzacja wewnątrz pętli renderowania alokuje pamięć na stercie, która zostanie w końcu zebrana przez garbage collector. Pauza GC powoduje widoczne zacinania. Alokuj wszystko z góry, poza pętlą:

// ❌ Alokuje w każdej klatce
function update() {
  const forces = particles.map(p => ({ fx: 0, fy: gravity }));
}

// ✅ Ponowne użycie wcześniej zaalokowanych tablic
const fx = new Float32Array(N);
const fy = new Float32Array(N);
function update() {
  fy.fill(gravity);  // reset, bez alokacji
}
7

Haszowanie przestrzenne dla N-ciał lub kolizji

Sprawdzanie sąsiadów metodą brute-force O(N²) zabija wydajność powyżej ~500 cząstek. Proste haszowanie przestrzenne redukuje to do średnio O(N):

const CELL = 20;  // rozmiar komórki siatki w pikselach
const grid = new Map();

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

// Wstawianie
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);
}

// Zapytanie o sąsiadów cząstki i (tylko 9 komórek do sprawdzenia)
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

Najpierw profiluj, potem optymalizuj

Chrome DevTools → zakładka Performance → nagraj 5 sekund działania symulacji. Szukaj długich żółtych zadań JS na wykresie płomieni. Najwyższe słupki wskażą dokładnie, którą funkcję optymalizować. Nie zgaduj — profiler pokazuje prawdę.

Częsty błąd: optymalizowanie pętli fizyki, gdy wąskim gardłem są w rzeczywistości wywołania rysowania (albo odwrotnie). Profiluj, zanim spędzisz godzinę na refaktoryzacji niewłaściwej rzeczy.

Składając to wszystko razem

Symulacja Reakcji-Dyfuzji na tej stronie uruchamia siatkę Graya-Scotta 160×160 przy 60 FPS, korzystając z technik 3, 4 i 6 powyżej. Symulacja Boids 2D obsługuje 1000 agentów za pomocą technik 1, 2, 4 i 7. Model ruchu drogowego (NaSch) aktualizuje pierścieniową drogę ze 200 komórkami za pomocą techniki 3 i wyświetla diagram czasoprzestrzenny o 80 wierszach za pomocą jednego wywołania putImageData na klatkę.

Żadna z nich nie wymagała Three.js ani WebGL. Różnicę zrobiły właśnie powyższe optymalizacje — nie renderer.