咱们在项目中,优化性能最重要的一个环节就是合批处理,,在早期Unity中,对于合批的处理手段主要有三种:
Static Batching
Dynamic Batching
GPU Instancing
如今Unity 为了提升合批范围与效率,提供了新的合批方式(SRP Batcher)。
一:初识 Draw Call、Batcher、 Sat pass Call
衡量CPU处理渲染速率的参考值
Draw Call
衡量CPU在渲染时的资源消耗大多都是是通过Draw Call的数量 因为CPU在渲染流水线中的处理阶段是应用程序阶段,主要是做一些数据的准备与提交工作,而Draw Call的数量代表了CPU向GPU提交的数据的次数,Draw Call本身只是一些数据流的字节,主要的性能消耗在于CPU的数据准备阶段。
Batcher
由于合批的出现,并不会每一个渲染对象都会产生一个Draw Call,所以这个时候就提出了一个新的衡量标准:Batcher
Sat pass Call
CPU
在渲染阶段,性能消耗的峰值一般不在于Draw Call
,而往往存在于对其数据准备的阶段,因此单纯以数据的提交数量为衡量标准并不准确,同时在数据准备的过程中,假如前后两个材质发生了变化,会更大幅度的消耗性能,这也是整个CPU
在渲染阶段最消耗性能的步骤,因此Unity
通过Set Pass Call
来作为性能消耗的标准。 (注意:实际上并没有减少Draw Call
)
二:主流合批技术详讲
1、Static Batching
原理:
- 将静态游戏对象转换到世界空间并为它们构建一个共享的顶点和索引缓冲区。
- 如果已启用
Optimized Mesh Data
,则Unity
会在构建顶点缓冲区时删除任何着色器变体未使用的任何顶点元素。为了执行此操作,系统会进行一些特殊的关键字检查;例如,如果Unity
未检测到LIGHTMAP_ON
关键字,则会从批处理中删除光照贴图UV
- 针对同一批次中的可见游戏对象,
Unity
会执行一系列简单的绘制调用,每次调用之间几乎没有状态变化。在技术上,Unity
不会减少API
绘制调用,而是减少它们之间的状态变化(这正是消耗大量资源的部分)。在大多数平台上,批处理限制为 64k 个顶点和 64k 个索引(OpenGLES
上为 48k 个索引,在macOS
上为 32k 个索引)
简单的来说,Static Batching
通过对一些小的网格进行合并备份到内存中,当执行渲染操作时,CPU
一次性将合并的内存的发送给GPU
来减少Draw Call
的数量,不过这样做有一定的限制:
- 对象必须是静态的,不可移动
- 合并的对象使用相同的材质
同时在使用Static Batching
时需要额外的内存来存储组合的几何体,导致内存在一定程度上的浪费。简单来说,作为通过内存的上的置换可以获得时间上的高效运行,需要根据实际情况来谨慎添加渲染对象,避免获取CPU
性能优势时产生不必要的内存问题
而关于Static Batching
的使用,首先需要在Project Setting
中的Player
选项中勾选Static Batching
:
接下来就可以在Inspector
面板中对需要Static Batching
的对象勾选上Batching Static
,具体位置如下图所示:
- 无法参与批处理情况
- 改变Renderer.material将会造成一份材质的拷贝,因此会打断批处理,你应该使用Renderer.sharedMaterial来保证材质的共享状态。
相同材质批处理断开情况
位置不相邻且中间夹杂着不同材质的其他物体,不会进行同批处理,这种情况比较特殊,涉及到批处理的顺序。
拥有lightmap的物体含有额外(隐藏)的材质属性,比如:lightmap的偏移和缩放系数等。所以,拥有lightmap的物体将不会进行同批处理(除非他们指向lightmap的同一部分)。
总结: 虽然静态合批可以有效地减少批次,但是可能无法减少DC。
2、Dynamic Batching
Dynamic Batching
同样是可以对于有共同材质的对象进行相关的合并,但是其对象可以为动态的,而且这一过程是动态进行的,只需要在Project Setting
中的Player
中勾选上Dynamic Batching
即可,但是注意,在URP
模板中,这一选项移到了URP
的配置文件中,具体位置如图:
虽然Dynamic Batching
的设置简单,但是其使用条件却很苛刻,Unity
官方在文档中详细罗列限制条件:
- 批处理动态游戏对象在每个顶点都有一定开销,因此批处理仅会应用于总共包含不超过 900 个顶点属性且不超过 300 个顶点的网格。如果着色器使用顶点位置、法线和单个 UV,最多可以批处理 300 个顶点,而如果着色器使用顶点位置、法线、UV0、UV1 和切线,则只能批处理 180 个顶点。
- 如果游戏对象在变换中包含镜像,则不会对这些对象进行批处理(例如,具有 +1 缩放的游戏对象 A 和具有 –1 缩放的游戏对象 B 无法一起接受批处理)。即使游戏对象基本相同,使用不同的材质实例也会导致游戏对象不能一起接受批处理。例外情况是阴影投射物渲染。
- 带有光照贴图的游戏对象具有其他渲染器参数:光照贴图索引和光照贴图偏移/缩放。通常,动态光照贴图的游戏对象应指向要批处理的完全相同的光照贴图位置。 多 pass 着色器会中断批处理。
- 几乎所有的 Unity 着色器都支持前向渲染中的多个光照,有效地为它们执行额外 pass。“其他每像素光照”的绘制调用不进行批处理。 旧版延迟(光照 pre-pass)渲染路径会禁用动态批处理,因为它必须绘制两次游戏对象
批处理中断情况:
位置不相邻且中间夹杂着不同材质的其他物体,不会进行同批处理,这种情况比较特殊,涉及到批处理的顺序,我的另一篇文章有详解。
物体如果都符合条件会优先参与静态批处理,再是GPU Instancing,然后才到动态批处理,假如物体符合前两者,此次批处理都会被打断。
GameObject之间如果有镜像变换不能进行合批,例如,"GameObject A with +1 scale and GameObject B with –1 scale cannot be batched together"。
拥有lightmap的物体含有额外(隐藏)的材质属性,比如:lightmap的偏移和缩放系数等。所以,拥有lightmap的物体将不会进行批处理(除非他们指向lightmap的同一部分)。
使用Multi-pass Shader的物体会禁用Dynamic batching,因为Multi-pass Shader通常会导致一个物体要连续绘制多次,并切换渲染状态。这会打破其跟其他物体进行Dynamic batching的机会。
我们知道能够进行合批的前提是多个GameObject共享同一材质,但是对于Shadow casters的渲染是个例外。仅管Shadow casters使用不同的材质,但是只要它们的材质中给Shadow Caster Pass使用的参数是相同的,他们也能够进行Dynamic batching。
Unity的Forward Rendering Path中如果一个GameObject接受多个光照会为每一个per-pixel light产生多余的模型提交和绘制,从而附加了多个Pass导致无法合批,如下图:
可以接收多个光源的shader,在受到多个光源是无法合批
总结:同时使用的Shader
一定要是单Pass
的。同时因为单Pass
的限定,对于延迟渲染来说,由于将光照分离到单独的Pass
去处理而导致受光的对象完全没有办法进行动态合批的操作,所以会直接屏蔽掉Dynamic Batching。
3、GPU Instanceing
适用前提:
兼容的平台及API
相同的Mesh与Material
支持不同的材质球属性块(MaterialPropertyBlock),用于解决动态修改材质的某些属性后无法合批的问题(因为动态改了相当于不同材质了)
不支持SkinnedMeshRenderer
Shader支持GPU Instancing
缩放为负值的情况下,会不参与加速。
受限于常量缓冲区在不同设备上的大小的上限,移动端支持的个数可能较低。
只支持一盏实时光,要在多个光源的情况下使用实例化,我们别无选择,只能切换到延迟渲染路径。为了能够让这套机制运作起来,请将所需的编译器指令添加到我们着色器的延迟渲染通道中。
效果:
- 批渲染Mesh相同的那些物体,以降低DrawCall数
- 这些物体可以有不同的参数,比如颜色与缩放,不同颜色要用(MaterialPropertyBlock)实现。
使用 GPU Instanceing
可使用少量绘制调用一次绘制(或渲染)同一网格的多个副本。它对于绘制诸如建筑物、树木和草地之类的在场景中重复出现的对象非常有用:
GPU Instanceing
在每次绘制调用时仅渲染相同的网格,但每个实例可以具有不同的参数(例如,颜色或比例)以增加变化并减少外观上的重复。GPU Instanceing
可以降低每个场景使用的绘制调用数量。可以显著提高项目的渲染性能。
GPU Instanceing
同样有一些使用限制条件:
Unity
自动选取要实例化的网格渲染器组件和Graphics.DrawMesh
调用。请注意,不支持SkinnedMeshRenderer
Unity
仅在单个GPU
实例化绘制调用中批量处理那些共享相同网格和相同材质的游戏对象。使用少量网格和材质可以提高实例化效率。要创建变体,请修改着色器脚本为每个实例添加数据
参考文献:GPU实例化GPU 实例化 - Unity 手册GPU实例化
上面是官方文档对于GPU Instanceing
的一些描述,可以看出与其他两种合批手段不同的是,除了材质相同之外,其主要是对于使用同一网格的物体有效,所以正如名字的Instanceing
那样,是通过GPU
直接对于某一物体进行实例化来降低CPU
对场景物体的数据命令准备所产生的性能消耗的技术手段。
原理:
简单地说就是一次对具有相同网格物体的多个对象发出一次绘图调用。CPU收集所有每个对象的变换和材质属性,并将它们放入数组中,然后发送给GPU。然后,GPU遍历所有条目,并按提供顺序对其进行渲染。
如何使用GUPInstancing:
//熊猫悟道
Shader "XiongMaoWuDao/Gpu_Instancing"
{
Properties{
_BaseColor("Color", Color) = (1.0, 1.0, 1.0, 1.0)
}
SubShader {
Pass {
HLSLPROGRAM
// GUIInstancing 调用
// 一次对具有相同网格物体的多个对象发出一次绘图调用。
// CPU收集所有每个对象的变换和材质属性,并将它们放入数组中,然后发送给GPU(SetPassCall)。
// 最后,GPU遍历所有条目,并按提供顺序对其进行渲染。
#pragma multi_compile_instancing
#pragma vertex vert
#pragma fragment frag
#include "UnlitPass.hlsl" // 里面定义了顶点着色器以及片元着色器
ENDHLSL
}
}
}
// --------------以下是UnlitPass.hlsl里的代码-----------------
// 为了支持GUIInstancing,这里CBUFFER_START改成用UNITY_INSTANCING_BUFFER_START宏
UNITY_INSTANCING_BUFFER_START(UnityPerMaterial)
UNITY_DEFINE_INSTANCED_PROP(float4, _BaseColor) // 把所有实例的_BaseColor放入内存缓冲区
UNITY_INSTANCING_BUFFER_END(UnityPerMaterial)
// 顶点着色器输入
struct Attributes{
float3 positionOS : POSITION;
UNITY_VERTEX_i_INSTANCE_ID // 启用GUIInstancing的时候,用此宏,可以让顶点传入实例化id
};
// 顶点着色器输出
struct Varyings {
float4 positionCS : SV_POSITION;
UNITY_VERTEX_i_INSTANCE_ID // 启用GUIInstancing的时候,用此宏,让顶点着色器输出实例化id
};
Varyings vert(Attributes i){
Varyings o;
UNITY_SETUP_INSTANCE_ID(i); // 从i中提取对象索引,并将其存储在其他GUIInstancing相关宏所依赖的全局静态变量中
UNITY_TRANSFER_INSTANCE_ID(i, o); // 把i中的实例化id转换到片元着色器中用的实例化id
float3 positionWS = TransformObjectToWorld(i.positionOS);
o.positionCS = TransformWorldToHClip(positionWS);
return o;
}
float4 frag(Varyings i) : SV_TARGET{
UNITY_SETUP_INSTANCE_ID(i); // 从i中提取对象索引,并将其存储在其他GUIInstancing相关宏所依赖的全局静态变量中
return UNITY_ACCESS_INSTANCED_PROP(UnityPerMaterial, _BaseColor); // 根据实例id从_BaseColor数组中取出对应的_BaseColor
}
当需要创建海量mesh的时候,一般不要用实例化游戏物体的方式,这样会比较消耗性能,推荐使用Graphics.DrawMeshInstanced来创建。
例如 :
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
// 动态生成大量球体mesh,用来测试GPUInstancing
public class MeshBall : MonoBehaviour
{
static int baseColorId = Shader.PropertyToID("_BaseColor");
[SerializeField]
Mesh mesh = default; // 手动拖入mesh
[SerializeField]
Material material = default; // 手动拖入支持GPUInstancing的材质球
Matrix4x4[] matrices = new Matrix4x4[500];
Vector4[] baseColors = new Vector4[500];
MaterialPropertyBlock block;
private void Awake()
{
for (int i = 0; i < matrices.Length; i++)
{
matrices[i] = Matrix4x4.TRS(Random.insideUnitSphere * 10f, Quaternion.identity, Vector3.one);
baseColors[i] = new Vector4(Random.value, Random.value, Random.value, 1f);
}
}
private void Update()
{
if(block == null)
{
block = new MaterialPropertyBlock();
block.SetVectorArray(baseColorId, baseColors);
}
Graphics.DrawMeshInstanced(mesh, 0, material, matrices, 500, block);
}
}
注意: Graphics.DrawMeshInstanced()这方法还有两问题
1.一次最多画1023个元素,如果超出就会报错,所以需要将草进行分类管理。
2.它不提供裁切的功能,也就是说摄像机看不到的地方,这些草是不会被剔除掉的,依然会被渲染。
解决这个问题,为了避免运行时暴力的for循环来判断是否在视野内,我采取的方法是预先将场景分成20X20若干个格子(可根据游戏的可视范围而定)根据玩家的位置,始终只渲染周围9个格子内的草元素,这样将大幅度减少运行时for循环的次数。
如果每个草的顶点色是不一样的怎么办呢?可以用MaterialPropertyBlock来让同一个材质求有不同的属性。
4、SRP Batcher
适用前提:
需要是同一个shader变体,可以是不同的材质球,项目需要使用自定义渲染管线,Shader代码必须兼容SRP Batcher。
但是不支持用材质球属性块(MaterialPropertyBlock)
渲染的物体必须是一个mesh或者skinned mesh。不能是粒子。
效果:
可以有效降低SetPassCall(设置渲染状态)的数目,用于CPU性能优化。
开启SRP Batch: 要使用 SRP Batcher
,项目必须使用可编程渲染管线。可编程渲染管线可以是:
- 通用渲染管线 (
URP
) - 高清渲染管线 (
HDRP
) - 自定义
SRP
由于后两种方式不常用,所以本文章会基于URP
模板来介绍,而关于URP
的具体细节,可以查看该文章:Unity 升级项目到Urp(通用渲染管线)以及画面后处理
当我们在项目中使用URP模板后,就可以在资源目录中找到当前项目的URP
配置文件,在其中可以看到SRP Batcher
的控制选项:
同时当项目在URP
模板下时,Dynamic Batching
的开关控制选项也被迁移到了配置文件,但是相比于默认渲染管线该技术默认是被关闭的,因为其相对于SRP Batcher
来说并没有优势
参考文献:可编程渲染管线 SRP Batcher - Unity 手册
SRP Batcher原理:
Unity
中,可以在一帧内的任何时间修改任何材质的属性。但是,这种做法有一些缺点。例如,DrawCall
使用新材质时,要执行许多作业。因此,场景中的材质越多,Unity
必须用于设置GPU
数据的 CPU
也越多。解决此问题的传统方法是减少 DrawCall
的数量以优化CPU
渲染成本,因为 Unity
在发出 DrawCall
之前必须进行很多设置。实际的 CPU
成本便来自该设置,而不是来自 GPU DrawCall
本身(DrawCall
只是 Unity
需要推送到 GPU
命令缓冲区的少量字节)
正如Set Pass Call
的描述那样,游戏在渲染阶段CPU
的性能消耗主要在与材质切换阶段的一些作业,而SPR Batcher
通过在GPU
的数据缓冲区的持久化存储来换取CPU
的新材质的准备时间,从而降低CPU
的数据准备压力
SRP Batcher
通过批处理一系列 Bind
和Draw GPU
命令来减少 DrawCall
之间的 GPU
设置,具体过程如图所示:
为了获得最大渲染性能,这些批次必须尽可能大。为了实现这一点,可以使用尽可能多具有相同着色器的不同材质,但是必须使用尽可能少的着色器变体
在内渲染循环中,当 Unity
检测到新材质时,CPU
会收集所有属性并在 GPU
内存中设置不同的常量缓冲区。GPU
缓冲区的数量取决于着色器如何声明其 CBUFFER 。
为了在场景使用很多不同材质但很少使用着色器变体的一般情况下加快速度,SRP
在原生集成了范例(例如GPU
数据持久性)。
SRP Batcher
是一个低级渲染循环,使材质数据持久保留在 GPU
内存中。如果材质内容不变,SRP Batcher
不需要设置缓冲区并将缓冲区上传到 GPU
。实际上,SRP Batcher
会使用专用的代码路径来快速更新大型 GPU
缓冲区中的 Unity
引擎属性,如下所示:
这是 SRP Batcher
渲染工作流程。SRP Batcher
使用专用的代码路径来快速更新大型 GPU
缓冲区中的 Unity
引擎属性。在此处,CPU
仅处理上图中标记为 Per Object large buffer
的 Unity
引擎属性。所有材质在 GPU
内存中都有持久的 CBUFFER
,可供随时使用。这样会加快渲染速度,原因是: 现在,所有材质内容都持久保留在 GPU
内存中。 专用代码针对所有每对象属性,管理着一个大型的每对象GPU CBUFFER
。
优化原理:
简单的说,就是把同一种shader对应的材质球的材质、颜色通通放到一个缓冲区中,不用每帧设置给GPU,每帧仅仅设置坐标、缩放、转换矩阵等变量给GPU。
SRP Batcher 限制条件:
为了使 SRP Batcher
代码路径能够渲染对象:
- 渲染的对象必须是网格或蒙皮网格。该对象不能是粒子。
- 着色器必须与
SRP Batcher
兼容。HDRP
和URP
中的所有光照和无光照着色器均符合此要求(这些着色器的“粒子”版本除外)。 为了使着色器与 SRP Batcher 兼容: - 必须在一个名为
UnityPerDraw
的CBUFFER
中声明所有内置引擎属性。例如unity_ObjectToWorld
或unity_SHAr
- 必须在一个名为
UnityPerMaterial
的CBUFFER
中声明所有材质属性
如何让Shader支持SRPBatcher:
1、必须声明所有内建引擎properties 在一个名为"UnityPerDraw"的CBUFFER里。
// 如果需要支持SRP合批,内置引擎属性必须在“UnityPerDraw”的 CBUFFER 中声明
CBUFFER_START(UnityPerDraw)
float4x4 unity_ObjectToWorld; // 模型空间->世界空间,转换矩阵(uniform 值。它由GPU每次绘制时设置,对于该绘制期间所有顶点和片段函数的调用都将保持不变)
float4x4 unity_WorldToObject; // 世界空间->模型空间
float4 unity_LODFade;
real4 unity_WorldTransformParams; // 包含一些我们不再需要的转换信息,real4向量,它本身不是有效的类型,而是取决于目标平台的float4或half4的别名。(需要引入unityURP库里的"Packages/com.unity.render-pipelines.core/ShaderLibrary/Common.hlsl"才能使用real4)
CBUFFER_END
注意:URP内置的UnityInput.hlsl里自带更全面的代码。也就是说,如果你的代码有引用或间接引用UnityInput.hlsl,那就不用做这一步了。
2、必须声明所有材质properties在一个名为"UnityPerMaterial"的CBUFFER里。
// 使用核心RP库中的CBUFFER_START宏定义,因为有些平台是不支持常量缓冲区的。这里不能直接用cbuffer UnityPerMaterial{ float4 _BaseColor };
// Properties大括号里声明的所有变量如果需要支持合批,都需要在UnityPerMaterial的CBUFFER中声明所有材质属性
// 在GPU给变量设置了缓冲区,则不需要每一帧从CPU传递数据到GPU,仅仅在变动时候才需要传递,能够有效降低set pass call
CBUFFER_START(UnityPerMaterial)
float4 _BaseColor; // 将_BaseColor放入特定的常量内存缓冲区
CBUFFER_END
若需要配合GPUInstancing则需要改写为
UNITY_INSTANCING_BUFFER_START(UnityPerMaterial)
UNITY_DEFINE_INSTANCED_PROP(float4, _BaseColor) // 把所有实例的_BaseColor以数组的形式声明并放入内存缓冲区
UNITY_INSTANCING_BUFFER_END(UnityPerMaterial)