От переводчика

Что это такое

Это перевод одной из глав книги Game programming patterns Роберта Найстрома. Так как книга по сути состоит из подробного описания шаблонов проектирования, каждая глава может рассматриваться как самостоятельная статья, чем я и воспользовался и перевел, как мне кажется, статью с самым важным паттерном в игростроении — Game loop.

Глоссарий

Я буду использовать русский перевод терминов, но чтобы вы точно понимали, о чем будет идти речь на самом деле, вот ключевые термины, о которых будет идти речь в статье:

Game loop — игровой цикл
Event loop — цикл обработки событий, обработчик событий
Time step — шаг обновления, временной шаг


Назначение игрового цикла

Сделать течение игрового времени независимым от ввода пользователя и скорости процессора.

Мотивация

Если и существует какой-то паттерн, без которого эта книга точно не могла существовать, так это он — игровой цикл. Игровые циклы — это каноничный пример "игрового паттерна программирования". Практически у каждой игры он есть, но нет ни одного абсолютно идентичного, а вне мира геймдева его используют относительно малое количество программных продуктов.

Чтобы увидеть, насколько они полезны и чему служат, давайте проведем маленькую историческую справку, просто 30 секунд или одну минуту. Вы не против? В стародавнюю эпоху программирования, когда каждый первый носил бороду, программы работали примерно как ваша посудомоечная машина. Вы сгружали в нее код, нажимали кнопку, ждали и забирали результат. Все. Это были программы, так сказать, "пакетного режима" — программа завершалась, как только полезная работа была выполнена.

Ада Лавлейс и контр-адмирал Грейс Хоппер носили самые почетные бороды.

Вы можете наблюдать такие программы и в наши дни, хотя, к счастью, нам больше не требуется набивать их код на перфокартах. Шелл-скрипты, CLI-утилиты и даже мелкие Python-скрипты, которые превращают кучу Markdown-разметки в эту статью — это все программы пакетного режима.

Беседы с ЦПУ

В конечном итоге, программисты осознали, что закидывать партию кода в вычислительный офис, а потом возвращаться за результатами спустя пару часов, было ужасно медленным способом получения очередной порции багов. Им захотелось мгновенного фидбека. Появились интерактивные программы. Некоторые из первых интерактивных программ были играми:

YOU ARE STANDING AT THE END OF A ROAD BEFORE A SMALL BRICK
BUILDING . AROUND YOU IS A FOREST. A SMALL
STREAM FLOWS OUT OF THE BUILDING AND DOWN A GULLY.

> GO IN
YOU ARE INSIDE A BUILDING, A WELL HOUSE FOR A LARGE SPRING.

Это —  Colossal Cave Adventure, первая адвенчура.

У вас могла быть живая беседа с программой. Она ждала вашего ввода, потом отвечала вам. Вы отвечали обратно, делая свой ход. Прямо как в детском саду — сначала тебе кидают мячик, потом его кидаешь ты. Во время вашего хода программа смирно ждала ответа и ничего не делала. Что-то вроде:

while (true)
{
    char* command = readCommand();
    handleCommand(command);
}

Это бесконечный цикл, поэтому возможности выйти из игры нет. В реальной игре было бы сделано что-то вроде while (!done), и выставлялся бы флаг done, чтобы выйти. Я упустил этот момент для упрощения.

Циклы обработки событий

Приложения с современным графическим интерфейсом удивительно похожи на старые адвенчуры, если содрать с них внешнюю оболочку. Ваш текстовый процессор обычно просто замирает на месте, ничего не делая, пока вы не нажмете на клавишу или кликните на что-нибудь:

while (true)
{
    Event* event = waitForEvent();
    dispatchEvent(event);
}

Главное отличие состоит в том, что вместо текстовых команд, программа ждет событий пользовательского ввода — клика мыши или нажатия клавиши. Но это по-прежнему работает так же, как и старые текстовые адвенчуры, где программа блокируется, ожидая ввода от пользователя. И это проблема.

Большинство циклов обработки событий все же имеют "события простоя", чтобы делать периодические манипуляции, не зависящие от ввода пользователя. Например, мигание курсора или прогресс бар. Но это все нерелевантные вещи, если говорить об играх.

