24/04/2019

3D рендеринг: как работает GPU

Всем привет. Меня зовут Глеб Булгаков, я — программист. Вместе с тех. артистом Романом Лещенко мы работаем в компании Fractured Byte и хотим поделиться нашими знаниями и опытом в деле оптимизации реалтаймового контент пайплайна.

Работая вместе в компании BWF, мы успели приложить руку ко множеству разных по жанру, целевой платформе и сложности проектов. Среди них было и портирование всемирно известных проектов на мобильные платформы (Life Is Strange, Brothers: A Tale of Two Sons), разработка собственных проектов на разные платформы (In Fear I Trust, Renoir), и даже прототипирование и R’n’D  для VR игр. У нас 9 лет опыта работы с движком Unreal Engine, начинали с UE3. Мы успели поработать с такими компаниями как Square Enix, Disney, 505 Games, Framestore, Chillingo и т.д. Таким образом, мы почти никогда не сталкивались с одними и теми же задачами и нам всегда приходилось изыскивать возможности отрисовать много контента на экране за минимальное количество миллисекунд. Так что, можно сказать, что мы съели на этом пару собак :)

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

Обзор

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

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

  1. Загружаем ассеты (модели, шейдера, текстуры, партиклы и т.д.) в RAM-память и память видеокарты.
  2. С помощью Culling систем на CPU (детально про них ниже) отбрасываем невидимые в данный момент из камеры объекты, формируя тем самым список объектов, которые мы должны отрисовать.
  3. Вызываем команды для отрисовки этих объектов на GPU.
  4. GPU делает ряд сложных вычислений, проецируя 3D-объекты на 2D экран, беря в учет условия освещения в сцене и материалы объектов.
  5. Применяем пост-эффекты (пост-процессы).

Как вы могли заметить, основная “магия” происходит в четвертом пункте. Что это за сложные вычисления, как они влияют на производительность и как научится их контролировать? Этим мы сегодня и займемся, а начнём с того, какие объекты можно создавать и какие операции можно делать над ними, а потом плавно перейдем к отрисовке сцены.

Также перед тем, как начать, стоит сказать, что эта статья не описывает какой-то конкретный конвейер рендера в конкретном движке (хотя мы и будем приводить много примеров из UE4), а старается обобщить наши знания таким образом, чтобы вы могли понять откуда “растут ноги” у современных контент-пайплайнов и графических фич,  а также поняли, почему количество полигонов в модели уже давно не является адекватным мерилом производительности.

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

Какие ресурсы можно создать на GPU?

Текстуры

Самый известный и самый объемный ресурс — это, наверное, текстура. Чисто для примера мы будем использовать простую текстуру 8х8 пикселей с призраком из игры Pac-man:

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

Одномерные (в терминологии DirectX 11 — Texture1D).
По своей природе одномерные текстуры лучше всего подходят для градиент-маппинга. UE4 не использует этот вид текстур, но предоставляет альтернативу — Curve Atlas, который является оберткой над двумерной текстурой и позволяет работать со множеством одномерных текстур сразу. Детальнее с ними можно ознакомиться тут.

Одномерная текстура. Чтобы взять какой-то пиксель из этой текстуры, нужно указать смещение этого пикселя U (по умолчанию текстура находится в пределах 0...1) вдоль единственной стороны текстуры.

Двумерные (в терминологии DirectX 11 — Texture2D).
Тут и рассказывать особо нечего — это самый распространенный тип текстур, который с большой вероятностью будет делать ~90% картинки в вашем проекте. Текстуры объектов, интерфейса, служебные LUT, карты высот — как правило, это все Texture2D.

Двумерная текстура. Для указания конкретного пикселя стоит указать смещение вдоль осей U и V.

Трехмерные (в терминологии DirectX 11 — Texture3D).
Такие текстуры можно представить как массив или слои двумерных текстур. В редакторе создавать их как ассеты можно, но для этого их нужно включить (r.AllowVolumeTextureAssetCreation 1). В основном, 3D текстуры используются самим движком для работы с Vector Fields, Distance Fields, Volumetric Lightmaps и т.д. То есть там, где необходимо пространственное представление данных.

Трехмерная текстура. Тут чтобы взять пиксель нужно указать уже 3 смещения — U, V и W.

Самый последний тип и, наверное, самый интересный — куб текстуры (CubeMaps, в терминологии DirectX 11 — TextureCube).
В памяти они представлены как 6 двухмерных текстур, которые соответствуют граням куба (+x -x +y -y +z -z). По сути, куб текстура — это способ сделать привязку пикселя к направлению. Самый яркий пример — скайбокс. В UE4 эта текстура отображается как Равнопромежуточная проекция (Long Lat Projection).

Куб текстура

Каждая текстура может обладать уменьшенными копиями самой себя — это так называемые мип-мапы (mip maps). Дело в том, что когда мы рисуем объект, он может находится как близко, так и далеко от игрока и соответственно иметь разный размер на экране. В результате, если объект имеет текстуру большего разрешения, чем его фактический размер на экране в этот момент — невозможно стабильно получить один и тот же цвет пикселя на экране с течением времени. В результате возникает эффект, известный под названием Муар (от фр. Moire).

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

Мип мапы. Призрак на последней мипмапе не такой уж и страшный :)

Стоит еще указать об особом свойстве текстур — RenderTarget. Это свойство текстуры, которое позволяет рисовать (рендерить) картинку в неё в реальном времени. С точки зрения GPU, это именно свойство текстуры, хотя в UE4 рендер таргеты — отдельные ассеты, которые можно создавать и использовать. Детальнее — тут. Все рендер таргеты используют формат, который не поддерживает сжатие. Таким образом, простой рендер таргет 1024х1024 с форматом RGBA8 будет занимать 4 Мб. Другими словами, с рендер таргетами нужно знать меру и следить за тем, чтобы они выгружались.

