Level of Detail в Unity BRG

Level of Detail в Unity BRG

Lizzy Fox - https://t.me/infinity_world_developer_diary
Изображение из Unity Blog по BRG.

Что такое BRG

Для того, чтобы описать что же такое Batch Renderer Group (BRG) нужна отдельная статья, поэтому тут я только кратко расскажу про основные его принципы и больше расскажу про интеграцию поддержки Level of Detail (LOD).

Batch Renderer Group (далее BRG), если обратиться к официальной документации Unity, это API для высокопроизводительного пользовательского рендеринга в проектах, которые используют Scriptable Render Pipeline (SRP).

Что дает BRG на практике? Самое главное - контроль за тем, как и что у нас рисуется. Это бывает полезно, когда мы хотим рисовать много, очень много одинаковых объектов: тысячи, десятки и сотни тысяч и даже миллионы. И хотим рисовать это максимально быстро. Как пример - трава, или детали окружения, то есть все то, для чего можно очень быстро посчитать матрицы трансформаций (например в Job System) и отправить на отрисовку.

Чем отличается BRG от Graphics.DrawMeshInstanced? Тем, что можно сгруппировать все инстансы и отбраковать (culling) те, которые не видны нам из камеры, тем способом, который нам больше всего подходит. Фактически, BRG - это SRP Batcher с "ручным управлением", внутри которого все тот же GPU-instancing. Но как и все в этом мире, "ручное управление" имеет не только плюсы, но и минусы. Главный же минус, который я со временем выяснила на практике (после вдохновения "ох я сейчас как сделаю супер-пупер быструю реализацию"), заключается в том, что все фичи, которые доступны в Unity из коробки с древнейших времен, придется реализовывать самой. А это, как вы понимаете, требует не только времени, но и определенных знаний и навыков.

И если базовая реализация BRG инструмента заняла примерно одну рабочую неделю и прошла достаточно просто, так как есть множество материалов от Unity, а также описана документация, то расширение пошло со скрипом, так как углубляться в API и графический пайплайн пришлось уже сильнее.



Level of Details

Что такое LOD?

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

LOD решает сразу несколько задач в играх:

  • уменьшение количества треугольников в кадре;
  • уменьшение плотности треугольников per pixel;
  • уменьшение качества и сложности материалов для далеких объектов.

Когда мы рисуем объекты через GPU-instancing, то обычно не решаем подобные задачи: хорошо, хоть объекты рисуются без GameObject. Но в трехмерных играх часто этим проблемы появляются, и тут нам на помощь приходит BRG.


[вернуться в начало статьи]


Level of Detail в BRG


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

var visibleIndices = new NativeList<int>(maxInstanceCountPerBatch * 2, Allocator.TempJob);
Умножаем maxInstanceCountPerBatch на 2 по той причине, что на каждый инстанс может быть 2 видимых лода в один и тот же момент (при cross fade).


Теперь его необходимо заполнить. В базовом случае мы проводим операцию отбраковывания (culling) и все индексы, которые видны нам из камеры, добавляем в список. Это можно сделать с помощью IJobFilter в Job Systems, например Frustum Culling будет выглядеть так:

for (var i = 0; i < CullingPlanes.Length; i++)
{
    var plane = CullingPlanes[i];
    var normal = plane.normal;
    var distance = math.dot(normal, aabb.Center) + plane.distance;
    var radius = math.dot(aabb.Extents, math.abs(normal));

    if (distance + radius <= 0)
        return false;
}

return true;


Но в случае с LOD, нам необходимо вначале определить, какой конкретно уровень LOD может рисоваться для каждого инстанса. Потому что от выбранного LOD зависит Mesh, а от него зависит размер AABB (Axis-Aligned Bounding Box) для операции отбраковывания. Если мы выберем неправильный размер, то можем получить неправильное поведение, когда объект вроде бы должен быть виден, но при этом отбраковывается.


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

var distance = math.select(LODParams.DistanceScale * math.length(LODParams.CameraPosition - position), LODParams.DistanceScale, LODParams.IsOrthographic);
Стоит заметить что камера может быть как перспективной, так и ортогональной. В первом случае у нас есть искажение, которое необходимо учитывать, а во втором случае фактическая дистанция не имеет значения.


Далее, полученную позицию мы сравниваем с диапазоном каждого LOD, и если попадаем в диапазон, значит находимся на этом уровне, если вообще не попадаем, то объект находится слишком далеко и нам не виден (Culled LOD).

Но что такое диапазон LOD? В обычном виде, это минимальная и максимальная дистанции, при которых данный конкретный LOD рисуется. Но представьте, что у вас есть маленький объект вдалеке, но при этом подходящий под один из диапазонов LOD. Чтобы решить задачу "уменьшение плотности треугольников per pixel" нам необходимо этот объект максимально упростить, а то и скрыть. А значит, нам необходимо и тут учитывать размер объекта.

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