В отличие от большинства программного обеспечения, игры продолжают жить своей жизнью, даже если пользователь ничего не делает. Если вы сидите и просто смотрите в экран, игра не застывает. Анимации продолжают работать. Визуальные эффекты продолжают сверкать и искриться. Монстр продолжает пожирать вашего героя.

Это первая ключевая особенность настоящего игрового цикла: он обрабатывает пользовательский ввод, но не блокирует программу ради этого. Цикл крутится все время:

while (true)
{
    processInput();
    update();
    render();
}

Потом мы усовершенствуем этот код, но основные части уже здесь. processInput() обрабатывает любой пользовательский ввод, который произошел с момента последнего вызова. Далее, update() продвигает игровую симуляцию на один шаг вперед. Здесь отрабатывает AI и физика. И, наконец, render() отрисовывает игру, чтобы игрок мог видеть, что происходит на экране.

Как вы можете догадаться по имени функции, update() —  это хорошее место для использования паттерна Update Method.

Мир вне времени

Если этот цикл не блокируется вводом, это приводит нас к очевидному вопросу: насколько быстро цикл крутится? Каждую итерацию игрового цикла состояние игры немного продвигается. С точки зрения обитателя игрового мира стрелка часов делает маленький шажок вперед.

Общепринятые термины для такого единичного продвижения игрового цикла — "тик" и "кадр".

В то же время, с точки зрения игрока, настоящие часы идут постоянно. Если мы измеряем, насколько быстро игровой цикл исполняется в реальном времени, мы получим игровые "кадры в секунду" (frames per second или FPS). Если игровой цикл исполняется быстро, FPS высокий, а игра работает плавно и быстро. Если цикл медленный, игра дергается как в старом покадровом фильме.

В таком грубом и примитивном игровом цикле, который у нас есть сейчас — который крутится настолько быстро, насколько он может — два фактора определяют частоту кадров. Первый — это насколько много работы ему нужно выполнить за каждый кадр. Сложные физические расчеты, куча игровых объектов и множество графических деталей будут занимать ваш ЦПУ и ГПУ, и на завершение кадра уйдет больше времени.

Второй фактор — скорость вашего железа. Более быстрые процессоры перемолят большее количество инструкций в одно и то же количество времени. Ядра процессора, ГПУ, выделенная аудио-карта и ваша ОС — все это влияет на то, как много работы будет сделано в один момент времени.

Секунды в секунду

В ранних видеоиграх второй фактор был фиксированным. Если вы писали игру под NES или Apple IIe, вы знали точно, на каком ЦПУ будет запущена ваша игра и могли сделать (и делали!) код заточенным под него. Все, о чем вам приходилось беспокоиться — это о том, как много работы вам нужно выполнить в один тик.

Старые игры были аккуратно написаны так, чтобы просто выполнять достаточный объем работы каждый кадр, чтобы игра работала со скоростью, с какой хотел разработчик. Но если вы пытались играть в одну и ту же игру на более быстрой или медленной машине, то сама игра либо ускорялась, либо замедлялась.

Именно поэтому у старых компьютеров была кнопка "turbo". Новые компьютеры были быстрее и не могли нормально запускать старые игры, поскольку они оказывались сильно ускоренными. Выключение кнопки Turbo позволяло замедлить машину и сделать старые игры вновь играбельными.

Сегодня, однако, не такое большое количество программистов имеют роскошь знать точное железо, на котором их игра будет работать. Вместо этого, наши игры должны умно адаптироваться к различным устройствам.

Это еще одна ключевая задача игрового цикла: он продвигает игру с постоянной скоростью независимо от железа, на котором запущена игра.

Паттерн

Игровой цикл постоянно работает в течение всего геймплея. Каждый свой шаг цикл неблокирующе обрабатывает пользовательский ввод, обновляет состояние игры и отрисовывает игру. Он отслеживает течение времени, чтобы контролировать частоту выполнения всего в игре.

Когда использовать

Использование неверного паттерна может быть хуже, чем неиспользование никакого паттерна, поэтому эта секция обычно здесь для того, чтобы предостеречь от чрезмерного энтузиазма. Цель шаблонов проектирования — не впихнуть их побольше в вашу кодовую базу. Шаблоны проектирования про другое.

Но я могу достаточно уверенно сказать, что вы будете использовать этот паттерн. Если вы используете игровой движок, вы не будете писать игровой цикл самостоятельно, но он уже будет у вас под капотом.

