← Модуль 1/Игровой цикл
EN
Модуль 1 · Аркады и основы

Игровой цикл и fixed timestep

Почему «двигать на скорость каждый кадр» — это баг, и как один аккумулятор развязывает симуляцию от частоты кадров. Самый фундаментальный паттерн движка.
🏠 лаба~18 мин
Суть за 20 секунд
Любая игра крутит цикл: ввод → обновить состояние → отрисовать. Наивная ошибка — привязать движение к кадру (x += v за кадр): игра идёт быстрее на быстром железе. Лечится фиксированным шагом: симуляция тикает строго через постоянный dt, а рендер — как успевает. Мост между ними — аккумулятор: копишь прошедшее время и прокручиваешь столько фикс-шагов, сколько в него влезло; остаток времени гасишь интерполяцией при отрисовке. Это даёт кадронезависимость, стабильную физику и детерминизм (реплеи, лок-степ неткод).

Механизм

Простейший цикл одинаков с 1970-х:

while(running){ input(); update(); render(); }

На 60-герцовом железе на один оборот есть бюджет:

tframe= 1f, 160 с≈16.67 мс; 130 с≈33.3 мс

Не уложился в бюджет — пропущенный кадр, фриз, рассинхрон звука. Но настоящая ловушка глубже — чему равен шаг времени в update().

Баг №1: движение, привязанное к кадру

Если внутри update() писать «сдвинь на v за вызов» — скорость объекта становится пропорциональна частоте кадров. Пройденный за реальную секунду путь:

s=v·f (на 120 FPS — вдвое дальше, чем на 60)

Это буквально причина кнопки Turbo на старых PC: игру калибровали под один процессор, на более быстром она шла неиграбельно быстро, и «турбо» приходилось выключать, чтобы притормозить тактовую частоту.

Половинчатый фикс: умножить на dt

Очевидный шаг — масштабировать на реальную дельту: x += v·dt. Скорость теперь от FPS не зависит. Но dt гуляет от кадра к кадру, и это бьёт по физике: переменный шаг интегрирования → разная ошибка каждый кадр, нестабильные контакты, туннелирование сквозь стены на большом dt, и — главное — недетерминированность: один и тот же ввод даёт разный результат. Реплей и лок-степ сломаны.

Решение: фиксированный шаг + аккумулятор

Развязываем симуляцию (строго постоянный dt) и рендер (как выйдет). Копим прошедшее время в аккумуляторе a и прокручиваем целое число фикс-шагов:

a += frameTime while(a >= dt){ update(dt); a -= dt; } render( alpha = a / dt )

Число шагов за кадр — это

n= ⌊adt⌋

Симуляция всегда видит один и тот же dt — детерминизм и стабильная физика. Рендер может идти и чаще (между тиками), и реже (несколько тиков на кадр).

Остаток времени → интерполяция

После цикла в аккумуляторе остаётся a < dt — «недокрученное» время. Если рисовать последнее состояние как есть, при render>sim получишь дёрганье (temporal aliasing): картинка обновляется только simHz раз в секунду. Лечится интерполяцией между прошлым и текущим состоянием симуляции:

α=adt, xrender= (1−α) xprev + α· xcurr
кадр = накопить время → прокрутить фикс-шаги → отрисовать с интерполяцией sim фиксированный dt (равные интервалы) render переменный FPS (как успевает железо) α = a/dt кадр между тиками → интерполяция

Числовой пример. 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. По каждому кейсу: как сделано (или сломано) и что включить/покрутить, чтобы увидеть руками. От «вообще без дисциплины цикла» к «сделано правильно».

DOS-эра и кнопка Turbo 1980–90-е · цикла нет, скорость = такты CPU

Ранние PC-игры крутили цикл «на максимум» без всякого тайминга: скорость геймплея = тактовая частота процессора. Игру калибровали под конкретный CPU; на более быстром она летела неиграбельно. Поэтому у корпусов была кнопка Turbo, которая на деле понижала частоту — чтобы старые игры не превращались в слайд-шоу наоборот.

🎮 Сыграй: открой любую DOS-игру в DOSBox и покрути ползунок «cycles» (Ctrl+F11 / Ctrl+F12). Вся игра — движение, анимация, таймеры — ускоряется и замедляется вместе с «процессором». Это игровой цикл вообще без развязки от железа.

