БЛОГ QUANTECH – ОПТИМИЗАЦИЯ 3D-ДВИЖКА ДЛЯ ПК

Quantic Dream придерживается главного принципа в отношении технологий: они должны обеспечивать погружение в игровой мир. Технологии не должны стоять на пути творчества, напротив, они должны способствовать ему.

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

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

Эту статью по оптимизации 3D-движка для ПК написал Кевин Хавранек (Kevin Havranek), разработчик 3D-движка.

Оглавление

  • Оглавление
  • Введение
  • Интерфейс к графическому API
    • Библиотека RENDER_CONTEXT
    • Реализация интерфейса для конвейеров
    • Реализация интерфейса для ресурсов
    • Псевдокод
  • Сокращение количества вызовов отрисовки
    • Ограничение обмена данными между ЦП и видеокартой
    • Исключение вершинных буферов
    • Объединение индексных (и других) буферов
  • Заключение
  • Благодарности

Введение
Исторически сложилось так, что внутренние технологии в Quantic Dream разрабатывались для PlayStation, а для целей публикации игр у нас также была версия движка для ПК на основе OpenGL. По классическому канону наш 3D-движок использует библиотеку, служащую интерфейсом между игровым движком и задействованной графической библиотекой.
Чтобы портировать Detroit: Become Human на ПК, нам пришлось переписать интерфейс движка рендеринга для поддержки Vulkan. Об этом вы можете прочитать в моей статье для блога AMD GPUOpen. С тех пор мы продолжаем заниматься оптимизацией, чтобы расширить возможности нашего 3D-движка для будущих игр.
Оптимизации интерфейса рендеринга необходимы для того, чтобы как можно быстрее передавать данные на видеокарту. Например, особенно часто повторяется вызов функции DrawPrimitive() и её разновидностей. При наличии множества объектов критически важно, чтобы вызов происходил достаточно быстро для поддержания высокой частоты кадров.
Ради экономии времени и простоты первую реализацию нашего интерфейса для Vulkan мы сделали прямолинейно, переписав классы OpenGL и DirectX. Мы проделали большую работу, чтобы реализовать интерфейс для обработки объектов Vulkan и изменить движок для их использования.

Интерфейс к графическому API
Библиотека RENDER_CONTEXT
В нашем движке RENDER_CONTEXT — это просто интерфейс, обеспечивающий связь с различными поддерживаемыми графическими API, такими как Vulkan, OpenGL и PlayStation.
В этой библиотеке предусмотрен основной класс RENDER_CONTEXT, который позволяет передавать данные на видеокарту, а также создавать классы для текстур, буферов, целей рендеринга и т. п. У каждого класса есть реализация графического API.
При портировании нашего движка на ПК посредством Vulkan большая часть работы касалась этого интерфейса. Так как мы были ограничены по времени, на тот момент мы не могли глубоко модифицировать движок и сосредоточились на реализации API для Vulkan, поддерживающей наш интерфейс.
Мы быстро осознали, что для достижения хорошей производительности необходимо реализовать в нашем интерфейсе обработку некоторых объектов Vulkan, а для этого придётся глубоко модифицировать движок.

Реализация интерфейса для конвейеров
В отличие от OpenGL и других графических API, в Vulkan используется объект VkPipeline, который может содержать набор шейдеров и их состояний. При использовании OpenGL формированием различных конвейеров для вывода изображения занимался драйвер. Но при использовании Vulkan объекты VkPipeline должны создавать разработчики.
В первой реализации Vulkan для Detroit: Become Human программисты передавали шейдеры и их состояния напрямую в наш интерфейс. Затем наш интерфейс контролировал создание и использование различных конвейеров во время вывода изображения. В итоге мы воспроизвели поведение OpenGL, но на уровне нашего интерфейса.

Это привело к двум проблемам:

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

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

Реализация интерфейса для ресурсов
Для управления ресурсами и их передачи на видеокарту посредством Vulkan необходимо использовать объект VkDescriptorSet. В то время как в OpenGL простой вызов функции bind позволяет легко передать ресурс в шейдер, в Vulkan сначала необходимо обновить объект VkDescriptorSet, а затем прикрепить его к VkCommandBuffer, который выполнит шейдер. Это усложняет процесс передачи ресурсов.


