查看原文
其他

在低端设备上实现高帧率!BatchRendererGroup示例详解

Unity Unity官方平台 2023-10-13

本文基于 BatchRendererGroup Shooter 示例,该示例目标是在低端手机上实现高帧率的效果,它也可以对多个交互式对象进行动画和渲染处理。示例使用了 BatchRendererGroup、Burst Compiler 以及 C# Job System,采用 Unity 2022.3 版本运行,并且无需 entities 或 entities.graphics 包。

先来通过以下视频了解一下项目效果。此示例能以稳定 60 帧运行在三星 Galaxy A51(该款手机 GPU 使用 Mali G72-MP3),项目的图形 API 设置为 GLES 3.0。

您只需安装 Unity 2022.3 及以上版本,即可点击阅读原文或访问如下链接下载项目:

https://github.com/Unity-Technologies/brg-shooter
本文将着重讲解 BatchRendererGroup 和 BRG_Container 脚本,可以在 BRG_Background 和 BRG_Debris 脚本中,学习动画和物理相关的代码。

设定场景

在深入了解之前,先来探索下示例。
  • 背景里的地面由许多方块构成,它们都带有上下移动的动画。

  • 飞船可以在屏幕上水平移动,并向彩色圆球发射导弹。(可以通过点击屏幕来更快地发射导弹)

  • 当一枚导弹飞过地面上方时会产生一块磁场,略微抬高并高亮显示方块,地面上的残骸碎块也会被抛到空中。

  • 当导弹击中球体时,球体会爆炸成彩色的碎块。

  • 当残骸碎块撞击到地面,受冲击的方块会闪白光。碎块越多,方块的颜色就会变得越深。另外,碎块的重量会导致地面凹陷。

渲染

地面格和残骸碎块都由立方体组成,每个立方体有着各自的位置和颜色。我们希望使用 CPU 对所有东西进行动画处理和管理,让地面与残骸的互动运算更轻松(残骸不仅仅是装饰用的图像,不能只用 GPU 渲染)。
在渲染时不会为每个物品创建一个 GameObject,以免在低端移动设备上造成不必要的性能影响。作为代替使用了新推出的 BatchRendererGroup API。
  为何不用 Graphics.DrawMeshInstanced 类?
Graphics.DrawMeshInstanced 的确能方便快速地在不同位置渲染许多相似的模型网格。然而相比于 BatchRendererGroup,它具有以下限制:
  • 需要提供带有矩阵的托管内存组来执行垃圾数据回收。并且,即使着色器不需要逆矩阵,CPU 仍然会进行计算。

  • 若想自定义除 obj2world 矩阵外的任何属性(像如每个实例具有一种颜色),就必须用 Shader Graph 或从零写一个自定义 Shader。

  • 矩阵或自定义数据在每次绘制时必须被上传至 GPU 内存。Graphics.DrawMeshInstanced 的 GPU 内存数据不能持续存在。特定情况下,这会造成巨大的性能冲击。
  什么是 BatchRendererGroup?
BatchRendererGroup(或 BRG)API 可以高效地从 C# 生成绘制指令、发起 GPU 实例化的绘制调用。它不依赖于托管内存,所以可以用 Burst Compiler 来生成指令。

*专门渲染 entities 的 entites.graphics 软件包(ECS 包)就是基于 BRG 开发的。它能代为处理所有 GPU 内存管理和最优绘制指令的创建。示例中并未用到 ECS,所以需要直接操作 BRG。

BRG 着色器数据模型

BRG 有一种特殊的 GPU 数据布局和专门的派生着色器。该变体能从标准的常量缓冲器(UnityPerMaterial)或自定义的大型 GPU 缓冲器那获取数据。这些原始缓冲器为 Shader Storage Buffer Object(SSBO,或字节地址缓冲器),其数据的存储方式将由您来决定。默认的BRG数据布局为阵列结构(SoA)。
不用创建自定义着色器就能实例化材质的属性。在示例中想要实例化 obj2world 矩阵(来放置立方体)、world2obj 矩阵(用于打光),和每个立方体实例的 BaseColor(每个地面格子或残骸有自己的颜色)。
方块的其他属性都是相同的(如平滑度数值),可以借助元数据来赋予特定属性自定义的数值。

