60 FPS na każdym urządzeniu — inżynieria wydajności dla symulacji przeglądarkowych

Budżetowy smartfon ze Snapdragonem 695 powinien odtwarzać nasze symulacje cząstek równie płynnie jak stacjonarny RTX 4090 — tyle że z mniejszą liczbą cząstek. Ten devlog szczegółowo opisuje zryw wydajnościowy 4 kwartału 2027 roku: dyscyplinę stałego kroku czasowego, haszowanie przestrzenne do odsiewania cząstek, przeniesienie fizyki do Web Workera oraz adaptacyjne ograniczanie zależne od baterii, które utrzymuje wentylatory w ciszy, a baterie przy życiu.

Liczby platformy, 4 kwartał 2027

345+
Aktywnych symulacji
80+
Kategorii
68
Wpisów na blogu

Zawartość fali 10

Ten devlog zamyka falę 10, w ramach której ukazały się jeszcze dwa dogłębne wpisy obok tego:

Wpis Temat Data
🔥 Spotlight #20 Termodynamika i przenoszenie ciepła Lis 2027
🧮 Learning #19 Algorytmy i złożoność obliczeniowa Lis 2027
⚙️ Devlog #29 60 FPS na każdym urządzeniu (ten wpis) Gru 2027

Problem: rzeczywistość budżetu klatki

Symulacja przeglądarkowa działa wewnątrz requestAnimationFrame, który na większości ekranów odpala się z częstotliwością 60 Hz — dając dokładnie 16,67 ms na klatkę. Każda klatka musi obsłużyć fizykę, wykrywanie kolizji i renderowanie. Na nowoczesnym komputerze stacjonarnym z dedykowaną kartą graficzną ten budżet jest komfortowy. Na telefonie ze średniej półki z 2019 roku jest już napięty. Na budżetowym urządzeniu z wolnym silnikiem JavaScript to ograniczenie bezlitośnie karze każde algorytmiczne marnotrawstwo.

Przed sprintem optymalizacyjnym 4 kwartału 2027 roku 47 naszych symulacji spadało poniżej 55 FPS na naszym referencyjnym słabym urządzeniu (Snapdragon 695, 4 GB RAM, Chrome 119). Po sprincie wszystkie 345 symulacji osiąga co najmniej 59 FPS przy domyślnej liczbie cząstek, z automatycznym zmniejszaniem jakości na wolniejszych urządzeniach.

Technika 1: stały krok czasowy z interpolacją

Pułapka zmiennego dt

Większość samouczków dotyczących symulacji całkuje fizykę na podstawie rzeczywistej delty klatki: position += velocity * dt. Wydaje się to rozsądne — jeśli ostatnia klatka trwała 33 ms zamiast 16 ms, fizyka przesuwa ruch o te 33 ms, utrzymując symulację w „czasie rzeczywistym”. Ale zmienne dt niszczy determinizm, powoduje dryf energii w integratorach symplektycznych i sprawia, że wykrywanie kolizji pomija szybko poruszające się obiekty (tunelowanie).

Wzorzec pętli gry ze stałym krokiem czasowym

const FIXED_DT = 1 / 60;   // krok fizyki 16,67 ms

let accumulator = 0;
let prevTime = performance.now();

function loop(now) {
  const frameTime = Math.min((now - prevTime) / 1000, 0.25);
  prevTime = now;
  accumulator += frameTime;

  while (accumulator >= FIXED_DT) {
    physicsStep(FIXED_DT);   // deterministyczny, ograniczony
    accumulator -= FIXED_DT;
  }

  const alpha = accumulator / FIXED_DT;  // współczynnik interpolacji
  render(alpha);             // interpolacja między poprzednim a bieżącym stanem
  requestAnimationFrame(loop);
}

Zalety stałego dt:
  - Deterministyczne odtwarzanie (te same dane wejściowe → ten sam wynik)
  - Stabilne zachowanie energii (symplektyczny Euler, Verlet)
  - Bezpieczne CCD: sprawdzanie tunelowania w każdym interwale FIXED_DT
  - Ograniczony budżet fizyki nawet przy wolnych klatkach (limit 0,25 s)

Koszt:
  - Stan musi być przechowywany dwukrotnie (poprzedni + bieżący) do interpolacji
  - Dodatkowa pamięć: 2× tablice cząstek ≈ pomijalne przy typowym n

Limit 0,25 s dla frameTime jest kluczowy: jeśli karta przeglądarki zostanie zminimalizowana w tle albo urządzenie jest przeciążone, nie pozwalamy akumulatorowi eksplodować w „spiralę śmierci”, która kolejkuje setki kroków fizyki i zamraża stronę.

Technika 2: haszowanie przestrzenne dla kolizji cząstek

Od O(n²) do O(n)