Для меня это и есть разница между "движком" и "библиотекой". В случае с библиотеками игровой цикл — ваш, и вы обращаетесь к библиотеке внутри этого игрового цикла. Движок же сам владеет игровым циклом и сам вызывает ваш код.

Вы можете подумать, что не нуждаетесь в игровом цикле, если делаете пошаговую игру. Но даже там, хотя игровое состояние и не продвинется до тех пор, пока пользователь не сделает свой ход, визуальное и аудио-состояния все же потребуют этого. Анимации и музыка продолжают работать даже когда игра "ждет", пока вы сделаете свой ход.

Что нужно иметь в виду

Цикл, о котором мы здесь говорим — это одна из самых важных вещей в вашей игре. Говорят, что программа тратит 90% своего времени в 10% кода. Ваш игровой цикл будет четко в этих 10%.

Такие выдуманные статистики, как эта — причина, почему "настоящие" инженеры вроде инженеров-механиков и электриков не воспринимают нас с вами всерьез.

У вас может быть необходимость координироваться с циклом обработки событий платформы

Если вы собираете вашу игру на базе операционной системы или платформы, у которой есть графический интерфейс и встроенный цикл обработки событий, то тогда у вас сосуществует два прикладных цикла. Им нужно будет ладить друг с другом.

Иногда у вас есть возможность перехватить контроль и сделать ваш цикл единственным. Например, если вы пишете игру для почтенного Windows API, внутри вашего main() может быть обычный игровой цикл. В нем вы можете вызывать PeekMessage(), чтобы обрабатывать и рассылать события от операционной системы. В отличие от GetMessage()PeekMessage() ничего не блокирует в ожидании пользовательского ввода, поэтому игровой может продолжить крутиться.

Другие же платформы могут не позволить вам так легко уклониться от их обработчика событий. Если ваша целевая платформа — веб-браузер, цикл обработки событий будет встроен глубоко в модель исполнения браузера. Здесь уже обработчик событий будет заказывать музыку, и вам придется использовать уже его в качестве вашего игрового цикла. Вы вызовете что-нибудь в духе requestAnimationFrame(), и он сделает обратный вызов в ваш код, чтобы продолжить исполнение вашей игры.

Пишем код

Для такого долгого вступления код для игрового цикла покажется достаточно простым и прямолинейным. Мы пройдемся по некоторым вариациям и обсудим их достоинства и недостатки.

Игровой цикл приводит в движение AI, рендеринг и другие игровые системы, но это не суть самого паттерна, поэтому мы просто будем вызывать фиктивные методы. На самом деле имплементация функций render()update() и остальных мы оставим в качестве упражнения для читателя (очень сложного упражнения для читателя!).

Беги — беги так быстро, насколько можешь

Мы уже видели простейший игровой цикл:

while (true)
{
    processInput();
    update();
    render();
}

Его проблема в том, что у вас нет контроля того, как быстро игра выполняется. На быстрой машине этот цикл будет крутиться так быстро, что пользователи не смогут увидеть, что происходит. На медленной машине игра будет еле плестись. Если у вас часть игры содержит тяжелый контент или в игре есть место, где происходит много AI расчетов или физики, игра будет играться медленнее в этих местах.

Немного вздремнем

Первая вариация, которую мы рассмотрим, добавляет небольшой фикс. Скажем, вы хотите, чтобы ваша игра выполнялась в 60 FPS. Это дает вам около 16 миллисекунд в кадр.

1000 мс / кадры в секунду = мс в кадр

До тех пор, пока вы можете надежно выполнять всю игровую обработку и рендеринг за время, меньшее чем эти 16 миллисекунд, вы можете удерживать стабильные 60 FPS. Все, что вам нужно сделать — это обработать кадр и потом ждать до тех пор, пока не настанет время следующего кадра, примерно так:

Код будет выглядеть следующим образом:

while (true)
{
    double start = getCurrentTime();
    processInput();
    update();
    render();

    sleep(start + MS_PER_FRAME - getCurrentTime());
}