В первой реализации Vulkan для Detroit: Become Human (аналогично VkPipeline) управление объектами VkDescriptorSet выполнялось внутренними средствами нашего интерфейса. При этом управление объектами VkDescriptorSetLayout осуществлялось автоматически, позволяя нам описывать различные ресурсы и их форматы наших шейдеров / объектов VkDescriptorSet. Таким образом, управление этими объектами VkDescriptorSetLayout привнесло новые концепции, которые нам пришлось проанализировать для оптимизации нашего движка.


Разница с OpenGL заключается в том, что нам необходимо заранее знать комбинации дескрипторов для создания VkDescriptorSetLayout. За интерфейсом и без указания от разработчика эту комбинацию невозможно узнать даже перед выполнением шейдера и связанных с ним ресурсов. Поэтому было необходимо формировать VkDescriptorSetLayout и VkDescriptorSet в момент вызова отрисовки или диспетчеризации. Даже при выполнении операций с мало различающимися комбинациями дескрипторов мы все равно должны были формировать пару VkDescriptorSetLayout / VkDescriptorSet для выполнения каждой операции. То есть первой проблемой являлось чрезмерное количество объектов VkDescriptorSet и их обновлений, в то время как их можно было выполнять при инициализации, а не во время первого выполнения.


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


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


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


Благодаря этой оптимизации мы смогли вдвое уменьшить время обновления наборов дескрипторов.

Псевдокод
// До
void RENDERER::Init()
{
}

void RENDERER::Display()
{
    RENDER_CONTEXT::SetTexture(0, _Tex0);
    RENDER_CONTEXT::SetBuffer(0, _Buf0);
    RENDER_CONTEXT::SetDepthTest(true);
    RENDER_CONTEXT::SetDepthFunc(LESS);
    RENDER_CONTEXT::SetCullMode(FRONT);

    RENDER_CONTEXT::SetVertexShader(_VertexShader);
    RENDER_CONTEXT::SetPixelShader(_PixelShader);

    RENDER_CONTEXT::Draw();
}

// После
void RENDERER::Init()
{
    BINDINGS_LAYOUT BindingsLayout;
    BindingsLayout.RegisterTexture(0, TEXTURE_TYPE);
    BindingsLayout.RegisterBuffer(0, BUFFER_TYPE);

    PIPELINE_CREATE_INFOS CreateInfos;
    CreateInfos._bDepthTest = true;
    CreateInfos._eDepthFunc = LESS;
    CreateInfos._eCullMode = FRONT;
    CreateInfos._pVertexShader = _VertexShader;
    CreateInfos._pPixelShader = _PixelShader;

    _pPipeline = RENDER_CONTEXT::CreatePipeline(CreateInfos, BindingsLayout);
    _pResourceSet = RENDER_CONTEXT::CreateResourceSet(BindingsLayout);

    _pResourceSet->SetTexture(0, _Tex0);
    _pResourceSet->SetBuffer(0, _Buf0);
}

void RENDERER::Display()
{
    RENDER_CONTEXT::SetPipeline(_pPipeline);
    RENDER_CONTEXT::SetResourceSet(0, _pResourceSet);

    RENDER_CONTEXT::Draw();
}
 

Сокращение количества вызовов отрисовки
Ограничение обмена данными между ЦП и видеокартой

При работе с видеокартой желательно ограничить обмен данными с ЦП. Одним из способов оптимизации является уменьшение количества вызовов отрисовки: чем меньше вызовов отрисовки, тем выше частота кадров (FPS).


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


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

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


Для этого мы создали унифицированную систему доступа к данным с учётом требуемой декларации шейдеров. Поэтому мы использовали дескриптор унифицированного типа (например, unit) с последующей реконструкцией данных. Предположим, координаты заданы 3 значениями типа float, тогда мы передаём 3 значения типа uint, интерпретируя их как float.


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

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


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

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


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

Кевин Хавранек (Kevin Havranek)

Благодарности
Ronan Marchalot, Eric Lescot, Grégory Lecot, Clément Roge, Alexandre Lamure, Nathan Biette, Bertrand Cavalie, Jean-Charles Perrier, Max Besnard, Lisa Pendse, Mélanie Sfar.