пятница, 13 апреля 2012 г.

Batch Drawing

Я решил потратить время и написать отдельную демку, которая бы показывала преимущество от рисования сцены батчами над традиционными методами рисования, в Direct3D 11. Общая идея уже была описана ранее, теперь дело стало за концептом.

Суть теста такова: он должен визуализировать большой массив объектов, каждый из которых имеет свою уникальную трансформацию. Программа должна реализовывать несколько способов отрисовки, чтобы можно было оценить прирост быстродействия по отношению к методу "в лоб". В идеале оптимизированный вариант должен всегда упираться в GPU.


Для простоты я взял один геометрический объект - Box, и размножил его в массив. На самом деле, как я уже показывал, можно использовать самую разную геометрию (если топология примитивов постоянна), но в данном случае это не принципиально. Вершины и нормали бокса хранятся в формате half, индексы - в ushort, при объединении в один буфер индексы переводятся в формат uint. Массив боксов имеет размеры 32x32 (1024 объекта), т. к. это максимальное количество матриц размерностью 4x4, которое может быть размещено в одном буфере констант (ограничение SM 4.0). Можно взять и больше, использовав, например, Buffer из SM 5.0 или даже текстуру для хранения констант, но я остановился на самом простом варианте. 

Массив боксов рисуется дважды - один раз в Z-only pass, затем в diffuse pass, т. е. делается 2K draw call-ов. Базовый способ отрисовки - по-объектный; оптимизированный - без изменения render states; наиболее быстрый - одним вызовом ID3D11DeviceContext::DrawIndexed(). Все они уже были описаны мною ранее.

После того, как была написана первая рабочая реализация, стало понятно, что в оптимизированных вариантах программа упирается уже не в API, а в расчёт матриц трансформации, т. е. всё равно в CPU. Например, в цикле для каждого объекта нужно рассчитать WVP матрицу и записать её в буфер констант:

XMMATRIX rot = build_rotation(p, y, r);
XMMATRIX trans = build_translation(x, y, z);
XMMATRIX world = rot * trans;
XMMATRIX world_view_proj = world * precomputed_view_proj;

Этот код CPU-bound, несмотря даже на то, что используются SSE-интринсики из XNA Math. Его оптимизация превратилась в увлекательное занятие. Первое, что можно тут сделать - не перемножать матрицы поворота и переноса, а просто записать вектор переноса в четвёртую строку матрицы поворота. Второе - не умножать на матрицу вида-проекции, а записывать в буфер констант сразу матрицу преобразования в мировые координаты. Матрица вида-проекции записывается в отдельный буфер констант в начале кадра, а в вершинном шейдере вместо одного умножения на матрицу последовательно выполняется два. Далее, в XNA Math матрица поворота из трёх углов на самом деле строится в два приёма: сначала строится кватернион поворота, а затем он конвертируется в матрицу 4x4, что неоптимально. Я решил записывать в константный буфер этот самый кватернион + вектор переноса, а в шейдере умножение на матрицу заменить умножением на кватернион и сложением с вектором переноса. Матрица 4x4 занимает 64 байт, тогда как пара кватернион/вектор занимает 32 байт, т. е. через шину PCI передаём в два раза меньше данных. В шейдере умножение на кватернион занимает 8 инструкций dxasm против 4-х для матрицы. Т. е. мы последовательно разгружаем CPU за счёт увеличения нагрузки на вершинный шейдер, но т. к. изначально программа CPU-bound и нужно сместить workload, то все эти методы оправданы. К слову, общее кол-во вершин в демке невелико и раздувшийся вершинный шейдер тут слабо влияет на скорость работы. В реальном случае со сложной геометрией потребление ресурсов GPU конечно будет расти быстрее.

После всех оптимизаций расчёт трансформаций перестал быть "бутылочным горлышком", даже использование кватернионов не дало значительного эффекта :) Это значит, что программа стала "упираться" в пересылку констант на GPU. В кавычки взято потому, что затрачиваемое время никак нельзя назвать значительным для такого кол-ва констант. Наиболее ощутимым эффект от оптимизации проявился на слабых системах, для примера возьмём мой ноутбук (процессор Intel® Celeron® M Processor ULV 723, видео AMD Radeon HD 4330):

per-object: 9.3 ms
w/o render state alternation: 1.85 ms
one batch: 1.33 ms
one batch w/ quaternions: 1.33 ms

Т. е. получилось ускорить вывод почти на порядок! Между вторым и третьим способом различия в производительности заключаются в десятых долях миллисекунды, что свидетельствует о том, что DIP cost в рантайме/драйвере незначителен в случае отсутствия изменений на конвейере. На мощных системах (процессвор Intel Core i5, видео GeForce GTX 560) выигрыш не так заметен, т. к. изначально даже неоптимизированный вариант занимает меньше 2 ms - нужна более сложная сцена:

per-object: 1.32 ms
w/o render state alternation: 0.34 ms
one batch: 0.19 ms
one batch w/ quaternions: 0.17 ms

Ссылка на демо: batchdraw.zip

Доступны два exe, один выполняется в окне, другой - в полноэкранном разрешении 1280x720.
Отрисовка текста сжирает большую часть времени, поэтому его можно прятать клавишей T.

понедельник, 9 апреля 2012 г.

У

Ох уж эта педаль сцепления!


суббота, 31 марта 2012 г.

DIP Cost Reduction

Часто такие способы отображения сцены как forward rendering и deferred lighting подвергаются критике за необходимость отрисовывать одну и ту же геометрию несколько раз. Например, в DL требуется отдельно заполнить depth/normal буфер, что увеличивает DIP count в два раза по сравнению с DS. В forward rendering-е практически обязательно делать отдельный early Z-pass... При большом кол-ве вызовов функции Draw*() программа легко может стать CPU-bound. Особенно это критично при портировании DirectX-программ на какой-нибудь Mac, где драйверы OpenGL значительно хуже оптимизированы для большого кол-ва вызовов.

Я задумался над оптимизацией early Z-pass с точки зрения нагрузки на CPU. Сразу необходимо отметить, что я не рассматриваю варианты, где возможно применение инстансинга, речь пойдёт о выводе различной геометрии с топологией triangle list. Ключевым преимуществом является то, что в early Z-pass  изменения render states минимальны, типичный сценарий отрисовки примерно такой:
for each object
{
UpdateMatrix();
SetVertexBuffer();
SetIndexBuffer();
DrawGeometry();
}
Легко видеть, что между вызовами Draw() меняются только константы (матрица WorldViewProj) и вершинный/индексный буферы. Это можно оптимизировать, если объединить все геометрические данные в одном буфере. В Direct3D 10/11 с помощью параметров StartIndexLocation и BaseVertexLocation метода ID3D11DeviceContext::DrawIndexed() можно управлять, какую порцию вершинных данных необходимо отрисовать. Тогда оптимизированный вариант может выглядить так:
SetBigVertexBuffer();
SetBigIndexBuffer();
for each object
{
UpdateMatrix();
DrawGeometry();
}
Как мешают эти матрицы! А что, если обновлять матрицы не поодиночке, а сразу массив матриц для всех объектов? Тогда возникает вопрос, каким образом применить трансформацию индивидуально к каждому объекту? К сожалению, в Direct3D из шейдера невозможно узнать номер вызова отрисовки (по примеру SV_InstanceID в режиме инстансинга), но можно ценой дополнительных усилий организовать это вручную. Допустим, нам нужно отрисовать три объекта, каждый из которых имеет 3, 4 и 8 вершин. Создадим дополнительный вершинный буфер, атрибуты которого содержат ID объекта. Если мы объединим геометрию последовательно, то дополнительный буфер должен иметь вид:
000111122222222
Если подсоединить этот буфер на дополнительный IA-слот, то в шейдере станет возможным выбирать матрицу трансформации по ID из этого буфера. Теперь оптимизированный вариант рендеринга будет выглядить так:
UpdateMatricesForAllObjects();
SetBigVertexBuffer();
SetMeshIDVertexBuffer();
SetBigIndexBuffer();
for each object
{
DrawGeometry();
}
В итоге, в цикле остались только непосредственно вызовы метода рисования. Direct3D runtime оптимизирован в том смысле, что DIP cost зависит от количества изменений на конвейере, т. е. чем меньше изменений с момента предыдущего DIP, тем дешевле следующий за ним. В последнем варианте наиболее тяжёлым будет только первый вызов, все остальные - значительно легче (насколько именно - зависит от вендора и версии драйвера).
Присутствие цикла объясняется тем, что в кадре обычно видна только часть всех объектов сцены, поэтому необходимо определять их видимость из виртуальной камеры, заносить в отдельный список и рисовать объекты только из этого списка. Если специфика игры такова, что все объекты видны в камере, цикл можно заменить единственным(!) вызовом, подготовив отдельный буфер индексов и указав IndexCount для всех объектов. Если от цикла нельзя избавиться, можно попробовать сократить кол-во его итераций: например, если какие-то объекты видны в камере и одновременно лежат в буфере линейно, вызовы отрисовки для каждого из них можно объединить в один (этакий DIP-merge).