Функция sleep() здесь гарантирует, что игра не будет исполняться слишком быстро, если кадр будет обработан в слишком короткое время. Это, однако же, не поможет, если ваша игра будет исполняться слишком медленно. Если обновление кадра или рендеринг будут занимать больше 16 мс, ваше время сна станет отрицательным. Если бы у нас были компьютеры, которые могли путешествовать во времени, множество вещей стало бы проще, но у нас нет таких компьютеров.

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

Один маленький шаг, один огромный скачок

Давайте попробуем что-то посложнее. Наша проблема в основном сводится к:

  1. Каждый update() продвигает игровое время на определенную величину

  2. Эта обработка занимает определенное количество реального времени

Если второй шаг занимает больше времени, чем первый шаг, игра замедляется. Если для продвижения игрового времени на 16 мс требуется больше 16 мс реального времени, то мы просто не успеваем и не можем идти в требуемом темпе. Но если мы могли бы продвинуть игру больше, чем на 16 мс игрового времени за один раз, то смогли бы нагнать темп ценой более редких обновлений.

Основная идея здесь — продвигать игру на столький временной промежуток, сколько реального времени прошло с предыдущего кадра. Чем длиннее занял времени кадр, тем бóльший шаг применяется для симуляции игры. Такой подход гарантирует, что симуляция всегда будет поспевать за реальным временем, поскольку будут браться настолько большие шаги, насколько это потребуется. Это называют переменным или плавающим временным шагом. Он выглядит вот так:

double lastTime = getCurrentTime();
while (true)
{
    double current = getCurrentTime();
    double elapsed = current - lastTime;
    processInput();
    update(elapsed);
    render();
    lastTime = current;
}

Каждый кадр мы определяем, как много реального времени прошло с момента последнего обновления (elapsed). Когда мы обновляем игровое состояние, мы передаем это время в функцию update(). А далее движок ответственен за продвижение симуляции игрового мира на этот промежуток времени.

А теперь предположим, что у вас есть пуля, летящая через весь экран. С фиксированным шагом обновления вы бы двигали пулю каждый кадр в соответствии с ее скоростью. В ситуации же с переменным временным шагом вы будете домножать скорость пули на пройденное время. Если шаг становится больше, пуля двигается более протяженными рывками каждый кадр. Эта пуля пройдет через весь экран за одно и то же количество реального времени, будь то двадцать мелких коротких шажков или же четыре длинных медленных шага. И этот подход выглядит как победитель в нашем состязании:

  • Игра работает с одинаковой частотой на разном железе

  • Игроки с более мощными машинами будут награждены более плавным геймплеем

Но, увы, впереди скрывается серьезная проблема: мы сделали игру недетерминированной и нестабильной.

"Детерминированный" значит, что каждый раз, когда вы запускаете программу, если вы даете ей одни и те же данные на вход, вы всегда получите одинаковый результат на выходе. Как вы можете понять, намного проще отлавливать баги в детерминированных программах — найти входные данные, вызывающие баг, и иметь возможность воспроизводить это поведение каждый раз.

Компьютеры детерминированы по своей природе; они механически следуют инструкциям. Недетерминированность проявляется, когда сюда вкрадывается хаотичный реальный мир. Например, сеть, системные часы и планирование потоков полагаются на части внешнего мира, которые находятся вне контроля программы.

Вот один пример ловушки, в которую мы поймали сами себя:

Скажем, у нас есть сетевая игра для двух игроков, и у Фреда зверь-ПК, в то время как Джордж пользуется бабушкиным древним компьютером. Эта вышеупомянутая пуля летит через оба их экрана. На машине Фреда игра работает супер-быстро, и каждый шаг обновления очень мал. Мы перемалываем, скажем, 50 кадров за секунду, когда пуля уйдет за экран. Бедная машина Джорджа может вместить в этот же промежуток времени только 5 кадров.

Это означает, что на машине Фреда физический движок обновляет положение пули 50 раз, пока на машине Джорджа — только 5. Большинство игр используют числа с плавающей точкой, а они подвержены ошибкам округления. Каждый раз, когда вы суммируете два числа с плавающей точкой, ответ, который вы получаете, может получиться с небольшой погрешностью. Машина Фреда выполняет в десять раз больше операций, поэтому он аккумулирует бóльшую ошибку, чем Джордж. Одна и та же пуля окажется в разных местах на их машинах.

