Игровой цикл и fixed timestep
x += v за кадр): игра идёт быстрее на быстром железе. Лечится фиксированным шагом: симуляция тикает строго через постоянный dt, а рендер — как успевает. Мост между ними — аккумулятор: копишь прошедшее время и прокручиваешь столько фикс-шагов, сколько в него влезло; остаток времени гасишь интерполяцией при отрисовке. Это даёт кадронезависимость, стабильную физику и детерминизм (реплеи, лок-степ неткод).
Механизм
Простейший цикл одинаков с 1970-х:
На 60-герцовом железе на один оборот есть бюджет:
Не уложился в бюджет — пропущенный кадр, фриз, рассинхрон звука. Но настоящая ловушка глубже — чему равен шаг времени в update().
Баг №1: движение, привязанное к кадру
Если внутри update() писать «сдвинь на v за вызов» — скорость объекта становится пропорциональна частоте кадров. Пройденный за реальную секунду путь:
Это буквально причина кнопки Turbo на старых PC: игру калибровали под один процессор, на более быстром она шла неиграбельно быстро, и «турбо» приходилось выключать, чтобы притормозить тактовую частоту.
Половинчатый фикс: умножить на dt
Очевидный шаг — масштабировать на реальную дельту: x += v·dt. Скорость теперь от FPS не зависит. Но dt гуляет от кадра к кадру, и это бьёт по физике: переменный шаг интегрирования → разная ошибка каждый кадр, нестабильные контакты, туннелирование сквозь стены на большом dt, и — главное — недетерминированность: один и тот же ввод даёт разный результат. Реплей и лок-степ сломаны.
Решение: фиксированный шаг + аккумулятор
Развязываем симуляцию (строго постоянный dt) и рендер (как выйдет). Копим прошедшее время в аккумуляторе a и прокручиваем целое число фикс-шагов:
Число шагов за кадр — это
Симуляция всегда видит один и тот же dt — детерминизм и стабильная физика. Рендер может идти и чаще (между тиками), и реже (несколько тиков на кадр).
Остаток времени → интерполяция
После цикла в аккумуляторе остаётся a < dt — «недокрученное» время. Если рисовать последнее состояние как есть, при render>sim получишь дёрганье (temporal aliasing): картинка обновляется только simHz раз в секунду. Лечится интерполяцией между прошлым и текущим состоянием симуляции:
Числовой пример. dt = 16.67 мс (sim 60 Гц). Кадр рендера занял 55 мс (просадка): a накопил 55 мс → n = ⌊55/16.67⌋ = 3 шага (3·16.67 = 50.01 мс), остаток a ≈ 4.99 мс. Симуляция честно «нагнала» 3 тика, а недокрученные ~5 мс уйдут в интерполяцию. Кадр занял 8 мс (рендер на 120 Гц): n = 0 шагов, a = 8 мс → рисуем интерполяцией с α = 8/16.67 ≈ 0.48 между двумя последними тиками.
Спираль смерти — и клемма от неё
Если update() сам по себе дольше dt, аккумулятор растёт быстрее, чем гасится: каждый кадр добавляет шагов больше, чем успел прокрутить → следующий кадр ещё длиннее → спираль смерти, игра встаёт колом. Защита — клемма на максимум шагов (или на frameTime): если накопилось слишком много, лишнее время отбрасываем. Симуляция честно «замедляется» (slow-mo) вместо полного зависания. На практике: frameTime = min(realDt, 0.25 с) → не больше ~15 шагов на кадр при 60 Гц.
🕹 В какие игры поиграть — и что заметить
Один и тот же дефект — «логика привязана к кадру» — всплывает на всех эпохах, от DOS до Bethesda. По каждому кейсу: как сделано (или сломано) и что включить/покрутить, чтобы увидеть руками. От «вообще без дисциплины цикла» к «сделано правильно».
Ранние PC-игры крутили цикл «на максимум» без всякого тайминга: скорость геймплея = тактовая частота процессора. Игру калибровали под конкретный CPU; на более быстром она летела неиграбельно. Поэтому у корпусов была кнопка Turbo, которая на деле понижала частоту — чтобы старые игры не превращались в слайд-шоу наоборот.
🎮 Сыграй: открой любую DOS-игру в DOSBox и покрути ползунок «cycles» (Ctrl+F11 / Ctrl+F12). Вся игра — движение, анимация, таймеры — ускоряется и замедляется вместе с «процессором». Это игровой цикл вообще без развязки от железа.
На версиях с 60 FPS (PC, current-gen) прочность оружия падала примерно вдвое быстрее, чем на 30-FPS-консолях, — при ударах по телам, стенам и полу. Причина каноничная: вычитание прочности считалось раз в кадр рендера, а не раз в фикс-тик. Вдвое больше кадров → вдвое больше списаний. FROM пофиксили патчем в 2015-м.
🎮 Сыграй: на непропатченной DS2 побей трупа/стену на 30 и на 60 FPS и сравни падение прочности. Классический «per-frame вместо per-tick», который должен был жить в фиксированном шаге.
Интегратор Havok жёстко завязан на 60 FPS (fMaxTime ≈ 0.0166 с). Снимаешь лок частоты — и выше 60 физика сходит с ума: предметы разлетаются, вода мерцает, NPC сбиваются с маршрутов, тележки улетают в небо. Народный фикс — динамический fMaxTime по текущему FPS: ровно clamp-аккумулятор из этого урока, прикрученный снаружи.
🎮 Сыграй: в Skyrim сними потолок кадров (без модов-фиксов) и зайди в захламлённую комнату — посуда и трупы устроят полтергейст. Вот что значит «шаг физики прибит к частоте рендера».
Точные платформеры держат симуляцию на фиксированном шаге, поэтому она детерминирована: один и тот же ввод покадрово даёт один и тот же результат. Отсюда возможны фрейм-перфектные трюки, воспроизводимые TAS и реплеи. «Игра-фил» из game feel держится на том, что тик стабилен, а ввод опрашивается предсказуемо.
🎮 Посмотри: запись TAS Celeste или спидран — фрейм-перфектные приёмы повторяются идентично от прогона к прогону. Это работает только потому, что симуляция идёт равными фикс-шагами независимо от рендера.
Хардкор: численное интегрирование — почему фиксированный dt стабилизирует физикуможно пропустить
Движение — это ОДУ ẋ = v, v̇ = a(x). Реальный кадр дискретизирует её, и метод дискретизации решает, взорвётся ли симуляция.
Явный (forward) Эйлер — и почему он «накачивает» энергию
Обновляем позицию по старой скорости: x += v·dt; v += a·dt. Для осциллятора (пружина, орбита) полная энергия растёт каждый шаг — амплитуда расходится. Ошибка на шаг — O(dt²), глобально O(dt); на большом dt ещё и неустойчив.
Полунеявный (симплектический) Эйлер — рабочая лошадка игр
Сначала скорость, потом позиция по новой скорости: v += a·dt; x += v·dt. Одна перестановка строк — и метод становится симплектическим: энергия не дрейфует систематически, орбиты/пружины стабильны. Почти все игровые движки интегрируют так. Бесплатно по цене, на порядок стабильнее.
Почему именно ФИКСИРОВАННЫЙ dt
- Постоянная ошибка. Переменный
dt= разная локальная ошибка каждый кадр → дрожащее поведение; фикс-шаг делает её предсказуемой. - Нет туннелирования. На большом
dtобъект перепрыгивает сквозь тонкую стену (за шаг сдвиг > толщины коллайдера). Маленький фикс-шаг ограничивает максимальный сдвиг за тик. - Детерминизм. Одинаковые шаги + один сид → побитово одинаковый прогон. Это обязательное условие лок-степ-неткода (RTS) и rollback (файтинги), а также реплеев и TAS.
RK4 точнее, но требует нескольких вычислений сил на шаг и для геймплейной физики избыточен — физические движки берут фикс-шаг + симплектику + sequential impulse для контактов.
Хардкор · инженерия: где dt живёт в движках и откуда «плавающий» вводможно пропустить
- Разнесение в API движков. Unity:
FixedUpdate()— физика на фикс-шаге (Time.fixedDeltaTime, по умолчанию 0.02 с = 50 Гц);Update()— рендер/ввод с переменной дельтой. Godot:_physics_process(delta)(фикс, «Physics Ticks/sec», 60) против_process(delta)(переменный). Unreal — sub-stepping в физике. Это и есть аккумулятор, спрятанный в движке. - Интерполяция против дёрганья. Рендер быстрее симуляции → визуально интерполируешь между двумя последними тиками (
Rigidbody.interpolationв Unity). Без этого высокий FPS не спасает от стробления при низком sim-Hz. - Конвейер CPU→GPU. CPU идёт на 1–2 кадра впереди GPU. Ввод опрашивается раз на кадр рендера → ощущается «плавающим»; смягчают поздним опросом ввода, буферизацией, NVIDIA Reflex / AMD Anti-Lag.
- Тикрейт сервера. В сетевой игре авторитетная симуляция тикает на фиксированной частоте (например, 64 Гц); рендер клиента развязан и интерполирует чужие сущности. Без фикс-тика рассинхрон неизбежен.
dt, что бы ни происходило вокруг. Рендер — фотограф, который щёлкает кадры в произвольные моменты. Аккумулятор — очередь деталей, накопившихся между тактами; интерполяция — это когда фотограф ловит деталь между двумя позициями ленты и показывает её там, где она была бы сейчас. Такт ленты неизменен — иначе детали выходят разного размера (недетерминизм).
ML / AI (твой домен): фикс-шаг ⇄ дискретизация в решателях ОДУ, Neural ODE и диффузии (фикс- vs адаптивный шаг; почему DDPM идёт фиксированной сеткой шумовых шагов). Replay buffer в RL — это буквально аккумулятор между частотой шагов среды и частотой апдейтов обучения; gradient accumulation — накопление микробатчей до одного фикс-«шага» оптимизатора. Детерминизм = воспроизводимый трейнинг (фикс-сид, фиксированный порядок данных).
Бэкенд / системы: token-bucket rate-limiting = тот же аккумулятор; развязка скорости ingest и обработки через очередь; контур управления на фиксированной частоте вместо «как придёт».
Теория управления / DSP: фиксированная частота дискретизации (Найквист), дискретные регуляторы — переменный шаг ломает и анализ, и устойчивость.
Принцип: не давай частоте потребителя диктовать твою логику; копи в буфер и обрабатывай фиксированными порциями — тогда результат устойчив и воспроизводим.
_physics_process(delta) и в _process(delta) по очереди. Сними V-Sync и задери/опусти FPS: версия в _process поедет с другой скоростью или задёргается, версия в _physics_process — нет. Покрути «Physics Ticks/sec» (60 → 10) и включи/выключи интерполяцию — увидишь стробление и как оно лечится.n кадров вместо секунд. Спровоцируй просадку (открой тяжёлую сцену) и проверь, нет ли «спирали смерти» вместо плавного slow-mo.Раз x += v·dt уже кадронезависимо — зачем вообще фиксированный шаг?
dt даёт разную ошибку интегрирования каждый кадр (дрожащая физика), риск туннелирования на просадках и, главное, недетерминизм: один и тот же ввод → разный результат. Реплеи, лок-степ-неткод и TAS требуют побитовой воспроизводимости, а её даёт только постоянный шаг. v·dt чинит скорость, но не чинит стабильность и воспроизводимость.Почему перестановка двух строк (полунеявный Эйлер) так меняет устойчивость?
Что физически такое «спираль смерти» и почему clamp её лечит?
update() длится дольше dt, за кадр в аккумулятор втекает больше времени, чем вытекает → число шагов на следующий кадр растёт → кадр ещё длиннее → положительная обратная связь, игра встаёт. Clamp на frameTime (или на максимум шагов) разрывает петлю: лишнее время отбрасывается, симуляция переходит в slow-mo вместо зависания. Цена — на тяжёлых кадрах игровое время отстаёт от реального, но это лучше фриза.Если sim 60 Гц, а монитор 144 — без интерполяции будет дёрганье на 144 Гц?
α = a/dt между двумя последними тиками заполняет промежутки и даёт гладкое движение на любой частоте рендера. Альтернатива — экстраполяция (предсказать вперёд), но она ошибается на резких изменениях и даёт рывки-откаты.Можно ли просто поднять sim-частоту повыше и забыть про интерполяцию?
a полностью (рендер всё равно не кратен sim). Для физики иногда так и делают, но «бесплатная гладкость» — это интерполяция, а не грубая сила. На мобилках/слабом железе высокий sim-Hz просто не вывозит.- Glenn Fiedler, «Fix Your Timestep!» (gafferongames.com) — канонический разбор аккумулятора и интерполяции.
- Robert Nystrom, «Game Programming Patterns» — глава Game Loop (бесплатно онлайн).
- Модуль 1, «Core Game Loop Architecture» + Модуль 8, «Game Loop Patterns».