BRG 元数据

BRG 元数据是一个可选的 32 位数值,可在每个着色器属性上单独设定。它会告诉着色器代码怎样从 GPU 内存的何处加载属性。位 0-30 定义了属性在 BRG 原始缓冲器内的偏移量,位 31 表明属性值在所有实例上是否相同,或偏移值是否位于组的开头,且每个实例都有自己的值。
BRG 元数据的意义还取决于着色器属性类型。我们来总结下所有可能性:

可以用 BRG 元数据描述每个实例的哪些属性(如 obj2world、world2obj、baseColor)能有自定义的数值。其他属性的数值在所有实例上都是相同的(仍旧以传统的 UnityPerMaterial cbuffer 作为数据源)。

BRG 剔除与可见度索引

不同于 Graphics.DrawMeshInstanced,BRG 使用的是持久的 GPU 内存缓冲区。假设原始缓冲区中有 10 个立方体的坐标和色值,但只有 0、3 和 7 是可见的,所以如果您只需要画出这三个立方体,着色器就必须读取这些立方体的位置和色值。为此,BRG 着色器会用到额外的中间层。这个可见度缓冲区是在绘制指令生成时自动填充的 int 数组。
例中,需要给数组填入{0,3,7}三个整数,然后生成三个实例的 BRG 绘制指令。

BRG 派生着色器一定会用可见度中间层从原始缓冲区获取数据。可以根据需要为每一帧生成可见度中间层缓冲区。

抓取 “baseColor” 属性的着色器代码是这样的(左右滑动查看):

if ( metadata_baseColor&(1<<31) )
{
// get the real index from the visibility buffer indirection
int visibleId = brg_visibility_array[GPU_instanceId];
uint base = (metadata_baseColor&0x7ffffffc);
uint offset = visibleId * sizeof(baseColor);
// fetch data from a custom array in BRG raw buffer
baseColor = brg_raw_buffer.Load( base + offset );
}
else
{
// fetch data from UnityPerMaterial (as usual)
baseColor = UnityPerMaterial.baseColor;
}