Это только одна неприятная проблема, которую может вызвать переменный шаг обновления, но их на самом деле больше. Чтобы успевать работать в режиме реального времени, игровые физические движки — это лишь очень приближенные симуляции реальных законов физики. Чтобы держать эти приближения и допущения в узде и не допустить взрыва, применяется гашение. Это гашение аккуратно подстроено под конкретный временной шаг. Стоит начать его менять, и физика становится нестабильной.

"Взрыв" здесь в какой-то степени употребляется в буквальном значении. Когда физическому движку нехорошо, объекты могут принимать совершенно неверные скорости и катапультировать себя в воздух.

Игра в догонялки

Рендеринг — часть движка, которая обычно никак не затрагивается переменным шагом обновления. Так как движок отрисовки каждый раз отображает сиюминутный срез игры, ему все равно, как много времени прошло с предыдущего кадра. Он отобразит все, что сейчас происходит.

Это правда лишь отчасти. Такие вещи, как motion blur могут зависеть от временного шага, но даже если там вкрадется какая-то погрешность, едва ли игрок обратит на это внимание.

Мы можем использовать этот факт в наших интересах. Мы будем обновлять игру фиксированным шагом, чтобы сделать все проще и получить более стабильную физику и AI. Но мы позволим себе немного гибкости на стадии рендера и освободим немного процессорного времени.

Это работает так: конкретное количество реального времени прошло с момента предыдущего состояния игры. Это столько времени, сколько нужно игре, чтобы симулировать игровое "сейчас", чтобы поспеть за временем игрока. Для этого каждую итерацию цикла мы будем применять фиксированный шаг обновления столько раз, сколько нам потребуется, чтобы догнать время. Код выглядит так:

double previous = getCurrentTime();
double lag = 0.0;
while (true)
{
    double current = getCurrentTime();
    double elapsed = current - previous;
    previous = current;
    lag += elapsed;

    processInput();

    while (lag >= MS_PER_UPDATE)
    {
        update();
        lag -= MS_PER_UPDATE;
    }

    render();
}

Здесь несколько моментов. В начале каждого кадра мы увеличиваем lag, основываясь на том, сколько прошло реального времени. Эта переменная показывает, насколько игровые часы отстали от реальных. Затем мы обновляем игру во внутреннем цикле фиксированным временным шагом, раз за разом, пока не догоним реальное время. Как только мы догнали, мы рендерим кадр и начинаем все с начала. Вы можете представить себе это как:

Заметьте, что шаг обновления здесь — это уже не визуальная частота смены кадров. MS_PER_UPDATE — это просто детализация обновления состояния игры. Чем короче шаг, тем больше времени займет поспевание за реальным временем. Чем длиннее шаг, тем топорнее становится геймплей. В идеале вы хотели бы достаточно короткий шаг, зачастую быстрее, чем 60 FPS, так чтобы игра симулировалась с высокой достоверностью на быстрых машинах.

Но будьте внимательны и не сделайте его слишком коротким. Вам нужно удостовериться, что шаг обновления больше, чем время, которое занимает обработка функции update(), даже не медленном железе. Иначе ваша игра просто не сможет угнаться за реальным временем.

Оставлю это здесь — вы можете обезопасить себя, сделав принудительный выход из внутреннего цикла через определенное пороговое количество итераций. Да, ваша игра замедлится, но по крайней мере не захлебнется окончательно.

К счастью мы зарезервировали себе место для передышки. Трюк в том, что мы вынули рендеринг из внутреннего цикла. Это высвобождает некоторое время для ЦПУ. И в конечном результате у нас игра симулируется с постоянной частотой при помощи фиксированного шага обновления, что гарантирует нам нормальную работу на широком спектре железа; и только визуальное представление игры становится менее стабильным на слабых машинах.

Застрять в середине

У нас осталась одна проблема, и это — остаточная задержка. Мы обновляем игру с фиксированным шагом обновления, но рендер происходит в произвольные периоды времени. Это значит, что с пользовательской точки зрения игра будет часто показывать нам игру между двумя ее состояниями.

Вот временная линия, показывающая это:

Как видите, мы обновляемся с четким, фиксированным интервалом. В то же время мы рендерим тогда, когда можем. Рендеринг происходит реже, чем обновление; а еще он не равномерный. И то и другое — нормально. Плохая сторона здесь заключается в том, что мы не всегда рендерим в тот же момент, когда делаем обновление. Посмотрите на время третьего по счету рендера — он происходит ровно между двумя обновлениями состояния игры:

