Пишем простую программу с использованием DirectX

9 сентября 2024

Недавно я наткнулся на видео I Created My Own Custom 3D Graphics Engine. Автор рассказывает о том, как он изучал DirectX, а затем написал на нем простой трехмерный шутер. Выглядело это настолько воодушевляюще, что мне тоже захотелось написать что-нибудь на DirectX, пусть для начала и совсем простое.

О выборе платформы и API

Давние подписчики «Записок программиста» наверняка помнят мои опыты с OpenGL. Возникает закономерный вопрос — как так автора заинтересовал еще один графический API?

OpenGL хорош своей переносимостью. Программа на OpenGL может работать на любой операционной системе, с любом CPU / GPU и собираться любыми компиляторами. Программисты обычно хотят, чтобы у написанных ими программ были пользователи, и чем больше, тем лучше. Так в проекте возникает поддержка нескольких платформ. По моему субъективному опыту, вот тут-то и кроется большая проблема. В масштабах нескольких лет код неизбежно ломается. Как результат, вы перестаете заниматься графикой. Вместо этого вы занимаетесь чем-то вроде починки сторонних зависимостей на последних версиях MacOS, или проблемой несовместимости определенных версий CMake с определенными версиями компиляторов. Ну или не занимаетесь, и получаете сломанный проект.

Если вы изучаете OpenGL и хотите заниматься именно графикой, то следует ограничиться поддержкой какой-то одной платформы. Но если платформа одна, то зачем брать кроссплатформенный OpenGL? Ведь можно взять нативный API для этой платформы — DirectX для Windows, Metal для MacOS и Vulkan для Linux. Как минимум, это позволит добиться большей производительности. Ведь при работе с графикой вычислительные ресурсы не бывают лишними. А еще можно привязать проект к конкретным версиям IDE и SDK, чтобы уж наверняка забыть о проблемах переносимости.

Тогда какую платформу выбрать? Все они по-своему интересны, но я лично остановился на Windows. Вообще-то, в качестве основной системы я сейчас использую MacOS. Но также у меня есть игровой ретро-ноутбук под Windows. Ноутбуку ~20 лет, однако он способен выдавать приятную глазу картинку, и мне нравятся игры на нем. Помимо этого, Windows является наиболее популярной десктоп-системой (~72% пользователей) и самой популярной платформой среди пользователей Steam (~96% на декабрь 2023). Отсюда можно предположить, что многим читателей блога также будет интересен Windows. Наконец, программы для Windows могут быть запущены под Linux и MacOS, при помощи Wine или CrossOver соответственно. Либо при помощи VirtualBox.

Осталось определиться с версией DirectX. Основных вариантов я вижу два:

  • DirectX 12 — наиболее новая версия на момент написания этих строк. Единственный вариант для тех, кто отдал 1600+$ за GeForce RTX 4090 и хочет выжимать из нее все соки;
  • DirectX 9 — версия 2002-го года. Является самой переносимой. Только этот DirectX с одинаковым успехом работает как на современных, так и на старых версиях Windows вплоть до самой XP. Несмотря на возраст, способен выдавать очень даже приличную картинку;

Опять же, оба варианта по-своему интересны. Я лично отдал предпочтение DirectX 9. GeForce RTX 4090 у меня все равно нет. Если бы и был, на данном этапе я не способен раскрыть весь потенциал данной видеокарты. Зато для меня важно, чтобы программу мог скомпилировать и/или запустить любой желающий.

Готовим окружение для разработки

Версии IDE и SDK я выбрал под стать DirectX’у — Visual Studio 2005 и DirectX SDK August 2006. Это сочетание также прекрасно работает на Windows XP и старше. В частности, я не поленился установить их на 64-х битной Windows 10, запущенной под VirtualBox. Visual Studio встала со второй попытки, потому что в первый раз система вмешалась в работу установщика и подсунула другую (совместимую) версию .NET Framework. В остальном же все прошло отлично.

Как вариант, вы можете воспользоваться более новым DirectX SDK June 2010, который до сих пор доступен на сайте Microsoft (зеркало). Однако учтите, что он на 72 Мб тяжелее и ему нужен Visual Studio 2008 или 2010. Я лично предпочитаю старые версии данной IDE. По моим наблюдениям, они лучше оптимизированы в плане производительности и занимаемого места на диске, а функционал тот же.

После установки DirectX SDK открываем Visual Studio, идем в Tools → Options… там находим Projects and Solutions → VC++ Directories. Справа будет выпадающий список, в нем выбираем Include Files. Добавляем путь до (DirectX SDK)\Include. Затем выбираем Library Files, добавляем путь до (DirectX SDK)\Lib\x86.

