BatchRendererGroup 示例:即使在预算有限的设备上也能实现高帧频

在本篇文章中,我们将介绍一个小型射击游戏示例,该示例可对多个交互对象进行动画和渲染。许多演示只针对高端 PC,但这里的目标是使用 GLES 3.0 在经济型手机上实现高帧率。本示例使用BatchRendererGroup、Burst 编译器和C# 作业系统。它可在 Unity 2022.3 中运行,无需使用 entities 或 entities.graphics DOTS 包。
让我们开始吧。
让我们直接来看看样本是什么。该样本在预算有限的 2019 款三星 Galaxy A51(使用 Mali G72-MP3 GPU)上以稳定的 60 fps 运行。图形 API 设置为 GLES 3.0。
您可以从GitHub 下载项目,研究代码并在自己喜欢的平台上试用。您只需要使用 Unity 2022.3 版。
在这篇文章中,我们主要关注 BatchRendererGroup 和示例类BRG_Container.cs。您还可以研究BRG_Background.cs和BRG_Debris.cs类中的动画和物理代码。
在深入探讨如何制作之前,让我们先来看看我们所看到的。
- 背景地板由许多立方体构成。所有方框都有上下移动的动画效果。
- 主飞船在屏幕上水平移动,向彩色球体发射导弹。(点击屏幕可以更快地发射导弹)。
- 当导弹飞过地板时,磁场会稍微抬起并突出地板细胞。它还能将地面碎片抛向空中。
- 导弹击中球体后,会爆炸成彩色碎片。
- 当碎片撞击地板时,地板上的碰撞单元会闪烁白光。撞击细胞的碎片越多,细胞的颜色就越深。此外,碎片的重量也会造成地面凹陷。
地板细胞和碎片都是由立方体构成的。每个立方体都有不同的位置和颜色。我们希望使用 CPU 制作动画并管理一切,使地板和碎片之间的互动更加容易。(碎片不仅仅是外观上的视觉效果,所以不能只用 GPU 来处理)。
在渲染时,我们不会为每个项目创建一个 GameObject,以避免在低端移动设备上造成不必要的性能损失。相反,我们使用新引入的 BatchRendererGroup API。
Graphics.DrawMeshInstanced是一种方便快捷的方法,可以在不同位置渲染许多相似的网格。不过,与 BatchRendererGroup API 相比,它有以下局限性:
- 它需要提供一个包含矩阵的受管内存数组,因此可能会出现垃圾回收。此外,即使着色器不需要,反转矩阵也是由 CPU 计算的(例如,使用 URP/unlit)。
- 如果您想自定义 obj2world 矩阵以外的任何属性(例如每个实例只有一种颜色),您需要提供自己的自定义着色器,可以从头开始编写,也可以使用着色器图
- 每次绘制时,必须将矩阵或自定义数据上传到 GPU 内存。使用 Graphics.DrawMeshInstanced 无法获得持久的 GPU 内存数据。根据具体情况,这可能会对性能造成巨大影响。
BatchRendererGroup(或 BRG)是一种应用程序接口,可有效地从 C# 生成绘制命令,并生成与 GPU 相关的绘制调用。由于它不使用托管内存,因此也可以使用 Burst 编译器生成命令。

小贴士entities.graphics软件包用于渲染实体(ECS 软件包),建立在 BRG 的基础之上。entities.package 可为您完成所有 GPU 内存管理和最佳绘图命令创建。在这个样本中,我们不使用 ECS,所以直接驱动 BRG。
BRG 使用特定的 GPU 数据布局和专用着色器变体。着色器变体可以从标准常量缓冲区(UnityPerMaterial)或自定义大型 GPU 缓冲区(BRG 原始缓冲区)获取数据。原始缓冲区是着色器存储缓冲对象(SSBO,或字节地址缓冲区),如何在原始缓冲区中存储数据取决于您的管理。默认的 BRG 数据布局是数组结构 (SoA) 类型。
您可以实例化材质的任何属性,而无需创建自定义着色器。在示例中,我们希望实例化 obj2world 矩阵(用于定位方块)、world2obj 矩阵(用于照明)以及每个方块实例的 BaseColor(因为每个地板单元格或碎片都有自己的颜色)。
所有其他属性对于所有立方体都是一样的(例如平滑度值),你可以使用元数据来描述每个实例的自定义属性值。
BRG 元数据是一个可选的 32 位值,您可以为每个着色器属性进行设置。它告诉着色器代码如何从 GPU 内存加载属性值以及加载的位置。第 0-30 位定义了属性在 BRG 原始缓冲区中的偏移量,第 31 位告诉我们是所有实例的属性值都相同,还是偏移量是一个数组的起始值,每个实例一个值。
BRG 元数据的确切含义也取决于着色器属性类型。让我们总结一下所有的可能性:


