На хабре уже есть много статей про использование вычислительных шейдеров с Unity, однако статью о использовании вычислительного шейдера на "чистом" Win32 API + DirectX 11 затруднительно. Однако эта задача ненамного сложнее, подробнее — под катом.
Для этого будем использовать:
- Windows 10
- Visual Studio 2017 Community Edition с модулем "Разработка классических приложений на C++"
После создания проекта укажем компоновщику использовать библиотеку `d3d11.lib`
Для подсчета количества кадров в секунду будем использовать стандартную библиотеку
#include <time.h>
Выводить количество кадров в секунду будем через заголовок окна, для чего нам понадобится формировать соответствующую строку
#include <stdio.h>
Не будем подробно рассматривать обработку ошибок, в нашем случае достаточно, чтобы приложение упало в отладочной версии и указало на момент падения
#include <assert.h>
Заголовочные файлы для WinAPI
#define WIN32_LEAN_AND_MEAN
#include <tchar.h>
#include <Windows.h>
Заголовочные файлы для Direct3D 11
#include <dxgi.h>
#include <d3d11.h>
Идентификаторы ресурсов для загрузки шейдера. Можно вместо этого загружать в память объектный файл шейдера, генерируемый компилятором HLSL. Создание файла ресурсов описано позже.
#include "resource.h"
Константы, общие для шейдера и вызывающей части, объявим в отдельном заголовочном файле.
#include "SharedConst.h"
Объявим функцию обработки Windows-событий, которая будет определена позже
LRESULT CALLBACK WndProc(HWND hWnd, UINT Msg, WPARAM wParam, LPARAM lParam);
Напишем функции для создания и уничтожения окна
int windowWidth, windowHeight;
HINSTANCE hInstance;
HWND hWnd;
void InitWindows()
{
// Получаем информацию о локальном модуле
hInstance = GetModuleHandle(NULL);
windowWidth = 800;
windowHeight = 800;
WNDCLASS wc;
// Без дополнительных параметров
wc.style = 0;
// Задаем обработчик событий
wc.lpfnWndProc = &WndProc;
// Нам не нужно дополнительное выделение памяти к структуре окна и структуре класса
wc.cbClsExtra = 0;
wc.cbWndExtra = 0;
// Модуль (программа), которому принадлежит обработчик событий
wc.hInstance = hInstance;
// Загружаем стандартный курсор и стандартную иконку
wc.hIcon = LoadIcon(hInstance, IDI_APPLICATION);
wc.hCursor = LoadCursor(hInstance, IDC_ARROW);
// Цвет фона не важен, поэтому не задаем "кисть"
wc.hbrBackground = NULL;
// Окно без меню
wc.lpszMenuName = NULL;
// Задаем название класса окна
wc.lpszClassName = _T("WindowClass1");
// Регистрируем класс окна
ATOM result = RegisterClass(&wc);
// Проверяем, что класс успешно зарегистрирован
assert(result);
// Стандартное окно -- имеет заголовок, можно изменить размер и т.д.
DWORD dwStyle = WS_OVERLAPPEDWINDOW;
RECT rect;
// Клиентская область (внутри рамки) по центру экрана и заданного размера
rect.left = (GetSystemMetrics(SM_CXSCREEN) - windowWidth) / 2;
rect.top = (GetSystemMetrics(SM_CYSCREEN) - windowHeight) / 2;
rect.right = rect.left + windowWidth;
rect.bottom = rect.top + windowHeight;
// Вычислим область окна с рамкой. Последний параметр -- наличие меню
AdjustWindowRect(&rect, dwStyle, FALSE);
hWnd = CreateWindow(
_T("WindowClass1"),
_T("WindowName1"),
dwStyle,
// Левый верхний угол окна
rect.left, rect.top,
// Размер окна
rect.right - rect.left,
rect.bottom - rect.top,
// Родительское окно
// HWND_DESKTOP раскрывается в NULL
HWND_DESKTOP,
// Меню
NULL,
// Модуль (программа), которой принадлежит окно
hInstance,
// Дополнительные свойства
NULL);
// Проверяем, что окно успешно создано
assert(hWnd);
}
void DisposeWindows()
{
// Удаляем окно
DestroyWindow(hWnd);
// Удаляем класс
UnregisterClass(_T("WindowClass1"), hInstance);
}
Далее — инициализация интерфейса обращения к видеокарте (Device и DeviceContext) и цепочки буферов вывода (SwapChain)
IDXGISwapChain *swapChain;
ID3D11Device *device;
ID3D11DeviceContext *deviceContext;
void InitSwapChain()
{
HRESULT result;
DXGI_SWAP_CHAIN_DESC swapChainDesc;
// Разрмер совпадает с размером клиентской части окна
swapChainDesc.BufferDesc.Width = windowWidth;
swapChainDesc.BufferDesc.Height = windowHeight;
// Ограничение количества кадров в секунду задается в виде рационального числа
// Т.к. нам нужна максимальная частота кадров, отключаем
swapChainDesc.BufferDesc.RefreshRate.Numerator = 0;
swapChainDesc.BufferDesc.RefreshRate.Denominator = 1;
// Формат вывода -- 32-битный RGBA
swapChainDesc.BufferDesc.Format = DXGI_FORMAT_R8G8B8A8_UNORM;
// Не задаем масштабирования при выводе
swapChainDesc.BufferDesc.ScanlineOrdering = DXGI_MODE_SCANLINE_ORDER_UNSPECIFIED;
swapChainDesc.BufferDesc.Scaling = DXGI_MODE_SCALING_UNSPECIFIED;
// Не используем сглаживание
swapChainDesc.SampleDesc.Count = 1;
swapChainDesc.SampleDesc.Quality = 0;
// Используем SwapChain для вывода
swapChainDesc.BufferUsage = DXGI_USAGE_RENDER_TARGET_OUTPUT;
// Один "задний" (не отображаемый) буфер
swapChainDesc.BufferCount = 1;
// Задаем окно для вывода
swapChainDesc.OutputWindow = hWnd;
// Оконный режим
swapChainDesc.Windowed = TRUE;
// Отбрасываем старую информацию из буфера при выводе на экран
swapChainDesc.SwapEffect = DXGI_SWAP_EFFECT_DISCARD;
swapChainDesc.Flags = 0;
// Используем DirectX 11.0, т.к. его нам достаточно
D3D_FEATURE_LEVEL featureLevel = D3D_FEATURE_LEVEL_11_0;
// В Debug-версии создаем поток отладки DirectX
#ifndef NDEBUG
UINT flags = D3D11_CREATE_DEVICE_DEBUG;
#else
UINT flags = 0;
#endif
result = D3D11CreateDeviceAndSwapChain(
// Используем видеоадаптер по-умолчанию
NULL,
// Используем аппаратную реализацию
D3D_DRIVER_TYPE_HARDWARE, NULL,
// См. выше
flags,
// Используем одну версию DirectX
&featureLevel, 1,
// Версия SDK
D3D11_SDK_VERSION,
// Передаем созданное ранее описание
&swapChainDesc,
// Указатели, куда записать результат
&swapChain, &device, NULL, &deviceContext);
// Проверяем, что операция прошла успешно
assert(SUCCEEDED(result));
}
void DisposeSwapChain()
{
deviceContext->Release();
device->Release();
swapChain->Release();
}
Инициализация доступа из шейдеров к буферу, в который будет производиться отрисовка
ID3D11RenderTargetView *renderTargetView;
void InitRenderTargetView()
{
HRESULT result;
ID3D11Texture2D *backBuffer;
// Берем "задний" буфер из SwapChain
result = swapChain->GetBuffer(0, __uuidof(ID3D11Texture2D), (void **)&backBuffer);
assert(SUCCEEDED(result));
// Инициализируем доступ к буферу для записи и для отрисовки
result = device->CreateRenderTargetView(backBuffer, NULL, &renderTargetView);
assert(SUCCEEDED(result));
// Указатель на буфер больше нам не нужен
// Стоит отметить, что сам буфер при этом не удаляется,
// т.к. на него всё ещё указывает SwapChain,
// Release() лишь освобождает указатель
backBuffer->Release();
// Используем созданный View для отрисовки
deviceContext->OMSetRenderTargets(1, &renderTargetView, NULL);
// Задаем область отрисовки
D3D11_VIEWPORT viewport;
viewport.TopLeftX = 0;
viewport.TopLeftY = 0;
viewport.Width = (FLOAT)windowWidth;
viewport.Height = (FLOAT)windowHeight;
viewport.MinDepth = 0;
viewport.MaxDepth = 1;
deviceContext->RSSetViewports(1, &viewport);
}
void DisposeRenderTargetView()
{
renderTargetView->Release();
}
До инициализации шейдеров нужно их создать. Visual Studio умеет распознавать расширение файла, поэтому мы можем просто создать исходник с расширением .hlsl
, или же напрямую создавать шейдер через меню. Я выбрал первый способ, т.к. все равно через свойства придется задавать использование Shader Model 5
Аналогично создаем вершинный и пиксельный шейдеры.
В вершинном шейдере будем просто преобразовывать координаты из двумерного вектора (т.к. позиции точек у нас именно двухмерные) в четырехмерный (принимаемый видеокартой)
float4 main(float2 input: POSITION): SV_POSITION
{
return float4(input, 0, 1);
}
В пиксельном шейдере будем возвращать белый цвет
float4 main(float4 input: SV_POSITION): SV_TARGET
{
return float4(1, 1, 1, 1);
}
Теперь вычислительный шейдер. Зададим такую формулу для взаимодействий точек:
При массе, принятой 1
Так будет выглядеть реализация этого на HLSL
#include "SharedConst.h"
// Буфер позиций, UAV в слоте 0
RWBuffer<float2> position: register(u0);
// Буфер скоростей, UAV в слоте 1
RWBuffer<float2> velocity: register(u1);
// Количество потоков выполнения
[numthreads(NUMTHREADS, 1, 1)]
void main(uint3 id: SV_DispatchThreadID)
{
float2 acc = float2(0, 0);
for (uint i = 0; i < PARTICLE_COUNT; i++)
{
// Вектор от одной точки до другой
float2 diff = position[i] - position[id.x];
// Берем минимальное значение модуля вектора, чтобы не рассматривать случай 0-вектора
float len = max(1e-10, length(diff));
float k = 1e-9 * (len - 0.25) / len;
acc += k * diff;
}
position[id.x] += velocity[id.x] + 0.5 * acc;
velocity[id.x] += acc;
}
Можно заметить, что в шейдер включается файл SharedConst.h
. Это тот заголовочный файл с константами, который включается в main.cpp
. Вот содержание этого файла:
#ifndef PARTICLE_COUNT
#define PARTICLE_COUNT (1 << 15)
#endif
#ifndef NUMTHREADS
#define NUMTHREADS 64
#endif
Просто объявление количества частиц и количества потоков в одной группе. Мы выделим по одному потоку каждой частице, поэтому количество групп зададим как PARTICLE_COUNT / NUMTHREADS
. Это число должно быть целым, поэтому нужно, чтобы число частиц делилось на число потоков в группе.
Загрузку скомпилированного байткода шейдеров будем производить при помощи механизма ресурсов Windows. Для этого создадим следующие файлы:
resource.h
, где будут содержаться ID соответствующего ресурса
#pragma once
#define IDR_BYTECODE_COMPUTE 101
#define IDR_BYTECODE_VERTEX 102
#define IDR_BYTECODE_PIXEL 103
И resource.rc
, файл для генерации соответствующего ресурса следующего содержания:
#include "resource.h"
IDR_BYTECODE_COMPUTE ShaderObject "compute.cso"
IDR_BYTECODE_VERTEX ShaderObject "vertex.cso"
IDR_BYTECODE_PIXEL ShaderObject "pixel.cso"
Где ShaderObject
— тип ресурса, а compute.cso
, vertex.cso
и pixel.cso
— соответствующие названия файлов Compiled Shader Object в выходной директории
Чтобы файлы были найдены, следует в свойствах resource.rc
прописать путь до выходной директории проекта
Visual Studio автоматически распознала файл как описание ресурсов и добавила его в сборку, вручную это делать не нужно
Теперь можно написать код инициализации шейдеров
ID3D11ComputeShader *computeShader;
ID3D11VertexShader *vertexShader;
ID3D11PixelShader *pixelShader;
ID3D11InputLayout *inputLayout;
void InitShaders()
{
HRESULT result;
HRSRC src;
HGLOBAL res;
// Инициализация вычислительного шейдера
// Берем встроенные в исполняемый файл ресурсы
// Проверка ошибок не производится, т.к. байткод расположен внутри
// исполняемого файла и не может быть не найден
src = FindResource(hInstance, MAKEINTRESOURCE(IDR_BYTECODE_COMPUTE), _T("ShaderObject"));
res = LoadResource(hInstance, src);
// Инициализируем шейдер
result = device->CreateComputeShader(
// Байткод шейдера и его размер
res, SizeofResource(hInstance, src),
// Свойства для компоновщика. В нашем случае не используется, т.к. шейдер из одного объекта
NULL,
// Указатель на шейдер
&computeShader);
assert(SUCCEEDED(result));
FreeResource(res);
// Аналогичные операции для пиксельного шейдера
src = FindResource(hInstance, MAKEINTRESOURCE(IDR_BYTECODE_PIXEL), _T("ShaderObject"));
res = LoadResource(hInstance, src);
result = device->CreatePixelShader(res, SizeofResource(hInstance, src),
NULL, &pixelShader);
assert(SUCCEEDED(result));
FreeResource(res);
// Аналогично для вершинного шейдера
src = FindResource(hInstance, MAKEINTRESOURCE(IDR_BYTECODE_VERTEX), _T("ShaderObject"));
res = LoadResource(hInstance, src);
result = device->CreateVertexShader(res, SizeofResource(hInstance, src),
NULL, &vertexShader);
assert(SUCCEEDED(result));
// Задаем, как в вершинный шейдер будут вводиться данные
// Описание первого (и единственного) аргумента функции
D3D11_INPUT_ELEMENT_DESC inputDesc;
// Семантическое имя аргумента
inputDesc.SemanticName = "POSITION";
// Нужно только в случае, если элементов с данным семантическим именем больше одного
inputDesc.SemanticIndex = 0;
// Двумерный вектор из 32-битных вещественных чисел
inputDesc.Format = DXGI_FORMAT_R32G32_FLOAT;
// Необязательный аргумент
inputDesc.AlignedByteOffset = D3D11_APPEND_ALIGNED_ELEMENT;
// Для каждой вершины
inputDesc.InputSlotClass = D3D11_INPUT_PER_VERTEX_DATA;
// Первый параметр
inputDesc.InputSlot = 0;
// Используем вершины для отрисовки
inputDesc.InstanceDataStepRate = 0;
result = device->CreateInputLayout(
// Массив описаний аргументов и его длина
&inputDesc, 1,
// Байткод и его длина
res, SizeofResource(hInstance, src),
// Структура ввода
&inputLayout);
assert(SUCCEEDED(result));
FreeResource(res);
}
void DisposeShaders()
{
inputLayout->Release();
computeShader->Release();
vertexShader->Release();
pixelShader->Release();
}
Код инициализации буферов
ID3D11Buffer *positionBuffer;
ID3D11Buffer *velocityBuffer;
void InitBuffers()
{
HRESULT result;
float *data = new float[2 * PARTICLE_COUNT];
// Описание массива, который будет записан в буфер при создании
D3D11_SUBRESOURCE_DATA subresource;
// Указатель на массив
subresource.pSysMem = data;
// Имеет значение только для текстур
subresource.SysMemPitch = 0;
// Имеет значение только для трехмерных текстур
subresource.SysMemSlicePitch = 0;
// Описание буфера
D3D11_BUFFER_DESC desc;
// Его размер
desc.ByteWidth = sizeof(float[2 * PARTICLE_COUNT]);
// Доступ на чтение и запись
desc.Usage = D3D11_USAGE_DEFAULT;
// Буфер позиций используем и для отрисовки, и при вычислениях как массив
desc.BindFlags = D3D11_BIND_VERTEX_BUFFER | D3D11_BIND_UNORDERED_ACCESS;
// Доступ с процессора не нужен
desc.CPUAccessFlags = 0;
// Дополнительные флаги не нужны
desc.MiscFlags = 0;
// Размер одного элемента буфера
desc.StructureByteStride = sizeof(float[2]);
// Инициализируем массив позиций
for (int i = 0; i < 2 * PARTICLE_COUNT; i++)
data[i] = 2.0f * rand() / RAND_MAX - 1.0f;
// Создаем буфер позиций
result = device->CreateBuffer(&desc, &subresource, &positionBuffer);
assert(SUCCEEDED(result));
// Буфер скоростей используется только при вычислениях как массив
desc.BindFlags = D3D11_BIND_UNORDERED_ACCESS;
// Инициализируем массив скоростей
for (int i = 0; i < 2 * PARTICLE_COUNT; i++)
data[i] = 0.0f;
// Создаем буфер скоростей
result = device->CreateBuffer(&desc, &subresource, &velocityBuffer);
assert(SUCCEEDED(result));
// Освобождаем память, использованную для инициализации
delete[] data;
}
void DisposeBuffers()
{
positionBuffer->Release();
velocityBuffer->Release();
}
И код инициализации доступа к буферам из вычислительного шейдера
ID3D11UnorderedAccessView *positionUAV;
ID3D11UnorderedAccessView *velocityUAV;
void InitUAV()
{
HRESULT result;
// Описание доступа к буферу из шейдера как к массиву
D3D11_UNORDERED_ACCESS_VIEW_DESC desc;
// Двумерный вектор из 32-битных вещественных чисел
desc.Format = DXGI_FORMAT_R32G32_FLOAT;
// Доступ к буферу, есть также варианты с текстурами
desc.ViewDimension = D3D11_UAV_DIMENSION_BUFFER;
// Доступ с первого элемента
desc.Buffer.FirstElement = 0;
// Количество элементов
desc.Buffer.NumElements = PARTICLE_COUNT;
// Без дополнительных флагов
desc.Buffer.Flags = 0;
// Инициализация доступа к буферу позиций
result = device->CreateUnorderedAccessView(positionBuffer, &desc,
&positionUAV);
assert(!result);
// Инициализация доступа к буферу скоростей
result = device->CreateUnorderedAccessView(velocityBuffer, &desc,
&velocityUAV);
assert(!result);
}
void DisposeUAV()
{
positionUAV->Release();
velocityUAV->Release();
}
Далее стоит указать драйверу использовать созданные шейдеры и связки с буферами
void InitBindings()
{
// Устанавливаем используемые шейдеры
// Вычислительный
deviceContext->CSSetShader(computeShader, NULL, 0);
// Вершинный
deviceContext->VSSetShader(vertexShader, NULL, 0);
// Пиксельный
deviceContext->PSSetShader(pixelShader, NULL, 0);
// Устанавливаем доступ к буферу скоростей у вычислительного шейдера
deviceContext->CSSetUnorderedAccessViews(1, 1, &velocityUAV, NULL);
// Устанавливаем способ записи аргументов вершинного шейдера
deviceContext->IASetInputLayout(inputLayout);
// Рисовать будем точки
deviceContext->IASetPrimitiveTopology(D3D11_PRIMITIVE_TOPOLOGY_POINTLIST);
}
Для подсчета среднего времени кадра будем использовать следующий код
const int FRAME_TIME_COUNT = 128;
clock_t frameTime[FRAME_TIME_COUNT];
int currentFrame = 0;
float AverageFrameTime()
{
frameTime[currentFrame] = clock();
int nextFrame = (currentFrame + 1) % FRAME_TIME_COUNT;
clock_t delta = frameTime[currentFrame] - frameTime[nextFrame];
currentFrame = nextFrame;
return (float)delta / CLOCKS_PER_SEC / FRAME_TIME_COUNT;
}
А на каждом кадре — вызывать такую функцию
void Frame()
{
float frameTime = AverageFrameTime();
// Выводим фреймрейт
char buf[256];
sprintf_s(buf, "average framerate: %.1f", 1.0f / frameTime);
SetWindowTextA(hWnd, buf);
// Очищаем буфер черным цветом
float clearColor[] = { 0.0f, 0.0f, 0.0f, 0.0f };
deviceContext->ClearRenderTargetView(renderTargetView, clearColor);
// Двумерные вектора из 32-битных вещественных идут подряд
UINT stride = sizeof(float[2]);
UINT offset = 0;
ID3D11Buffer *nullBuffer = NULL;
ID3D11UnorderedAccessView *nullUAV = NULL;
// Убираем доступ вершинного шейдера к буферу позиций
deviceContext->IASetVertexBuffers(0, 1, &nullBuffer, &stride, &offset);
// Устанавливаем доступ вычислительного шейдера к буферу позиций
deviceContext->CSSetUnorderedAccessViews(0, 1, &positionUAV, NULL);
// Вызываем вычислительный шейдер
deviceContext->Dispatch(PARTICLE_COUNT / NUMTHREADS, 1, 1);
// Убираем доступ вычислительного шейдера к буферу позиций
deviceContext->CSSetUnorderedAccessViews(0, 1, &nullUAV, NULL);
// Устанавливаем доступ вершинного шейдера к буферу позиций
deviceContext->IASetVertexBuffers(0, 1, &positionBuffer, &stride, &offset);
// Вызываем отрисовку
deviceContext->Draw(PARTICLE_COUNT, 0);
// Выводим изображение на экран
swapChain->Present(0, 0);
}
На случай, если размер окна изменился, нам нужно также изменить размер буферов отрисовки:
void ResizeSwapChain()
{
HRESULT result;
RECT rect;
// Получаем актуальные размеры окна
GetClientRect(hWnd, &rect);
windowWidth = rect.right - rect.left;
windowHeight = rect.bottom - rect.top;
// Для того, чтобы изменить размер изображения, нужно
// освободить все указатели на "задний" буфер
DisposeRenderTargetView();
// Изменяем размер изображения
result = swapChain->ResizeBuffers(
// Изменяем размер всех буферов
0,
// Новые размеры
windowWidth, windowHeight,
// Не меняем формат и флаги
DXGI_FORMAT_UNKNOWN, 0);
assert(SUCCEEDED(result));
// Создаем новый доступ к "заднему" буферу
InitRenderTargetView();
}
Наконец, можно определить функцию обработки сообщений
LRESULT CALLBACK WndProc(HWND hWnd, UINT Msg, WPARAM wParam, LPARAM lParam)
{
switch (Msg)
{
case WM_CLOSE:
PostQuitMessage(0);
break;
case WM_KEYDOWN:
if (wParam == VK_ESCAPE)
PostQuitMessage(0);
break;
case WM_SIZE:
ResizeSwapChain();
break;
default:
return DefWindowProc(hWnd, Msg, wParam, lParam);
}
return 0;
}
И функцию main
int main()
{
InitWindows();
InitSwapChain();
InitRenderTargetView();
InitShaders();
InitBuffers();
InitUAV();
InitBindings();
ShowWindow(hWnd, SW_SHOW);
bool shouldExit = false;
while (!shouldExit)
{
Frame();
MSG msg;
while (!shouldExit && PeekMessage(&msg, NULL, 0, 0, PM_REMOVE))
{
TranslateMessage(&msg);
DispatchMessage(&msg);
if (msg.message == WM_QUIT)
shouldExit = true;
}
}
DisposeUAV();
DisposeBuffers();
DisposeShaders();
DisposeRenderTargetView();
DisposeSwapChain();
DisposeWindows();
}
Скриншот работающей программы можно увидеть в заголовке статьи.
Проект целиком выложен на GitHub
Автор: asurkis