Обзор
Продолжая часть 1, эта статья исследует ключевые аспекты разработки графического движка DirectX 12, включая текстуры, дескрипторы, константные буферы и объекты состояния конвейера (PSO), предоставляя практические рекомендации по оптимизации управления ресурсами и операций шейдеров в современном GPU-программировании.
Ресурсы в шейдерах
Одним из наиболее распространенных примитивов является текстура, например размером 1000 на 1000 пикселей. В DirectX это называется ресурсом.
- Это примитив, который находится в памяти GPU и используется в графическом или вычислительном конвейере.
- Он создается с помощью
CreateCommittedResource, который принимает параметры, определяющие ресурс, который необходимо выделить.
Дескрипторы
Ресурсы максимально простые. Наиболее распространенное неправильное понимание касается _дескрипторов_ и их связи с выделяемыми ресурсами.
В целях оптимизации DirectX позволяет использовать один и тот же ресурс по-разному с различными форматами, и здесь вступают в игру дескрипторы. Вместо привязки самой текстуры, вы привязываете дескриптор текстуры при использовании его в шейдере.
Например, если вы хотите отрендерить в текстуру, используя стандартный графический конвейер, а затем использовать ту же текстуру в качестве входного параметра для другого вычислительного шейдера во время постобработки, или вы хотите показать текстуру ресурса на экране для отладки — вам не нужно создавать отдельные ресурсы.
Вместо этого вам нужны два дескриптора:
- Render Target View (RTV) для привязки текстуры к буферу кадра для рендеринга.
- Shader Resource View (SRV) для привязки текстуры к шейдеру в качестве входного параметра.
Существуют другие типы дескрипторов для различных целей, такие как Unordered Access View (UAV) или Depth Stencil View (DSV), но все они служат для использования одного и того же ресурса разными способами.
Куча дескрипторов
Дескриптор не может существовать где-то на видеокарте. Он должен занимать место в Descriptor Heap.
Сама куча — это просто блок памяти, где находятся дескрипторы. Куча имеет различные представления для хранения разных дескрипторов, таких как:
D3D12_DESCRIPTOR_HEAP_TYPE_CBV_SRV_UAV— хранит все типы дескрипторов ресурсов шейдеров.D3D12_DESCRIPTOR_HEAP_TYPE_RTV— хранит дескрипторы, ссылающиеся только на цели рендеринга.D3D12_DESCRIPTOR_HEAP_TYPE_DSV— хранит дескрипторы, ссылающиеся только на цели глубины-трафарета.

Константные буферы
Часто требуется передавать часто изменяющиеся данные в шейдер, такие как:
- Время отсчета
- Матрицы камеры
- Мировые матрицы
- и т.д.
DirectX предоставляет несколько вариантов:
- Привязать переменную прямо к шейдеру: не удобно для больших блоков данных, но просто в использовании.
- Привязать структуру к шейдеру: немного сложнее, потому что это включает константные буферы (CB).
Суть здесь в том, чтобы иметь блок памяти в RAM, а затем отобразить его на память GPU, чтобы шейдер мог его использовать!
Это достигается путем создания ресурса с определенным флагом D3D12_HEAP_TYPE_UPLOAD и затем отображением данных из RAM в VRAM.
Таблицы дескрипторов
Но есть случаи, когда вам может потребоваться привязать диапазон дескрипторов! Вы можете иметь несколько текстур, которые собираетесь использовать внутри шейдера, и для этой цели вы должны использовать Descriptor Table.
Это в основном структура, которая описывает диапазон дескрипторов внутри кучи дескрипторов и привязывает этот диапазон к шейдеру. Это более эффективно, чем привязывать каждый дескриптор к регистру шейдера отдельно.
Объект состояния конвейера
Как вы уже знаете, рендеринг — это процесс, который включает множество различных этапов, таких как сборка примитивов (точек), их перевод в пространство отсечения, растеризация на экран и так далее.
Но кроме рендеринга со всеми этапами, нам может потребоваться выполнить вычисления, то есть выделить SIMD-процессоры и, например, сложить _1000_ матриц. Для этой конфигурации используется PSO.
В DirectX 12 PSO представляет ID3D12PipelineState, который является интерфейсом, передаваемым в Command List, когда вы хотите установить конвейер выполнения.
Для создания состояния конвейера вам нужно заполнить дескриптор PSO D3D12_GRAPHICS_PIPELINE_STATE_DESC или D3D12_COMPUTE_PIPELINE_STATE_DESC и затем передать его в ID3D12Device::CreateGraphicsPipelineState или ID3D12Device::CreateComputePipelineState.