与 Graphics.DrawMeshInstanced 不同,BRG 使用持久的 GPU 内存缓冲区。假设原始缓冲区中有 10 个立方体的位置和颜色,但只有 0、3 和 7 是可见的。您只想绘制三个立方体,但需要着色器正确读取这些立方体的位置和颜色。为此,BRG 着色器使用了一个小的附加间接。可见性缓冲区只是一个 "int "数组,在生成绘制命令时填充。
在此示例中,您需要在一个由三个 int 组成的数组中填充 {0,3,7},然后生成一个包含三个实例的 BRG 绘图命令。

获取 "baseColor "属性的着色器代码如下所示:
未知块类型 "codeBlock",请在 "serializers.type "道具中为其指定一个序列化器
比样本更进一步:由于可以实例化 SRP 着色器的任何属性(unlit、simplelit、lit),所有材质属性都有一个 "if metadata&(1<<31" 分支。即使每个实例不需要自定义平滑度值,这也会带来一定的性能代价。在示例中,我们只想实例化 baseColor。您可以创建一个着色器图形,其中只有颜色会被定义为 BRG 可实例化。因此,生成的代码只对颜色属性进行了大量的数据间接获取。着色器在低端 GPU 上的运行速度应该会稍快一些。
在我们的游戏样本中,地板由 32x100 个单元组成,即 3200 个单元。每个单元格都有位置、高度和颜色,当摄像机保持静止时,单元格会滚动。当一行滚动出视图时,我们会注入一行新的 32 个单元格。

在任何时刻都有 3 200 个单元格的情况下,其实没有必要进行剔除(所有单元格始终都在摄像机的视野范围内)。要定位每个单元格,每个单元格都需要一个 obj2world 矩阵、用于照明的反转矩阵和颜色。要渲染整个楼层,我们只需使用一条 BRG 绘制命令。

样本的碎片由小方块组成,每个小方块都有其垂直轴上的位置、颜色和旋转。这与地板细胞非常相似。为此,我们创建了 BRG_Container.cs。该类管理一个 BRG 对象,用于渲染楼层单元或爆炸碎片。所有物理动画和交互都是通过使用BRG_Debris.cs 的 C# 代码完成的。
与地板单元不同,整个框架的碎屑量各不相同。初始化时,您需要指定 BRG_Container 的最大项目数。在我们的示例中,碎片的数量为 16,384 个(每个爆炸由 1,024 个碎片方块组成),我们使用异步作业来制作重力场中碎片的动画。当碎石撞击地面单元时,会与地面产生相互作用。
为了优化 GPU 内存存储和带宽,BRG 使用float3x4 代替float4x4 来存储矩阵。请记住,原始缓冲区中的 BRG 矩阵是 48 字节,而不是 64 字节。

原始缓冲区看起来是这样的