Представьте пулю, летящую через экран. На первом обновлении она в левой части экрана. Второе обновление перемещает ее в правую часть экрана. Рендер происходит между этими двумя состояниями игры, и пользователь ожидает, что пуля окажется в центре экрана. При нашей текущей реализации она будет все еще находиться в левой части экрана. Это значит, что движение будет ощущаться неровным и скачущим.

Что удобно, так это, что мы точно знаем, насколько далеко между двумя обновлениями мы делаем рендер: эта информация есть в переменной lag. Мы выходим из внутреннего цикла обновления, когда lag меньше, чем шаг обновления, а не когда он равен нулю. Вот это остаточное значение — это то, насколько далеко мы продвинулись к следующему кадру.

Когда мы дойдем до рендера, мы передадим эту информацию внутрь:

render(lag / MS_PER_UPDATE);

Мы делим на MS_PER_UPDATE, чтобы нормализовать значение. Оно станет лежать в диапазоне от 0 (момент предыдущего кадра) до 1 (момент следующего кадра), независимо от величины временного шага. Таким образом рендер не должен будет беспокоиться о частоте обновления. Он просто будет иметь со значениями в диапазоне от 0 до 1.

Отрисовщик знает про каждый игровой объект и его текущую скорость. Скажем, пуля находится в 20 пикселях от левой части экрана и двигается вправо со скоростью 400 пикселей в кадр. Если мы находимся ровно посередине между двумя состояниями игры, то в render() передастся 0.5. И он отрисует пулю на полкадра вперед — на 220 пикселе от левой части экрана. Та-да: плавное движение.

Конечно, может оказаться, что экстраполяция получилась неверной. Когда мы будем считать следующий кадр, может оказаться, что пуля встретилась с препятствием или замедлилась или что-нибудь еще. Мы отрисовали ее положение, интерполированное между моментом, где она была в последний раз, и где, как мы предполагаем, она будет находиться в следующем кадре. Но мы не узнаем этого наверняка, пока реально не совершим очередное обновление физики или AI.

Так что экстраполяция — а это в какой-то мере угадывание, и иногда оно будет неверным. Но, к счастью, такие виды поправок обычно не заметны. По крайней мере менее заметны, чем скачки, которые вы получаете, если вовсе не экстраполируете.

Проектные решения

Несмотря на длину этой статьи, я оставил за скобками больше, чем написал. Как только вы включаете в рассмотрение такие вещи, как синхронизацию с частотой обновления экрана, многопоточность и ГПУ, настоящий игровой цикл оказывается достаточно витиеватым. Но на высоком уровне абстракции так же остается еще несколько вопросов, на которые вы скорее всего должны будете себе ответить:

Владею ли я игровым циклом или им владеет платформа?

Надо сказать, это в меньшей степени ваш выбор, а в большей — выбор, сделанный за вас. Если вы делаете игру под браузер, вы в общем-то не сможете написать классический игровой цикл. Событийно-ориентированная природа браузера предопределяет это. Аналогично, если вы используете существующий игровой движок, вы скорее всего будете полагаться на его готовый игровой цикл вместо того, чтобы создавать свой собственный.

  • Использовать цикл обработки событий платформы

    • Это легко. Вам не нужно беспокоиться по поводу написания и оптимизации игрового цикла.

    • Хорошая интеграция с платформой. Вам не нужно явно выделять хосту время для обработки его событий, их кэширования или иным образом управлять несоответствием импеданса между входной моделью платформы и вашей

    • Вы теряете контроль над таймингом. Платформа будет вызывать ваш код тогда, когда посчитает нужным. Если это будет происходить не так часто или плавно, как вам бы хотелось — что ж, не повезло. И хуже того, большинство обработчиков событий у приложений не были спроектированы с оглядкой на игры и обычно они медленные и топорные

  • Использовать игровой цикл движка

    • Вам не нужно его писать. Написание своего игрового цикла может быть достаточно сложным делом. Так как это основополагающий код, запускающийся каждый кадр, минорные баги или проблемы с производительностью будут иметь большое влияние на вашу игру. Хороший, качественный игровой цикл — одна из причин рассмотреть уже существующее решение на базе готового игрового движка

    • Вам не дадут его написать. Конечно же, обратная сторона монеты — потеря контроля, если у вас есть необходимость в чем-то, что не предоставляется движком

  • Написать самому

    • Полный контроль. Вы можете сделать с ним все, что захотите. Вы сможете спроектировать его под нужды вашей конкретной игры

    • У вас нет взаимодействия с платформой. Фреймворки и операционные системы обычно ожидают, что у них будет промежуток времени, чтобы обработать события и сделать другую свою работу. Если у вас свой игровой цикл, они не получат этого времени. Вам придется периодически явно отдавать контроль, чтобы платформа не повисла или не стала работать некорректно

