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.
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
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
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.
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);
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
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
}
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;
}
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.