[Перевод] Портируем DOOM на serverless-платформу

[Перевод] Портируем DOOM на serverless-платформу

Благодаря своей открытой кодовой базе и чистым абстракциям DOOM компании id Software стал одной из самых портируемых в истории игр. Мне показалось, что это идеальный проект для портирования на платформу Compute@Edge, созданную в нашей вычислительной serverless-среде, для экспериментов с различными способами применения нашего продукта.

Демонстрация интерактивной работы DOOM на Compute@Edge позволила бы расширить границы производительности продукта и показать его удивительные возможности. В этой статье я расскажу, как мы этого добились.

Краткая история DOOM

DOOM — это игра, разработанная в 1993 году id software и выпущенная в декабре того же года. Id software зарабатывала на жизнь разработкой высококачественных 2D-игр, но создав в 1992 году Wolfenstein, а в следующем году DOOM, компания совершила исторический шаг в 3D, воспользовавшись быстрым прогрессом PC для расширения границ отрасли.

Исходники DOOM были открыты в 1997 году, в файле README было написано «Портируйте его на свою любимую операционную систему». Многие фанаты игры так и поступили — DOOM портировали на сотни систем, от самых очевидных до самых неизвестных. Будучи фанатом DOOM, я захотел проверить потенциал Compute@Edge, поэтому портировал эту культовую игру на платформу.

Примечание: Game Engine Black Book Фабьена Санглара — потрясающий ресурс, к которому я часто обращался в процессе работы над проектом. Эта и другая его книга о Wolfenstein являются подробным анализом ключевых моментов истории разработки видеоигр, к тому же очень увлекательны и познавательны.

Портирование

При портировании DOOM я решил использовать двухэтапный процесс:

  1. Добиться компиляции и запуска платформонезависимого кода (т.е. кода, не использующего никаких системных вызовов или SDK какой-то конкретной архитектуры/платформы). Это основная часть того, что большинство людей считает «геймплеем» игры.
  2. Заменить платформозависимые вызовы API в соответствии с целевой платформой. Это код, который в основном занимается вводом-выводом, в том числе рендерингом и звуком.

Официального открытого интерфейса для привязок C не существует, поэтому для экспериментов нам понадобится получить API C из крейта fastly-sys.

Общий код

Запуск DOOM без рендеринга и звука на Compute@Edge был достаточно простой задачей. В кодовой базе каждое имя функции имеет префиксы, и все специфические для реализации функции имеют префикс «I_», поэтому было довольно легко пройтись по кодовой базе и удалить их из компиляции. Закончив с этим, я использовал wasi-sdk чтобы сделать целевым двоичный файл Wasm. WebAssembly спроектирован так, чтобы без особых проблем компилировать нативный код, поэтому это изменение было очень простым.

Мне нужно было внести изменения в двоичный файл WebAssembly, потому что DOOM разрабатывался во времена 32-битных вычислений. В коде есть места, где предполагается, что указатели имеют размер 4 байта, что на тот момент было вполне разумным выбором. Данные в DOOM загружаются из файла, содержащего все ресурсы, созданные командой разработки и объединённые в один файл в момент релиза. Эти данные загружаются непосредственно в память, и сопоставляются с представляющей их внутриигровой структурой C. Если в этих структурах задействуются указатели, то загрузка данных в 64-битной среде приведёт к неправильному наложению данных на структуру и к неожиданному поведению. Эти проблемы выявить было довольно легко, и первоначально они приводили к достаточно очевидным сбоям.

Изменения в игровом цикле

Чтобы иметь возможность запускать общий код на Compute@Edge, мне нужно было отрефакторить традиционный игровой цикл, используемый в DOOM. Обычно игра инициализируется, а затем выполняется в бесконечном цикле, постоянно совершая с нужной частотой такты «ввод->симуляция->вывод», получая ввод от таких локальных устройств ввода, как клавиатура, мышь или контроллер, и выводя видео со звуком. Однако на Compute@Edge подобный процесс был бы исключён платформой, потому что задача инстанса заключается в запуске, выполнении некой работы, а затем возврате к вызывающей стороне. Поэтому я полностью избавился от цикла и изменил инстанс так, чтобы он выполнял только один кадр игры.

При выполнении в цикле полученный результат выглядит примерно так:

Ниже я подробнее расскажу о каждом из этих этапов.

Вывод

