在低端设备上实现高帧率!BatchRendererGroup示例详解
本文基于 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 及以上版本,即可点击阅读原文或访问如下链接下载项目:
设定场景
背景里的地面由许多方块构成,它们都带有上下移动的动画。
飞船可以在屏幕上水平移动,并向彩色圆球发射导弹。(可以通过点击屏幕来更快地发射导弹)
当一枚导弹飞过地面上方时会产生一块磁场,略微抬高并高亮显示方块,地面上的残骸碎块也会被抛到空中。
当导弹击中球体时,球体会爆炸成彩色的碎块。
当残骸碎块撞击到地面,受冲击的方块会闪白光。碎块越多,方块的颜色就会变得越深。另外,碎块的重量会导致地面凹陷。
渲染
需要提供带有矩阵的托管内存组来执行垃圾数据回收。并且,即使着色器不需要逆矩阵,CPU 仍然会进行计算。
若想自定义除 obj2world 矩阵外的任何属性(像如每个实例具有一种颜色),就必须用 Shader Graph 或从零写一个自定义 Shader。
矩阵或自定义数据在每次绘制时必须被上传至 GPU 内存。Graphics.DrawMeshInstanced 的 GPU 内存数据不能持续存在。特定情况下,这会造成巨大的性能冲击。
BRG 着色器数据模型
BRG 元数据
可以用 BRG 元数据描述每个实例的哪些属性(如 obj2world、world2obj、baseColor)能有自定义的数值。其他属性的数值在所有实例上都是相同的(仍旧以传统的 UnityPerMaterial cbuffer 作为数据源)。
BRG 剔除与可见度索引
BRG 派生着色器一定会用可见度中间层从原始缓冲区获取数据。可以根据需要为每一帧生成可见度中间层缓冲区。
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 上运行得稍快一些。
渲染地面格
渲染爆炸残骸
所有残骸都带有简单的重力物理效果,还可与地面格子互动。所有运算都用 Burst C# jobs 在 CPU 上运行。
BRG 矩阵格式
为了改善 GPU 带宽,BRG 矩阵只有 48 字节(即三个 float4)。
地面格子动画
BRG 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 绘制指令里使用(让着色器知道怎样从批次里抓取数据)。
GLES 异常
// 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 来获取准确的数值)。
上传数据
Main BRG 用户回调
public JobHandle OnPerformCulling(BatchRendererGroup rendererGroup, BatchCullingContext cullingContext, BatchCullingOutput cullingOutput, IntPtr userContext)
回调代码应负责两件事:
生成所有绘制指令至 BatchCullingOut 输出结构
在剔除代码内使用(或不用)BatchCullingContext 只读结构提供的上下文信息
BatchCullingOutputDrawCommands 结构文档:
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,...}。
生成绘制指令
int drawCommandCount = (m_instanceCount + m_maxInstancePerWindow - 1) / m_maxInstancePerWindow;
drawCommands.drawCommands = Malloc<BatchDrawCommand>((uint)drawCommandCount);
为了避免将相似的参数复制到多个绘制指令中,BatchCullingOutputDrawCommands 有一个 BatchDrawRange 结构体数组,可以在 BatchDrawRange.filterSettings 里设定各种参数,比如 renderingLayerMask、接收阴影标签等。
BatchDrawRange 结构文档:
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引擎动向,学习进阶开发技能
点击“阅读原文”,下载示例项目