Я предпочитаю выкидывать из исполняемого файла все ненужное. Как это сделать ранее было описано в посте Готовим окружение для программирования на WinAPI. Создаем x86 проект с именем Project и настраиваем его согласно посту. Получаем шаблон проекта. Этот шаблон будем копировать вместо создания новых проектов. Не беспокойтесь по поводу того, что проект 32-х битный. Программа прекрасно запустится как на 64-х битных, так и 32-х битных процессорах. Зато мы с вами будем заниматься графикой (физикой, ИИ…), а не кроссплатформенностью. Впрочем, вам никто не помешает собрать и 64-х битную версию программы, если вы так хотите.

Далее открываем свойства проекта (Alt+F7), и в Linker → Input → Additional Dependencies прописываем d3d9.lib. Как альтернативный вариант, в код программы можно добавить строчку:

#pragma comment(lib, "d3d9.lib")

Также дописываем:

// Reduce .exe file size a bit by merging two read-only sections
#pragma comment(linker, "/MERGE:.rdata=.text")

// Silence error LNK2001: unresolved external symbol __fltused
// See https://stackoverflow.com/a/1583220
EXTERN_C int _fltused = 0;

// Silence error LNK2001: unresolved external symbol _memset
// Surprisingly we don't have to do the same for memcpy()
#pragma function(memset)
void* memset(void* b, int c, size_t len) {
    unsigned char* p = (unsigned char*)b;
    while(len--)
        *p++ = c;
    return b;
}

Увы, писать на чистом C будет проблематично. Дело в том, что DirectX активно орудует абстрактными классами C++ (что странно, ведь ломается предсказатель переходов). Чтобы это правильно работало, в свойствах проекта нужно найти C/C++ → Advanced → Compile As и убедиться, что там выбран C++. По факту код у нас будет не столько на C++, сколько на «C с классами».

Создаем окно на WinAPI

Графику нужно куда-то выводить, а в Windows для этого нужно создать окно:

#include <windows.h>

CONST WCHAR WND_CLASS_NAME[] = L"MainClass";

// Should be used only after RegisterClassEx() succeeds
VOID CleanupAndExit(UINT uExitCode) {
    HINSTANCE hInstance = GetModuleHandle(NULL);
    UnregisterClass(WND_CLASS_NAME, hInstance);

    ExitProcess(uExitCode);
}

LRESULT WINAPI WindowProc(HWND hWnd, UINT msg,
                          WPARAM wParam, LPARAM lParam)
{
    switch(msg) {
        case WM_DESTROY:
            PostQuitMessage(0);
            return 0;
    }

    return DefWindowProc(hWnd, msg, wParam, lParam);
}

void main() {
    HWND hWnd;
    MSG msg;
    WNDCLASSEX wc;

    memset(&wc, 0, sizeof(wc));
    wc.cbSize = sizeof(wc);
    wc.style = CS_HREDRAW | CS_VREDRAW;
    wc.lpfnWndProc = WindowProc;
    wc.hInstance = GetModuleHandle(NULL);
    wc.hbrBackground = (HBRUSH)COLOR_WINDOW;
    wc.lpszClassName = WND_CLASS_NAME;

    if(RegisterClassEx(&wc) == 0) {
        ExitProcess(1);
    }

    hWnd = CreateWindow(WND_CLASS_NAME, L"Main Window",
                        WS_OVERLAPPEDWINDOW, 100, 100, 300, 300,
                        NULL, NULL, wc.hInstance, NULL);

    ShowWindow(hWnd, SW_SHOWDEFAULT);
    UpdateWindow(hWnd);

    while(GetMessage(&msg, NULL, 0, 0)) {
        TranslateMessage(&msg);
        DispatchMessage(&msg);
    }

/*
    for(;;) {
        if(PeekMessage(&msg, NULL, 0, 0, PM_REMOVE)) {
            TranslateMessage(&msg);
            DispatchMessage(&msg);

            if(msg.message == WM_QUIT)
                break;
        }
        else
            RenderScene();
    }
*/


    CleanupAndExit(0);
}

Данный код хорошо знаком любому, кто писал GUI на чистом WinAPI в начале нулевых. Мне не хотелось бы на нем задерживаться, поскольку это не туториал по WinAPI. Заинтересованным читателям рекомендуется обратиться к Win32 Programmer’s Reference (версия в формате .chm). Справочный файл содержит всю необходимую информацию, включая описание использованных функций, а также их аргументов и возвращаемых значений.