Для практической проверки всех этих мыслей на коленке была написана демка. Она показывает четыре объекта с разной геометрией, каждый из которых имеет свою трансформацию:


Ссылка на демо: dipreduc.exe

P.S. Поразмышлять на тему оптимизации меня побудил следующий вброс: DX11 vs GL Driver Overhead
где просто предлагается сделать 100000 DIP-ов чтобы прочувствовать мощь расширений NVidia :)

понедельник, 26 марта 2012 г.

Fast Ray-BV Intersection

Когда-то давно, ещё в 2009, я написал несколько функций для теста пересечения луча и ограничивающего объёма (сфера, эллипс, коробка). Эти функции могут быть полезны, например, для оптимизации трассировки лучей в пиксельном шейдере. Идея была в том, чтобы реализовать действительно оптимальные варианты функций для каждого ограничивающего объёма. Спустя несколько лет я вернулся к этим кодам и после тщательного анализа мне удалось найти способ сократить эти функции ещё на одну-две инструкции! Теперь я уверен, что реализованный функционал действительно близок к эталонному. Тогда же я решил сделать реализацию open source, т. к. сам в прошлом потратил много времени на гугл, пытаясь найти что-нибудь подходящее - увы, многие коды были громоздкими, неоптимальными и часто даже не на С/С++.

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

Кстати, если сможете написать оптимальнее - дайте знать! :)
Ссылка на пример с исходным кодом на Google Code:

raybv.zip

Удачного рэйтрейсинга!

вторник, 13 марта 2012 г.

Crinkler Edition

Обновил немного код своих демок: некоторые из них не компилировались c однобайтовыми (char) строками, были другие мелкие погрешности. Из Release конфигурации решено было исключить CRT и всякие buffer security checks, т. к. реально из этого почти ничего не использовалось, а довесок в .exe получается существенным - кода из CRT вставлялось больше, чем было самого кода программы.

Также я немного поигрался с разными упаковщиками .exe файлов и выбрал Crinkler, как наиболее эффективный. Он работает на стадии компоновки и может быть использован вместо стандартного студийного link.exe. Теперь размеры .exe файлов демок занимают не 20-30 Кб, а 3-10 кб. На слабых системах с HDD диском такие программы "на глазок" стартуют легче и быстрее.

Правда, с упаковкой надо быть осторожным, т. к. время распаковки может длиться дольше, чем загрузка .exe бОльшего размера :)

четверг, 8 марта 2012 г.