Вывод треугольника в Direct3D 11
С каждой новой версией DirectX создание первого приложения, которое использует трёхмерную графику, становится всё сложнее. В данном уроке мы пройдём все шаги инициализации Direct3D 11. Но, сначала, нам нужно понять что такое Direct3D.
Direct3D
Direct3D используется для рендернига трёхмерной графики. Рендеринг - это процесс создания двухмерного изображения из 3д сцены.
Для разработки приложений Direct3D 11 требуется DirectX 11 SDK. SDK расшифровывается как Software Development Kit - набор разработки программного обеспечения. Какое-то время назад нужно было скачивать SDK отдельно с сайта Microsoft, но сейчас DirectX SDK входит в состав Windows SDK (с момента выхода Windows 8).
Direct3D 11.3 (последняя версия DirectX 11) состоит из следующих частей: Direct3D Graphics, DXGI, HLSL, DDS. DDS (графический формат) больше не используется, HLSL (шейдеры) мы обсудим во второй части урока. Начнём же мы с первых двух частей.
Direct3D Graphics
Это основная часть Direct3D. Она включает в себя интерфейсы и ресурсы для 3д рендеринга и используется для настройки графического конвейера. Эти интерфейсы начинаются с префикса ID3D11, например: ID3D11Device, ID3D11DeviceContext.
Эта часть построена на DXGI.
DXGI
DXGI - DirectX Graphics Infrastructure (графическая инфраструктура DirectX). DXGI - это низкоуровневые команды. DXGI позволяет Direct3D напрямую общаться с видеокартой. Разработчик может использовать напрямую какие-то части DXGI, другие же скрыты высокоуровневыми интерфейсами. DXGI интерфейсы начинаются с префикса. DXGI содержит важный интерфейс IDXGISwapChain. Этот интерфейс позволяет показать финальное изображение в окне.
Важные интерфейсы Direct3D 11.3
Устройство D3D11 это виртуальное представление видеокарты в нашей программе. Устройства D3D11 представлены интерфейсом ID3D11Device. Устройства D3D11 могут создавать различные ресурсы.
Контекст устройства (Device Context). Его интерфейс - ID3D11DeviceContext. Контекст отвечает за рендеринг. Данный интерфейс содержит комманды для создания 3д сцены.
Цепочка обмена (Swap Chain). Интерфейс IDXGISwapChain. Содержит буферы в которые рендерится сцена. Цепочка обмена очень важная концепция, поэтому важно понимать как она работает.
IDXGISwapChain
Когда создаётся цепочка обмена, она привязывается к окну (HWND) и при этом создаётся устройство d3d11 (ID3D11Device). Любая цепочка обмена состоит из одного или более буферов. Буфер в данном случае это всего лишь прямоугольное изображение (в памяти это просто массив). Размер буферов должен совпадать с клиентской областью окна. Каждая цепочка обмена должна иметь основной буфер (front buffer) и какое-то количество фоновых (back buffers) - ноль и более.
Рендеринг 3д сцены происходит много раз в секунду. Созданное 2д изображение копируется в один из фоновых буферов цепочки обмена. Когда фоновый буфер заполнен, он меняется местами с основным. Это называется представлением (presenting). После этого содержимое фонового буфера становится видимым в окне приложения. Главная цель цепочки обмена - показать отрендеренное изображение на экране.
Теперь мы готовы к инициализации приложения Direct3D.
Инициализация Direct3D 11
1. Вначале нужно создать устройство d3d11. Затем связать контекст устройства и цепочку обмена с устройством.
2. Потом создать целевой объект отрисовки (Render-target View - RTV). RTV - это изображение, куда рендерится 3д сцена.
После выполнения этих пунктов можно начинать загрузку ресурсов программы.
Создание ID3D11Device
В приложении DirectX все действия выполняются интерфейсами. За исколючением одного. Первый интерфейс создаётся функцией. Главный интерфейс в Direct3D 11 - это ID3D11Device. Получить этот интерфейс можно с помощью двух функций: D3D11CreateDevice, D3D11CreateDeviceAndSwapChain. Первая функция создаёт только устройство. Вторая создаёт и устройство d3d11, и цепочку обмена. Мы пойдём по простому пути и воспользуемся D3D11CreateDeviceAndSwapChain. Прототип выглядит так:
Функция D3D11CreateDeviceAndSwapChain
Функция получает 12 аргументов. Она создаёт три интерфейса и одну переменную.
1. IDXGIAdapter *pAdapter - указатель на видеоадаптер. Видеокарта по умолчанию будет использована, когда этот аргумент равен NULL. В примерах всегда будет NULL.
2. D3D_DRIVER_TYPE DriverType - тип драйвера. Одно из значений перечисления D3D_DRIVER_TYPE. Мы будем использовать D3D_DRIVER_TYPE_HARDWARE, т.е. будем предполать, что все команды выполняются железом. Другие значения можно использовать если ваша видеокарта не поддерживает DirectX 11.
3. HMODULE Software - указатель на библиотеку DLL, которая использует программный растеризатор. Используется когда предыдущий параметр равен D3D_DRIVER_TYPE_SOFTWARE. Мы будем передавать NULL.
4. UINT Flags - набор флагов из перечисления D3D11_CREATE_DEVICE_FLAG. Основной флаг - D3D11_CREATE_DEVICE_DEBUG. Это значение запускает отладочный уровень (Debug Layer) - содержит описание ошибок и дополнительные условия связывания ресурсов и шейдеров. Direct3D 11 во время работы состоит из двух уровней: уровень ядра и отладочный уровень. Уровень ядра - это как раз Direct3D API. При использовании отладочного уровня можно получить дополнительную информацию. Мы рассмотрим отладочный уровень в отдельном уроке, а до этого будем использовать только уровень ядра. Поэтому данному параметру зададим 0.
5. const D3D_FEATURE_LEVEL *pFeatureLevels - массив уровней функциональности. На данный момент самый высокий - D3D_FEATURE_LEVEL_11_1 (для одиннадцатой версии). Мы будем использовать его. Этот параметр принимает указатель на массив. В массиве должны содержаться разные уровни функциональности, начиная с наивысшего. Если видеокарта не поддерживает самый высокий уровень, DirectX попытается создать устройство d3d11 со следующим уровнем функциональности в массиве. Если передать NULL, то будет использоваться следующий массив:
В примере я использовал переменную вместо массива. Это равнозначно передаче массива с одним элементом.
6. UINT FeatureLevels - количество элементов в массиве из предыдущего параметра. Опять же, если передавать один элемент, то в предыдущем параметре вместо массива можно передать указатель на переменную D3D_FEATURE_LEVEL.
7. UINT SDKVersion - версия SDK. Для Direct3D 11 нужно передавать D3D11_SDK_VERSION.
8. const DXGI_SWAP_CHAIN_DESC *pSwapChainDesc - указатель на структуру, описывающую цепочку обмена.
9. IDXGISwapChain **ppSwapChain - указатель на интерфейс цепочки обмена.
10. ID3D11Device **ppDevice - указатель на интерфейс устройства D3D11.
11. D3D_FEATURE_LEVEL *pFeatureLevel - в этой переменной будет сохранён используемый уровень функциональности. Все дальнейшие уроки будут предполагать использование D3D_FEATURE_LEVEL_11_1. Это значение зависит от пятого аргумента.
12. ID3D11DeviceContext **ppImmediateContext - указатель на контекст устройства d3d11.
После вызова функции D3D11CreateDeviceAndSwapChain мы получим переменную с уровнем функциональности и три интерфейса: IDXGISwapChain, ID3D11Device, ID3D11DeviceContext. Восьмой аргуметнт - указатель на структуру DXGI_SWAP_CHAIN_DESC. Эту структуру нужно заполнить перед вызовом D3D11CreateDeviceAndSwapChain. Взглянем на определение этой структуры:
DXGI_SWAP_CHAIN_DESC structure
DXGI_SWAP_CHAIN_DESC говорит, какие свойства должна иметь цепочка обмена.
1. DXGI_MODE_DESC BufferDesc - структура, описывающая буферы: BufferDesc.Width - width, BufferDesc.Height - высота, BufferDesc.Format - формат пикселей в буфере, BufferDesc.ScanlineOrdering - метод растеризации - сейчас не важно, BufferDesc.Scaling - метод масштабирования - не важно, BufferDesc.RefreshRate - частота обновления в герцах, имеет два поля: BufferDesc.RefreshRate.Numerator - числитель, и BufferDesc.RefreshRate.Denominator - знаменатель.
2. DXGI_SAMPLE_DESC SampleDesc - параметры мультисэмплинга. Структура состоит из двух полей: количество и качество. По умолчанию для качества используется 0, а для количества - 1.
3. DXGI_USAGE BufferUsage - это поле задаёт как будет использоваться буфер, а также доступ центрального процессора к буферу. Мы будем использовать буфер для рендеринга 3д сцены в окно, поэтому будем использовать значение DXGI_USAGE_RENDER_TARGET_OUTPUT.
4. UINT BufferCount - количество буферов.
5. HWND OutputWindow - окно, куда будет выводиться финальное двухмерное изображение.
6. BOOL Windowed - задаёт оконный режим.
7. DXGI_SWAP_EFFECT SwapEffect - поле задаёт, что будет происходить с содержимым буфера после выполнения команды Present. Значение по умолчанию - DXGI_SWAP_EFFECT_DISCARD. В следующих уроках мы узнаем больше об этом поле.
8. UINT Flags - набор флагов, описывающих поведение цепочки обмена. Будем задавать 0.
Создание устройства D3D11 и цепочки обмена
Посмотрим на код:
Здесь мы заполняем описание DXGI_SWAP_CHAIN_DESC и вызываем D3D11CreateDeviceAndSwapChain function.
Создание RTV (Render-Target View)
Теперь нам нужно понять ответственность каждого интерфейса и зачем нам нужен RTV. Устройство D3D11 создаёт различные ресурсы. Цепочка обмена представляет содержимое фонового буфера на экран. Контекст устройства выполняет весь рендеринг. Контекст устройства "рисует" графику в буфер, а цепочка обмена показывает картинку в окне. Но есть одна проблема. Контекст устройства не может работать с цепочкой обмена напрямую. Для этой задачи нам нужен render-target view. Контекст устройства для рендеринга использует интерфейс ID3D11RenderTargetView. Поэтому нам нужно привязать этот интерфейс к фоновому буферу цепочки обмена. Для начала нужно получить адрес фонового буфера:
UINT Buffer - индекс буфера. Если для цепочки обмена мы задали DXGI_SWAP_EFFECT_DISCARD, то этот аргумент всегда будет получать 0.
REFIID riid - здесь мы указываем тип интерфейса, который мы хотим получить. Нам нужно получить интерфейс ID3D11Texture2D (2д изображение). Тип этого аргумента относится к COM. Мы будем разбирать COM в отдельном уроке. Пока же мы зададим этот аргумент как __uuidof(ID3D11Texture2D).
void **ppSurface - указатель на интерфейс (в нашем случае - ID3D11Texture2D).
После вызова метода GetBuffer, мы получим интерфейс ID3D11Texture2D, у которого есть доступ к фоновому буферу цепочки обмена. Теперь, используя этот интерфейс мы можем создать RTV.:
ID3D11Resource *pResource - интерфейс, который мы получили в предыдущем методе.
const D3D11_RENDER_TARGET_VIEW_DESC *pDesc - описание RTV. Просто передадим NULL.
ID3D11RenderTargetView **ppRTView - адрес ID3D11RenderTargetView. Этот интерфейс будет использоваться для рендеринга контекстом устройства d3d11.
Код создания RTV:
Итак, мы подготовили устройство, контекст устройства и цепочку обмена для рендеринга. Теперь самое время узнать, что такое шейдеры:
HLSL - шейдеры в DirectX
Шейдер - важнейшая часть современной компьютерной графики. DirectX использует HLSL (High-Level Shader Language - высокоуровневый язык шейдеров) для шейдеров. Шейдер - это просто программа, которая выполняется видеокартой. Мы можем добавить шейдер в нашу программу следующим образом: создать отдельный файл с кодом шейдера, прочитать его в нашей программе и скомпилировать.
Существуют разные типы шейдеров. При этом обязательных только два: вершинный и пиксельный.
Шейдеры хранятся в отдельных файлах с расширением .hlsl. Для данного урока я добавил в проект файл shaders.hlsl. В нём хранятся оба шейдера. Мы будем хранить вершинный и пиксельный шейдеры в одном файле, а также самостоятельно их компилировать, поэтому нужно исключить этот файл из сборки. Щёлкните правой кнопкой на файле shaders.hlsl и выберите свойства (properties). Затем измените значение для "Excluded From Build" на "Yes".

