Пример BatchRendererGroup: Достигайте высокой частоты кадров даже на бюджетных устройствах

ARNAUD CARRÉ / UNITY TECHNOLOGIESContributor
Oct 3, 2023|15 Мин
Пример BatchRendererGroup: Достигайте высокой частоты кадров даже на бюджетных устройствах
Эта веб-страница была переведена с помощью машинного перевода для вашего удобства. Мы не можем гарантировать точность или надежность переведенного контента. Если у вас есть вопросы о точности переведенного контента, обращайтесь к официальной английской версии веб-страницы.

В этом посте мы опишем небольшой пример игры-шутера, который анимирует и рендерит несколько интерактивных объектов. Многие демонстрационные ролики предназначены только для высокопроизводительных ПК, но здесь мы хотим добиться высокой частоты кадров на бюджетном телефоне с помощью GLES 3.0. В этом примере используются BatchRendererGroup, компилятор Burst и система заданий C#. Он работает в Unity 2022.3 и не требует пакетов DOTS entities или entities.graphics.

Давайте начнем.

Представляем образец

Давайте сразу перейдем к тому, что представляет собой образец. Этот образец работает на стабильных 60 кадрах в секунду на бюджетном Samsung Galaxy A51 2019 года (с графическим процессором Mali G72-MP3). Графический API установлен на GLES 3.0.

Вы можете изучить код и попробовать его на своей любимой платформе, скачав проект с GitHub. Вам понадобится только стоковая версия Unity 2022.3.

В этом посте мы в основном сосредоточимся на BatchRendererGroup и примере класса BRG_Container.cs. Вы также можете изучить код анимации и физики в классах BRG_Background.cs и BRG_Debris.cs.

Обстановка

Давайте изучим то, что мы видим, прежде чем углубляться в то, как его сделать.

  • Фоновый пол построен из множества кубиков. Все ящики анимированы и двигаются вверх и вниз.
  • Главный корабль перемещается по экрану горизонтально и стреляет ракетами по цветным сферам. (Вы можете запускать ракеты быстрее, нажимая на экран).
  • Когда ракета пролетает над полом, магнитное поле слегка приподнимает и высвечивает ячейки пола. Он также подбрасывает в воздух обломки земли.
  • Когда ракета попадает в сферу, она взрывается, превращаясь в разноцветные обломки.
  • Когда мусор падает на пол, сталкивающаяся клетка на полу вспыхивает белым цветом. Чем больше мусора попадает в клетку, тем сильнее темнеет ее цвет. Кроме того, вес обломков приводит к образованию вмятин в земле.
Рендеринг

И клетки пола, и мусор состоят из кубиков. Каждый кубик имеет свое положение и цвет. Мы хотим анимировать и управлять всем с помощью процессора, чтобы упростить взаимодействие между полом и мусором. (Обломки - это не просто косметический визуальный эффект, поэтому их нельзя сделать только с помощью GPU).

Для рендеринга мы не создаем GameObject для каждого элемента, чтобы избежать ненужного падения производительности на мобильных устройствах низкого класса. Вместо этого мы используем недавно появившийся API BatchRendererGroup.

Почему бы не использовать классический Graphics.DrawMeshInstanced?

Graphics.DrawMeshInstanced - это удобный и быстрый способ отрисовки множества одинаковых сеток в разных позициях. Однако он имеет следующие ограничения по сравнению с API BatchRendererGroup:

  • Это требует предоставления управляемого массива памяти с матрицами, поэтому вы можете столкнуться со сбором мусора. Кроме того, инвертированные матрицы вычисляются процессором, даже если шейдеру это не нужно (например, при использовании URP/unlit).
  • Если вы хотите настроить любое свойство, кроме матрицы obj2world (например, один цвет на экземпляр), вам нужно создать свой собственный шейдер, написав его с нуля или используя Shader Graph.
  • Матричные или пользовательские данные должны загружаться в память GPU при каждом рисовании. Вы не можете иметь постоянные данные в памяти GPU с Graphics.DrawMeshInstanced. В зависимости от контекста, это может быть огромным снижением производительности.
Что такое BatchRendererGroup?

BatchRendererGroup (или BRG) - это API, который позволяет эффективно генерировать команды рисования из C# и создавать вызовы рисования с поддержкой GPU. Поскольку он не использует управляемую память, вы также можете генерировать команды с помощью компилятора Burst.

