Данная статья рассказывает про то, как можно рисовать миллионы уникальных объектов в 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, в которой хранятся абсолютно все вершины или индексы, которые необходимы для рисования всей геометрии этим способом.
Логический буфер: представляет собой структуру данных, состоящую из смещения в физическом буфере и размера одного блока данных.
Эти два понятия легко проиллюстрировать на следующей картинке:
В 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 — смещение в физическом буфере, по которому находятся данные этого буфера. Это поле заполняется при перестроении физического буфера(об этом ниже).
При создании логического буфера физический буфер должен знать про логический буфер и создать место для хранения данных.
Класс физического буфера выглядит следующим образом:
physicalBuffer — буфер, где лежат вершины или индексы.
physicalBufferView — shader resource view для доступа к данным из шейдера.
physicalDataSize — размер физического буфера в байтах.
isDirty — флажок, который показывает необходимость обновления буфера (буфер необходимо обновлять после каждой аллокации/деаллокации логического буфера).
allPages — все логические буферы, аллоцированные в этом физическом буфере.
При каждом создании и удалении логических буферов необходимо информировать об этом физический буфер. Операции allocate/release тривиальны и в дополнительных пояснениях не нуждаются:
Гораздо интереснее рассмотреть функцию 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 herereturn;
}
Обязательно нужно проследить, что StructureByteStride совпадает со структурой, которую пытается читать вершинный шейдер. Также нужна возможность записи в буфер со стороны CPU.
После этого нужно создать shader resource view, это тривиальная операция:
Теперь перейдём непосредственно к заполнению физического буфера. Алгоритм следующий:
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 herereturn;
}
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 (речь идёт о десятках мегабайт данных). Поэтому не рекомендуется перестраивать физический буфер на каждый чих.
Хранение и рисование геометрии таким способом подразумевает также и нетривиальное управление константами, о котором сейчас пойдёт речь.
Управление константами для каждого объекта
Традиционные константные буферы в данной ситуации не подходят по очевидным причинам. В связи с этим не остаётся другого выбора, кроме как использовать ещё один глобальный буфер, аналогичный физическому буферу, описанному выше.
Помимо этого туда необходимо передать информацию про логические вершинный и индексный буферы для текущего инстанса, тип рисуемой геометрии (индексированная или нет) и количество вершин.
Создание такого буфера ничем не отличается от создания описанного выше физического буфера, поэтому останавливаться подробно на нём не буду.
При заполнении этого буфера первые 4 32 битных регистра я использую для служебной информации шейдера (для считывания данных из физического буфера). Также, для упрощения кода примера, я подразумеваю что константы в шейдере всегда имеют размер 2048 (разумеется в реальной жизни надо будет это ограничение убрать).
После этих 4 чисел идут обычные константы, такие как матрица проекции, которые необходимы для рисования обычной геометрии.
Сейчас небольшое лирическое отступление. Я напрямую ничего не рисую, вместо этого я использую вот такую структуру, которая представляет собой 1 вызов отрисовки. Сюда же идут и константы и всё остальное, необходимое для рисования.
Для примера я сильно упростил эту структуру, чтобы не утомлять читателя лишними подробностями.
Приложение заполняет массив этих структур, используя примерно следующий набор функций, на которых подробно останавливаться также не буду (обычный буфер команд, что с него взять-то?)
После создания буфера команд необходимо скопировать в глобальный буфер с константами все константы из буфера команд, после чего заполнить InternalData и, собственно, нарисовать всё это безобразие.
Обновление констант тривиальное, просто проходим циклом по буферу команд и копируем нужные данные в нужное место:
Всё, теперь данные готовы для непосредственной отрисовки средствами D3D!
Шейдер и как это вообще рисовать?
После того, как данные были подготовлены, наступает время рисования. Для этого просто выставляем шейдеру физические буферы и глобальные константные буферы и делаем DrawInstanced:
Почти готово! Осталось объяснить некоторые важные моменты.
1. DrawInstanced надо вызывать с максимальным количеством вершин, которое есть в буфере команд.
Это необходимо потому, что вызов отрисовки у нас 1, и если у мешей разное количество вершин или индексов — это нужно как-то учитывать. Я предпочитаю всегда рисовать наибольшее количество вершин, а лишние отсекать, отправляя их за clipping plane.
Таким образом появляется много дополнительной ненужной нагрузки на вершинный шейдер, поэтому надо следить за тем, чтобы разница между минимальным и максимальным vertex count была в разумных пределах (500—600 вершин). Нужно помнить, что эта разница равна количеству «мусорных» вершин на каждый инстанс, и она растёт со скоростью геометрической прогрессии в квадрате. Следите за художниками!
2. Одного вызова DrawInstanced достаточно, чтобы рисовать как индексированную так и не индексированную геометрию, потому что вся работа с вершинами делается руками в шейдере.
3. Топологии вроде TriangleStrip, TriangleFan и им подобные — не поддерживаются по очевидным причинам. Поддерживаются только топологии с изолированными примитивами (TriangleList, PointList, *List).
Вершинный шейдер, который считывает вершины с индексами и обрабатывает их, не должен вызывать особых сложностей.
В первую очередь надо продублировать в шейдере все структуры, которые используются со стороны CPU (структура вершины, структура константы и т.д.):
Дальше код, который считывает константы и служебные данные из буферов и обрабатывает вершины (обратите внимание на то, как реализованы индексированный и неиндексированный режимы):
Как видно, всё очень просто и понятно. Для справки привожу полный код шейдера
Опытный читатель мог заметить, что я ничего не сказал про текстуры. Про это следующая часть.
А что делать с текстурами?
Это самый серьёзный недостаток предлагаемого метода. При подобном подходе хочется также иметь уникальные текстуры для каждого инстанса, но в 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. Нужно постоянно держать все данные логических буферов в оперативной памяти, это слегка увеличивает потребление памяти приложением.
Sigrlinn — Велосипедная библиотека для абстрагирования от графического АПИ, которая реализует в том числе и этот метод (ОСТОРОЖНО: код сырой и неюзабельный, не пытайтесь использовать дома)