Naiwne wykrywanie kolizji sprawdza każdą cząstkę względem każdej innej: O(n²). Przy 500 cząstkach to 125 000 sprawdzeń na krok; przy 5000 cząstkach — 12,5 miliona. Standardowym rozwiązaniem jest haszowanie przestrzenne: podziel przestrzeń na siatkę komórek, przypisz każdą cząstkę do jej komórki, a następnie sprawdzaj tylko cząstki w tej samej lub sąsiednich komórkach.

Haszowanie przestrzenne — budowa i zapytania

Rozmiar komórki: wybierz ≈ 2r (średnica cząstki) dla gęstego upakowania

Funkcja haszująca:
  cellX = Math.floor(px / cellSize)
  cellY = Math.floor(py / cellSize)
  hash  = ((cellX * 73856093) ^ (cellY * 19349663)) % tableSize

Faza budowy (każda klatka):
  O(n): iteracja po wszystkich cząstkach, wstawianie do tabeli haszującej
  Użyj płaskiego Uint32Array (unikaj obciążenia GC przez mapy obiektów)

Faza zapytania (dla każdej cząstki):
  Sprawdź 9 sąsiednich komórek (sąsiedztwo 3×3)
  Średnia liczba sąsiadów: 4-9 cząstek (gęsto) vs 0-1 (rzadko)
  Najgorszy przypadek: O(n) tylko gdy wszystkie cząstki są w jednej komórce

Oczekiwana złożoność: O(n) budowa + O(n·k) zapytanie, k = średnia liczba sąsiadów
  k ≈ π·(2r)²·ρ = stała dla ustalonej gęstości → łącznie O(n)

Układ pamięci (przyjazny dla pamięci podręcznej):
  pos[0..n]: x₀,y₀,x₁,y₁,…  (przeplecione, Float32Array)
  vel[0..n]: vx₀,vy₀,vx₁,vy₁,… (przeplecione, Float32Array)
  Unika „gonienia za wskaźnikami", utrzymuje pamięć L1/L2 rozgrzaną podczas iteracji

W naszych symulacjach cząstek (Bąbelki, Dym, Ruchy Browna, Dynamika molekularna) ta zmiana skróciła czas wykrywania kolizji z ~8 ms do ~0,4 ms dla 2000 cząstek — 20-krotne przyspieszenie, które zwolniło budżet klatki na więcej cząstek i wyższą jakość renderowania.

Technika 3: przeniesienie fizyki do Web Workera

Uwalnianie wątku głównego

Wątek główny przeglądarki obsługuje JavaScript, układ strony, malowanie, zdarzenia wejściowe oraz zrzuty bufora Canvas/WebGL. Rywalizacja z tym wszystkim o budżet fizyki powoduje szarpnięcia — widoczne zacinanie się nawet wtedy, gdy średni czas klatki mieści się w budżecie, ponieważ nagła pauza GC lub zdarzenie DOM może ukraść 2–5 ms. Rozwiązaniem jest uruchomienie fizyki w dedykowanym Web Workerze, dzielącym stan za pomocą SharedArrayBuffer.

Architektura Web Worker + SharedArrayBuffer

Wątek główny                     Worker fizyki
──────────────────────────────────────────────────────
SharedArrayBuffer (SAB):
  positionsBuf: Float32Array(n*2)
  velocitiesBuf: Float32Array(n*2)
  controlBuf:   Int32Array(4)   [krok, gotowe, pauza, n]

// Wątek główny: uruchomienie workera
const worker = new Worker('physics.js');
worker.postMessage({ sab: positionsBuf.buffer, ... });

// requestAnimationFrame: renderowanie ostatniego stanu
function render(alpha) {
  Atomics.wait(controlBuf, 1, 0);  // czekaj na flagę "gotowe"
  drawParticles(positionsBuf);
  Atomics.store(controlBuf, 0, 1); // sygnał: kolejny krok
}

// physics.js Worker: pętla fizyki
self.onmessage = ({ data }) => {
  const pos = new Float32Array(data.sab);
  while (true) {
    Atomics.wait(data.ctrl, 0, 0); // czekaj na sygnał kroku
    physicsStep(pos, vel, FIXED_DT);
    Atomics.store(data.ctrl, 1, 1); // sygnał: gotowe
  }
};

Zalety:
  - Fizyka nigdy nie blokuje wejścia ani malowania
  - Wykorzystuje drugi rdzeń CPU na wszystkich nowoczesnych telefonach
  - Pauzy GC w wątku głównym nie zatrzymują fizyki

Zastrzeżenia:
  - SharedArrayBuffer wymaga nagłówków COOP/COEP
  - Atomics.wait() blokuje wątek wywołujący — używaj ostrożnie
  - Bez serializacji: bez kosztu strukturalnego klonowania

Wymóg bezpieczeństwa: SharedArrayBuffer wymaga nagłówków HTTP Cross-Origin-Opener-Policy: same-origin oraz Cross-Origin-Embedder-Policy: require-corp. Są one ustawione w konfiguracji Cloudflare Pages dla wszystkich tras symulacji.