Изображение
Совет: Пакет entities.graphics предназначен для рендеринга сущностей (пакет ECS) и построен поверх BRG. entities.package выполняет за вас все управление памятью GPU и создание оптимальных команд рисования. В этом примере мы не используем ECS, поэтому будем управлять непосредственно BRG.
Модель данных шейдеров BRG

BRG использует особую компоновку данных GPU и специальный вариант шейдеров. Вариант шейдера может получать данные из стандартного константного буфера (UnityPerMaterial) или из пользовательского, большого буфера GPU (BRG raw buffer). Вы сами решаете, как хранить данные в необработанном буфере, который представляет собой Shader Storage Buffer Object (SSBO, или буфер байтовых адресов). По умолчанию в BRG используется структура массивов (SoA).

Свойства для каждого экземпляра - или нет

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

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

Метаданные BRG

Метаданные BRG - это необязательное 32-битное значение, которое можно задать для каждого свойства шейдера. Он указывает коду шейдера, как и откуда загрузить значение свойства из памяти GPU. Биты 0-30 определяют смещение свойства в буфере BRG raw, а бит 31 говорит о том, является ли значение свойства одинаковым для всех экземпляров или смещение - это начало массива, с одним значением на экземпляр.

Точное значение метаданных BRG также зависит от типа свойства шейдера. Давайте суммируем все возможности:

Таблица
Рисунок 1. Используя метаданные BRG, вы можете описать, какие свойства имеют пользовательское значение для каждого экземпляра (например, obj2world, world2obj, baseColor). Все остальные свойства имеют одинаковое значение для всех экземпляров (и по-прежнему используют классический буфер UnityPerMaterial в качестве источника данных).
Рисунок 1: Используя метаданные BRG, вы можете описать, какие свойства имеют пользовательское значение для каждого экземпляра (например, obj2world, world2obj, baseColor). Все остальные свойства имеют одинаковое значение для всех экземпляров (и по-прежнему используют классический буфер UnityPerMaterial в качестве источника данных).
Выбраковка BRG и индексы видимости

В отличие от Graphics.DrawMeshInstanced, BRG использует постоянный буфер памяти GPU. Допустим, у вас есть 10 позиций и цветов кубов в необработанном буфере, но видны только кубы 0, 3 и 7. Вы хотите нарисовать только три куба, но вам нужно, чтобы шейдер правильно считал положение и цвет этих кубов. Для этого шейдер BRG использует небольшое дополнительное перенаправление. Этот буфер видимости - просто массив "int", который вы заполняете при генерации команд рисования.

В этом примере вам нужно заполнить массив из трех интов значением {0,3,7}, а затем сгенерировать команду BRG draw из трех экземпляров.

Рисунок 2: В варианте шейдера BRG для получения данных из постоянного необработанного буфера всегда используется перенаправление видимости. Этот небольшой буфер перенаправления видимости может быть создан для каждого кадра в соответствии с вашими потребностями.
Рисунок 2: В варианте шейдера BRG для получения данных из постоянного необработанного буфера всегда используется перенаправление видимости. Этот небольшой буфер перенаправления видимости может быть создан для каждого кадра в соответствии с вашими потребностями.

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

Неизвестный тип блока "codeBlock", укажите для него сериализатор в свойстве `serializers.types`.

Идите дальше образца: Поскольку вы можете инстанцировать любое свойство SRP-шейдеров (unlit, simplelit, lit), все свойства материалов имеют ветку "if metadata&(1<<31". Даже если вам не нужно настраивать значение плавности для каждого экземпляра, это требует определенных затрат производительности. В примере мы хотим инстанцировать только baseColor. Вы можете создать шейдерный график, в котором только цвет будет определен как инстанс BRG. Таким образом, сгенерированный код имеет тяжелое перенаправление для получения данных только для свойства color. Шейдер должен работать даже немного быстрее на низкоуровневом GPU.

Рендеринг напольных ячеек

В нашем игровом примере пол состоит из 32x100 ячеек, или 3 200. У каждой из них есть положение, высота и цвет, а ячейки прокручиваются, в то время как камера остается неподвижной. Когда строка прокручивается из вида, мы вводим новую строку из 32 ячеек.

Новый ряд ячеек вставляется, когда весь ряд прокручивается за пределы экрана. Для новых ячеек используются случайные высота и цвет. Вы можете посмотреть на BRG_Background.InjectNewSlice() в примере.
Новый ряд ячеек вставляется, когда весь ряд прокручивается за пределы экрана. Для новых ячеек используются случайные высота и цвет. Вы можете посмотреть на BRG_Background.InjectNewSlice() в примере.

