Wzorce kroku czasowego fizyki — stałe dt, sub-stepping, deterministyczny replay

Wybór strategii kroku czasowego decyduje o tym, czy twoja symulacja eksploduje, drga, czy działa identycznie na laptopie 30 Hz i monitorze do gier 144 Hz. Ta wskazówka omawia standardowe wzorce stosowane w silnikach fizyki, serwerach gier i naszych symulacjach webowych, wraz z gotowymi do skopiowania implementacjami JavaScript.

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ę.

JavaScript — akumulator stałego kroku czasowego
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:

JavaScript — sub-stepping
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):

JavaScript — interpolacja stanu
// 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:

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.

Szybkie odniesienie

Akumulator stałego kroku czasowego Semi-uwikłany Euler Velocity Verlet Sub-stepping Interpolacja stanu renderowania Limit spirali śmierci Deterministyczny replay Sieciowanie lockstep Wykrywanie desynchronizacji przez hash stanu