var d = worldSpaceSize / lodDescription.LodDistances[i];
worldSpaceSize тут максимальный размер по одной из оси, поэтому d - просто число с плавающей запятой. Причем d - тоже в world space, и обозначает относительную дистанцию.


Сравнив с каждым диапазоном дистанцию до инстанса, мы получаем индекс LOD, который может от 0 до N, где N - количество LOD. Больше 8 количество LOD не имеет смысла, так как очень сильно повышает стоимость контента. Количество инстансов тоже не может быть больше десятков миллионов, но под instanceID выделяется целое число со знаком (int) с размером в 4 байта. Из этих 4 байт используется 2-3 байта, а 4-й - нет. Вот его можно взять, чтобы временно сохранить индекс LOD.

var instanceIndex = index & 0x00FFFFFF;
instanceIndex |= lod << 24;
Тогда, во время отбраковки, чтобы получить данные меша, мы получаем LOD из instanceIndex также из последних 8 бит: instanceIndex >> 24.


Все, что нам остается сделать после Frustum Culling, так это отсортировать список индексов по их LOD и создать команды отрисовки, учитывая то, что первые N индексы относятся к LOD0, вторые K - к LOD1, третьи - к LOD2 и т.д.


Пример выборки LOD, где каждый цвет кубика означает тот или иной LOD.

[вернуться в начало статьи]


LOD Cross Fade

Если мы хотим плавно смешивать LOD, то для каждого инстанса необходимо посчитать значение коэффициента видимости. И в случае, если оказывается, что один LOD смешивается с другим, то добавить дублирующий индекс в список индексов, но уже с другим LOD, чтобы потом создать и для него draw-команду.

В Unity для LOD уже есть отдельная техника - Cross Fade, она основана на эффекте Dithering. В SRP также есть стандартный Shader Pass для Cross Fade.

Чтобы в BRG включить возможность смешивать LOD, необходимо создавать BatchDrawCommand с флагом BatchDrawCommandFlags.LODCrossFade.

К сожалению, какой-либо документации по Cross Fade я не нашла, поэтому пришлось опытным путем выяснять, что и куда необходимо указывать.

Если обратиться к документации по LOD, то можно узнать, что переход от одного LOD к другому определен зоной перехода вот такого вида:

Cross-fading между одного LOD к другому

Логично предположить, что коэффициент видимости скорее всего значение от 0 до 1, но если заглянуть в xml-документацию BatchDrawCommandFlags, то можно увидеть вот такое описание:

The draw command has instances that have an 8-bit crossfade dither factor in the highest bits of their visible instance index.

То есть нам выделяют самые старшие 8 бит из instanceID (в которые мы временно также пишем значение LOD). А раз это 8 бит, то есть 1 байт, то значение должно быть в диапазоне от 0 до 255.

После множества попыток, оказалось, что это тоже не совсем так. Пришлось смотреть реализацию Unity GPU Driven Rendering. И оказалось, что диапазон от 0 до 255 разделяется на два участка: от 0 до 126 - fade out, от 126 до 254 - fade in. Причем подаваемое значение для упаковки должно быть от -1 до 0 для fade out и от 0 до 1 для fade in.

// float [-1.0f... 1.0f] -> uint [0...254]
private static uint PackFloatToUint8(float percent)
{
    var packed = (uint)((1.0f + percent) * 127.0f + 0.5f);
    // avoid zero
    if (percent < 0.0f)
        packed = math.clamp(packed, 0, 126);
    else
        packed = math.clamp(packed, 128, 254);
    return packed;
}

Но, окончательное значение должно быть переведено в новый диапазон: от -127 до -1 - fade out, 0 - у нас исходный LOD, от 1 до 127 - fade in.

var fadeValue = (int)math.select(PackFloatToUint8(-rawLodFade.Value), PackFloatToUint8(rawLodFade.Value),
    rawLodFade.Lod == lod);
fadeValue -= 127;

Упаковка в instanceID уже проста, как и раньше, в случае с индексом LOD:

instanceIndex |= fadeValue << 24;


А как же считать само значение? Тут все достаточно просто. У каждого LOD должно быть значение Fade Width, которое будет определять диапазон, в котором данный LOD начинает скрываться (и следующий показываться соответственно):

var fadeDistance = math.max(0.0f, lodRange.MaxDistance - lodRange.MinDistance) * LodDescription.FadeWidth[i];

И также необходимо посчитать оставшуюся дистанцию до полного скрытия:

var diff = math.max(0.0f, lodRange.MaxDistance - distance);

А дальше высчитываем значение коэффициента видимости:

fadeValue = math.saturate(math.sin(diff / fadeDistance * math.PI * 0.5f));
Стоит заметить, что самый простой способ - diff / fadeDistance, синусоида в примере используется только для большего сглаживания.


Пример Cross Fade. Видно, как смешиваются LOD с разными цветами.

[вернуться в начало статьи]

Report Page