1024х1024 RGBA8 - 4 Мб
2048х2048 RGBA8 - 16 Мб
1920x1080 RGBA8  ~ 8 Мб

Константные буферы

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

Рассмотрим простейшую модель — куб. Как вам, возможно, известно — треугольник является наименьшим элементом, из которого состоят все 3D-модели. И чтобы описать модель способом, который будет понятен GPU, нам нужно 2 буфера — вершинный и индексный. Вершинный описывает, как ни странно, вершины модели: их позицию, нормали, текстурные координаты и т. д. Индексный буфер содержит индексы вершин, порядок соединения этих вершин, необходимый для построения на экране отдельного треугольника.

Вершинный и индексный буферы для куба

Шейдера и стейты

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

SamplerState — стейт, который определяет, как GPU делает выборку с текстуры: фильтрацию, адресацию. Это, наверно, единственный стейт в UE4, на который мы явно можем влиять, когда устанавливаем текстуру в материале или в настройках самой текстуры. При описании текстур я избегал слова “сэмплить” (sample), под сэмплингом подразумевается вычисление цвета, именно вычисление, а не простая загрузка из памяти. Каждый раз, когда мы сэмплим из текстуры, GPU делает много вычислений, чтобы определить, с какой мипмапы брать цвет, возможен даже блендинг между мипмапами; для определенных типов фильтрации, таких как Anisotropic, в учет берется и положение треугольника в пространстве. Сэмплинг — достаточно сложная операция, и чем меньше текстур используется при рендеринге объекта, тем лучше.

Типы фильтрации текстур. Point — выбираем ближайший пиксель, Linear — интерполируем цвет между 4 ближайшими пикселями, Anisotropic — находим усредненный цвет в выделенной области, которая зависит от положения примитива.

А что будет, если мы попытаемся взять пиксель за пределами текстуры (напомним, что текстура находится в пределах 0...1)? Для этого и была придумана адресация.

Типы адресации текстур. Wrap — повторение текстуры, Clamp — повторение крайнего пикселя, Mirror — отражение текстуры.

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

Настройки Blend State, непрозрачная геометрия (слева), прозрачная геометрия (справа)

Одним из важных элементов в Render Pipeline является z-buffer. Идея z-buffer заключается в присвоении каждому пикселю значения глубины. Когда GPU рисует геометрию, на экране она сравнивает значение глубины нового пикселя и уже существующего, чтобы определить, какой пиксель находится ближе к камере. Можно рассматривать z-buffer как некоторую версию occlusion кулинга (про него ниже), правда, не очень эффективную, поскольку мы уже отрисовываем объект, который на самом деле никак не повлияет на результирующую картинку. Все взаимодействие с z-buffer происходит с помощью стейта DepthStencilState.

Визуализация z-buffer’a

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

Шейдер — это программа, которая выполняется на GPU. Есть разные виды шейдеров, но мы остановимся только на двух основных: Vertex Shader (вершинный) и Pixel Shader (пиксельный). Основной задачей вершинного шейдера является трансформация объекта в пространстве (перемещение, поворот и масштабирование) и проецирование на экран. После того, как GPU спроецировала примитив на экран, вступает в работу пиксельный шейдер, который и вычисляет цвет пикселя.

В UE4 шейдеры создаются путём визуального программирования в Material Editor. Каждый материал имеет в себе предопределенные входы (инпуты) (Base Color, Metallic, Roughness, Emissive и т.д.), и мы, создавая ноды и соединяя их в определенной последовательности, генерируем код шейдера. Этот код является только частью шейдера и определяет основные параметры материала. В свою очередь, материал генерирует не один шейдер, а целое множество шейдеров для разных проходов материала.

Операции на GPU

Перед тем как начать рисовать новый кадр, нужно почистить весь мусор из предыдущего. Обычно очищают только z-buffer с помощью команды Clear. Текстуру с основной картинкой не очищают: из-за особенностей любой сцены, каждый пиксель будет перезаписан. Другими словами — это лишняя работа.

Когда дело доходит до отрисовки меша, мы используем команды Draw*, а если точнее — семейство команд. Нельзя сказать, насколько тяжелый один DrawCall, нужно учитывать контент, который вы подаете, и сколько он занимает места на экране. Эти факторы прямым образом влияют на производительность в целом. Нарисовав 1000 объектов в сцене, мы не так убьем производительность, как партиклом с прозрачной геометрией, который отображается на весь экран (а то и физически больше него).

На GPU есть особая текстура — Swapchain, которая представляет собой область экрана, где рисуется наша сцена. Swapchain состоит из нескольких буферов: front buffer — содержит то, что сейчас мы видим на экране, и back buffer — текстура, в которую мы рисуем следующий кадр. Когда наша сцена отрисована и находится в back buffer, мы вызываем команду Present — она меняет front buffer и back buffer местами. И отрисовка следующего кадра начинается снова.

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

Recent Posts

10 инсайдерских советов для художников, которые хотят попасть в игровую индустрию

13/05/2019
Суть этой статьи — дать знания, которые будут актуальны независимо от того, прошли ли вы курсы, выучились сами, либо уже работаете в индустрии.

3D рендеринг: отрисовка сцены

08/05/2019
Рассмотрим на практике, как отрисовывается сцена на GPU, используя знания, полученные в первой части статьи. Внутри — интерактивное демо, в котором можно “пощупать” кадр прямо в браузере.

Этапы создания окружения во время разработки игры

06/05/2019
Рассказываем о ключевых этапах создания окружения для игры ААА-класса и о том, какие ассеты обычно для этого используются.