Как управлять энергопотреблением?

Это не было существенной проблемой пять лет назад (прим. переводчика: книга Game programming patterns появилась примерно в 2014 году, поэтому речь идет о примерно 2010-ых годах). Игры запускались на штуках, которые питаются от розетки в стене или на выделенных обрабатывающих устройствах. Но с пришествием смартфонов, ноутбуков и мобильного гейминга, велика вероятность, что теперь вас может волновать такой вопрос. Игра, которая играется красиво и эффектно, но превращает телефоны игроков в обогреватели, а потом за полчаса выжимает все соки из их батарей — это не та игра, которая сделает людей счастливыми.

Теперь у вас, возможно, есть необходимость задумываться не только над тем, насколько хорошо выглядит ваша игра, но и над тем, как сделать так, чтобы нагружать ЦПУ по минимуму. Скорее всего у вас будет верхняя планка производительности, где вы позволите ЦПУ заснуть, если сделаете всю работу, которую нужно было выполнить за один кадр.

  • Выполняться настолько быстро, насколько возможно Это то, чего вы скорее всего хотите для игр на ПК (хотя даже они все чаще могут играться на ноутбуках). Ваш игровой цикл никогда не будет явно просить ЦПУ заснуть. Вместо этого все свободные такты будут потрачены на нагнетание FPS или плавность отрисовки.

Это даст вам максимально возможный геймплейный опыт, но будет использовать настолько большое количество энергии, сколько сможет взять. Если игрок за ноутбуком, его колени отлично прогреются.

  • Ограничить FPS Мобильные игры часто сфокусированы на качестве геймплея, нежели на увеличении детализации графики. Множество таких игр устанавливает ограничение на FPS (обычно 30 или 60 FPS). Если игровой цикл завершает обработку кадра до того, как выделенный ему кусок времени будет истрачен до конца, он просто заснет на оставшееся время.

Это даст игроку достаточный опыт, а батарея будет расходоваться в щадящем режиме.

Как контролировать скорость геймплея?

У игрового цикла есть две ключевые части: неблокирующий пользовательский ввод и адаптация к течению времени. Пользовательский ввод прост и понятен. Магия же в том, как быть со временем. Есть практически бесконечное количество платформ, на которых могут запускаться игры; а отдельно взятая игра может запускаться на нескольких платформах. Ключевой момент — как происходит адаптация то платформы к платформе.