Остановлюсь лишь на одном моменте. Основной цикл программы разгребает очередь сообщений к окну. Он может быть написан как на GetMessage, так и на PeekMessage (закомментированный фрагмент). Разница в том, что первый вызов является блокирующим, а второй — нет. Обычно GUI приложения используют первый вариант. PeekMessage приводит к тому, что программа большую часть времени вхолостую крутится в цикле, потребляя 100% процессорного времени. Однако в приложениях на DirectX выгоднее использовать PeekMessage. Этим приложениям всегда есть что перерисовывать, и процессор не простаивает.

Если вам совсем не интересен WinAPI, то можете просто не обращать внимания на этот код. Он нужен лишь для того, чтобы DirectX было куда выводить графику.

Рисуем треугольник на DirectX

Следующий код рисует треугольник в получившемся окне:

#include <d3d9.h>

LPDIRECT3D9 g_pD3D = NULL;
LPDIRECT3DDEVICE9 g_pd3dDevice = NULL;
LPDIRECT3DVERTEXBUFFER9 g_pVB = NULL;

struct CUSTOMVERTEX {
    FLOAT x, y, z, rhw;
    DWORD color;
};

// Our custom Flexible Vertex Format (FVF)
#define D3DFVF_CUSTOMVERTEX (D3DFVF_XYZRHW|D3DFVF_DIFFUSE)

// Initializes Direct3D
HRESULT InitDirectX(HWND hWnd) {
    D3DPRESENT_PARAMETERS d3dpp;

    g_pD3D = Direct3DCreate9(D3D_SDK_VERSION);
    if(g_pD3D == NULL)
        return E_FAIL;

    memset(&d3dpp, 0, sizeof(d3dpp));
    d3dpp.Windowed = TRUE;
    d3dpp.SwapEffect = D3DSWAPEFFECT_DISCARD;
    d3dpp.BackBufferFormat = D3DFMT_UNKNOWN;

    if(FAILED(g_pD3D->CreateDevice(
        D3DADAPTER_DEFAULT,
        D3DDEVTYPE_HAL,
        hWnd,
        D3DCREATE_SOFTWARE_VERTEXPROCESSING,
        &d3dpp,
        &g_pd3dDevice)))
    {
        return E_FAIL;
    }

    return S_OK;
}

// Creates a vertex buffer and fills it with our vertices
HRESULT InitVertexBuffer() {
    static const CUSTOMVERTEX vertices[] = {
        { 150.0f,  50.0f, 0.5f, 1.0f, 0xffff0000, },
        { 250.0f, 250.0f, 0.5f, 1.0f, 0xff00ff00, },
        {  50.0f, 250.0f, 0.5f, 1.0f, 0xff0000ff, },
    };

    if(FAILED(g_pd3dDevice->CreateVertexBuffer(
        3*sizeof(CUSTOMVERTEX),
        0,
        D3DFVF_CUSTOMVERTEX,
        D3DPOOL_DEFAULT,
        &g_pVB,
        NULL)))
    {
        return E_FAIL;
    }

    VOID* pVertices;
    if(FAILED(g_pVB->Lock(0, sizeof(vertices), (void**)&pVertices, 0)))
        return E_FAIL;
    memcpy(pVertices, vertices, sizeof(vertices));
    g_pVB->Unlock();

    return S_OK;
}

// Should be used only after RegisterClassEx() succeeds
VOID CleanupAndExit(UINT uExitCode) {
    HINSTANCE hInstance = GetModuleHandle(NULL);
    UnregisterClass(WND_CLASS_NAME, hInstance);

    if(g_pVB != NULL)
        g_pVB->Release();

    if(g_pd3dDevice != NULL)
        g_pd3dDevice->Release();

    if(g_pD3D != NULL)
        g_pD3D->Release();

    ExitProcess(uExitCode);
}

VOID RenderScene() {
    g_pd3dDevice->Clear(0, NULL, D3DCLEAR_TARGET,
                        D3DCOLOR_XRGB(0,0,0), 1.0f, 0);

    if(SUCCEEDED(g_pd3dDevice->BeginScene())) {
        g_pd3dDevice->SetStreamSource(0, g_pVB, 0,
                                      sizeof(CUSTOMVERTEX));
        g_pd3dDevice->SetFVF(D3DFVF_CUSTOMVERTEX);
        g_pd3dDevice->DrawPrimitive(D3DPT_TRIANGLELIST, 0, 1);

        g_pd3dDevice->EndScene();
    }

    g_pd3dDevice->Present(NULL, NULL, NULL, NULL);
}