小贴士碎片原始缓冲区数据看起来与地面数据类似,因为它也使用了三个自定义属性(obj2world、world2obj 和颜色)。碎片的最大条数为 16,384 条,即原始缓冲区为 112x16,384 字节,或 1.75 MiB。在大多数情况下,并非所有碎片都会被呈现,这取决于特定时间内存在的碎片方块数量。
我们有一个 358 400 字节的 GPU 图形缓冲区。由于动画是由 CPU 完成的,因此我们也在系统内存中分配了一个类似的缓冲区(CPU 可以在系统内存中全速处理数据)。我们把第二个缓冲区称为 GPU 内存的 "影子副本"。C# 代码将利用 sin 和阴影副本中的碎屑为地板单元格制作动画。动画制作完成后,我们会使用GraphicsBuffer.SetDataAPI 将阴影副本缓冲区上传到 GPU。
比样本更进一步:优化 GPU 渲染通常意味着优化数据量。在我们的样本中,我们使用了标准和库存 SRP 着色器。这就是为什么我们使用三个 float4 表示矩阵,一个 float4 表示颜色。你可以进一步编写自定义着色器来减小数据大小,也可以使用 32 位地板单元高度值。
如果希望继续前进,可以使用单元格索引计算其世界位置,然后在着色器中计算矩阵和反转矩阵。最后,使用 32 位整数来存储颜色。最后,每个项目上传 8 个字节,而不是 112 个字节。这使得 GPU 数据上传的速度提高了 14 倍。这意味着要重写着色器获取代码。
任何 BRG 绘图命令都需要网格 ID、材料 ID 和批次 ID。前两者很容易理解,但 BatchID 则更加微妙。把 BatchID 想象成 "一种批次"。要渲染地板,您需要注册一种批处理,其定义如下:
1."unity_ObjectToWorld" 属性是从 BRG 原始缓冲区偏移 0 开始的数组
2."unity_WorldToObject" 属性是从偏移量 153 600 开始的数组
3."_BaseColor" 属性是一个数组,从偏移量 307 200 开始
在创建时注册此类批处理的代码与此类似:
未知块类型 "codeBlock",请在 "serializers.type "道具中为其指定一个序列化器
我们会在创建时获取 m_batchId,然后将其用于每个 BRG 绘图命令(这样着色器就能准确地知道如何为该批次获取数据)。
小贴士BatchRendererGroup.AddBatch 不是渲染命令。它用于注册一种批处理,以便将来执行渲染命令。
到目前为止,我们可以对楼层单元制作动画,将阴影复制系统内存缓冲区上传到 GPU,并使用一个包含 3200 个实例的 DrawCommand 渲染所有单元。
这将适用于大多数平台:DirectX、Vulkan、Metal 和各种游戏机,但不包括 GLES。问题是大多数 GLES 3.0 设备无法在顶点阶段访问 SSBO(即 GL_MAX_VERTEX_SHADER_STORAGE_BLOCKS 值为 0)。因此,当图形 API 设置为 GLES 时,BRG 将使用常量缓冲区或 UBO 来存储原始数据。
这就增加了制约因素:常量缓冲区可以是任意大小,但在着色器运行时,任何时候都只能看到其中的一小部分(窗口)。窗口大小取决于硬件和驱动程序,但普遍接受的值是 16 KiB。
小贴士在 UBO 模式下,应始终使用 BatchRendererGroup.GetConstantBufferMaxWindowSize() API 来获取正确的 BRG 窗口大小。
让我们看看如果要在 GLES 上运行,我们的代码会有什么变化。楼层单元的数据总量为 350 KiB。我们不能进行一次 DrawInstanced(3,200),因为着色器无法同时看到 350 KiB 的数据。因此,我们必须在 UBO 中拆分数据,以最大限度地增加每次绘制的实例数量,使其适合 16 KiB 的数据块。一个楼层单元是 112 字节(两个矩阵和一种颜色),因此在 16 KiB 块中可以容纳 16,384 除以 112,即 146 个实例。要呈现 3200 个实例,我们需要发出 21 次 DrawInstanced(146) 和最后一次 DrawInstanced(134)。
现在,350KiB 的 UBO 将被分割成 22 个窗口块,每个窗口块 16KiB,就像这样:

小贴士在 UBO 模式下,每个窗口偏移量应按照 BatchRendererGroup.GetConstantBufferOffsetAlignment() 对齐。典型的对齐值范围为 4 至 256 字节。
在 GLES 中,由于 UBO 和 16 KiB 窗口的存在,您需要注册 22 个 BatchID 才能存储每个窗口的偏移量。初始化代码需要一个循环:
未知块类型 "codeBlock",请在 "serializers.type "道具中为其指定一个序列化器
小贴士为了在游戏示例中支持 GLES (UBO) 和其他图形 API (SSBO),BRG_Container.cs 在初始化时设置了一些变量。在 SSBO 模式下,m_windowCount 为 1,m_alignedGPUWindowSize 为总缓冲区大小。在 UBO 模式下,m_alignedGPUWindowSize 为 16 KiB,m_windowCount 包含 16 KiB 块的数量。(16 KiB 的值是为了便于读取。使用 GetConstantBufferMaxWindowSize() API 获取正确值)。
一旦 CPU 更新了系统内存中的所有矩阵和颜色,就可以将数据上传到 GPU。这是通过BRG_Container.UploadGpuData函数完成的。由于 SoA 数据模型的原因,您无法上传单个内存块。对于碎片,缓冲区为 16 384 个项目。在 GLES 模式下,如果屏幕上有 16 384 个碎片,这意味着需要 113 个窗口,每个窗口 16 KiB。
但是,如果一个框架内只有 5 300 个碎片方块呢?由于每个窗口有 146 个项目,这意味着应上传前 36 个连续的 16 KiB 窗口,以便使用单个 SetData(36x16 KiB)。在最后一个窗口中,只显示 44 个碎片方块。上传 44 个矩阵、反转矩阵和颜色,并使用三条 SetData 命令。最后,应发出四条 SetData 命令。