Technika 4: adaptacyjna jakość zależna od baterii

Utrzymywanie telefonów w chłodzie

Uruchamianie fizyki cząstek przy 60 FPS szybko rozładowuje baterię telefonu i wywołuje throttling termiczny — częstotliwość taktowania CPU/GPU urządzenia spada, aby rozproszyć ciepło, powodując nagły spadek FPS, gorszy niż stabilnie niższa wydajność. Nasz system świadomy stanu baterii wykrywa poziom naładowania i stan ładowania za pomocą Battery Status API i automatycznie dostosowuje liczbę cząstek oraz jakość symulacji.

Adaptacyjna jakość — integracja z Battery API

// Poziomy jakości
const QUALITY = {
  high:   { particles: 1.0, substeps: 4, shadows: true  },
  medium: { particles: 0.6, substeps: 2, shadows: false },
  low:    { particles: 0.3, substeps: 1, shadows: false },
};

async function selectQuality() {
  if (!navigator.getBattery) return 'high';
  const bat = await navigator.getBattery();

  if (bat.charging)          return 'high';
  if (bat.level > 0.5)       return 'high';
  if (bat.level > 0.2)       return 'medium';
  return 'low';
}

// Rozwiązanie awaryjne oparte na czasie klatki (brak Battery API na desktopie)
let slowFrames = 0;
function checkPerformance(dt) {
  if (dt > 20) slowFrames++;        // 20ms = 50 FPS
  else         slowFrames = Math.max(0, slowFrames - 1);
  if (slowFrames > 60) downscale(); // 1 sekunda wolnych klatek
}

// Wynik na urządzeniu referencyjnym (Snapdragon 695):
//   Domyślnie (high):    ~58 FPS, zużycie baterii 12%/godz.
//   Adaptacyjnie (medium): ~60 FPS, zużycie baterii  7%/godz.
//   Adaptacyjnie (low):    ~60 FPS, zużycie baterii  4%/godz.

Technika 5: optymalizacje po stronie renderowania

Fizyka to tylko połowa budżetu klatki. Wywołania rysowania WebGL i narzut Three.js stanowią drugą połowę w złożonych scenach. Cztery zmiany miały największy wpływ:

Wyniki benchmarków

Przed / po — urządzenie referencyjne (Snapdragon 695)

Symulacja                   Przed (FPS)  Po (FPS)  Zastosowana technika
──────────────────────────────────────────────────────────────────────
Stada ptaków (500 agentów)     38           60        Haszowanie przestrzenne + Worker
N-ciał (200 ciał)              44           60        Barnes-Hut + Worker
Ruch Browna (2000)             29           60        Haszowanie przestrzenne + SAB
Bąbelki (1000)                 41           60        Mesh instancjonowany + hasz
Lennard-Jones MD (800)         35           60        Stały dt + Worker
Płyn SPH (600 pkt)             22           58        Hasz + Worker + SAB
Automat komórkowy (256²)       60           60        Już optymalne
Sortowanie (1000 słupków)      60           60        Czysty JS, bez zmian

Ogółem: 47 symulacji poniżej 55 FPS → 0 symulacji poniżej 55 FPS
Średni wzrost FPS na słabszym urządzeniu: +19 FPS

Najważniejsze materiały fali 10

Spotlight #20 — Termodynamika

Spotlight #20 obejmuje nasze sześć symulacji termodynamiki, koncentrując się na tym, dlaczego entropia i przepływ ciepła są najgłębszymi prawami fizyki. Przewodnik omawia prawo chłodzenia Newtona, granicę sprawności Carnota, rozkłady prędkości Maxwella-Boltzmanna, kwantową poprawkę Plancka dla katastrofy ultrafioletowej, formowanie się wzorów konwekcji Bénarda oraz diagramy fazowe stopów binarnych. Każda symulacja jest wyjaśniona od podstaw.

Learning #19 — Algorytmy i złożoność

Learning #19 łączy wszystkie symulacje algorytmów w jednej perspektywie złożoności obliczeniowej. Zaczynając od notacji Big-O, przewodnik obejmuje sortowanie (naoczna różnica O(n²) kontra O(n log n)), wyszukiwanie ścieżki A* kontra Dijkstra (porównanie odwiedzonych węzłów), nawracanie dla problemu N hetmanów z propagacją ograniczeń, NP-trudny problem komiwojażera z lokalnym przeszukiwaniem 2-opt oraz algorytmy genetyczne wykorzystujące TSP jako krajobraz przystosowania.

Co dalej — zapowiedź fali 11

1 kwartał 2028 roku skupi się na dwóch niedostatecznie rozwiniętych obszarach katalogu symulacji: kolekcji chemicznej oraz kolekcji nauk społecznych i ekonomii, które znacząco się rozrosły od pchnięcia kategorii stosowanych z devlogu #26. Wpisy fali 11: