ПрограммированиеСтатьиГрафика

Dynamic vertex pulling в Direct3D11

Автор:

Данная статья рассказывает про то, как можно рисовать миллионы уникальных объектов в Direct3D11 с минимальным оверхедом на CPU и максимально близкой к D3D12 или «AMD Mantle» скоростью. В статье показан пример использования так называемых Structured Buffers.

Мотивация
Основная идея
Логические и физические буферы
Управление константами для каждого объекта
Шейдер и как это вообще рисовать?
А что делать с текстурами?
Недостатки и ограничения
Демо и исходники
Ссылки и литература

Мотивация

Мотивация очень простая: в текущем проекте резко стало не хватать возможностей обычного инстансинга. Не хватать стало из-за разнообразия возможных деревьев, для которых работает простая арифметика:

1. 9 базовых видов деревьев,
2. 3 стадии роста для каждого дерева (росток, деревце, большое дерево),
3. 3 стадии здоровья для каждой стадии роста дерева(здоровое, больное, умирающее),
4. 5 уровней детализации (LOD) для каждой стадии здоровья каждой стадии роста каждого дерева (включая импостеры).

Это порождает серьёзный комбинаторный взрыв, из-за чего эффективность инстансинга сильно снижается. Ниже я предлагаю решение, позволяющее обойти проблему такого комбинаторного взрыва и рисовать все эти деревья за 1 вызов отрисовки (draw call, DIP), имея при этом отдельный набор констант для каждого объекта и потенциально уникальный меш для каждого же объекта.

Основная идея

D3D11 и OpenGL 4.0 поддерживают [RW]StructuredBuffer (D3D) и ARB_shader_storage_buffer_object (GL), которые представляют собой некий буфер со структурированными данными, из которого шейдер может читать по произвольному индексу.

Я предлагаю использовать 2 глобальных буфера для хранения вершин/индексов и в вершинном шейдере считывать оттуда вершины по vertex id. Таким образом можно передавать смещения (offset) в глобальном буфере как константу и начинать читать вершины начиная с этого смещения.

Как это всё реализовать?

Логические и физические буферы

Введём понятия логический буфер и физический буфер.

Физический буфер: представляет собой область в памяти GPU, в которой хранятся абсолютно все вершины или индексы, которые необходимы для рисования всей геометрии этим способом.

Логический буфер: представляет собой структуру данных, состоящую из смещения в физическом буфере и размера одного блока данных.

Эти два понятия легко проиллюстрировать на следующей картинке:
pvp_i0 | Dynamic vertex pulling в Direct3D11

В D3D11 и С++ для логического буфера много данных не нужно:

struct DXLogicalMeshBuffer final
{
    uint8_t* data             = nullptr;
    size_t   dataSize         = 0;
    size_t   dataFormatStride = 0;
    size_t   physicalAddress  = 0;

    BufferType type; // vertex or index buffer
};

Пройдёмся по полям этого класса.

  data — указатель на данные буфера (вершины или индексы),
  dataSize — размер массива данных в байтах,
  dataFormatStride — размер одного элемента массива в байтах,
  physicalAddress — смещение в физическом буфере, по которому находятся данные этого буфера. Это поле заполняется при перестроении физического буфера(об этом ниже).

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

Класс физического буфера выглядит следующим образом:

struct DXPhysicalMeshBuffer final
{
    ID3D11Buffer*             physicalBuffer     = nullptr;
    ID3D11ShaderResourceView* physicalBufferView = nullptr;
    size_t                    physicalDataSize   = 0;
    bool                      isDirty            = false;

    typedef DynamicArray<DXLogicalMeshBuffer*> PageArray;
    PageArray allPages;

    DXPhysicalMeshBuffer() = default;
    inline ~DXPhysicalMeshBuffer()
    {
        if (physicalBuffer != nullptr)     physicalBuffer->Release();
        if (physicalBufferView != nullptr) physicalBufferView->Release();
    }

    void allocate(DXLogicalMeshBuffer* logicalBuffer);
    void release(DXLogicalMeshBuffer* logicalBuffer);
    void rebuildPages(); // very expensive operation
};

Поля класса нужны для следующего:

  physicalBuffer — буфер, где лежат вершины или индексы.
  physicalBufferView — shader resource view для доступа к данным из шейдера.
  physicalDataSize — размер физического буфера в байтах.
  isDirty — флажок, который показывает необходимость обновления буфера (буфер необходимо обновлять после каждой аллокации/деаллокации логического буфера).
  allPages — все логические буферы, аллоцированные в этом физическом буфере.

При каждом создании и удалении логических буферов необходимо информировать об этом физический буфер. Операции allocate/release тривиальны и в дополнительных пояснениях не нуждаются:

void DXPhysicalBuffer::allocate(DXLogicalMeshBuffer* logicalBuffer)
{
    allPages.Add(logicalBuffer);
    isDirty = true;
}

void DXPhysicalBuffer::release(DXLogicalMeshBuffer* logicalBuffer)
{
    allPages.Remove(logicalBuffer);
    isDirty = true;
}

Гораздо интереснее рассмотреть функцию rebuildPages().

Эта функция должна создать физический буфер и заполнять его данными из всех используемых логических буферов. В Direct3D11 для этого нужно создать d3d-шный буфер, который можно отображать (map) в оперативную память, обновлять в нём данные, привязывать (bind) как shader resource и использовать как structured buffer:

size_t vfStride = allPages[0]->dataFormatStride; // TODO: right now will not work with different strides
size_t numElements = physicalDataSize / vfStride;

if (physicalBuffer != nullptr)     physicalBuffer->Release();
if (physicalBufferView != nullptr) physicalBufferView->Release();

D3D11_BUFFER_DESC bufferDesc;
bufferDesc.BindFlags           = D3D11_BIND_SHADER_RESOURCE;
bufferDesc.ByteWidth           = physicalDataSize;
bufferDesc.Usage               = D3D11_USAGE_DYNAMIC;
bufferDesc.MiscFlags           = D3D11_RESOURCE_MISC_BUFFER_STRUCTURED;
bufferDesc.StructureByteStride = vfStride;
bufferDesc.CPUAccessFlags      = D3D11_CPU_ACCESS_WRITE;

if (FAILED(g_pd3dDevice->CreateBuffer(&bufferDesc, nullptr, &physicalBuffer))) {
    handleError(...); // handle your error here
    return;
}

Обязательно нужно проследить, что StructureByteStride совпадает со структурой, которую пытается читать вершинный шейдер. Также нужна возможность записи в буфер со стороны CPU.

После этого нужно создать shader resource view, это тривиальная операция:

D3D11_SHADER_RESOURCE_VIEW_DESC viewDesc;
std::memset(&viewDesc, 0, sizeof(viewDesc));

viewDesc.Format              = DXGI_FORMAT_UNKNOWN;
viewDesc.ViewDimension       = D3D11_SRV_DIMENSION_BUFFER;
viewDesc.Buffer.ElementWidth = numElements;

if (FAILED(g_pd3dDevice->CreateShaderResourceView(physicalBuffer, &viewDesc, &physicalBufferView)))
{
    // TODO: error handling
    return;
}

Теперь перейдём непосредственно к заполнению физического буфера. Алгоритм следующий:
1. Отобразить (Map) адресное пространство только что созданного физического буфера в оперативную память.
2. Пройтись циклом по всем ассоциированным логическим буферам, для каждого логического буфера:
  a. Посчитать смещение логического буфера в физическом буфере (physicalAddress),
  b. Скопировать данные из логического буфера в отображенную (mapped) область памяти физического буфера по нужному смещению,
  c. Перейти к следующему логическому буферу.
3. Сделать Unmap физического буфера.

Код довольно простой и выглядит следующим образом:

// fill the physical buffer
D3D11_MAPPED_SUBRESOURCE mappedData;
std::memset(&mappedData, 0, sizeof(mappedData));

if (FAILED(g_pImmediateContext->Map(physicalBuffer, 0, D3D11_MAP_WRITE_DISCARD, 0, &mappedData)))
{
    handleError(...); // insert error handling here
    return;
}

uint8_t* dataPtr = reinterpret_cast<uint8_t*>(mappedData.pData);
size_t pageOffset = 0;
for (size_t i = 0; i < allPages.GetSize(); ++i) {
    DXLogicalMeshBuffer* logicalBuffer = allPages[i];
    // copy logical data to the mapped physical data
    std::memcpy(dataPtr + pageOffset, logicalBuffer->data, logicalBuffer->dataSize);
    // calculate physical address
    logicalBuffer->physicalAddress = pageOffset / logicalBuffer->dataFormatStride;
    // calculate offset
    pageOffset += logicalBuffer->dataSize;
}

