1. Архитектура игрового цикла
Архитектура игрового цикла
Любая программа, которую вы писали до этого, скорее всего, работала по принципу пакетной обработки: вы запускаете код, он выполняет последовательность инструкций, выдает результат и завершается. Игры работают иначе. Они не могут просто ждать, пока пользователь введет данные, или завершаться после одного вычисления. Игра должна непрерывно реагировать на действия игрока, просчитывать физику, обновлять анимации и отрисовывать графику, даже если игрок ничего не делает.
Сердцем этой непрерывной работы является игровой цикл (game loop). Это фундаментальный архитектурный паттерн, который отделяет течение игрового времени от скорости работы процессора и пользовательского ввода.
> Назначение игрового цикла — сделать течение игрового времени независимым от ввода пользователя и скорости процессора. > > Game Programming Patterns
Анатомия базового цикла
В своей простейшей форме игровой цикл состоит из трех последовательных фаз, которые повторяются десятки или сотни раз в секунду:
!Схема классического игрового цикла
Если мы реализуем это в лоб на C#, код будет выглядеть примерно так:
У этого подхода есть фатальный архитектурный недостаток: скорость игры напрямую зависит от мощности железа.
Представьте, что в методе Update() персонаж сдвигается на метр. На старом ноутбуке цикл успеет выполниться раз за секунду (30 FPS), и персонаж пройдет метров. На мощном игровом ПК цикл выполнится раза, и персонаж пролетит метра. Это разрушает игровой баланс и делает мультиплеер невозможным.
Переменный шаг времени (Delta Time)
Чтобы отвязать скорость игры от частоты кадров, разработчики ввели понятие переменного шага времени (variable time step), или (дельта тайм).
— это время, прошедшее с момента предыдущего кадра.
Теперь мы передаем это значение в метод обновления логики, и формула движения выглядит так:
Где — новая позиция, — старая позиция, — скорость (в единицах в секунду), а — время кадра в секундах.
Пример с числами: скорость персонажа метров в секунду.
В обоих случаях за одну реальную секунду персонаж пройдет ровно метров. Проблема решена?
!Визуализация влияния FPS на физику
Ловушка переменного шага
К сожалению, переменный шаг времени создает новую, более скрытую проблему — недетерминированность физики.
Физические движки используют сложные математические интегралы для расчета столкновений и отскоков. Когда постоянно скачет (например, , затем , затем ), результаты вычислений с плавающей запятой (floating-point) накапливают погрешности.
Если выстрелить из одного и того же оружия под одним и тем же углом, при стабильных 60 FPS пуля попадет в цель, а при просадке до 45 FPS — пролетит мимо, потому что физический движок рассчитал траекторию с другим шагом времени. В соревновательных играх это недопустимо.
Архитектурный стандарт: Полуфиксированный цикл
Чтобы получить лучшее от обоих подходов (плавную картинку на любых мониторах и стабильную физику), современные движки, такие как Unity или Unreal Engine, используют паттерн накопителя (Accumulator).
Суть подхода в том, чтобы отделить частоту обновления логики (которая всегда фиксирована) от частоты рендеринга (которая зависит от монитора и видеокарты).
Сравним эти подходы:
| Характеристика | Переменный шаг (Update) | Фиксированный шаг (FixedUpdate) | |---|---|---| | Частота вызовов | Зависит от FPS (может быть 30, 60, 144 в секунду) | Строго задана (например, ровно 50 раз в секунду) | | Что обрабатывать | Ввод игрока, анимации, UI, эффекты | Физика, перемещение, ИИ, сетевая синхронизация | | Стабильность | Низкая (зависит от лагов) | Абсолютная (детерминированная) |
Реализация паттерна Accumulator на C#
Давайте посмотрим, как это выглядит в коде ядра игрового движка. Это именно та архитектура, которая работает «под капотом».
Разберем этот код по шагам, так как это важнейший концепт для архитектора игр:
lag накапливает реальное время, которое прошло с прошлого кадра.while (lag >= MS_PER_UPDATE) — это сердце системы. Если у игрока мощный ПК и кадр отрендерился за мс, lag будет равен . Условие ложно, поэтому FixedUpdate не вызовется. Движок просто отрисует кадр. lag станет . Снова не хватает. lag станет . Теперь . Движок вызовет FixedUpdate один раз, отнимет от lag (останется ) и пойдет рендерить.Таким образом, независимо от того, выдает ли компьютер 300 FPS или 20 FPS, метод FixedUpdate вызовется ровно 60 раз за одну реальную секунду.
Спираль смерти (Spiral of Death)
У архитектуры с накопителем есть одна уязвимость. Что произойдет, если метод FixedUpdate содержит слишком тяжелую логику (например, спавн 10 000 врагов) и выполняется дольше, чем MS_PER_UPDATE?
Допустим, MS_PER_UPDATE = мс, но само выполнение FixedUpdate занимает мс.
Реальное время (elapsedTime) будет огромным. Накопитель lag быстро переполнится. Внутренний цикл while попытается вызвать FixedUpdate несколько раз подряд, чтобы «догнать» реальное время. Но каждый вызов занимает мс, что генерирует еще больше реального времени!
Игра зависает намертво, пытаясь симулировать время, которое она сама же и тратит. Это называется Спиралью смерти. Архитектурное решение этой проблемы — введение предохранителя: ограничение максимального количества вызовов FixedUpdate за один кадр (например, не больше 5). Если игра лагает сильнее, мы просто замедляем внутреннее игровое время (эффект slow-motion), но спасаем движок от зависания.
Интерполяция рендеринга
Внимательный разработчик заметит аргумент lag / MS_PER_UPDATE в методе Render(). Зачем он нужен?
Представьте, что lag остался равен мс (ровно половина от шага обновления). Это значит, что физически объекты находятся между двумя состояниями. Если мы просто отрисуем их текущие координаты, на мониторах с высокой герцовкой (144 Гц) движение будет казаться дерганым (stuttering), так как рендер будет показывать одну и ту же позицию несколько кадров подряд, пока не сработает следующий FixedUpdate.
Значение lag / MS_PER_UPDATE дает нам коэффициент от до (в нашем случае ). В подсистеме рендеринга мы используем этот коэффициент (часто называемый alpha), чтобы визуально предсказать позицию объекта:
Это позволяет отвязать логику от графики: физика может работать при 30 FPS, а рендеринг — при 144 FPS, и движение будет абсолютно плавным. Именно так устроена архитектура сетевых шутеров (например, CS:GO или Valorant), где сервер считает физику на низком тикрейте (tickrate), а клиент интерполирует картинку под частоту монитора игрока.
Понимание того, как работает игровой цикл, определяет то, как вы будете проектировать все остальные системы. В следующих статьях мы рассмотрим, как правильно встраивать в этот цикл менеджеры памяти, системы событий и многопоточность.