При наличии 3 200 ячеек в любой момент времени отбраковка не требуется (все ячейки всегда находятся в поле зрения камеры). Для позиционирования каждой ячейки вам понадобится матрица obj2world для каждой ячейки, матрица invert для освещения и цвет. Чтобы отрисовать весь пол, мы используем одну команду BRG draw.

Рендеринг обломков взрыва
Все обломки имеют простую физику гравитации и взаимодействуют с ячейками пола. Все выполняется на процессоре с помощью заданий Burst C#.
Все обломки имеют простую физику гравитации и взаимодействуют с ячейками пола. Все выполняется на процессоре с помощью заданий Burst C#.

Обломки образца состоят из маленьких кубиков, каждый из которых имеет положение, цвет и вращение вокруг вертикальной оси. Это очень похоже на напольные клетки. Для этого мы создали файл BRG_Container.cs. Класс управляет объектом BRG для отображения ячеек пола или обломков от взрыва. Вся физическая анимация и взаимодействие осуществляются с помощью кода на C#, используя BRG_Debris.cs.

В отличие от напольных ячеек, количество мусора варьируется по всей раме. При инициализации вы указываете максимальное количество элементов в BRG_Container. В нашем примере это 16 384 обломка (каждый взрыв состоит из 1024 кубиков обломков), и мы используем асинхронные задания для анимации обломков в гравитационном поле. Когда мусор попадает в ячейку пола, он взаимодействует с ней, погружаясь в землю.

Формат матрицы BRG

Чтобы оптимизировать объем памяти GPU и пропускную способность, BRG использует для хранения матрицы float3x4 вместо float4x4. Помните, что матрица BRG в необработанном буфере занимает 48 байт, а не 64.

Матрица BRG имеет размер всего 48 байт (т.е. три float4) для повышения пропускной способности GPU
Матрица BRG имеет размер всего 48 байт (т.е. три float4) для повышения пропускной способности GPU

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

Рисунок 3: Необработанный буфер SSBO объемом 350 килобайт содержит данные для 3200 экземпляров при использовании схемы SoA.
Рисунок 3: Необработанный буфер SSBO объемом 350 килобайт содержит данные для 3200 экземпляров при использовании схемы SoA.
Совет: Необработанные данные буфера мусора похожи на данные пола, поскольку также используют три пользовательских свойства (obj2world, world2obj и color). Максимальное количество элементов - 16 384 для мусора, что означает необработанный буфер размером 112x16 384 байт, или 1,75 Мбайт. Не все обломки рендерятся большую часть времени, в зависимости от количества кубиков обломков, существующих в данный момент.
Анимация клеток пола

У нас есть графический буфер GPU GraphicsBuffer размером 358 400 байт. Поскольку анимация выполняется с помощью процессора, мы также выделяем аналогичный буфер в системной памяти (процессор может обрабатывать данные на полной скорости в системной памяти). Назовем этот второй буфер "теневой копией" памяти GPU. Код на C# будет анимировать клетки пола, используя грех и мусор из копии тени. Когда анимация завершена, мы загружаем буфер теневой копии в GPU с помощью API GraphicsBuffer.SetData.

Идите дальше образца: Оптимизация рендеринга на GPU часто означает оптимизацию объема данных. В нашем примере мы используем стандартные и стоковые шейдеры SRP. Поэтому мы использовали три float4 для матрицы и один float4 для цвета. Вы можете пойти дальше и написать собственный шейдер, чтобы уменьшить размер данных, или использовать 32-битное значение высоты ячейки пола.

Если вы хотите продолжить, используйте индекс ячейки для вычисления ее мирового положения, затем вычислите матрицу и инвертируйте матрицу в шейдере. Наконец, для хранения цвета используйте 32-битное целое число. В конце загрузите 8 байт на элемент вместо 112. Это позволяет в 14 раз ускорить загрузку данных с GPU. Это потребует переписать код получения шейдеров.

BRG BatchID

Любая команда рисования BRG нуждается в MeshID, MaterialID и BatchID. Первые два варианта легко понять, но BatchID более тонкий. Считайте, что BatchID - это "вид партии". Для визуализации пола необходимо зарегистрировать один вид партии, который определяется следующим образом:

1. Свойство "unity_ObjectToWorld" представляет собой массив, начинающийся со смещения 0 в буфере BRG raw

2. Свойство "unity_WorldToObject" - это массив, начинающийся со смещения 153,600

3. Свойство "_BaseColor" представляет собой массив, начинающийся со смещения 307,200

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

Неизвестный тип блока "codeBlock", укажите для него сериализатор в свойстве `serializers.types`.