Dark Souls II 2014 · PC · расчёт износа на кадр

На версиях с 60 FPS (PC, current-gen) прочность оружия падала примерно вдвое быстрее, чем на 30-FPS-консолях, — при ударах по телам, стенам и полу. Причина каноничная: вычитание прочности считалось раз в кадр рендера, а не раз в фикс-тик. Вдвое больше кадров → вдвое больше списаний. FROM пофиксили патчем в 2015-м.

🎮 Сыграй: на непропатченной DS2 побей трупа/стену на 30 и на 60 FPS и сравни падение прочности. Классический «per-frame вместо per-tick», который должен был жить в фиксированном шаге.

Skyrim / Fallout (Creation Engine) физика Havok прибита к 60 Гц

Интегратор Havok жёстко завязан на 60 FPS (fMaxTime ≈ 0.0166 с). Снимаешь лок частоты — и выше 60 физика сходит с ума: предметы разлетаются, вода мерцает, NPC сбиваются с маршрутов, тележки улетают в небо. Народный фикс — динамический fMaxTime по текущему FPS: ровно clamp-аккумулятор из этого урока, прикрученный снаружи.

🎮 Сыграй: в Skyrim сними потолок кадров (без модов-фиксов) и зайди в захламлённую комнату — посуда и трупы устроят полтергейст. Вот что значит «шаг физики прибит к частоте рендера».

Celeste / аккуратный платформер фикс-шаг сделан правильно → детерминизм

Точные платформеры держат симуляцию на фиксированном шаге, поэтому она детерминирована: один и тот же ввод покадрово даёт один и тот же результат. Отсюда возможны фрейм-перфектные трюки, воспроизводимые 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, что бы ни происходило вокруг. Рендер — фотограф, который щёлкает кадры в произвольные моменты. Аккумулятор — очередь деталей, накопившихся между тактами; интерполяция — это когда фотограф ловит деталь между двумя позициями ленты и показывает её там, где она была бы сейчас. Такт ленты неизменен — иначе детали выходят разного размера (недетерминизм).
Почему это важно
Кадронезависимость — это граница между «работает на моей машине» и «зашипили». Привяжешь логику к FPS — игра ускорится на быстром железе (DOS), сожрёт прочность на 60 кадрах (DS2) или разнесёт физику выше 60 (Skyrim). Фиксированный шаг с интерполяцией — скучный правильный фундамент под каждым движком и единственный путь к детерминизму, без которого нет ни лок-степа, ни реплеев, ни честного TAS.
🔁 За пределами игр — куда это переносится
Урок даёт три переносимых приёма: развязать частоту производителя и потребителя через буфер, дискретизировать непрерывное время фиксированным шагом ради устойчивости и воспроизводимости, и детерминизм через фикс-шаг + фикс-сид.

ML / AI (твой домен): фикс-шаг ⇄ дискретизация в решателях ОДУ, Neural ODE и диффузии (фикс- vs адаптивный шаг; почему DDPM идёт фиксированной сеткой шумовых шагов). Replay buffer в RL — это буквально аккумулятор между частотой шагов среды и частотой апдейтов обучения; gradient accumulation — накопление микробатчей до одного фикс-«шага» оптимизатора. Детерминизм = воспроизводимый трейнинг (фикс-сид, фиксированный порядок данных).

Бэкенд / системы: token-bucket rate-limiting = тот же аккумулятор; развязка скорости ingest и обработки через очередь; контур управления на фиксированной частоте вместо «как придёт».

Теория управления / DSP: фиксированная частота дискретизации (Найквист), дискретные регуляторы — переменный шаг ломает и анализ, и устойчивость.

Принцип: не давай частоте потребителя диктовать твою логику; копи в буфер и обрабатывай фиксированными порциями — тогда результат устойчив и воспроизводим.