Игростроение — кажется, часть человеческой сущности, поскольку каждый раз, когда мы создаем машину, которая может делать вычисления, одна из первых вещей, которые на ней делаем — это игры. PDP-1 был двух-килогерцовой машиной с всего лишь 4096 словами памяти, и все же Стив Рассел и его друзья смогли создать "Spacewar!" на ней.

  • Фиксированный шаг обновления без синхронизации
    Это был наш первый вариант кода. Вы просто гоняете игровой цикл так быстро, как можете.

    • Это легко. Это основное (ну и, пожалуй, единственное) достоинство

    • Скорость игры напрямую зависит от железа и сложности вычислений в игре. И главный недостаток — если что-то из этого варьируется во времени, это сказывается на скорости игры в режиме реального времени. Это велосипед с фиксированной скоростью от мира игровых циклов.

  • Фиксированный шаг обновления с синхронизацией
    Следующий шаг вверх по лестнице сложности — это исполнение игры с фиксированным шагом обновления, но с добавлением задержки в конце цикла, чтобы удержать игру от слишком быстрого темпа.

    • По-прежнему легко. Всего на одну строку кода больше, чем у возможно-слишком-простого-чтобы-нормально-работать цикла из предыдущего пункта. В большинстве игровых циклов вы скорее всего увидите синхронизацию. Вы, вероятно, будете использовать двойную буферизацию графики и синхронизировать переключение буфера для частоты обновления экрана.

    • Энергосберегающе. Это удивительно важный пункт для мобильных игр. Вы не захотите без особой надобности убивать пользовательскую батарею. Простым засыпанием на несколько миллисекунд вместо впихивания большего и большего количества вычислений в один тик вы существенно уменьшите энергопотребление

    • Игра не будет работать слишком быстро. Это устраняет половину беспокойств фиксированного цикла

    • Игра может работать медленно. Если обновление и рендер занимают слишком много времени, игра может замедлиться. Поскольку данный подход не разделяет обновление от рендера, вероятно, замедление настанет быстрее, чем в более продвинутых версиях. Вместо пропуска кадров отрисовки, чтобы наверстать упущенное, игра просто замедлится.

  • Переменный шаг обновления
    Я оставлю это здесь как вариант среди множества допустимых решений с оговоркой, что большинство разработчиков игр против этого подхода. И хорошо помнить, почему именно это плохая идея:

    • Нивелирует и медленную и быструю игру. Если игра не может поспеть за реальным временем, цикл просто будет симулировать все бóльшие и бóльшие шаги обновления, пока не нагонит время

    • Делает геймплей недетерминированным и нестабильным. И это, конечно, серьезная проблема. С переменным шагом обновления в частности становится сложнее с физикой и сетевым кодом.

  • Фиксированный шаг обновления, переменный рендер
    Последний вариант, который мы описали в коде. Самый сложный, но самый гибкий. Он обновляет игру с фиксированным шагом обновления, но может отбрасывать кадры отрисовки, если необходимо догнать реальное время.

    • Нивелирует и медленную и быструю игру. Пока игра может обновляться в реальном времени, она не будет отставать. Если у игрока хорошее железо, у него будет более плавный игровой процесс.

    • Сложный. Основной недостаток здесь — это что в реализации происходит много всего. Вам нужно настроить шаг обновления так, чтобы он был одновременно и достаточно коротким, чтобы задействовать потенциал мощных машин, но при этом чтобы слабое железо тоже не захлебывалось

Комментарии (3)


  1. ildarin
    07.05.2024 00:17
    +1

    Экстраполяция мастхэв при сетевой с тонким клиентом. Делал попробовать такую архитектуру - по сути весь клиентский код это rendering и input, с полным update на сервере. Тогда лаг может быть ≈200мс, разница очень существенная. Кроме этого случая никогда рендер не приходилось экстраполировать, даже сложно представить - зачем это? Если проц и так нагружен вычислением координат, еще парралельно ту же операцию запускать на видюхе что ли?

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

    Про сетевую архитектуру с real time было бы интереснее прочитать. А так - да, while(true) это столп рилтайм игрового лупа. И это именно рилтайм паттерн, в пошаговых все иначе. Хотя для отрисовки интерфейса и анимаций gui все равно его юзают. Так то под капотом он все равно есть, цикл просто прячут, вытаскивая всякие event. Это на тему:

    а вне мира геймдева его используют относительно малое количество программных продуктов

    До css3 с анимациями, наверное 90% всего веба на setinterval анимациях жило.


  1. OptimumOption
    07.05.2024 00:17
    +3

    а смысл в отрывке из книги, если можно взять её целиком тут https://igorcomputer.ru/books/patterns/gamedev_patterns.pdf


  1. SadOcean
    07.05.2024 00:17

    Спасибо, статья хорошая
    Хо, хотя конечно аналогичные уже были, тема все же довольно старая.

    Я думаю можно поправить термины немного.
    Вы описали разницу между движком и библиотекой, но обычно так описывается разница между фреймворком и библиотекой.
    Вы вызываете код библиотеки.
    Фреймворк вызывает ваш код.
    С этой точки зрения "игровой движок" - очень широкий термин. Это может быть и библиотека / набор библиотек, и фреймворк и целый тул инструментов, включающий редактор и дополнительные инструменты типа редакторов материалов, текстур и анимаций.
    То есть в минимальном варианте это библиотека для графики, к примеру.
    В максимальном варианте это Unity или Unreal, в которых вы работаете в редакторе и встраиваете ваши скрипты в готовую структуру.