# 前言

之前分别整理了 图片 和 音效 相关设置与影响,接下来应该轮到模型了。

理论上来说应该先写一篇关于 Mesh 各个设置详细功能、影响相关的,不过写了一下,方向感觉就变成 合批、优化 这块去了,于是本篇文章直接就先整理一下这一块。

首先,我们都知道由于 CPU 和 GPU 是两个不同的执行对象,所以一般问题都出在两者『数据交换』,由于 GPU 并行结构设计,通常情况都是 GPU 等待 CPU,而 CPU 干事情得一件件做,渲染一个对象就要先把一个对象的属性 (切换材质、切换贴图、设置材质属性等) 准备好,然后通知 GPU 渲染该对象,这中间通常就存在空挡。

CPU 准备数据越多,最终渲染一帧消耗时间就越长 —— 所以优化的时候,通常提到了减少 DrawCall ,也就是 CPU 去通知 GPU 渲染一个对象前这块的『准备消耗』,DrawCall 命令本身消耗不能算很高,高的是在这之前的一系列准备操作。所以说是『优化 DrawCall』这个也不决对,但是通常减少 DrawCall 确实是最明显的优化。

其它例如减少带宽、减少中间切换次数、减少向 GPU 上载数据理论上应该都可以算作减少『渲染』消耗这一块的优化 (应该)。

# Unity DrawCall

在 Unity 中,可以主要关注两个数据:

  • SetPass Call
    • 在我们 Sahder 中可能会存在有多个 Pass,例如阴影就是单独一个 Pass 处理
  • Batches
    • 提交一次数据并执行绘制

在 Unity 中 Batches 可以看做实际的 DrawCall。

为什么一般优化 DrawCall 是提升效率的手段?

因为上述工作原理,CPU 和 GPU 处于两个不同的硬件结构,两个硬件的数据交换是存在等待机制的,两者频繁数据交换就会导致要么你等我,要么我等你这种情况,浪费大量效率。

例如有 100 个对象需要绘制,分别每提交一个对象的材质、模型等数据就发起一次调用,就有 100 次通知 GPU 渲染的调用。
将 100 个对象属性一次性设置,然后统一提交再申请绘制,就只需要一次。

效率就上来了 —— 当然也会带来一些其它问题,例如剔除精度降低。

Unity 会将一些可以合并渲染命令合成一个 『Batches』, SetPass Call 可以看做渲染状态改变次数 (不同材质导致必须的渲染切换数量),在 Unity 中 Batches 可以看做实际的 DrawCall—— 减少 Batches 不一定减少 SetPass Call,不过一般减少 SetPass Call 的都伴随着 Batches 减少。

# 实际操作

因此在实际中,一般渲染优化追求的是在 GPU 工作能力内,尽可能一次性让其绘制。

体现出来就叫做 『批处理』。

在 Unity 中,目前引擎存在的批处理通常有 动态静态GPU InstancingSRP Batcher ,其它的例如 UGUI 也有特有的合批规则。

由于合批操作通常都有指定限制,因此平时也得注意避免出现合批中断情况。

例如,像是一些单个物体材质属性操作,就可能导致合批中断:

  • renderer.material:调用时会创建并返回一个新的材质 (不推荐)。
  • renderer.sharedMaterial:材质引用,修改的话所有引用该材质对象都会受到改变。

# MaterialPropertyBlock

为解决 sharedMaterial 的缺点,Unity 提供了 MaterialPropertyBlock。

这个东西可以做到跟 sharedMaterial 一样不创建成新材质对象的情况下,单独修改某个对象材质属性 (而不影响其它同材质对象)。

使用方式也很简单,直接可以在 MaterialPropertyBlock 设置与材质上同样的变量与值,然后赋值给材质。

简单使用代码:

MeshRenderer renderrender = GetComponent<MeshRenderer>();
MaterialPropertyBlock mpb = new MaterialPropertyBlock();
mpb.SetColor("_Color", Color.red);
render.SetPropertyBlock(mpb);

官方称内置地形系统的树,渲染方式就是这样的,通过这种方式修改每棵树的材质属性,产生不同的颜色、缩放和风力系数等。