В видеоиграх память, содержащая отображаемое игроку готовое изображение, называется буфером кадров. В современных играх буфер кадров часто создаётся специализированным оборудованием GPU, так как готовое изображение часто является результатом выполнения множества этапов конвейера GPU. Однако в 1993 году рендеринг выполнялся программно, и в DOOM готовый буфер был доступен программисту как простой массив C. Такая схема упростила портирование DOOM на новые платформы, так как предоставляла разработчикам простую и понятную отправную точку для начала работы.

В случае с Compute@Edge я хотел возвращать буфер кадров в браузер игрока, где его можно отобразить. Для этого было достаточно использовать API C для написания буфера кадров в теле ответа, а затем отправлять это тело вниз по потоку:

// gets a pointer to the framebuffer
byte* framebuffer = GetFramebuffer(&framebuffer_size);
BodyWrite(bodyhandle, framebuffer, framebuffer_size,...);
SendDownStream(handle, bodyhandle, 0);

Когда запущенный в браузере клиент получает http-ответ от Compute@Edge, он парсит из него буфер кадров и рендерит его в браузере.

Состояние

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

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

// gets a pointer to the framebuffer
byte* resp = GetFramebuffer(&framebuffer_size);
// gets the gamestate, appends it to the framebuffer
resp+fb_size = GetGameState(&state_size);

BodyWrite(bodyhandle, framebuffer, framebuffer_size + state_size,...);
SendDownStream(handle, bodyhandle, 0);

Наряду с этим изменением был модифицирован и клиент, теперь он разделяет буфер кадров и состояние, и хранит состояние локально, а буфер кадров отображает в браузере. Когда он в следующий раз отправляет запрос к Compute@Edge, то передаёт в теле запроса состояние, которое инстанс Compute@Edge может считать из тела запроса и передать игре следующим образом:

BodyRead(bodyhandle, buffer,...);
LoadGameFromBuffer(buffer);

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

Ввод

Далее нам нужно реализовать ввод пользователя, чтобы в игру действительно можно было играть! Система ввода DOOM абстрагирована при помощи концепции событий ввода. Например, «игрок нажал клавишу W» или «игрок переместил мышь по X». При помощи слушателей событий Javascript (event listeners) мы достаточно легко можем генерировать в браузере события ввода, сопоставляемые с теми, которые ожидает DOOM:

document.addEventListener(‘keydown’, (event) => {
	// save event.keyCode in a form we can send later
});

Я отправляю события ввода вместе с состоянием, когда совершаю http-запрос к Compute@Edge. Затем инстанс парсит их в вид, который можно передать в движок игры перед вычислением кадра.

Оптимизации

Первая работающая версия этого демо совершала весь путь одного такта примерно за 200 мс. Для интерактивной игры это неприемлемо. Обычно игры вычисляют кадр за 33 мс, что аналогично 30FPS, или за 16 мс, что аналогично 60FPS. Учитывая, что задержки будут нетривиальной частью частоты обновлений, я решил, что стоит стремиться к 50 мс, то есть к четырёхкратному улучшению.

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

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

Мне удалось добиться, чтобы игра работала со скоростью 50-75 мс на такт. Можно поработать ещё, чтобы приблизиться к исходным показателям DOOM, но такие оптимизации показали, что мы можем итеративно работать с подобными проектами на Compute@Edge.

Выводы

Это была моя первая проба Compute@Edge, и я не знал, чего ждать от платформы с точки зрения отладки и итеративной работы. Платформа активно и непрерывно совершенствуется, и за три недели работы над проектом я увидел эти улучшения. В частности я хотел бы упомянуть Log Tailing, позволивший мне почти в реальном времени просматривать выводимую в DOOM информацию. При работе с довольно непрозрачной программой на C, особенно до того, как мне удалось добиться экранного рендеринга, просмотр этих выводимых данных был неоценим при отладке. В целом развёртывание на Compute@Edge оказалось похожим, например, на работу с традиционной видеоигровой консолью.

Честно говоря, она не была бы идеальным решением для запуска игры реального времени, требующей обновлений с точными таймингами. От подобного способа запуска игры не удастся получить никакой реальной выгоды. Задачей этого эксперимента было расширение границ возможного для платформы, создание реального демо для открытия и демонстрации потенциала. Разумеется, эту платформу в некоторых случаях можно использовать для видеоигр, и мы продолжаем исследовать способы использования продукта Compute@Edge в различных отраслях.


На правах рекламы

Мощные VDS с процессорами AMD EPYC для разработчиков. Частота ядра CPU до 3.4 GHz. Максимальная конфигурация позволит оторваться на полную — 128 ядер CPU, 512 ГБ RAM, 4000 ГБ NVMe.

 

Источник

Compute@Edge, Doom, портирование игр

Читайте также