Dlaczego krok czasowy ma znaczenie
Całkowanie numeryczne równań fizyki nie jest dokładne. Całkowanie Eulera kumuluje błąd proporcjonalny do dt² na krok. Jeśli podwoisz krok czasowy, poczworujesz błąd. Co ważniejsze, wiele układów staje się numerycznie niestabilnych powyżej krytycznego kroku czasowego — energia rośnie wykładniczo, a symulacja eksploduje, niezależnie od tego, jak fizycznie dokładne są równania.
Jednocześnie pętla renderowania przeglądarki nie tyka w
deterministycznym tempie. requestAnimationFrame
próbuje synchronizować się z odświeżaniem wyświetlacza
(60/120/144 Hz), ale karty w tle, obciążenie systemu i
skalowanie wyświetlacza mogą wytworzyć dowolny interwał między
4 ms a 60+ ms. Jeśli podasz to zmienne dt bezpośrednio
do fizyki, symulacja zachowuje się różnie na każdej maszynie.
Wzorzec 1 — zmienne dt (naiwny)
❌ Tego nie rób
Podawanie performance.now() - lastTime
bezpośrednio do physics.step(dt). Jedna pauza
GC i twoje obiekty przenikają przez ściany. Niedeterministyczne.
Różne wyniki na każdej maszynie.
✅ Kiedy jest to akceptowalne
Czyste systemy cząstek bez wykrywania kolizji, efekty czysto wizualne, animacje. Wszędzie tam, gdzie poprawność fizyczna nie ma znaczenia, a drgania są niewidoczne.
Wzorzec 2 — akumulator o stałym dt (zalecany)
Akumuluj czas rzeczywisty, a następnie konsumuj go w porcjach o stałym rozmiarze. To oddziela fizykę od tempa renderowania i gwarantuje identyczne wyniki symulacji niezależnie od liczby klatek na sekundę.
const FIXED_DT = 1 / 120; // fizyka 120 Hz, niezależna od tempa renderowania
const MAX_STEPS = 8; // zabezpieczenie — zapobiega spirali śmierci
let accumulator = 0;
let lastTime = performance.now() / 1000;
function loop(now) {
requestAnimationFrame(loop);
const elapsed = now / 1000 - lastTime;
lastTime = now / 1000;
accumulator += Math.min(elapsed, MAX_STEPS * FIXED_DT); // ograniczenie skoku opóźnienia
while (accumulator >= FIXED_DT) {
physics.step(FIXED_DT); // zawsze wywoływane z dokładnie tym samym dt
accumulator -= FIXED_DT;
}
const alpha = accumulator / FIXED_DT; // 0..1 — do interpolacji renderowania
renderer.render(physics.interpolate(alpha));
}
Spirala śmierci: jeśli krok fizyki zajmuje
więcej czasu rzeczywistego niż FIXED_DT,
akumulator rośnie szybciej, niż się opróżnia. Symulacja
zostaje w tyle, próbuje nadrobić, zwalnia jeszcze bardziej,
zostaje jeszcze bardziej w tyle. Limit MAX_STEPS
wymienia dokładność na przetrwanie — symulacja zwalnia w
czasie rzeczywistym zamiast blokować przeglądarkę.
Wzorzec 3 — sub-stepping dla sztywnych więzów
Sztywne układy (ciała sztywne, tkaniny, sprężyny o wysokiej sztywności) wymagają bardzo małego dt dla stabilności. Zamiast uruchamiać całą symulację przy 2000 Hz, uruchom ją przy 60 Hz, ale podziel każdy krok na N wewnętrznych podkroków:
function step(dt) {
const SUB_STEPS = 8;
const subDt = dt / SUB_STEPS;
for (let i = 0; i < SUB_STEPS; i++) {
integrateBodies(subDt); // velocity Verlet lub semi-uwikłany Euler
resolveConstraints(subDt); // PBD, impuls lub siły karne
}
}
Wzorzec 4 — interpolacja renderowania
Przy każdej konfiguracji stała-fizyka/zmienne-renderowanie stan
renderowania jest zawsze nieco „w tyle” — pokazuje ostatni
zatwierdzony stan fizyki, a nie dokładny stan bieżący. By
wyeliminować widoczne drgania, interpoluj między poprzednim
stanem fizyki a bieżącym, wykorzystując skumulowany ułamek
(alpha):
// W klasie ciała fizycznego:
class Body {
commit() {
this.prevPos.copy(this.pos);
this.prevRot.copy(this.rot);
}
interpolate(alpha) {
renderPos.lerpVectors(this.prevPos, this.pos, alpha);
renderRot.slerpQuaternions(this.prevRot, this.rot, alpha);
}
}
Deterministyczny replay i synchronizacja sieciowa
Symulacja o stałym dt, z tym samym ziarnem PRNG i stanem początkowym, wyprodukuje dokładnie ten sam wynik przy każdym uruchomieniu — to umożliwia:
- Systemy replay — nagrywaj tylko dane wejściowe (naciśnięcia klawiszy, zmiany parametrów) i odtwarzaj je przez tę samą symulację, by odtworzyć dokładnie dowolny moment.
- Sieciowy lockstep — wszyscy klienci postępują razem; host transmituje jedynie dane wejściowe, a nie pozycje. Jedno sprawdzenie na krok: jeśli hasze stanu się rozbiegają, wykrywana jest desynchronizacja.
- Testy regresyjne — uruchom symulację przez 10 000 kroków w trybie headless i sprawdź, czy kluczowe wartości pasują do zapisanego zrzutu stanu.
Zastrzeżenie dotyczące determinizmu zmiennoprzecinkowego: arytmetyka IEEE 754 jest deterministyczna tylko dla tej samej kolejności instrukcji. Różne przeglądarki, poziomy JIT i architektury procesorów mogą dawać nieznacznie różne wyniki. Dla prawdziwego determinizmu międzyplatformowego użyj arytmetyki stałoprzecinkowej na liczbach całkowitych lub ogranicz się do 32-bitowych floatów z jawną kontrolą FMA.