额外扩展:每当您实例化 SRP 着色器(unlit、simplelit、lit)的任意属性,所有材质属性都会带有 “if metadata&(1<<31” 分支。即使每个实例不需要自定义平滑度值,这也会产生一定的性能开销。可以创建一张 Shader Graph,仅将颜色定义为 BRG 可实例化的属性。由此生成的代码只会用中间层来抓取颜色属性。这样着色器就能在低端 GPU 上运行得稍快一些。

渲染地面格

在示例游戏里,地面由 32x100,3200 个格子组成。每个都有位置、高度和颜色,格子会相对于固定的摄像机滚动。当一行格子移动到视线之外,就插入一行 32 个格子的新行。

一整行格子滚动出视野后新的一行格子会被插入。新格子带有随机的高度和颜色。详细情况可在示例的 BRG_Background.InjectNewSlice 函数中了解。
由于任意时间点都存在 3200 个格子,剔除就没那么必要了(所有格子都存在于摄像机视野内)。要放入格子,需要每一格的 obj2world 矩阵、打光用的反向矩阵,以及一个颜色值。在渲染完整的地面时仅会使用单次 BRG 绘制指令。

渲染爆炸残骸

所有残骸都带有简单的重力物理效果,还可与地面格子互动。所有运算都用 Burst C# jobs 在 CPU 上运行。

示例中的残骸碎块由小的立方体组成,每个立方体都带有位置坐标、颜色和相对于垂直轴的旋转角,类似于地面格子。为了执行运算,我们编写了 BRG_Container 脚本,控制 BRG 对象来渲染地面格子或爆炸残骸。所有物理动画和互动都是用 BRG_Debris 脚本的 C# 代码完成
不同于地面格子,每帧上的残骸数量都是不固定的。初始化时,可以在 BRG_Container 上确定它的最大数量。示例中残骸最多能有 16384 个(每次爆炸会产生 1024 个残骸方块),残骸在重力场内的动画则用 async job 执行运算。残骸击中地面格子时会往地面下沉。

BRG 矩阵格式

为了优化 GPU 内存存储和带宽,BRG 使用了 float3x4 而非 float4x4 来储存矩阵。注意,原始缓冲区里的 BRG 矩阵是 48 字节的,不是 64 字节。

为了改善 GPU 带宽,BRG 矩阵只有 48 字节(即三个 float4)。

原始缓冲区看起来就像这样:
采用了 SoA 布局的 350 KiB SSBO 原始缓冲器包含着 3200 个实例的数据。
*残骸原始缓冲区的数据看起来很像地面格子的数据,它也用到了三种自定义属性(obj2world、world2obj、color)。残骸数的最大值为 16384,则原始缓冲区便为 112x16384 字节,或 1.75 MiB。大部分时候不是所有的残骸都会被渲染,具体数量取决于特定时间点存在的残骸方块数。

地面格子动画

动画的 GPU GraphicsBuffer 占据了 358400 字节。鉴于动画由 CPU 完成,我们在系统内存(CPU 能全速处理系统内存里的数据)里分配了一块类似的缓冲区。我们把第二个缓冲区称作 GPU 内存的 “shadow copy”。C# 代码会使用 sin 函数及 shadow copy 内的残骸来生成地面格子的动画。动画生成完毕后便用 GraphicsBuffer.SetData API 把 shadow copy buffer 上传至 GPU。
额外扩展:优化 GPU 渲染经常意味着优化数据量。例中用的是标准量产 SRP 着色器,所以用三个 float4 组成矩阵、一个 float4 表示颜色。您可以更进一步,写出自己的着色器来降低数据大小,或利用 32 位的地面格高度值。
如果想要进一步优化,可以用格子的索引来计算它的世界位置,再到着色器里计算矩阵和反向矩阵,接着用 32 位的整数来储存颜色,最后为每个方块上传 8 字节而非 112 字节的数据。这会让 GPU 数据上传快 14 倍,也意味着需要改写着色器抓取数据的代码。

BRG BatchID

任何 BRG 绘制指令都需要一个 MeshID、MaterialID 和 BatchID。前两个理解起来很容易,但 BatchID 更难懂一些。它能被看作 “一种批次”。要渲染地面,需要像下方一样注册一个批次类:
  • 从 BRG 原始缓冲区偏移量 0 开始的 “unity_ObjectToWorld” 属性

  • 从偏移量 153600 开始的 “unity_WorldToObject” 属性

  • 从偏移量 307200 开始的 “_BaseCOLOR” 属性
在创建时注册此类批次的代码看起来是这样的(左右滑动查看):
int objectToWorldID = Shader.PropertyToID("unity_ObjectToWorld");
int worldToObjectID = Shader.PropertyToID("unity_WorldToObject");
int colorID = Shader.PropertyToID("_BaseColor");
var batchMetadata =
new NativeArray<MetadataValue>(3, Allocator.Temp, NativeArrayOptions.UninitializedMemory);

batchMetadata[0] = CreateMetadataValue(objectToWorldID, 0, true); // matrices
batchMetadata
[1] = CreateMetadataValue(worldToObjectID, 3200*3*16, true); // inverse matrices
batchMetadata
[2] = CreateMetadataValue(colorID, 3200*3*16*2, true); // colors
m_batchId = m_BatchRendererGroup.AddBatch(batchMetadata, m_GPUPersistentRawBuffer.bufferHandle, 0, 0);

在创建时获取 m_batchId,在每次 BRG 绘制指令里使用(让着色器知道怎样从批次里抓取数据)。

*BatchRendererGroup.AddBatch 不是渲染指令。它的作用是注册一类批次用于未来的渲染指令。

GLES 异常

目前已生成了地面格子动画,上传了系统内存缓冲区的 shadow copy 上传到 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 函数来获取正确的 BRG 窗口大小。
来看看怎样修改代码才能在 GLES 上运行。对于地面格子,总数据量为 350 KiB。我们不能用单次 DrawInstanced(3200),因为着色器没法一次看完 350 KiB。所以需要在 UBO 里拆分数据以最大化每次绘制的实例数,并适应 16 KiB 的数据块。一块地面格子占 112 字节(两个矩阵和一种颜色),将 16384 除以 112,可得出 16 KiB 的数据块能塞入 146 个实例。要渲染 3200 个实例需要发出 21 次 DrawInstanced(146 个)和最后一次较小的 DrawInstanced(134 个)。
这下,350 KiB 的 UBO 会被分成 22 个 16 KiB 的窗口数据块,就像这样:

GLES 下,原始缓冲区属于 UBO(而非 SSBO)。3200 个实例的数据会被拆分成 22 个窗口。每次 DrawInstanced(146 个实例)会从一个 16 KiB 区域抓取数据。最后一块窗口仅包含 134 个实例,所以黄、绿、蓝区域间存在空缺。
*UBO 模式下,每个窗口的偏移量应与 BatchRendererGroup.GetConstantBufferOffsetAlignment().Typical 位于 4 到 256 字节范围内的对齐值相对齐。
出于 UBO 模式和 16 KiB 窗口的原因,需要注册 22 个 BatchID 才能储存每个窗口的偏移量。初始化代码需要执行一段循环(左右滑动查看):
// Register one BatchID per 16KiB window, using the right offsets
m_batchIDs = new BatchID[m_windowCount];
for (int b = 0; b < m_windowCount; b++)
{
batchMetadata[0] = CreateMetadataValue(objectToWorldID, 0, true); // matrices
batchMetadata[1] = CreateMetadataValue(worldToObjectID, m_maxInstancePerWindow * 3 * 16, true); // inverse matrices
batchMetadata[2] = CreateMetadataValue(colorID, m_maxInstancePerWindow * 3 * 2 * 16, true); // colors
int offset = b * m_alignedGPUWindowSize;
m_batchIDs[b] = m_BatchRendererGroup.AddBatch(batchMetadata, m_GPUPersistentInstanceData.bufferHandle, (uint)offset,(uint)m_alignedGPUWindowSize);
}

*为了让游戏支持 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 数据模型,所以不能只上传单块内存。对于残骸来说,缓冲区有 16384 项对象。假设所有 16384 块残骸都出现在屏幕上,GLES 模式下这就需要 113 个 16 KiB 大小的窗口。
但倘若某一帧上只有 5300 块残骸呢?如果每个窗口都有 146 项对象,那就需要上传头 36 个连续的 16 KiB 窗口,可以用单次 SetData(36x16 KiB)。最后一块窗口只需展示 44 块残骸。上传 44 个矩阵、反向矩阵和颜色值,加上三次 SetData 指令,共计需要发出四次 SetData 指令。

要上传 N 项对象最多需要四次 GfxBuffer.SetData 指令。
*即使是在 SSBO 模式下,只要对象数量小于最大数(比如 5300 块残骸就小于 16384块),那就必须发出至少三次 SetData 指令。请在 BRG_Container.UploadGpuData(int instanceCount)内了解详细的应用方法。

Main BRG 用户回调

BRG 的主要切入点是用户在创建时写下的剔除回调函数。函数原型是这样的(左右滑动查看):
public JobHandle OnPerformCulling(BatchRendererGroup rendererGroup, BatchCullingContext cullingContext, BatchCullingOutput cullingOutput, IntPtr userContext) 

回调代码应负责两件事:

  • 生成所有绘制指令至 BatchCullingOut 输出结构

  • 在剔除代码内使用(或不用)BatchCullingContext 只读结构提供的上下文信息
*回调函数返回有一个 JobHandle 以便在 async job 里执行这些操作。引擎会使用该句柄在需要时同步结果,让生成代码指令不会阻拦主线程。
BatchCullingContext 包含摄像机矩阵、摄像机视锥排布等信息,这些都是剔除及精简绘制指令所需的数据。例中,所有对象(地面格子和残骸)都能出现在摄像机视图内,所以没必要动用剔除代码。
BatchCullingOutputDrawCommands 结构包含各种数据,包括数组。用户需要负责为这些数组分配本机内存。引擎则负责在数据被消耗后释放这些内存。

BatchCullingOutputDrawCommands 结构文档:

https://docs.unity3d.com/2022.3/Documentation/ScriptReference/Rendering.BatchCullingOutputDrawCommands.html
内存分配应为 Allocator.TempJob 类型(左右滑动查看):
private static T* Malloc<T>(uint count) where T: unmanaged
{
return (T*)UnsafeUtility.Malloc(
UnsafeUtility.SizeOf<T>() * count,
UnsafeUtility.AlignOf<T>(),
Allocator.TempJob);
}

您需要分配的第一个数组应为可见度 int 数组。示例中假定所有东西皆可见,所以用递增数值填充了该数组,如{0,1,2,3,4,...}。

生成绘制指令

BRG 绘制指令类似于 GPU DrawInstanced 调用。此处最需要分配和填充的数组是 BatchDrawCommand。假设当前帧上有 4737 块残骸。
m_maxInstancePerWindow 在 GLES 模式下为 146。可以用 m_instanceCount 的封顶值除以 m_maxInstancePerWindow 来计算出绘制指令的次数并分配好缓冲区(左右滑动查看):
int drawCommandCount = (m_instanceCount + m_maxInstancePerWindow - 1) / m_maxInstancePerWindow;
drawCommands.drawCommands = Malloc<BatchDrawCommand>((uint)drawCommandCount);

为了避免将相似的参数复制到多个绘制指令中,BatchCullingOutputDrawCommands 有一个 BatchDrawRange 结构体数组,可以在 BatchDrawRange.filterSettings 里设定各种参数,比如 renderingLayerMask、接收阴影标签等。

BatchDrawRange 结构文档:

https://docs.unity3d.com/2022.3/Documentation/ScriptReference/Rendering.BatchDrawRange.html
由于所有绘制指令共享相同的渲染设定,可以在绘制指令 0 上分配好一个 DrawCommandRange 结构,并包含进所有 drawCommandCount 指令(左右滑动查看):
drawCommands.drawRanges[0] = new BatchDrawRange
{
drawCommandsBegin = 0,
drawCommandsCount = (uint)drawCommandCount,
filterSettings = new BatchFilterSettings
{
renderingLayerMask = 1,
layer = 0,
motionMode = MotionVectorGenerationMode.Camera,
shadowCastingMode = m_castShadows ? ShadowCastingMode.On : ShadowCastingMode.Off,
receiveShadows = true,
staticShadowCaster = false,
allDepthSorted = false
}
};

接着,填入绘制指令。每个 BatchDrawCommand 都包含一个 meshID、batchID(以利用起元数据)和 materialID。它还包括可见度 int 数组缓冲区的起始偏移量。由于不需要视锥剔除,可见度数组可以直接以{0,1,2,3,...}填充。所有绘制指令这下都会参照这个{0,1,2,3,...}中间层,每次BatchDrawCommand也都会以0作为数组的起始偏移量。下方代码会分配并填充所有需要的绘制指令(左右滑动查看):

drawCommands.drawCommands = Malloc<BatchDrawCommand>((uint)drawCommandCount);
int left = m_instanceCount;
for (int b = 0; b < drawCommandCount; b++)
{
int inBatchCount = left > maxInstancePerDrawCommand ? maxInstancePerDrawCommand : left;
drawCommands.drawCommands[b] = new BatchDrawCommand
{
visibleOffset = (uint)0, // all draw command is using the same {0,1,2,3...} visibility int array
visibleCount = (uint)inBatchCount,
batchID = m_batchIDs[b],
materialID = m_materialID,
meshID = m_meshID,
submeshIndex = 0,
splitVisibilityMask = 0xff,
flags = BatchDrawCommandFlags.None,
sortingPosition = 0
};
left -= inBatchCount;
}

直接操作 BatchRendererGroup 确实需要不少工作,但是能够省掉自定义着色器或其它额外软件包的步骤。在部分情况下,比如需要渲染大量 CPU 模拟对象,而对象又带有自定义实例化属性,BatchRendererGroup 就是优选。

点击阅读原文,即刻下载项目。您也可以前往 Unity 中国官方开发者社区,与我们讨论使用 C# Job System 和 Burst Compiler 在低端 CPU 上全速处理所有动画和交互的更多细节。



长按关注

Unity 官方微信

第一时间了解Unity引擎动向,学习进阶开发技能







 点击“阅读原文”,下载示例项目 


您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存