小贴士即使在 SSBO 模式下,如果项目数少于最大值(例如,5,300 个碎片超过最大值 16,384),也需要三条 SetData 命令。有关实现的详细信息,请参阅 BRG_Container.UploadGpuData(int instanceCount)。
BRG 的主要入口是创建时提供的剔除回调函数。原型看起来像
未知块类型 "codeBlock",请在 "serializers.type "道具中为其指定一个序列化器
您在该回调中的代码负责两件事:
1.将所有绘图命令生成输出 BatchCullingOut 结构体
2.在自己的剔除代码中使用(或不使用)BatchCullingContext 只读结构中提供的信息
请注意:回调会返回一个 JobHandle,以备您启动一个异步作业来执行这些操作。引擎会在需要结果时使用此功能进行同步,因此您的命令生成代码不会阻塞主线程。
BatchCullingContext 包含摄像机矩阵、摄像机方位图等信息。基本上,您需要的所有数据都可以用来筛选和生成更少的绘图命令。在示例中,所有对象(地板单元格和碎屑)都适合摄像机视图,因此无需使用剔除代码。
BatchCullingOutputDrawCommands结构包含各种数据,包括数组。用户有责任为这些数组分配本地内存。一旦数据消耗完毕,引擎将负责释放内存(由您分配,Unity 负责释放)。内存分配应为Allocator.TempJob类型。
未知块类型 "codeBlock",请在 "serializers.type "道具中为其指定一个序列化器
第一个要分配的数组是可见性 int 数组。在示例中,我们假设所有东西都是可见的,因此只需在可见性 int 数组中填入递增值,如 {0,1,2,3,4,...}。
BRG 绘制命令几乎就是 GPU DrawInstanced 调用。要分配和填充的最重要数组是 BatchDrawCommand。假设当前画面中有 4 737 个碎片方块。
m_maxInstancePerWindow 在 GLES 模式下为 146。您可以使用 m_instanceCount 的上限值除以 m_maxInstancePerWindow 来计算绘制命令的数量并分配缓冲区:
未知块类型 "codeBlock",请在 "serializers.type "道具中为其指定一个序列化器
为避免在多个绘制命令中重复类似的参数,BatchCullingOutputDrawCommands 包含一个BatchDrawRange结构数组。您可以在BatchDrawRange.filterSettings 中设置各种参数,如 renderingLayerMask、接收阴影标志等。由于所有绘图命令将共享相同的渲染设置,因此可以分配一个 DrawCommandRange 结构,该结构将从绘图命令 0 开始应用,并包含所有 drawCommandCount 命令。
未知块类型 "codeBlock",请在 "serializers.type "道具中为其指定一个序列化器
然后,填写绘制命令。每个 BatchDrawCommand 都包含网格 ID、批次 ID(用于了解如何使用元数据)和材质 ID。它还包含可见性 int 数组缓冲区的起始偏移量。由于在我们的上下文中不需要任何挫折剔除,因此我们将可见度数组填充为 {0,1,2,3,...}。然后,所有绘图命令都将引用相同的 {0,1,2,3,...} 间接值,因此每个 BatchDrawCommand 都将使用 0 作为可见性数组的起始偏移量:
未知块类型 "codeBlock",请在 "serializers.type "道具中为其指定一个序列化器
直接驱动 BatchRendererGroup 需要做一些工作。不过,它不需要自定义着色器或额外的软件包就能立即运行。在某些情况下,例如需要渲染大量具有自定义实例化属性的 CPU 模拟对象时,BatchRendererGroup 就是你最好的朋友。
您可以从该资源库下载该项目。
您还可以访问论坛讨论更多细节,了解我们如何使用 C# 作业系统和 Burst 编译器全速处理所有动画和交互,即使在低端 CPU 上也是如此。