🏠 Лаба — игровой цикл вживую
Интерактивная лаба без кода: три объекта едут с «одной» скоростью тремя стратегиями (привязка к кадру / умножение на dt / фикс-шаг с интерполяцией). Крути FPS рендера и частоту симуляции, включай интерполяцию, жми «просадку» — и смотри, кто рассинхронится и как заполняется аккумулятор. Открыть лабу →
Лучший момент: опусти FPS до 15 в режиме «привязка к кадру» — красный поедет вчетверо медленнее зелёного (фикс-шаг), хотя «скорость» у них одна.
🔧 Запусти и поковыряй — на домашнем компе
Во что играть — выше (🕹). Здесь — залезть в движок и увидеть фикс-шаг руками:
🔧 Поковырять (debug) ~40 мин, Godot
В Godot заведи падающее тело. Логику движения положи в _physics_process(delta) и в _process(delta) по очереди. Сними V-Sync и задери/опусти FPS: версия в _process поедет с другой скоростью или задёргается, версия в _physics_process — нет. Покрути «Physics Ticks/sec» (60 → 10) и включи/выключи интерполяцию — увидишь стробление и как оно лечится.
🧪 Потестить (глазами QA) ~15 мин
Лови frame-dependent баги: ускорение геймплея при высоком FPS, разная дальность прыжка/скорость на разных мониторах, физика-полтергейст выше 60, таймеры на n кадров вместо секунд. Спровоцируй просадку (открой тяжёлую сцену) и проверь, нет ли «спирали смерти» вместо плавного slow-mo.
Чеклист: увидел разницу _physics_process vs _process при снятом V-Sync; поймал стробление при низком sim-Hz и убрал интерполяцией; нашёл хотя бы один frame-dependent баг.
Связи
пример
Doom и BSP — у Doom логика крутилась на фиксированных 35 тиках/сек независимо от рендера; на этом детерминизме держатся его демки и сетевая игра.
смежное
Game feel — отзывчивость и «сочность» стоят на стабильном такте и предсказуемом опросе ввода; рваный цикл убивает фил.
дальше
ECS и data-oriented — каждый тик прогоняет системы по компонентам; здесь структура цикла встречается с раскладкой данных.
Вопросы пытливого ума
Раз x += v·dt уже кадронезависимо — зачем вообще фиксированный шаг?
Кадронезависимость — не единственное требование. Переменный dt даёт разную ошибку интегрирования каждый кадр (дрожащая физика), риск туннелирования на просадках и, главное, недетерминизм: один и тот же ввод → разный результат. Реплеи, лок-степ-неткод и TAS требуют побитовой воспроизводимости, а её даёт только постоянный шаг. v·dt чинит скорость, но не чинит стабильность и воспроизводимость.
Почему перестановка двух строк (полунеявный Эйлер) так меняет устойчивость?
Явный Эйлер обновляет позицию по старой скорости и для осциллятора систематически добавляет энергию — амплитуда расходится. Полунеявный сначала обновляет скорость, затем позицию по новой скорости; метод становится симплектическим — сохраняет фазовый объём, энергия колеблется around истинной, но не дрейфует. Та же стоимость, на порядок стабильнее; поэтому это дефолт в играх.
Что физически такое «спираль смерти» и почему clamp её лечит?
Если один update() длится дольше dt, за кадр в аккумулятор втекает больше времени, чем вытекает → число шагов на следующий кадр растёт → кадр ещё длиннее → положительная обратная связь, игра встаёт. Clamp на frameTime (или на максимум шагов) разрывает петлю: лишнее время отбрасывается, симуляция переходит в slow-mo вместо зависания. Цена — на тяжёлых кадрах игровое время отстаёт от реального, но это лучше фриза.
Если sim 60 Гц, а монитор 144 — без интерполяции будет дёрганье на 144 Гц?
Да. Состояние меняется только 60 раз/сек, а кадров рисуется 144 → одни и те же позиции показываются по 2–3 кадра подряд неравномерно → temporal aliasing (микро-стробление). Интерполяция с α = a/dt между двумя последними тиками заполняет промежутки и даёт гладкое движение на любой частоте рендера. Альтернатива — экстраполяция (предсказать вперёд), но она ошибается на резких изменениях и даёт рывки-откаты.
Можно ли просто поднять sim-частоту повыше и забыть про интерполяцию?
Частично. Высокий фикс-шаг (например, 120–240 Гц) снижает заметность стробления и улучшает точность контактов, но линейно дорожает по CPU и не убирает остаток a полностью (рендер всё равно не кратен sim). Для физики иногда так и делают, но «бесплатная гладкость» — это интерполяция, а не грубая сила. На мобилках/слабом железе высокий sim-Hz просто не вывозит.
Что почитать