Разбор
June 5, 2019

Как работает графика в играх. Doom (2016)

Спустя 23 года, id Software, которые в данный момент принадлежат Zenimax, и откуда ушли разработчики оригинальной игры, смогли показать всё свое мастерство в создании великих игр.

Doom 2016-го года хорошо дополняет всю франшизу. Он использует новый движок id Tech 6, в создании которого Tiago Sousa, бывший сотрудник Crytek, принимал участие в качестве lead render programmer, после ухода John Carmack. IdSoftware хорошо известна тем, что всегда,по прошествию нескольких лет, открывает код новых версий своего движка, что обычно сопровождается созданием хороших ремейков и разбором принципов работы движка. Надеюсь они сделают то же самое и с idTech 6, но в данный момент нам не нужен код движка, чтобы понять как работает встроенная в него графическая система.

Как рендерится кадр

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

В отличии от большинства игр под windows, которые выходят в последнее время, DOOM не использует Direct3D, но поддерживает OpenGL и Vulkan. Vulkan довольно новый продукт, плюс Baldur Karisson недавно добавил поддержку в RenderDoc...было довольно сложно сопротивляться и не изучить в деталях как работает DOOM. Все скриншоты, демонстрируемые в этой статье, были сделаны на Ultra настройках графики с GTX 980, а некоторые материалы я взял из презентации Siggraph за авторством Tiago Sousa и Jean Geffroy.

Мега-текстуры

Первый шаг - это техника использования мега-текстуры, которая уже была ранее представлена в id Tech 5 и использовалась в RAGE, а сейчас повторно использовалась в DOOM. Основная идея состоит в использовании нескольких больших текстур размером, в случае с DOOM, 16к на 8к, которые хранятся в памяти GPU как набор "плиточек" размером 128х128px.

Хранилище 16к на 8к с текстурами 128х128px

Все эти плиточки вместе представляют собой идеальный сет актуальных текстур с правильным mipmap уровнем.

Когда pixel шейдер считывает этот большой атлас, ему не нужно обрабатывать его целиком, он ограничивается сетом нужных текстурок размером 128 на 128 px.

Конечно, в зависимости от того, куда смотрит игрок в данный момент, этот сет будет меняться: новая модель появляется на экране, “виртуальная текстура” обновляется, подгружает новые “плиточки” и выгружает старые.

В самом начале построения кадра DOOM обновляет атлас текстур с помощью vkCmdCopyBufferToImage, чтобы перенести нужные его части в память GPU.

Больше информации про Мега-Текстуры тут и тут.

Атлас теневых карт

Для каждого источника света, который создает тени, генерируется и сохраняется в большой(8к на 8к) текстурный атлас, уникальная карта глубины. Тем не менее, DOOM не рендерит новые карты глубины для каждого кадра, а переиспользует ранее созданные и обновляет существующие в случае необходимости.

Когда источник света статичен и отбрасывает тени только на статичные объекты, имеет смысл сохранять карты глубины как есть, чтобы не производить ненужные просчеты. Если какой-то противник проходит перед этим источником света, то только в таком случае эти карты также пересчитываются. Размер карты глубины может разниться в зависимости от расстояния между источником света и камерой. Также, перепросчитаные карты глубины не обязательно остаются на том же месте в атласе, где были до этого. У DOOM довольно специфичная оптимизация. К примеру: он кэшируют статические части карт глубины, затем вычисляет только проекции динамических мешей и собирают из этих кусков финальную карту.

Предварительный просчет глубины

Все непрозрачные объекты просчитываются, генерируя карты глубины. Первым делом просчитывается оружие игрока, потом статическая геометрия и, в конце, динамическая геометрия.

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

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

