Практически в каждой игре необходимо наполнять игровые уровни объектами, которые создают визуальное богатство, красоту и вариативность виртуального мира. Возьмите любую игру с открытым миром. Там деревья, трава, земля и вода основные «заполнители» картинки. Сегодня GPGPU будет совсем немного, но я попробую рассказать, как нарисовать в кадре много деревьев и камней, когда нельзя, но очень хочется.
Сразу следует оговориться, что у нас маленькая инди девстудия, и ресурсов вырисовывать и вымоделивать каждую мелочь часто нету. Отсюда и требование к различным подсистемам, быть «надстройкой» над уже готовым функционалом движка. Так было и в первой статье цикла про анимации (там мы использовали и ускоряли уже готовую систему анимации Unity), так будет и тут. Это сильно упрощает внедрение нового функционала в игру (меньше учиться, меньше багов, etc).
Итак, задача: надо нарисовать много леса. Игра у нас стратегия реального времени (RTS) с большими уровнями (30х30 км), и это задает основные требования к системе отрисовки:
- С помощью миникарты мы можем мгновенно перенестись в любую точку уровня. И данные про объекты для новой позиции должны быть готовы. Мы не можем как в FPS или TPS играх полагаться на подгрузку ресурсов через какое-то время.
- Объектов на таких больших уровнях необходимо действительно большое количество. Сотни тысяч, если не миллионы.
- Опять же, большие уровни делают очень долгой и сложной процедуру расстановки «лесов» в ручном режиме. Необходима процедурная генерация леса, камней и кустов, но с возможностью ручной корректировки и расстановки в ключевых местах игрового уровня.
Как можно решить такую задачу? Такое количество обычных расставленных объектов юнити все равно не потянет. Умрем в куллинге и батчинге. Рендер возможен с помощью инстансинга. Надо писать систему управления. Деревья надо моделлить. Систему анимаций деревьев надо делать. Ох. Хочется красиво и сразу. Есть SpeedTree, но нет апи для анимаций, вид сверху на билборды ужасен, так как нет «horizontal billboard» и документация скудновата. Но когда нас такое останавливало? Будем оптимизировать SpeedTree рендер.
Рендеринг
Посмотрим для начала, так ли все плохо с обычными speedtree объектами:
Вот около 2000 деревьев на сцене. С рендером все в порядке, там instancing объединил деревья в батчи, а вот с CPU все плохо. Половина времени рендера камеры — это куллинг. А нам надо сотни тысяч. Однозначно отказываемся от GameObject'ов, но теперь нам надо расковырять структуру модельки SpeedTree, механизм переключения LOD'ов и сделать все ручками.
Дерево SpeedTree состоит из нескольких LODов (обычно 4), последний из которых billboard, а все остальные — геометрия различной степени детализации. Каждый из них состоит из нескольких сабмешей, со своим материалом:
Это не специфика именно SpeedTree. Такую структуру может иметь любой объект. Переключение LODов реализовано двумя доступными режимами:
- Cross Fade:
- Speed Tree:
CrossFade (в терминах шейдеров Unity он определяется define'ом препроцессора LOD_FADE_CROSSFADE) — основной метод переключения LOD для любых объектов сцены с несколькими уровнями детализации. Он заключается в том, что когда происходит смена LOD, меш, который должен исчезнуть, не просто исчезает (так будет отчетливо виден скачок качества модели), а «растворяется» на экране с помощью dithering'a. Простой эффект, а позволяет избежать использования настоящей прозрачности (alpha blending). Модель, которая должна появиться, абсолютно таким же образом «проявляется» на экране.
SpeedTree (LOD_FADE_PERCENTAGE) же сделан специально для деревьев. В геометрию листьев, веток и ствола, помимо основных координат, записаны еще и дополнительные координаты позиции вершин младшего по отношению к текущему LOD уровня. Степень перехода из одного уровня в другой и есть значение веса для линейной интерполяции этих двух позиций. Переход в/из billboard происходит с помощью CrossFade метода.
В принципе, это все, что надо знать для реализации собственной системы переключения LOD'ов. Сам рендеринг прост. Проходимся в цикле по всем типам деревьев, по всем LOD'ам и по всем сабмешам каждого LOD'a. Устанавливаем соответствующий материал, и отрисовываем все экземпляры данного объекта одним махом с помощью инстансинга. Таким образом, количество DrawCall равно количеству уникальных объектов на сцене. Как мы знаем, что рисовать? В этом нам поможет
Генератор Леса
Сама высадка проста и неприхотлива. Для каждого типа дерева разбиваем мир на квады так, чтобы в каждый помещалось одно дерево. Пробегаемся по всем квадам и проверяем маску вида:
в данной точке уровня, а можно ли садить тут дерево? Маску, c «лесистыми» местами, рисует level designer. Сначала все это дело было на CPU и C#. Генератор работал неспешно, а размеры уровней росли так, что ждать перегенерации по несколько десятков минут стало напряжно. Решено было перенести генератор на GPU и Compute шейдера. Тут тоже все просто. Нам нужна heightmap земли, маска посадки деревьев и AppendStructuredBuffer, куда мы добавляем сгенерированные деревья (позиция и ID, вот и все данные).
Расставленные руками деревья в ключевых точках специальный скрипт подтягивает в общие массивы и удаляет оригинальный объект со сцены.
Culling & LOD switсhing
Знать позицию и тип дерева недостаточно, чтобы сделать эффективный рендеринг. Надо каждый кадр определять, какие объекты видны и какой LOD (учитывая логику переходов) отправлять на рендер.
Этим также будет заниматься специальный сompute shader. Для каждого объекта сначала выполняется Frustum Culling:
Если объект видим, то далее выполняется логика переключения LOD. По размеру на экране определяем нужный LOD level. Если для LOD группы задан режим CrossFade, то инкрементируем время перехода для dithering'a. Если SpeedTree Percentage, то считаем нормализированное значение перехода между LOD'ами.
В современных графических АПИ есть замечательные функции, позволяющие draw submission информацию передавать в вызов отрисовки в compute buffer (например ID3D11DeviceContext::DrawIndexedInstancedIndirect для D3D11). Это значит, что заполнять этот compute buffer можно и на GPU. Таким образом получается сделать полностью CPU независимую систему (ну только Graphics.DrawMeshInstancedIndirect вызвать). В нашем случае необходимо записать только количество инстансов каждого сабмеша. Остальная информация (количество индексов в меше и смещения) статична.
Compute buffer, с аргументами для draw call разбиваем на секции, каждая из которых отвечает за вызов отрисовки своего submesh. В сompute shader'e для меша, который должен быть отрисован в текущем кадре, инкрементируем соответствующее InstanceCount значение.
Вот как это выглядит в рендере:
GPU occlusion culling — очевидный следующий шаг, но для RTS с такой камерой, и не очень большой холмистостью выигрыши не так очевидны (а для заинтересованных вот и вот). Я пока не сделал.
Чтобы все корректно рисовалось, надо немного подправить SpeedTree'шные шейдера, чтобы брать позицию и значения для переходов между LOD из соответствующих compute буферов.
Теперь у нас рисуются красивые, но статичные деревья. А на SpeedTree деревья реалистично влияет ветер, анимируя их. Вся логика таких анимаций находится в файле SpeedTreeWind.cginc, но никакой документации, ни доступа к внутренним параметрам из Unity нет.
CBUFFER_START(SpeedTreeWind)
float4 _ST_WindVector;
float4 _ST_WindGlobal;
float4 _ST_WindBranch;
float4 _ST_WindBranchTwitch;
float4 _ST_WindBranchWhip;
float4 _ST_WindBranchAnchor;
float4 _ST_WindBranchAdherences;
float4 _ST_WindTurbulences;
float4 _ST_WindLeaf1Ripple;
float4 _ST_WindLeaf1Tumble;
float4 _ST_WindLeaf1Twitch;
float4 _ST_WindLeaf2Ripple;
float4 _ST_WindLeaf2Tumble;
float4 _ST_WindLeaf2Twitch;
float4 _ST_WindFrondRipple;
float4 _ST_WindAnimation;
CBUFFER_END
Как бы нам выковырять их? Для этого, для каждого типа дерева отрендерим оригинальный SpeedTree объект где-нибудь в невидном месте (вернее в видном для Unity, но не видном в камере, а то параметры не будут обновляться). Этого можно добиться сильно увеличив bounding box, и поместив объект за камеру). Каждый кадр снимаем нужный набор значений с помощью material.GetVector(...).
Так, деревья колыхаются на ветру, но вид сверху на билборды удручающий:
С шейдер вариантом BILLBOARD_FACE_CAMERA_POS еще хуже:
Нам нужны horizontal (top-down) billboards. Это стандартная фича SpeedTree со времен царя Гороха, но, судя по форумам, до сих пор не реализованная в Unity. Пост с официального форума SpeedTree: «The Unity integration never used the horizontal billboard». Будем прикручивать руками. Саму геометрию сделать несложно. Как узнать UV координаты спрайта в атласе для нее?
Достаем старенький SpeedTreeRT SDK, и находим в документации структурку:
struct SBillboard
{
bool m_bIsActive;
const float* m_pTexCoords;
const float* m_pCoords;
float m_fAlphaTestValue;
};
«m_pTexCoords points to a set of 4 (s,t) texture coordinates that define the images used on the billboard. m_pTexCoords contains 8 entries.», говорится в ней на иностранном языке. Что ж, будем в бинарном spm файле искать последовательность из 4-х floating point значений, каждое из которых лежит в диапазоне [0..1]. Методом научного тыка выясняем, что нужная последовательность находится перед блоком из 12 float cо знаками соответствующими паттерну:
float signs[] = { -1, 1, -1, 1, 1, -1, 1, 1, 1, -1, 1, 1 };
Пишем небольшую консольную утилиту на плюсах, которая перебирает все spm файлы, и в них ищет uv координаты для горизонтальных билбордов. На выходе получается такая себе CSV табличка:
Azalea_Desktop.spm: 0, 1, 0.333333, 1, 0.333333, 0.666667, 0, 0.666667,
Azalea_Desktop_Flowers_1.spm: 0, 1, 0.333333, 1, 0.333333, 0.666667, 0, 0.666667,
Azalea_Desktop_Flowers_2.spm: 0, 1, 0.333333, 1, 0.333333, 0.666667, 0, 0.666667,
Leaf_Map_Maker_Desktop_1_Modeler_Use_Only.spm: Pattern not found!
Leaf_Map_Maker_Desktop_2_Modeler_Use_Only.spm: Pattern not found!
BarrelCactus_Cluster_Desktop_1.spm: 0, 0.592376, 0.407624, 0.592376, 0.407624, 0.184752, 0, 0.184752,
BarrelCactus_Cluster_Desktop_2.spm: 0, 1, 0.499988, 1, 0.499988, 0.500012, 0, 0.500012,
BarrelCactus_Desktop_1.spm: 0, 0.2208, 0.220748, 0.2208, 0.220748, 5.29885e-05, 0, 5.29885e-05,
BarrelCactus_Desktop_2.spm: 0, 1, 0.301392, 1, 0.301392, 0.698608, 0, 0.698608,
Для назначения текстурных координат геометрии horizontal billboard'у, находим нужную запись и парсим ее.
Теперь стало вот так:
Все еще не очень. Будем фейдить с помощью alpha test threshold вертикальный билборд, в записимости от угла к камере:
Итоги
Профайлер, показывающий динамическую (сколько всего чего рендерится) и статическую (сколько всего на сцене объектов и их параметры) статистику:
Ну и финальное красивое видео (во второй половине показаны переключения quality levels):
Что имеем в итоге:
- Система полностью CPU независима.
- Работает быстро.
- Использует готовые SpeedTree ассеты, коих можно купить в интернете.
- Конечно, я ее подружил с любыми LODGroup, а не только SpeedTree. Так что много камушков теперь тоже можно.
Из недостатков можно отметить отсутствие occlusion culling и таки не очень выразительные билборды.
Автор: Роман Марченко