Мы получаем m_batchId во время создания, а затем можем использовать его для каждой команды рисования BRG (чтобы шейдер точно знал, как получить данные для этого типа партии).

Совет: BatchRendererGroup.AddBatch не является командой рендеринга. Он используется для регистрации своего рода партии, для будущих команд рендеринга.
Дьявол кроется в деталях: Исключение GLES

Пока что мы можем анимировать ячейки пола, загрузить буфер памяти системы теневого копирования в GPU и отрисовать все ячейки с помощью одной команды DrawCommand, состоящей из 3 200 экземпляров.

Это работает на большинстве платформ: DirectX, Vulkan, Metal и на различных игровых консолях, но не на GLES. Проблема в том, что большинство устройств GLES 3.0 не могут получить доступ к SSBO на этапе вершин (т.е. значение GL_MAX_VERTEX_SHADER_STORAGE_BLOCKS равно 0). Поэтому, когда графический API установлен на GLES, BRG будет использовать постоянный буфер, или UBO, для хранения исходных данных.

Это добавляет ограничений: Постоянный буфер может быть любого размера, но в любой момент времени при работе шейдера видна только небольшая его часть (окно). Размер окна зависит от аппаратного обеспечения и драйвера, но общепринятым значением является 16 килобайт.

Совет: В режиме UBO всегда следует использовать API BatchRendererGroup.GetConstantBufferMaxWindowSize(), чтобы получить правильный размер окна BRG.

Давайте посмотрим, как изменится наш код, если мы захотим работать на GLES. Для напольных ячеек общий объем данных составляет 350 КиБ. Мы не можем сделать один DrawInstanced(3,200), потому что шейдер не сможет увидеть 350 килобайт за один раз. Таким образом, мы должны разделить данные внутри UBO, чтобы максимизировать количество экземпляров, помещающихся в 16-килобайтный блок. Одна ячейка пола занимает 112 байт (две матрицы и один цвет), поэтому в блок размером 16 килобайт можно поместить 16 384 экземпляра, разделенных на 112, или 146 экземпляров. Чтобы отобразить 3 200 экземпляров, нам нужно выдать 21 DrawInstanced(146) и последний DrawInstanced(134).

Теперь 350 КБ UBO будут разбиты на 22 оконных блока по 16 КБ каждый, как показано ниже:

Рисунок 4: В GLES необработанный буфер является UBO (не SSBO). Данные для 3 200 экземпляров разбиты на 22 окна. Каждый DrawInstanced(146) будет получать данные из области размером 16 килобайт. Обратите внимание, что последнее окно содержит только 134 экземпляра, поэтому между последней желтой, зеленой и синей областью есть небольшой промежуток.
Рисунок 4: В GLES необработанный буфер является UBO (не SSBO). Данные для 3 200 экземпляров разбиты на 22 окна. Каждый DrawInstanced(146) будет получать данные из области размером 16 килобайт. Обратите внимание, что последнее окно содержит только 134 экземпляра, поэтому между последней желтой, зеленой и синей областью есть небольшой промежуток.
Совет: В режиме UBO смещение каждого окна должно быть выровнено по BatchRendererGroup.GetConstantBufferOffsetAlignment(). Типичные значения выравнивания составляют от 4 до 256 байт.

В GLES из-за UBO и 16-килобайтных окон необходимо зарегистрировать 22 BatchID, чтобы хранить смещения каждого окна. Затем код инициализации нуждается в цикле:

Неизвестный тип блока "codeBlock", укажите для него сериализатор в свойстве `serializers.types`.

Совет: Для поддержки GLES (UBO) и других графических API (SSBO) в примере игры, BRG_Container.cs устанавливает некоторые переменные при инициализации. В режиме SSBO значение m_windowCount равно 1, а m_alignedGPUWindowSize - общему размеру буфера. В режиме UBO размер m_alignedGPUWindowSize равен 16 KiB, а m_windowCount содержит количество блоков по 16 KiB. (Значение 16 килобайт используется для удобства чтения. Используйте API GetConstantBufferMaxWindowSize(), чтобы получить правильное значение).
Загрузка данных

После того как CPU обновит все матрицы и цвета в системной памяти, вы можете загрузить данные в GPU. Это делается с помощью функции BRG_Container.UploadGpuData. Из-за модели данных SoA вы не можете загрузить один блок памяти. Для мусора буфер составляет 16 384 элемента. В режиме GLES это означает 113 окон по 16 килобайт каждое, если на экране 16 384 мусора.