g_pImmediateContext->Unmap(physicalBuffer, 0);

Стоит заметить, что перестроение физического буфера — очень дорогая операция, в нашем случае для вышеуказанного количества разных деревьев она занимает 300—500 миллисекунд. Высокая стоимость связана с большим количеством данных, которые необходимо переслать на GPU (речь идёт о десятках мегабайт данных). Поэтому не рекомендуется перестраивать физический буфер на каждый чих.

Полная функция rebuildPages() для справки

Хранение и рисование геометрии таким способом подразумевает также и нетривиальное управление константами, о котором сейчас пойдёт речь.

Управление константами для каждого объекта

Традиционные константные буферы в данной ситуации не подходят по очевидным причинам. В связи с этим не остаётся другого выбора, кроме как использовать ещё один глобальный буфер, аналогичный физическому буферу, описанному выше.

Помимо этого туда необходимо передать информацию про логические вершинный и индексный буферы для текущего инстанса, тип рисуемой геометрии (индексированная или нет) и количество вершин.

Создание такого буфера ничем не отличается от создания описанного выше физического буфера, поэтому останавливаться подробно на нём не буду.

+ Создание общего константного буфера

При заполнении этого буфера первые 4 32 битных регистра я использую для служебной информации шейдера (для считывания данных из физического буфера). Также, для упрощения кода примера, я подразумеваю что константы в шейдере всегда имеют размер 2048 (разумеется в реальной жизни надо будет это ограничение убрать).

Эти данные выглядят следующим образом:

struct InternalData
{
    uint32_t vb;
    uint32_t ib;
    uint32_t drawCallType;
    uint32_t count;
};

После этих 4 чисел идут обычные константы, такие как матрица проекции, которые необходимы для рисования обычной геометрии.

Сейчас небольшое лирическое отступление. Я напрямую ничего не рисую, вместо этого я использую вот такую структуру, которая представляет собой 1 вызов отрисовки. Сюда же идут и константы и всё остальное, необходимое для рисования.

struct DrawCall final
{
    enum Type : uint32_t
    {
        Draw        = 0,
        DrawIndexed = 1
    };

    enum
    {
        ConstantBufferSize = 2048 // TODO: remove hardcode
    };

    enum
    {
        MaxTextures = 8
    };

    uint8_t constantBufferData[ConstantBufferSize];

    DXLogicalMeshBuffer* vertexBuffer;
    DXLogicalMeshBuffer* indexBuffer;

    uint32_t count;
    uint32_t startVertex;
    uint32_t startIndex;
    Type     type;
};

Для примера я сильно упростил эту структуру, чтобы не утомлять читателя лишними подробностями.

Приложение заполняет массив этих структур, используя примерно следующий набор функций, на которых подробно останавливаться также не буду (обычный буфер команд, что с него взять-то?)

+ Drawing API

После создания буфера команд необходимо скопировать в глобальный буфер с константами все константы из буфера команд, после чего заполнить InternalData и, собственно, нарисовать всё это безобразие.

Обновление констант тривиальное, просто проходим циклом по буферу команд и копируем нужные данные в нужное место:

// update constants
{
    D3D11_MAPPED_SUBRESOURCE mappedData;
    if (FAILED(g_pImmediateContext->Map(psimpl->constantBuffer.dataBuffer, 0, D3D11_MAP_WRITE_DISCARD,
      0, &mappedData))) {
        // TODO: error handling
        return;
    }
    uint8_t* dataPtr = reinterpret_cast<uint8_t*>(mappedData.pData);
    for (size_t i = 0; i < numInstances; ++i) {
        size_t offset = i * internal::DrawCall::ConstantBufferSize;
        const internal::DrawCall& call = queue->getDrawCalls()[i];

        std::memcpy(dataPtr + offset, call.constantBufferData, internal::DrawCall::ConstantBufferSize);

        // fill internal data structure
        InternalData* idata = reinterpret_cast<InternalData*>(dataPtr + offset);

        DXLogicalMeshBuffer* vertexBuffer = static_cast<DXLogicalMeshBuffer*>(call.vertexBuffer.value);
        if (vertexBuffer != nullptr)
            idata->vb = vertexBuffer->physicalAddress;

        DXLogicalMeshBuffer* indexBuffer = static_cast<DXLogicalMeshBuffer*>(call.indexBuffer.value);
        if (indexBuffer != nullptr)
            idata->ib = indexBuffer->physicalAddress;

        idata->drawCallType = call.type;
        idata->count        = call.count;
    }
    g_pImmediateContext->Unmap(psimpl->constantBuffer.dataBuffer, 0);
}

