SRPBatcher > GPUInstancing > 动态合批
需要是同一个shader变体,可以是不同的材质球,项目需要使用自定义渲染管线,Shader代码必须兼容SRP Batcher。
但是不支持用材质球属性块(MaterialPropertyBlock)
渲染的物体必须是一个mesh或者skinned mesh。不能是粒子。
可以有效降低SetPassCall(设置渲染状态)的数目,用于CPU性能优化
简单的说,就是把同一种shader对应的材质球的材质、颜色通通放到一个缓冲区中,不用每帧设置给GPU,每帧仅仅设置坐标、缩放、转换矩阵等变量给GPU。
1.在过去的渲染架构中,Unity采取对一个材质分配一个CBuffer(or 一个Pass,这不是重点),这个CBuffer 包括shader里的显性的参数(你自己定义的uniform参数)和隐性的参数(unity固定的uniform modelMatrix,modelviewMatrix之类。),所以每一次drawcall,要更新这个CBuffer
2.在SRP渲染架构中,Unity采取的策略是对一个材质分配一个半CBuffer,为什么是一个半呢?首先shader的显性参数分配到一个CBuffer里,shader的隐性参数则是N个物体共享一个CBuffer。比如一个shader 对应 10个物体,在SRP渲染架构中,一共分配了11个CBuffer,其中10个分别存这10个物体材质中定义的显性参数。然后分配一个大的共享CBuffer,把这10个物体的modelMatrix这类隐性参数都放在一起。
乍一看这不是负优化吗?老架构更新10个CBuffer,你现在更新11个。
这个策略叫做动静分离,材质的显性参数大部分都是低频更新的(你总不能一个游戏所有的材质参数每桢都改变吧),所以在理想情况下,这10个放显性参数的CBuffer就基本不修改。而modelMatrix之类的隐性参数是高频更新的,很多模型会动来动去。他们被批量放在一个CBuffer里,一次更新可以更新一片。
如下图:大型的共享CBuffer和每个材质自己的CBuffer都有各自专门的代码进行更新,大部分情况只需要更新大型共享CBuffer,从而降低了一帧内SetPassCall的数目,和GUIInstancing对比,由于ConstantBuffer里不包含位置等信息(GUIInstancing是包括的),仅包括了显性属性,所以一次drawCall无法渲染所有物体,所以SRPbatcher的drawCall次数是没有降低的。而GUIInstancing需要相同的Mesh和材质球,条件更苛刻,但可以降低drawCall。
标准流程和SRPBatcher流程的区别:
以上,据Unity官方宣传 SRP Batcher 可以取得 1.2~4 倍的 CPU渲染时间提升(仅提升CPU部分,不是渲染耗时提升这么多,还得看cpu瓶颈占多大比重)
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)
兼容的平台及API
相同的Mesh与Material
支持不同的材质球属性块(MaterialPropertyBlock),用于解决动态修改材质的某些属性后无法合批的问题(因为动态改了相当于不同材质了)
不支持SkinnedMeshRenderer
Shader支持GPU Instancing
简单地说就是一次对具有相同网格物体的多个对象发出一次绘图调用。CPU收集所有每个对象的变换和材质属性,并将它们放入数组中,然后发送给GPU。然后,GPU遍历所有条目,并按提供顺序对其进行渲染。
Unity会在运行时对于正在视野中的符合要求的所有对象,将其位置、缩放、uv偏移、lightmapindex等相关信息放到CBuffer(Constant Buffer)中,然后统一保存在显存中的“统一/常量缓冲器”中,当一个对象作为实例送入渲染流程时,在执行DrawCall操作后,根据传入的InstanceID从显存中取出当前实例对应的部分共享信息与从GPU常量缓冲器中取出对应对象的相关信息一并传递到下一渲染阶段,与此同时,不同的着色器阶段都可以从缓存区中直接获取到需要的常量,不用设置两次常量。总的来说就是一次性存入所有对象的公共信息到CBuffer,后续根据id来取,不用每次都发数据到GPU。
流程图:
Shader "Custom RP/Unlit"
{
Properties{
_BaseColor("Color", Color) = (1.0, 1.0, 1.0, 1.0)
}
SubShader {
Pass {
HLSLPROGRAM
// 让shader支持GUIInstancing
// 一次对具有相同网格物体的多个对象发出一次绘图调用。
// CPU收集所有每个对象的变换和材质属性,并将它们放入数组中,然后发送给GPU(SetPassCall)。
// 最后,GPU遍历所有条目,并按提供顺序对其进行渲染。
#pragma multi_compile_instancing
#pragma vertex UnlitPassVertex
#pragma fragment UnlitPassFragment
#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_INPUT_INSTANCE_ID // 启用GUIInstancing的时候,用此宏,可以让顶点传入实例化id
};
// 顶点着色器输出
struct Varyings {
float4 positionCS : SV_POSITION;
UNITY_VERTEX_INPUT_INSTANCE_ID // 启用GUIInstancing的时候,用此宏,让顶点着色器输出实例化id
};
Varyings UnlitPassVertex(Attributes input){
Varyings output;
UNITY_SETUP_INSTANCE_ID(input); // 从input中提取对象索引,并将其存储在其他GUIInstancing相关宏所依赖的全局静态变量中
UNITY_TRANSFER_INSTANCE_ID(input, output); // 把input中的实例化id转换到片元着色器中用的实例化id
float3 positionWS = TransformObjectToWorld(input.positionOS);
output.positionCS = TransformWorldToHClip(positionWS);
return output;
}
float4 UnlitPassFragment(Varyings input) : SV_TARGET{
UNITY_SETUP_INSTANCE_ID(input); // 从input中提取对象索引,并将其存储在其他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[1023];
Vector4[] baseColors = new Vector4[1023];
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, 1023, block);
}
}
Graphics.DrawMeshInstanced()这方法还有两问题
1.一次最多画1023个元素,如果超出就会报错,所以需要将草进行分类管理。
2.它不提供裁切的功能,也就是说摄像机看不到的地方,这些草是不会被剔除掉的,依然会被渲染。
解决这个问题,为了避免运行时暴力的for循环来判断是否在视野内,我采取的方法是预先将场景分成20X20若干个格子(可根据游戏的可视范围而定)根据玩家的位置,始终只渲染周围9个格子内的草元素,这样将大幅度减少运行时for循环的次数。
如果每个草的顶点色是不一样的怎么办呢?可以用MaterialPropertyBlock来让同一个材质求有不同的属性
静态合批最关重要的一件事就是合并网格。
游戏引擎会把渲染器中的网格信息取出来,之后对网格上的顶点进行空间变换,变换到世界空间下,合并成一个新的大网格。即会形成一个新的大Vertex Buffer和Index Buffer(这两个缓冲区一旦确定了就不会再被修改了),最终会在一个批次中提交这些顶点信息。(如果之后需要对合并的网格进行空间变换,由于已经在同一坐标系了,所以直接对合并的节点进行矩阵变换即可。)
在Unity中,直接勾选物体的static(自动静态合批)或者通过代码调用合并函数(手动静态合批),均可以实现网格合并。
Unity会把合并网格的相关信息存在场景文件(.scene)下,所以对于那些存在静态物体的场景文件一般来说会大很多。
因此静态合批实际上是属于空间换时间的一种策略。并且不仅仅是文件增大,在运行时,静态合批的网格会常驻内存,如果存在大量的静态合批网格,那么内存压力会十分可怕。
还有一点需要注意,不同引擎、平台对于最终合并网格生成的大网格顶点数和索引数是有限制的。一旦超过了限制,就会合并成另一个新的大网格。
静态合批虽然通过合并网格生成了新的大Vertex Buffer和Index Buffer,从而可以在一个批次中提交顶点信息,但是这并不意味着这一个批次中只有一个DC。也就是说,虽然静态合批可以有效地减少批次,但是可能无法减少DC。
举个例子,当一个合并过的网格中某一部分发生剔除或是被隐藏时,其对应Vertex Buffer和Index Buffer并不会被修改。引擎会选择将整个大网格拆分成若干个小部分来进行分次渲染,每次小渲染都是一个DC,通过调整每个DC来跳过不显示的内容。
即使是一个批次中存在多个DC,但由于这些DC之间没有渲染状态的切换,所以渲染效率还是很高的。
(上图出自「Don里个冬」的原创文章https://blog.csdn.net/weixin_42186870/article/details/117675164)
Static batching也会带来一些性能的负面影响。Static batching会导致应用打包之后体积增大,应用运行时所占用的内存体积也会增大。
另外,在很多不同的GameObject引用同一模型的情况下,如果不开启Static batching,GameObject共享的模型会在应用程序包内或者内存中只存在一份,绘制的时候提交模型顶点信息,然后设置每一个GameObjec的材质信息,分别调用渲染API绘制。开启Static batching,在Unity执行Build的时候或手动调用代码合批的时候,场景中所有引用相同模型的GameObject都必须将模型顶点信息复制,并经过计算变化到最终在世界空间中,存储在最终生成的Vertex buffer中。这就导致了打包的体积或运行时内存的占用增大。例如,在茂密的森林级别将树标记为静态会严重影响内存[3]。可以在unity的Profiler窗口中的Memory->Detailed->SceneMemory->Mesh查看静态合批占的内存
在使用相同材质球的情况下,Unity会在运行时对于正在视野中的符合条件的动态对象在一个Draw call内绘制,所以会降低Draw Calls的数量。
Dynamic batching的原理也很简单,在进行场景绘制之前将所有的共享同一材质的模型的顶点信息变换到世界空间中,然后通过一次Draw call绘制多个模型,达到合批的目的。模型顶点变换的操作是由CPU完成的,所以这会带来一些CPU的性能消耗。并且计算的模型顶点数量不宜太多,否则CPU串行计算耗费的时间太长会造成场景渲染卡顿,所以Dynamic batching只能处理一些小模型。
Dynamic batching在降低Draw call的同时会导致额外的CPU性能消耗,所以仅仅在合批操作的性能消耗小于不合批,Dynamic batching才会有意义。而新一代图形API( Metal、Vulkan)在批次间的消耗降低了很多,所以在这种情况下使用Dynamic batching很可能不能获得性能提升。Dynamic batching相对于Static batching不需要预先复制模型顶点,所以在内存占用和发布的程序体积方面要优于Static batching。但是Dynamic batching会带来一些运行时CPU性能消耗,Static batching在这一点要比Dynamic batching更加高效。
可以接收多个光源的shader,在受到多个光源是无法合批
参考资料:关于静态批处理/动态批处理/GPU Instancing /SRP Batcher的详细剖析 - 知乎