Структура вычислительного PSO такая же простая. Вы можете указать шейдер, который должно использовать это PSO, корневую сигнатуру, некоторые флаги, узел GPU, если у вас их несколько, и предкомпилированное состояние PSO для оптимизации процесса создания.
Графический PSO, в отличие от вычислительного, намного сложнее в конфигурации и включает следующие элементы:
- Шейдеры. Все шейдеры, которые могут быть в вашем конвейере (Vertex, Pixel, Domain, Hull, Geometry).
- Состояние смешивания. Как только что обработанные пиксели будут смешиваться с уже записанными пикселями.
- Растеризатор. Как пиксели будут растеризованы после перевода в пространство отсечения.
- Глубина/Трафарет. Как будут работать тесты глубины и трафарета на этапе вывода.
- и т.д.
Но несмотря на то, что эти PSO могут выглядеть пугающе, как только вы полностью поймете концепцию графических и вычислительных конвейеров, вам не составит труда разбираться в спецификации любого API, потому что природа этих конвейеров одинакова везде.
Корневая сигнатура
В разделе PSO мы вкратце затронули Root Signature.
Каждый шейдер имеет входные параметры, будь то позиция вершины, текстура или константный буфер. Чтобы использовать этот шейдер в будущем, вам нужно получить доступ к этим входным параметрам и присвоить им какое-нибудь значение из вашего кода.
Корневая сигнатура — это в основном описание входных параметров шейдера в коде.
По умолчанию вы должны написать шейдер с его параметрами, а затем создать корневую сигнатуру, которая бы их отражала.
Но, как вы уже знаете, чтобы использовать шейдер, он должен быть скомпилирован. Из этого вытекает вопрос:
Если код шейдера должен быть скомпилирован для выполнения на GPU, почему бы не сгенерировать корневую сигнатуру во время выполнения на основе кода шейдера?
Более того, принимая во внимание тот факт, что может быть тысячи шейдеров, жестко закодированная корневая сигнатура — не решение. Нам нужно найти другой способ.
И способ — это shader reflection.
Она в основном компилирует шейдер с помощью DirectX Shader Compiler (DXC) и затем выводит готовую к использованию корневую сигнатуру со всеми индексами и именами параметров шейдера, чтобы вы могли легко получить к ним доступ!
Пример кода: трансляция Shader → Root Signature
// shader.hlsl
Texture2D TextureMap : register(t0, space0);
TextureCube CubeMap : register(t1, space0);
StructuredBuffer<MaterialData> MaterialData : register(t1, space4);
// main.cpp
texMapRange.Init(...);
cubeMapRange.Init(...);
materialDataRange.Init(...);
CD3DX12_ROOT_PARAMETER1 rootParameters[3];
rootParameters[0].InitAsDescriptorTable(1, &texMapRange, D3D12_SHADER_VISIBILITY_ALL);
rootParameters[1].InitAsDescriptorTable(1, &cubeMapRange, D3D12_SHADER_VISIBILITY_ALL);
rootParameters[2].InitAsDescriptorTable(1, &materialDataRange, D3D12_SHADER_VISIBILITY_ALL);
D3DX12SerializeVersionedRootSignature(...);
device->CreateRootSignature(...);

Цепочка замены
В графическом конвейере вам всегда нужно отрендерить в какую-то цель с несколькими графическими этапами, такими как Vertex и Hull шейдеры, растеризация и т.д.
Конечная точка всех этих процессов — рендеринг в _цель рендеринга_, или другими словами в _ресурс_.
Итак, цепочка замены указывает, какой буфер вы собираетесь использовать, какого формата, сколько их, его высоту и ширину и т.д. Как вы уже могли догадаться, она отвечает за тип буферизации, который у вас будет.
Если у вас есть два ресурса, один для рендеринга и другой для отображения, это двойная буферизация.
Наличие трех буферов называется тройной буферизацией.

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