Теперь взглянем на код шейдеров:
Код на HLSL похож на C/C++. Здесь две функции: VS (vertex shader) и PS (pixel shader).
VS принимает один аргумент - position с типом float4. float4 - это массив из четырёх элементов. В HLSL много типов, помогающих упростить математику. POSITION, SV_Position, SV_Target - примеры семантики. Данные, которые передаются между стадиями графического конвейера, являются обобщёнными (generic). Семантика даёт данным смысл. Бывают входящие и исходящие переменные. Семантику для исходящих данных мы задаём после списка аргументов, т.е. POSITION относится в входящим данным, а SV_Position - к исходящим (то, что мы вернём из функции).
POSITION говорит, что входящий параметр представляет позицию вершины в объектном пространстве. Вершинный шейдер получает одну вершину, делает какое-либо преобразование ("двигает", "вращает", "масштабирует"), и передаёт преобразованную вершину дальше, в следующую стадию конвейера. В конце вершинного шейдера координаты вершины должны быть в однородной форме. Пока мы ничего не делаем с вершиной - просто передаём её дальше.
Где-то между вершинным и пиксельным шейдером GPU делает растеризацию. Поэтому пиксельный шейдер получает пиксельные данные, а не вершину.
Пиксельный шейдер может выводить данные только с помощью SV_Target или SV_Depth семантик. SV_Target говорит, что эти данные будут помещены в фоновый буфер (точнее в render target). Каждый пиксель мы "заливаем" чёрным цветом. Обратите внимание, что только пиксели внутри треугольника будут окрашены. Т.е. пиксельный шейдер будет выполнен только для пикселей треугольника.
Возвращаемся назад в программу.
Компиляция шейдеров
В DirectX 11 под Windows 10 шейдеры можно скомпилировать тремя способами. Два из них используют устройство D3D11. Третий - это функция D3DCompileFromFile function, которой мы и воспользуемся, так как этом самый быстрый и простой способ.
На заметку: в документации говорится, что приложения которые используют функцию D3DCompileFromFile нельзя размещать в Windows Store.
Для использования компилятора шейдеров нужно включить заголовочный файл D3DCompiler.h и добавить библиотеку d3dcompiler.lib:
Функция D3DCompileFromFile
D3DCompileFromFile компилирует HLSL шейдеры. Она принимает файл с исходным кодом шейдера и возвращает (предпоследний аргумент) объект интерфейса ID3DBlob (blob - Binary Large Object - по сути это просто большой массив данных):
1. pFileName - имя файла с исходным кодом шейдера.
2. pDefines - массив макросов шейдера. Пока задаём NULL.
3. pInclude - этот аргумент задаётся если в файле есть директива #include. Задаём NULL.
4. pEntrypoint - точка входа - имя функции с кодом шейдера. По умолчанию Visual Studio будет искать main. Мы же используем VS и PS.
5. pTarget - версия и тип шейдера: вычислительный (compute), доменный (domain), геометрический (geometry), hull (поверхностный), pixel (пиксельный), vertex (вершинный). Пока нас интересуют два последних. В DirectX 11 нужно использовать пятую версия. Задаём значения: vs_5_0 для вершинного и ps_5_0 для пиксельного.
6-7. Флаги. Задаём пока NULL.
8. ID3DBlob объект, в котором будет храниться скомпилированный шейдер.
9. ppErrorMsgs - сообщения об ошибках будут храниться в этом объекте. Задаём NULL.
Теперь можно скомпилировать шейдеры:
Вершинный шейдер мы компилируем в vsBlob, а пиксельный - в psBlob.
Создание шейдерных объектов в DirectX 11
После компиляции шейдеров, можно создать шейдерные объекты. Шейдерный объект (Shader Object) - это представление шейдера в программе. ID3D11Device использует разные методы для создания объектов разных типов. Нам нужно два метода ID3D11Device::CreateVertexShader и ID3D11Device::CreatePixelShader. Они похожи (отличается только последний аргумент). Посмотрим на первый метод:
1. pShaderBytecode - адрес скомпилированного шейдера в памяти.
2. BytecodeLength - размер скомпилированного шейдера.
ID3DBlob интерфейс имеет два метода: GetBufferPointer - возвращает адрес скомпилированного шейдера, GetBufferSize - возвращает размер. Эти методы не имеют аргументов.
3. pClassLinkage - аргумент используемый для связывания. Задаём NULL.
4. ppVertexShader - в этой переменной будет сохранён шейдерный объект.
Теперь код:
После этого можно указать конвейеру, какие шейдерные объекты использовать:
Здесь контекст устройства задаёт текущие вершинный и пиксельный шейдеры. Второй и третий аргументы пока не важны.
Подготовка вершин треугольника
Мы подготовили Direct3D для рендеринга. Теперь мы можем отправлять данные в графический конвейер. Мы создадим геометрию треугольника и отправим её в GPU для отрисовки.
Direct3D может работать с разнымим примитивами. Сейчас нам нужны только треугольники. Каждый примитив состоит из вершин, треугольник состоит из трёх. Каждая вершина состоит из компонент - (x, y, z, w). Пока для последней компоненты мы будем задавать 1. Четвёртая компонента нужна, чтобы сделать математику проще.
Когда мы отправляем массив вершин в GPU, вершины проходят через графический конвейер (Rendering Pipeline). Сначала они проходят вершинный шейдер. В нашем случае вершинный шейдер ничего не делает и просто передаёт вершину дальше. В конце вершинного шейдера координаты должны быть в диапазоне от -1 до 1. Каждая вершина проходит вершинный шейдер.
Создадим геометрию. Нам потребуется структура для вершин:
Каждая вершина имеет поле с типом XMFLOAT4. XMFLOAT4 определён в directxmath.h. Это структура и она имеет четыре поля: x, y, z, w. Важно использовать типы DirectXMath, так как там определено много структур и функций преобразования в данные, которые быстро обрабатываются в GPU, мы поговорим об этом позже.
В данном примере мы задаём массив с тремя элементами. Треугольник задан в плоскости xy, компоненты z равны 0. Мы задаём перспективные проекционные координаты (perspective projected coordinates) - финальная форма координат - так они должны выглядеть после вершинного шейдера. Центр экрана - (0, 0). Диапазон значений от -1 до +1. Позже мы рассмотрим разные виды координат.
Теперь нам нужно связать нашу геометрию с графическим конвейером. Наши данные можно представить в GPU с помощью интерфейса ID3D11Buffer:
Вначале мы заполняем структурную переменную D3D11_BUFFER_DESC. ByteWidth - размер нашего буфера. Поле usage говорит, как буфер будет использоваться GPU и CPU. Мы зададим значение D3D11_USAGE_DEFAULT . BindFlags говорит, что будет в буфере. Нам нужен вершинный буфер. CPUAccessFlags задаёт, как CPU может получить доступ к буферу. Нам не нужен доступ к этому буферу из CPU, поэтому передадим 0.
После этого мы создаём структурную переменную D3D11_SUBRESOURCE_DATA. Эта структура используется для создания буферов. pSysMem указывает на начало массива вершин.
ID3D11Device::CreateBuffer создаёт буфер. В нашем случае - вершинный. Этот метод сохранит адрес буфера в указателе последнего аргумента.
Мы почти закончили. Далее, нужно связать буфер с графическим конвейером:
Первый аргумент в ID3D11DeviceContext::IASetVertexBuffers - первый входящий слот для связывания. Пока передаём 0. Второй аргумент говорит, сколько вершинных буферов мы хотим связать. Третий аргумент - адрес массива вершинных буферов. Четвёртый аргумент - шаг (stride) - это смещение между отдельными элементами вершинного буфера. Последний аргумент - смещение к элементу, с которого нужно начать вывод
Последнее что нам нужно сделать перед основным циклом - задать какую геометрию мы хотим выводить:
Мы будем выводить список треугольников. Мы также можем выводить другие примитивы (отрезки, точки) или треугольники в другом формате. Мы рассмотрим другие варианты позже.
Главный цикл с Direct3d
const float greyColor[4] = { 1.0f, 1.0f, 1.0f, 1.0f };
while (msg.message != WM_QUIT)
{
if (PeekMessage(&msg, NULL, 0, 0, PM_REMOVE))
{
TranslateMessage(&msg);
DispatchMessage(&msg);
}
devContext->ClearRenderTargetView(rtv, greyColor);
devContext->Draw(3, 0);
sc->Present(0, 0);
}
После проверки сообщений мы очищаем RTV и заливаем весь буфер серым цветом. Затем мы делаем вызов Draw. Первый аргумент - количество вершин, которое мы хотим нарисовать, второй - с какой вершины начать.
И, наконец, мы представляем фоновый буфер цепочкой обмена:
Первый аргумент - синхронизация, второй - флаги. Передаём нули.

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