Liczby platformy, 4 kwartał 2027
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:
-
Renderowanie mesh instancjonowanych: zastąpienie
N osobnych obiektów
Meshjednym wywołaniemInstancedMesh(geometry, material, N), co zmniejsza liczbę wywołań rysowania z N do 1. Kluczowe dla symulacji z dużą liczbą cząstek (Bąbelki, N-ciał, Stada ptaków). -
Wycinanie bryły widzenia dla cząstek: pomijanie
aktualizacji macierzy
InstancedMeshdla cząstek poza bryłą widzenia kamery. W szerokich, dalekozasięgowych symulacjach (Stada ptaków) usuwa to nawet 30% przesyłania macierzy. -
Podwójne buforowanie canvas dla symulacji 2D:
rysowanie na pozaekranowym
OffscreenCanvasw Workerze, a następnietransferToImageBitmap()do wątku głównego — bez serializacji, bez kosztu rysowania w wątku głównym. -
Opóźnione zwalnianie zasobów Three.js: wywołanie
geometry.dispose()imaterial.dispose()wrequestIdleCallback, zamiast synchronicznie przy opuszczaniu sceny, co pozwala uniknąć skoku GC powodującego jednoklatkowy biały błysk.
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:
- Spotlight #21: chemia i kinetyka chemiczna — reakcje-dyfuzja, spalanie, równowagi kwasowo-zasadowe oraz spiralne fale Biełousowa-Żabotyńskiego.
- Learning #20: modelowanie agentowe — od stad ptaków po modele epidemiologiczne SIR, optymalizację mrówkową oraz emergentną dynamikę ruchu drogowego.
- Devlog #30: podsumowanie roku — zamknięcie 2027, wkład społeczności i mapa drogowa na 2028 rok.