Патерни таймкроку фізики — фіксований dt, підкроки, детермінований реплей

Вибір стратегії таймкроку визначає, чи ваша симуляція вибухне, сіпатиметься, чи працюватиме однаково на 30-герцовому ноутбуці і на 144-герцовому ігровому моніторі. Ця порада охоплює стандартні патерни, що використовуються у фізичних рушіях, ігрових серверах та наших веб-симуляціях, разом із готовими до копіювання реалізаціями на JavaScript.

Чому таймкрок має значення

Числове інтегрування фізичних рівнянь не є точним. Інтегрування Ейлера накопичує похибку, пропорційну dt² на крок. Якщо ви подвоюєте таймкрок, ви вчетверо збільшуєте похибку. Що важливіше, багато систем стають числово нестабільними вище критичного таймкроку — енергія зростає експоненційно, і симуляція вибухає, незалежно від того, наскільки фізично точні рівняння.

Водночас цикл рендерингу браузера не тактується з детермінованою частотою. requestAnimationFrame намагається синхронізуватися з частотою оновлення дисплея (60/120/144 Гц), але фонові вкладки, навантаження системи та масштабування дисплея можуть давати будь-який інтервал від 4 мс до 60+ мс. Якщо подати цей змінний dt безпосередньо у фізику, симуляція поводитиметься по-різному на кожній машині.

Патерн 1 — Змінний dt (наївний)

❌ Так не робіть

Передавати performance.now() - lastTime безпосередньо в physics.step(dt). Одна пауза збирача сміття — і об'єкти проходять крізь стіни. Не детерміновано. Різні результати на кожній машині.

✅ Коли це прийнятно

Чисті системи частинок без виявлення зіткнень, візуальні ефекти, анімації. Будь-де, де фізична коректність не важлива, а сіпання непомітне.

Патерн 2 — Акумулятор з фіксованим dt (рекомендовано)

Накопичуйте час за годинником, а потім витрачайте його фіксованими порціями. Це відокремлює фізику від частоти рендерингу і гарантує однакові результати симуляції незалежно від частоти кадрів.

JavaScript — Акумулятор з фіксованим таймкроком
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 підкроків усередині:

JavaScript — Підкроки
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):

JavaScript — Інтерполяція стану
// У класі фізичного тіла:
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, тим самим сідом ГПВЧ (генератора псевдовипадкових чисел) та початковим станом видасть точно однаковий результат за кожного запуску — це уможливлює:

Застереження щодо детермінізму чисел з плаваючою комою: арифметика IEEE 754 детермінована лише для однакового порядку інструкцій. Різні браузери, рівні JIT та архітектури процесорів можуть давати дещо різні результати. Для справжнього крос-платформного детермінізму використовуйте цілочисельну математику фіксованої точки або обмежтеся 32-бітними числами з плаваючою комою з явним контролем FMA.

Швидкий довідник

Акумулятор з фіксованим таймкроком Напівнеявний Ейлер Verlet зі швидкістю Підкроки Інтерполяція стану рендерингу Обмеження спіралі смерті Детермінований реплей Мережевий лок-степ Виявлення розсинхронізації за хешем стану