
На Хабре уже есть много статей про использование вычислительных шейдеров с 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