Чтобы создать карту скорости, нужно всего лишь 2 текстурных канала: красный отвечает за скорость по горизонтальной оси, а зеленый за скорость по вертикальной оси. Монстр в кадре быстро движется в сторону игрока(окрашен в зеленый цвет), в то время, пока оружие игрока практически застыло(окрашено в черный цвет. Что насчет желтой области в кадре(когда красный и зеленый каналы эквивалентны 1)? Это, фактически, базовый цвет буфера, тут нет динамических объектов, это всё статичное пространство.

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

Карта скорости нам понадобится чуть позже для создания motion blur.

Определение видимости объекта. Occlusion queries

Наша задача состоит в том, чтобы отправлять как можно меньше геометрии на рендер к GPU, и для этого мы скрываем все объекты, которые игрок не видит напрямую. В DOOM это реализовано с помощью Umbra middleware, но в движке также есть дополнительные механизмы, чтобы уменьшать количество видимых объектов.

И так, в чем состоит идея GPU occlusion queries?

Первый шаг состоит в том, чтобы объединять несколько мешей по размеру и очертить их определенными “рамками”, после чего попросить GPU отрендерить их ещё раз с актуальными картами глубины. Если все пиксели внутри этих “рамок” не “прошли тест” картой глубины, это значит, что вся геометрия перекрыта другими объектами и игрок её не видит, что позволяет скрыть её при рендере.

Также стоит учитывать, что информация от occlusion queries не считывается моментально и её обработка переносится на следующий кадр, так что в этом случае также используется специальный алгоритм, который помогает избежать случайного появления/исчезания объектов.

Clustered-Forward-Rendering. Непрозрачные объекты

На этом этапе рендерятся все нужные непрозрачные объекты и декали. Информация про свет записывается в HDR(High Dynamic Range) буфер:

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

Кадр уже выглядит хорошо, но мы всё ещё не видим прозрачные объекты вроде стекла или частиц, а также окружение всё ещё не отражает свет.

Несколько слов про этот этап: тут используется clustered forward renderer, который создан на основе работ Emil Person’а и Ola Olsson’а.

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

И так, как же работает clustered renderer?

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

Clustered rendering двигает эту технику дальше, из 2D в 3D: вместо того, чтобы останавливаться на двухмерном разбиении вьюпорта на секции, он, по сути, производит 3D разделение “конуса” вида камеры(как на примере с картинкой ниже), создавая ещё одну линию сечения по оси Z.

Каждый “блок”(результат деления “конуса”) называется “cluster”. Их также можно назвать “frustum-shaped”, voxel или “froxel”.

Сверху, на примере, можно увидеть пример простого разделения вьюпорта размером 4 х 2 с 5-ю длинными линиями разделения, которые разбивают конус на 40 кластеров(cluster).

В DOOM этот конус от камеры разделяется на 3072 кластера(разбивка 16 х 8 х 24), где срез глубины расположен логарифмически по оси Z.

Типичный подход к работе у clustered renderer выглядит следующим образом:

  • Первым делом CPU просчитывает список объектов, которые влияют на освещение внутри каждого кластера: источники света, декали, кубмапа(cubemap)... Для этого все объекты “вокселизируются”, так что область их расположения может проверяться относительно кластеров. Информация записывается как индексный список в буфер GPU, так что шейдеры могут получить к нему доступ. В каждом кластере может находится до 256-ти источников света, 256-ти декалей и 256-ти кубмап.
  • Позже, когда GPU обрабатывает пиксели, происходит следующее: Основываясь на координатах и информации о глубине пикселя, определяется тот кластер, к которому пиксель принадлежит. Далее определяется список конкретных декалей/источников света в определенных кластерах. Код ограничивает все декали/источники света, просчитывает их и добавляет в кадр.

Примерно так пиксель шейдер извлекает списки декалей и источников света во время этого этапа:

Также существует список проб, который формируется таким же образом, но не на этом этапе, так что мы вернемся к нему позже.

Время, затраченное на все эти просчеты на CPU того стоит, ведь это значительно уменьшает сложность просчетов рендера на GPU.

Clustered-forward rendering в последнее время привлекает внимание, потому что он может обрабатывать больше источников света, чем forward render и делать это быстрее, чем deferred render, который считывает/записывает информацию из нескольких буферов геометрии(G-Buffer).

Но есть кое что ещё, о чем я не сказал: во время этого этапа также заполнились ещё два G-буфера с помощью MRT(Multiple Render Targets):

Normal map
Specular map

Карта normal записывается в формате R16G16(Red: 16 bits; Green 16 bits). Карта specular в R8G8B8A8(Red, Green, Blue, Alpha. Все по 8 bits), где А - альфа канал, в котором хранится информация про уровень сглаженности текстуры.

Так что DOOM, по факту, использует комбинированный подход, соединяя forward и deferred render. Эти дополнительные G-буферы, про которые мы только что говорили, нужны будут для создания эффектов вроде отражения.

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

GPU частицы

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

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

Затенения

Проще говоря, SSAO

Следующим пассом генерируется SSAO.

SSAO создает затенения на стыках объектов, в разных углах, на сгибах и т.д..

Он также используется для корректировки specular occlusion, что помогает избежать некорректного отражения или артефактов на затененных объектах.

В этот момент движок просчитывает результат в половину того разрешения, которое pixel shader считывает из буфера глубины, normal’а и specular’а, так что результат получается шумным.

Screen Space Reflection

На следующем этапе pixel шейдер генерирует карту SSR(Screen Space Reflection). Движок просчитывает отражение используя информацию, предоставленную в кадре. Он отражает лучи света от каждого пикселя во вьюпорте и считывает цвет пикселя, от которого отразился луч.

SSR map

В качестве вводных данных шейдер использует карту глубины(чтобы рассчитать положения пикселя в мире), normal карту(чтобы знать каким образом отражать лучи), карту specular(чтобы просчитать степень отражения) и предыдущие отрендеренные кадры(чтобы иметь представление о цвете). Также, pixel шейдер получает информацию про конфигурацию и положение камеры, чтобы можно было отслеживать изменения её позиции.

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

В общем, я считаю, что SSR в DOOM проработан достаточно хорошо, качество картинки довольно таки высокое, а перечисленные проблемы вы и не заметите, если специально не сконцентрируетесь на них.

Статическое отражение кубмапы

После того, как все динамические отражения из прошлого паса просчитаны, в дело включается IBL.

Техника заключается в использовании заготовленных кубмап в разрешении 128х128px, в которых запекается статичный свет и расположение различных статичных объектов, они также называются “пробы окружения”(environment probes). Как и декали с источниками света, пробы индексируются в разных кластерах. Все кубмапы на уровне хранятся в общем массиве. Их там довольно много, но вот для примера несколько штук:

Pixel шейдер считывает информацию из буфера глубины, normal, specular, просчитывает влияние кубмап внутри кластеров на каждый пиксель(чем ближе кубпама к пикселю, тем сильнее её влияние) и из всего этого генерирует static reflection map:

Соединение всех карт вместе

На этом этапе вычислительный шейдер(compute shader) комбинирует все карты и всю информацию, которую сгенерировал до этого. Он считывает глубину, specular map и смешивает их с освещением в следующем порядке:

  • информация об SSAO
  • SSR там, где это нужно
  • когда информация об SSR в каком-то месте пропадает, он подменяет её на информацию из reflection map
  • просчитывает дым, туман и т.д..

Освещение частиц

В нашей сцене есть немного дыма и освещение фактически просчитывается для каждого спрайта отдельно.

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

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

Именно на этом этапе DOOM разделяет фактический рендер основной картинки от рендера эффектов и частиц: в независимости от того, с каким разрешением вы играете(720p, 1080p, 4к…), освещение для частиц и эффектов будет просчитываться и записываться в эти маленькие текстурки.

Уменьшение и размытие

Кадр уменьшается несколько раз вплоть до размера в 40 пикселей. Эту уменьшенную версию сцены размывают отдельно по горизонтали и по вертикали.

Почему размытие происходит так рано? Такие вещи ведь обычно делают во время пост-обработки?

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

Прозрачные объекты

Все прозрачные объекты (стекло, частицы) находятся в верхней части нашего кадра:

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

Pixel шейдер просчитывает степень размытости для рефракции света и выбирает из атласа, про который мы говорили ранее, две размытые текстуры, которые наиболее близки по степени размытия к нужному нам. Он считывает эти две текстуры, а затем линейно интерполирует эти две карты, чтобы получить среднее значение и найти тот уровень преломления для стекла, который нужен. Благодаря этому подходу прозрачные объекты в DOOM выглядят настолько реалистично.

Карта дисторсии

Очень горячие объекты в кадре могут вызвать дисторсию света. Выше, на карте дисторсии, видно эффект от Gore Nest, который находится в нашем кадре на фоне.

Красный и зеленый каналы отображают силу дисторсии по горизонтали и вертикали соответственно, а в синем канале записывается информация про силу размытия картинки.

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

Пользовательский интерфейс

UI

UI рендерится в режиме premultiplied alpha и хранится он в LDR(Low dynamic range) формате.

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

При рендеринге не используются никакие техники пакетирования и т.д.. Объекты UI просто отрисовываются один за другим за примерно 120 откликов.

В конце UI просто накладывается поверх игрового изображения.

Temporal Anti-Aliasing и Motion-Blur

TAA и Motion-Blur назначаются основываясь на карте скорости и разнице в положении камеры в прошлом и актуальном кадрах. Благодаря ретропроекции фрагментов, pixel шейдер знает где пиксели текущего кадра находились в предыдущем. Алгоритм рендеринга, по сути, смещает картинку в каждом втором кадре на половину пикселя, что помогает избежать артефактов антиалайзинга.

Результат выглядит хорошо: грани мешей становятся более сглаженными, а также учитывается specular antialiasing. Результат выглядит намного лучше, чем если бы разработчики использовали техники пост-процессинга по типу FXAA.

Яркость в сцене

На этом этапе просчитывается средняя яркость сцены. Этот параметр в будущем будет использоваться tone mapper’ом для финализации картинки в кадре.

Буфер HDR освещения уменьшает разрешение картинки в половину во время каждой итерации до момента, пока не сожмет текстуру до размера 2 х 2. Каждая итерация рассчитывает цвет пикселя на основании среднего значения яркости 4 пикселей-родителей из текстуры более высокого разрешения, из прошлой итерации.

Bloom

Bright-pass фильтр назначается чтобы затемнить наиболее темные участки сцены.

Результат работы bright-pass фильтра затем масштабируется и размывается по аналогии того, как мы видели это ранее.

Слои размываются с помощью Gaussian blur отдельно по вертикали и горизонтали, а pixel шейдер вычисляет с какой силой и в каком направлении размывать изображение.

Финальный пост-процессинг

Весь этот пасс выполняется в одном лишь pixel шейдере:

  • Тепловая дисторсия применяется на основе карты дисторсии;
  • Bloom накладывается поверх буфера HDR света;
  • Добавляются эффекты по типу виньетки/грязи/линзы;
  • Среднее значение яркости кадра определяется основываясь на карте яркости размером 2 х 2 и назначаются дополнительные корректирующие параметры, tonemapping и цветовые градиенты.
Tonemapping

UI и зернистость

И наконец, поверх всего кадра накладывается UI и зернистость.

Вжух! У нас есть собранный кадр, который мы можем отправлять на дисплей монитора. Так много расчетов, так много слов, а ведь всё описанное происходит меньше чем за 16 миллисекунд.

Разработчики DOOM научились создавать очень красивую и одновременно оптимизированную картинку благодаря переиспользованию информации из старых вычислений. В общем сложности, для этого кадра использовалось 1331 draw calls, 132 текстуры и 50 итераций визуализации.

В конце оригинальной статьи ещё остались бонусные комментарии и материалы, советую посмотреть.

Если хотите копнуть глубже в изучении технологий idTech 6, вот вам немного материала на эту тему:

Дискуссии на эту тему: Slashdot, Hacker News, Reddit, Kotaku.

Спасибо за внимание!

▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬

P.S

Если хотите видеть переводы раньше всех - подписывайтесь на телеграм канал - https://t.me/CGTranslate

Все советы, предложения и критику пишите в мою личку телеграма @DenisNik или на почту [email protected]

Перевод был подготовлен в рамках проекта CgTranslate.