/* ... */

    // Added after hWnd = CreateWindow(...) :

    if(FAILED(InitDirectX(hWnd))) {
        CleanupAndExit(2);
    }

    if(FAILED(InitVertexBuffer())) {
        CleanupAndExit(3);
    }

/* ... */

А так выглядит результат:

Рисуем треугольник на DirectX

Думаю, что в общих чертах код будет понятен многим программистам. Тем не менее, разберем его более подробно.

Инициализация происходит в InitDirectX. Здесь у нас создается два объекта. Из функции Direct3DCreate9 возвращается LPDIRECT3D9. Это наш интерфейс ко всему DirectX. При помощи метода CreateDevice мы создаем Direct3D Device. Это наш основной интерфейс для работы с графикой. Благодаря ему нам не нужно знать тонкости устройства конкретной видеокарты, ее драйверов, и т.д. При инициализации мы выбираем между оконным режимом и полноэкранным, некоторые тонкости работы двойной буферизации, и т.п. Заинтересованные читатели найдут описание всех аргументов в документации из DirectX SDK.

Функция InitVertexBuffer создает буфер вершин. Вершины в DirectX могут храниться разными способами. Это называется Flexible Vertex Format или FVF. Соответственно, нам нужно как-то объяснить, что у нас за формат. Для этого мы объявляем битовую маску D3DFVF_CUSTOMVERTEX. Значение D3DFVF_XYZRHW в этой маске говорит о том, что вершина имеет координаты, притом, в пространстве экрана. Значение D3DFVF_DIFFUSE указывает на то, что у вершины есть 32-х битный цвет, используемый при рассеянном освещении (diffuse lighting).

Соответствующая структура, представляющая собой одну вершину:

struct CUSTOMVERTEX {
    FLOAT x, y, z, rhw; // эти поля от D3DFVF_XYZRHW
    DWORD color;        // а это - от D3DFVF_DIFFUSE
};

Цвет хранится в формате ARGB, по 8 бит на составляющую. Смысл координат Z и RHW в пространстве экрана для нас сейчас не важен. Просто не замечаем их и фокусируемся на X и Y.

Обладая этой информацией, не сложно понять вызов CreateVertexBuffer. Он создает буфер из трех вершин и сохраняет его в g_pVB. Чтобы заполнить буфер, мы вызываем его метод Lock. Получаем указатель на некоторый участок памяти pVertices. Заполняем его нашими значениями при помощи обычного memcpy, и говорим Unlock. Буфер вершин проинициализирован.

Код CleanupAndExit был модифицирован так, чтобы при выходе из программы у новых интерфейсов вызывались методы Release. Интуиция мне подсказывает, что при завершении программы ОС так или иначе все за нами подчистит, сделаем мы эти вызовы, или не сделаем. Иначе каждый раз при падении какой-то игры пользователям приходилось бы еще и перезагружаться. Тем не менее, добавить пару вызовов нам ничего не стоит, и их наличие точно не сделает хуже.

RenderScene вызывается из основного цикла программы. Функция использует методы созданного ранее Direct3D Device. При помощи метода Clear мы заполняем задний буфер черным цветом. Последующие вызовы должны находится между BeginScene и EndScene. Метод SetStreamSource выбирает буфер вершин, а вызов SetFVF указывает формат вершин. При помощи DrawPrimitive рисуется один треугольник. Метод Present меняет местами передний и задние буферы, благодаря чему пользователь видит картинку.

Заключение

Что же мы получили в итоге? Во-первых, относительно легковесное окружение для разработки на DirectX, которое работает везде. Во-вторых, шаблон для создания будущих проектов. И в-третьих, самодостаточный исполняемый файл размером всего лишь 2.5 Кб, работающий на Windows XP и старше. Здесь нужно отдать должное компании Microsoft. Не всякий вендор заботится об обратной совместимости так же, как этот.

По DirectX 9 написано множество обучающих материалов. В первую очередь стоит обратить внимание на статьи, справочные файлы и примеры, идущие в составе DirectX SDK. Они действительно хороши. В частности, ты открываешь примеры в Visual Studio, и они просто работают. Это сильно отличается от моего опыта с изучением OpenGL. Существуют также онлайн-туториалы и множество книг (см раз, два), хотя на данном этапе я и не могу рекомендовать конкретные.

Полную версию исходников и собранный исполняемый файл вы найдете здесь.

Метки: , .


Вы можете прислать свой комментарий мне на почту, или воспользоваться комментариями в Telegram-группе.