Но что, если в данном кадре находится только 5300 кубиков мусора? Поскольку у вас 146 элементов в окне, это означает, что первые 36 последовательных окон по 16 килобайт должны быть загружены, чтобы вы могли использовать один SetData (36x16 килобайт). В последнем окне должно отображаться только 44 куба обломков. Чтобы загрузить 44 матрицы, инвертировать матрицы и цвета, используйте три команды SetData. В самом конце необходимо выдать четыре команды SetData.

Для загрузки N элементов требуется до четырех команд GfxBuffer.SetData.
Для загрузки N элементов требуется до четырех команд GfxBuffer.SetData.
Совет: Даже в режиме SSBO, если количество элементов меньше максимального (например, 5 300 обломков при максимальном количестве 16 384), требуется три команды SetData. Подробности реализации можно посмотреть в BRG_Container.UploadGpuData(int instanceCount).
Основной обратный вызов пользователя BRG

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

Неизвестный тип блока "codeBlock", укажите для него сериализатор в свойстве `serializers.types`.

Ваш код в этом обратном вызове отвечает за две вещи:

1. Чтобы сгенерировать все команды рисования в выходную структуру BatchCullingOut

2. Чтобы использовать (или не использовать) информацию, предоставленную в структуре BatchCullingContext, предназначенной только для чтения, в вашем собственном коде выбраковки

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

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

Структура BatchCullingOutputDrawCommands содержит различные данные, включая массивы. Выделение собственной памяти под эти массивы - обязанность пользователя. Движок отвечает за освобождение памяти после того, как данные будут израсходованы (вы выделяете, Unity отвечает за освобождение). Выделение памяти должно быть типа Allocator.TempJob.

Неизвестный тип блока "codeBlock", укажите для него сериализатор в свойстве `serializers.types`.

Первым массивом, который вы должны выделить, является массив visibility int. В примере, поскольку мы предполагаем, что все видно, мы просто заполняем массив visibility int инкрементными значениями, например {0,1,2,3,4,...}.

Генерация команд рисования

Команда рисования BRG - это почти вызов GPU DrawInstanced. Наиболее важным массивом, который необходимо выделить и заполнить, является BatchDrawCommand. Допустим, в текущем кадре находится 4 737 кубиков мусора.

m_maxInstancePerWindow - 146 в режиме GLES. Вы можете вычислить количество команд рисования и распределить буфер, используя потолочное значение m_instanceCount, деленное на m_maxInstancePerWindow:

Неизвестный тип блока "codeBlock", укажите для него сериализатор в свойстве `serializers.types`.

Чтобы избежать дублирования одинаковых параметров в нескольких командах рисования, BatchCullingOutputDrawCommands имеет массив BatchDrawRange struct. В BatchDrawRange.filterSettings можно задать различные параметры, например, renderingLayerMask, флаги получения тени и т.д. Поскольку все команды рисования будут использовать одни и те же настройки рендеринга, можно выделить одну структуру DrawCommandRange, которая будет применяться начиная с команды рисования 0 и содержать все команды drawCommandCount.

Неизвестный тип блока "codeBlock", укажите для него сериализатор в свойстве `serializers.types`.

Затем выполните команды рисования. Каждая команда BatchDrawCommand содержит идентификатор сетки, идентификатор партии (чтобы знать, как использовать метаданные) и идентификатор материала. Он также содержит начальное смещение в буфере массива видимости int. Так как в нашем контексте не требуется выборка фрагментов, мы заполняем массив видимости {0,1,2,3,...}. Тогда все команды рисования будут ссылаться на один и тот же индирект {0,1,2,3,...}, поэтому каждая команда BatchDrawCommand будет использовать 0 в качестве начального смещения массива видимости. Следующий код выделяет и заполняет все необходимые команды рисования:

Неизвестный тип блока "codeBlock", укажите для него сериализатор в свойстве `serializers.types`.

Подведение итогов: Погрузитесь глубже в форумы

Непосредственное управление BatchRendererGroup требует некоторой работы. Однако он работает "из коробки", не требуя собственных шейдеров или дополнительных пакетов. В некоторых ситуациях, например, когда нужно отрисовать множество объектов, моделируемых процессором, с пользовательскими инстанцированными свойствами, BatchRendererGroup станет вашим лучшим другом.

Вы можете загрузить проект из этого репозитория.

Вы также можете посетить форумы, чтобы обсудить дополнительные детали того, как мы использовали систему заданий C# и компилятор Burst для обработки всех анимаций и взаимодействий на полной скорости даже на низкопроизводительном процессоре.