注:MaterialPropertyBlock 与 URP 的 SRP Batcher 会有冲突,URP 就不要用了。

# 不再受到材质属性修改影响

通过这种方式设置材质参数后,将不再受到材质本身属性修改的影响,如图:

材质本身的值将始终被 MaterialPropertyBlock 属性覆盖,此时再去修改材质,材质属性的改变不再影响这个对象了。

# 合批被打断

经过测试,通过 MaterialPropertyBlock 设置材质属性虽然不会导致创建新的材质实例,但是却会打断合批:

经过测试,无论动态合批、静态合批,都会被打断。
注:除非设置的是同一个颜色,倒是依然能够合批

例如,上述是 3 个 Cube,静态合批被打断情况:设置前 -> 设置后

SetPass Call 及 Batches 都增加了两个:说明 MaterialPropertyBlock 参数不同的话,依然会造成渲染状态改变。

# MaterialPropertyBlock 只是一个参数携带者

使用如下代码,仅使用一个 MaterialPropertyBlock 对象

Renderer render = GetComponentInChildren<Renderer>();
if (mpb == null)
    mpb = new MaterialPropertyBlock();
mpb.SetColor("_Color", _color);
render.SetPropertyBlock(mpb);

可以成功『分别』设置各个对象的『不同』颜色属性,而 MaterialPropertyBlock 是一个类,说明在调用 SetPropertyBlock 底层会生成一份当前类的临时参数数据,并覆盖掉材质上的本来设置。

# GPU Instancing

上述采用 MaterialPropertyBlock 修改材质属性后,会打断 静态 / 动态 合批功能。

如果想进一步优化,可以采用 GPU Instancing 技术。

GPU Instancing 也有限制:

  • 只能用于 Mesh
  • 同一个 Mesh 才能一次性绘制

需要注意的是:虽然 StandardShader 有勾选 Enable GPU Instancing 选项,勾选之后也能用,但是却没法跟 MaterialPropertyBlock 配合 —— 实测设置 MaterialPropertyBlock 后 GPU Instancing 依然都会被打断。

自定义 Shader :

  1. 增加: #pragma multi_compile_instancing 预定义后,材质面板会出现对应的 Enable GPU Instancing 选项
  2. 顶点数据接收结构体定义 UNITY_VERTEX_INPUT_INSTANCE_ID
  3. 顶点转片段 v2f 结构体定义 UNITY_VERTEX_INPUT_INSTANCE_ID
  4. 使用 UNITY_INSTANCING_BUFFER_START 定义可以进行修改的参数,例如我们修改颜色:
UNITY_INSTANCING_BUFFER_START(Props)
UNITY_DEFINE_INSTANCED_PROP(float4, _Color)
UNITY_INSTANCING_BUFFER_END(Props)
  1. 顶点处理函数中,在正常计算之前执行 Unity 提供的预定义:
UNITY_SETUP_INSTANCE_ID(v);
UNITY_TRANSFER_INSTANCE_ID(v, o);
// 正常必须的顶点转换处理
o.position = UnityObjectToClipPos(v.vertex);
  1. 片段函数中,执行设置,然后获取
//Unity 提供的预定义函数,对该对象进行设置
UNITY_SETUP_INSTANCE_ID(i);
// 从列表中获取该对象应当设置的属性
fixed4 color = UNITY_ACCESS_INSTANCED_PROP(Props, _Color);
return color;

该项对于『纯自定义 (Unlit Shader)』Shader 可能会繁琐一点,但 SurfaceShader 就简单些了,因为逻辑类似,这里直接上完整的 SurfaceShader ,也好可以做个对比:

Shader "Custom/GPUInstancingSurfaceShader"
{
    Properties
    {
        _Color ("Color", Color) = (1,1,1,1)
        _MainTex ("Albedo (RGB)", 2D) = "white" {}
        _Glossiness ("Smoothness", Range(0,1)) = 0.5
        _Metallic ("Metallic", Range(0,1)) = 0.0
    }
    SubShader
    {
        Tags { "RenderType"="Opaque" }
        LOD 200
        CGPROGRAM
        // Physically based Standard lighting model, and enable shadows on all light types
        #pragma surface surf Standard fullforwardshadows
        // Use shader model 3.0 target, to get nicer looking lighting
        #pragma target 3.0
        sampler2D _MainTex;
        struct Input
        {
            float2 uv_MainTex;
        };
        half _Glossiness;
        half _Metallic;
        //fixed4 _Color;
        // Add instancing support for this shader. You need to check 'Enable Instancing' on materials that use the shader.
        // See https://docs.unity3d.com/Manual/GPUInstancing.html for more information about instancing.
        // #pragma instancing_options assumeuniformscaling
        UNITY_INSTANCING_BUFFER_START(Props)
            // put more per-instance properties here
			UNITY_DEFINE_INSTANCED_PROP(fixed4, _Color)
        UNITY_INSTANCING_BUFFER_END(Props)
        void surf (Input IN, inout SurfaceOutputStandard o)
        {
		     //UNITY_SETUP_INSTANCE_ID(IN);
			 fixed4 realColor= UNITY_ACCESS_INSTANCED_PROP(Props, _Color);
            // Albedo comes from a texture tinted by color
            fixed4 c = tex2D (_MainTex, IN.uv_MainTex) * realColor;
            o.Albedo = c.rgb;
            // Metallic and smoothness come from slider variables
            o.Metallic = _Metallic;
            o.Smoothness = _Glossiness;
            o.Alpha = c.a;
        }
        ENDCG
    }
    FallBack "Diffuse"
}

这里 SurfaceShader 只需要两个步骤:

  1. 将对应需要修改的属性值定义到使用 UNITY_DEFINE_INSTANCED_PROP 定义在对应预定义块中,这点与 UnlitShader 一样
  2. 在片段着色方法中 通过 UNITY_ACCESS_INSTANCED_PROP 取值

就这么简单!其它的预定义 SurfaceShader 都已经帮忙处理好了。

效果:

Instancing 中三个不同颜色的 Cube 只占用了一个 DrawCall

# 问题:为什么 StandarSahder 设置颜色后会破坏合批?

原因是:

Unity’s Standard and StandardSpecular shaders have instancing support by default, but with no per-instance properties other than the transform.

即 —— 虽然它支持 GPU Instancing,但实际上除了变换之外之外的属性都没有写入实例化处理。所以如果想支持其它属性的变化,需要自定义 Shader 才行。

注 1:GPU Instancing 优先级在 SRPBatcher 及 静态合批 之后。
注 2:缩放为负也会打断 GPU Instancing 合批。
注 3:常量缓冲区不同设备可能大小不一样,因此一次性绘制也有上限。
注 4:GPU Instancing 只支持一个平行光,多放一个点光源就会导致多渲染 3 次 (每个对象分别一次)。(以上述 SurfaceShadaer 测试)

3 个 Cube、两个点光源:总共 7 个 DrawCall,3 个合批一次,然后分别渲染 2 次

# 扩展

GPU Instancing 只支持同一个模型,不支持带动画 SkinMeshRender,那么是否可以配合 GPU 动画 实现大批量动画模型绘制?

感觉理论上是可行的,后面再试试看。

# SRPBatcher

在 SRP 中,Unity 又提供了一种新的合批方式:SRPBatcher

这种方式的原理与 GPU Instancing 类似,都是需要在 Shader 中先定义受影响的属性值,Unity 渲染时先将这些值放进 GPU 的缓冲区,对象渲染时直接去取。但是它支持不同的材质和模型 —— 只要变体一致就行了。

# 总结

批处理顺序:静态合批 ->SRPBatcher->GPU Instancing-> 动态合批

静态合批优先级最高,相当于会与其它合批有冲突。
例如:个人测试中,GPU Instancing 和 静态合批 会有冲突,静态合批后使用 MaterialPropertyBlock 修改依然不会进入 GPU Instancing,也就是说 —— 直接勾选静态又去修改了材质参数反而降低效率。

前三者更有效,至于动态合批由于限制过多,而且本身就是 CPU 换 CPU,因此感觉也不用过多追求。像最新 HDRP 都去掉了动态合批功能 (当然 URP 还是有保留着的)。

参考文档:

  • DrawCallBatching
  • material-property-blocks
  • GPUInstancing