Всё, теперь данные готовы для непосредственной отрисовки средствами D3D!

Шейдер и как это вообще рисовать?

После того, как данные были подготовлены, наступает время рисования. Для этого просто выставляем шейдеру физические буферы и глобальные константные буферы и делаем DrawInstanced:

ID3D11ShaderResourceView* vbibViews[2] = {
    g_physicalVertexBuffer->physicalBufferView,
    g_physicalIndexBuffer->physicalBufferView
};

g_pImmediateContext->VSSetShaderResources(0, 2, vbibViews);

g_pImmediateContext->VSSetShaderResources(0 + 2, 1, &psimpl->constantBuffer.dataView);
g_pImmediateContext->HSSetShaderResources(0 + 2, 1, &psimpl->constantBuffer.dataView);
g_pImmediateContext->DSSetShaderResources(0 + 2, 1, &psimpl->constantBuffer.dataView);
g_pImmediateContext->GSSetShaderResources(0 + 2, 1, &psimpl->constantBuffer.dataView);
g_pImmediateContext->PSSetShaderResources(0 + 2, 1, &psimpl->constantBuffer.dataView);

g_pImmediateContext->DrawInstanced(maxDrawCallVertexCount, numInstances, 0, 0);

Почти готово! Осталось объяснить некоторые важные моменты.
1. DrawInstanced надо вызывать с максимальным количеством вершин, которое есть в буфере команд.
  Это необходимо потому, что вызов отрисовки у нас 1, и если у мешей разное количество вершин или индексов — это нужно как-то учитывать. Я предпочитаю всегда рисовать наибольшее количество вершин, а лишние отсекать, отправляя их за clipping plane.
  Таким образом появляется много дополнительной ненужной нагрузки на вершинный шейдер, поэтому надо следить за тем, чтобы разница между минимальным и максимальным vertex count была в разумных пределах (500—600 вершин). Нужно помнить, что эта разница равна количеству «мусорных» вершин на каждый инстанс, и она растёт со скоростью геометрической прогрессии в квадрате. Следите за художниками!
2. Одного вызова DrawInstanced достаточно, чтобы рисовать как индексированную так и не индексированную геометрию, потому что вся работа с вершинами делается руками в шейдере.
3. Топологии вроде TriangleStrip, TriangleFan и им подобные — не поддерживаются по очевидным причинам. Поддерживаются только топологии с изолированными примитивами (TriangleList, PointList, *List).

Вершинный шейдер, который считывает вершины с индексами и обрабатывает их, не должен вызывать особых сложностей.

В первую очередь надо продублировать в шейдере все структуры, которые используются со стороны CPU (структура вершины, структура константы и т.д.):

// vertex
struct VertexData
{
    float3 position;
    float2 texcoord0;
    float2 texcoord1;
    float3 normal;
};
StructuredBuffer<VertexData> g_VertexBuffer;
StructuredBuffer<uint>       g_IndexBuffer;

// pipeline state
#define DRAW 0
#define DRAW_INDEXED 1
struct ConstantData
{
    uint4    internalData;

    float4x4 World;
    float4x4 View;
    float4x4 Projection;

    float4   strideOffset[(2048 - 200) / 16];
};
StructuredBuffer<ConstantData> g_ConstantBuffer;

Дальше код, который считывает константы и служебные данные из буферов и обрабатывает вершины (обратите внимание на то, как реализованы индексированный и неиндексированный режимы):

uint instanceID = input.instanceID;
uint vertexID   = input.vertexID;

uint vbID     = g_ConstantBuffer[instanceID].internalData[0];
uint ibID     = g_ConstantBuffer[instanceID].internalData[1];
uint drawType = g_ConstantBuffer[instanceID].internalData[2];
uint drawCount = g_ConstantBuffer[instanceID].internalData[3];
    
VertexData vdata;
[branch] if (drawType == DRAW_INDEXED) vdata = g_VertexBuffer[vbID + g_IndexBuffer[ibID + vertexID]];
else     if (drawType == DRAW)         vdata = g_VertexBuffer[vbID + vertexID];

