Чому таймкрок має значення
Числове інтегрування фізичних рівнянь не є точним. Інтегрування Ейлера накопичує похибку, пропорційну dt² на крок. Якщо ви подвоюєте таймкрок, ви вчетверо збільшуєте похибку. Що важливіше, багато систем стають числово нестабільними вище критичного таймкроку — енергія зростає експоненційно, і симуляція вибухає, незалежно від того, наскільки фізично точні рівняння.
Водночас цикл рендерингу браузера не тактується з детермінованою
частотою. requestAnimationFrame намагається
синхронізуватися з частотою оновлення дисплея (60/120/144 Гц), але
фонові вкладки, навантаження системи та масштабування дисплея можуть
давати будь-який інтервал від 4 мс до 60+ мс. Якщо подати цей
змінний dt безпосередньо у фізику, симуляція
поводитиметься по-різному на кожній машині.
Патерн 1 — Змінний dt (наївний)
❌ Так не робіть
Передавати performance.now() - lastTime
безпосередньо в physics.step(dt). Одна пауза
збирача сміття — і об'єкти проходять крізь стіни. Не
детерміновано. Різні результати на кожній машині.
✅ Коли це прийнятно
Чисті системи частинок без виявлення зіткнень, візуальні ефекти, анімації. Будь-де, де фізична коректність не важлива, а сіпання непомітне.
Патерн 2 — Акумулятор з фіксованим dt (рекомендовано)
Накопичуйте час за годинником, а потім витрачайте його фіксованими порціями. Це відокремлює фізику від частоти рендерингу і гарантує однакові результати симуляції незалежно від частоти кадрів.
const FIXED_DT = 1 / 120; // фізика на 120 Гц, незалежно від частоти рендерингу
const MAX_STEPS = 8; // запобіжник — уникає «спіралі смерті»
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); // обмеження на сплеск затримки
while (accumulator >= FIXED_DT) {
physics.step(FIXED_DT); // завжди викликається з однаковим dt
accumulator -= FIXED_DT;
}
const alpha = accumulator / FIXED_DT; // 0..1 — для інтерполяції рендерингу
renderer.render(physics.interpolate(alpha));
}
Спіраль смерті: якщо ваш крок фізики займає
більше часу за годинником, ніж FIXED_DT, акумулятор
зростає швидше, ніж спорожнюється. Симуляція відстає, намагається
наздогнати, стає повільнішою, відстає ще більше. Обмеження
MAX_STEPS обмінює точність на виживання — симуляція
сповільнюється в реальному часі замість того, щоб заблокувати
браузер.
Патерн 3 — Підкроки для жорстких зв'язків
Жорсткі системи (тверді тіла, тканина, пружини з високою жорсткістю) вимагають дуже малого dt для стабільності. Замість того щоб запускати всю симуляцію на 2000 Гц, запускайте її на 60 Гц, але розділяйте кожен крок на N підкроків усередині:
function step(dt) {
const SUB_STEPS = 8;
const subDt = dt / SUB_STEPS;
for (let i = 0; i < SUB_STEPS; i++) {
integrateBodies(subDt); // Verlet зі швидкістю або напівнеявний Ейлер
resolveConstraints(subDt); // PBD, імпульс або штрафні сили
}
}
Патерн 4 — Інтерполяція рендерингу
У будь-якій конфігурації «фіксована фізика / змінний рендеринг»
стан рендерингу завжди трохи «позаду» — він показує останній
зафіксований стан фізики, а не точний поточний. Щоб усунути видиме
сіпання, лінійно інтерполюйте між попереднім та поточним станом
фізики, використовуючи накопичену частку (alpha):
// У класі фізичного тіла:
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);
}
}
Детермінований реплей і мережева синхронізація
Симуляція з фіксованим dt, тим самим сідом ГПВЧ (генератора псевдовипадкових чисел) та початковим станом видасть точно однаковий результат за кожного запуску — це уможливлює:
- Системи реплеїв — записуйте лише введення (натискання клавіш, зміни параметрів) і відтворюйте їх через ту саму симуляцію, щоб точно відтворити будь-який момент.
- Мережевий лок-степ — усі клієнти просуваються разом; хост транслює лише введення, а не позиції. Одна перевірка на крок: якщо хеші стану розходяться, виявляється розсинхронізація.
- Регресійне тестування — запустіть симуляцію на 10 000 кроків у безголовому режимі і перевірте, що ключові значення збігаються зі збереженим знімком.
Застереження щодо детермінізму чисел з плаваючою комою: арифметика IEEE 754 детермінована лише для однакового порядку інструкцій. Різні браузери, рівні JIT та архітектури процесорів можуть давати дещо різні результати. Для справжнього крос-платформного детермінізму використовуйте цілочисельну математику фіксованої точки або обмежтеся 32-бітними числами з плаваючою комою з явним контролем FMA.