[flatten] if (vertexID > drawCount)
    vdata = g_VertexOutsideClipPlane; // discard vertex by moving it outside of the clip plane

Как видно, всё очень просто и понятно. Для справки привожу полный код шейдера

Опытный читатель мог заметить, что я ничего не сказал про текстуры. Про это следующая часть.

А что делать с текстурами?

Это самый серьёзный недостаток предлагаемого метода. При подобном подходе хочется также иметь уникальные текстуры для каждого инстанса, но в Direct3D11 это реализовать очень нетривиально.

Возможные решения проблемы:
1. Использовать текстурный атлас.
  Недостатки: в один атлас много текстур не влезет, надо будет группировать инстансы по 3 или 4 штуки (количество текстур), и рисовать отдельно. Очевидно, что при таком подходе все плюсы предложенного метода сходят на нет.
2. Использовать текстурные массивы (Texture2DArray, Sampler2DArray).
  Недостатки: в один массив много текстур не влезет, надо группировать инстансы по 512 штук(количество элементов в массиве текстур) и рисовать отдельно. Очевидно, это лучше, чем текстурный атлас, но всё равно плохо.
3. Перейти на OpenGL 4.3, где есть bindless texture.
  Недостатки: влезут все текстуры, но есть один серьёзный недостаток под названием OpenGL.
4. Перейти на Direct3D12/AMD Mantle/Apple Metal/Whatever
  Недостатки: влезут все текстуры (из-за особенностей архитектуры), но поддерживается ограниченным количеством железа и не у всех есть доступ к SDK.
5. Использовать меньше текстур
  Недостатки: ну тут, думаю, всё очевидно — более скудная картинка и ограничения для художников.

Подробное рассмотрение всех этих решений выходит далеко за рамки этой статьи, поэтому ограничусь следующим: я предпочитаю использовать текстурные массивы на D3D11, и в случае D3D12 — родные средства этого API (разглашать которые мне не позволяет NDA).

Недостатки и ограничения

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

1. Оверхед, связанный с некоторым количеством мусорных вершин, которые необходимо отбрасывать (по сути — прятать).
2. Так называемый «оверхед на индирекцию» — доступ к вершинам, константам и индексам плохо поддаётся предсказуемому кешированию, так как адрес чтения каждый раз разный и вычисляется динамически. Индексированная геометрия — самый медленный вариант, потому что там двойная индирекция.
3. Подеррживаются только несколько видов топологий (TriangleList, PointList, и т.д.).
4. Очень и очень нетривиальная работа с текстурами, которую далеко не всегда получится решить для общего случая.
5. Пересоздание логических буферов — очень дорогая операция, поэтому если нужен активный стриминг этих данных, нужно переизобретать алгоритм выделения памяти на куче для случая логических буферов (из-за этого ещё и появится проблема фрагментации видеопамяти).
6. Нетривиальная работа с буферами и вершинным процессингом требует изобретения специальных механзимов для случая, когда, например, необходимо динамически генерировать вершины из вычислительных шейдеров (например, посчитать поверхность воды или симулировать физику тканей на GPU).
7. Нужно постоянно держать все данные логических буферов в оперативной памяти, это слегка увеличивает потребление памяти приложением.

Демо и исходники

В очень сыром и нечитабельном виде демка лежит на гитхабе: https://github.com/bazhenovc/sigrlinn/blob/master/demo/demo_grass.cc
Бинарной версии пока что нет, выложу позже.

Демка, 16384 уникальных кубика, 1.2ms, Intel HD 4400
pvp_i0 | Dynamic vertex pulling в Direct3D11

Демка, 4096 инстанса травы, 200к треугольников
Sigrlinn+D3D11+2015-01-28+17.05 | Dynamic vertex pulling в Direct3D11

Ссылки и литература

OpenGL Insights, III Bending the Pipeline, Programmable vertex pulling by Daniel Rákos - Не читал, но говорят, что тоже самое для OpenGL

Sigrlinn — Велосипедная библиотека для абстрагирования от графического АПИ, которая реализует в том числе и этот метод (ОСТОРОЖНО: код сырой и неюзабельный, не пытайтесь использовать дома)

Спасибо за внимание!

#Direct3D, #DirectX, #шейдеры

21 января 2015 (Обновление: 30 янв 2015)

Комментарии [38]