Draw Call优化

1,928 阅读7分钟

在说优化之前,要先明白Draw Call是什么?

  • 在Opengl中我们每次要将一个物体绘制出来时,CPU端都需要调用一次glDrawElements或者 glDrawArrays命令 (前者依靠索引绘制,后者直接靠顶点顺序绘制),其实这就是一个Draw Call。
  • 所以Draw Call本身只是一个很简单的命令(仅仅是向GPU的Command Buffer中加入几个字节的数据),对于强大的GPU来说,根本不值一提。

那么为什么好多人做优化时都会说要降低Draw Call呢?那是因为CPU在分配一个Draw Call之前需要做很多的准备工作,例如:

  • 提交和整理数据,例如提交顶点数据,顶点索引数据,shader,材质属性,光源信息等等,也就是State面板中显示Batches,一次Batch代表一次提交;
  • 切换渲染状态,主要出现在材质不同的时候,如果这次Draw Call使用的Draw Call和上一个Draw Call使用的不是同种材质或者同一个材质的不同pass,那么就要触发一次set pass call来重新设定渲染状态。
  • 如下图所示,8个不透明物体使用两种不同的材质,间接摆放,使用自定义渲染管线,使其渲染顺序从后向前渲染(真实情况与其相反,而且引擎内部有优化,此处只是更直观)。则会提交九次,并切换九次渲染状态,其中一次是绘制天空盒。
  • 使用同样的渲染顺序,这次由于同一种材质的都比另一种材质的靠前,虽然提交次数相同,但是切换渲染状态的次数明显减少。

  • 上述两点这才是消耗的重点。

所以优化本身要做的就是减少提交的数据和提交的次数,同时减少渲染状态的切换,(一次提交,多次绘制)这其实就是我们常说的批处理操作。

  • Static Batch:在使用相同材质球的条件下,在Build(项目打包)的时候Unity会自动地提取这些共享材质的静态模型的Vertex buffer和Index buffer。根据其摆放在场景中的位置等最终状态信息,将这些模型的顶点数据变换到世界空间下,存储在新构建的大Vertex buffer和Index buffer中。并且记录每一个子模型的Index buffer数据在构建的大Index buffer中的起始及结束位置;

    • 在后续的绘制过程中,做完视锥体剔除操作后,一次性提交所有的顶点数据,而只是提交需要渲染部分的索引数据。然后设置一次渲染状态,调用多次Draw call分别绘制每一个子模型。这期间并没有再次提交数据,也没有再次切换渲染状态,所以起到了优化作用。
    • 同时顶点预先变换到了世界空间,减少了运算量。
    • 但是,会增大应用程序的体积增大,运行时占有的运行内存也会增多。主要是因为场景中所有引用相同模型的GameObject都必须将模型顶点信息复制,并经过计算变化到最终在世界空间中,存储在最终生成的Vertex buffer中。这就导致了打包的体积及运行时内存的占用增大。
  • Dynamic Batch:在使用相同材质的情况下,Unity会在运行时将视野中的符合条件的对象的顶点转换到世界空间,并存储在一个新的Vertex Buffer和一个新的Index Buffer中,(这些运算发生在CPU端,而且每一帧都需要进行,所以会对CPU造成一定的消耗,所以顶点不宜太多),

    • 绘制时将所需的数据一次上传到GPU,并设置一次渲染状态,调用多次Draw Call分别绘制每一部分(其实是这些命令先加入CommandBuffer中,可以一并提交到GPU的CommandBuffer中)。
  • GPUInstance:使用相同材质,相同mesh的情况下,unity会自动收集视野中所有符合要求的对象,将其材质属性,矩阵信息(如物体到世界空间的矩阵),uv偏移等收集到结构数组中,一并传到GPU存储在Constant Buffer中,而且只需要传递一个mesh数据。最后调用类似OpenGL中 glDrawArraysInstancedglDrawElementsInstanced的接口多次绘制即可。

    • 也是只需一次提交,绘制多次,但是需要每帧都收集信息,并上传到GPU中
    • 由于数据都存储在Constant Buffer中,而Constant Buffer有大小限制,一个最大为64kB,一个shader中可以有多个CB,所有一次提交的物体信息的数量不可能太多,PC为500,移动端为250左右。
    • 因为在名为unity_Builtins0的结构数组中最少需要包含两个矩阵,object to world(物体到世界),world to object(世界到物体,用于变换法线) 。所以64000 / 128 = 500.
    #ifdef UNITY_ASSUME_UNIFORM_SCALING
        #define UNITY_WORLDTOOBJECTARRAY_CB 1
    #else
        #define UNITY_WORLDTOOBJECTARRAY_CB 0
    #endif
    //上面两句,若shader中声明了 #pragma instancing_options assumeuniformscaling
    //这句话,则会自动生成一个名为UNITY_ASSUME_UNIFORM_SCALING的宏
    // ---------------------------------------------
    UNITY_INSTANCING_BUFFER_START(PerDraw0)
        #ifndef UNITY_DONT_INSTANCE_OBJECT_MATRICES
            UNITY_DEFINE_INSTANCED_PROP(float4x4, unity_ObjectToWorldArray)
            #if UNITY_WORLDTOOBJECTARRAY_CB == 0
                UNITY_DEFINE_INSTANCED_PROP(float4x4, unity_WorldToObjectArray)
            #endif
        #endif
    //... 忽略其他不是必须的数据 
    UNITY_INSTANCING_BUFFER_END(unity_Builtins0)
    
    
    //这两个宏声明一个结构数组,数组的名字为UNITY_INSTANCING_BUFFER_END后的值加上“##Array”
    #define UNITY_INSTANCING_BUFFER_START(buf)      UNITY_INSTANCING_CBUFFER_SCOPE_BEGIN(UnityInstancing_##buf) struct {
    #define UNITY_INSTANCING_BUFFER_END(arr)        } arr##Array[UNITY_INSTANCED_ARRAY_SIZE]; UNITY_INSTANCING_CBUFFER_SCOPE_END
    //在结构中声明一个变量
    #define UNITY_DEFINE_INSTANCED_PROP(type, var)  type var;
    //根据unity_InstanceID访问结构数组中某个项中的某个变量
    #define UNITY_ACCESS_INSTANCED_PROP(arr, var)   arr##Array[unity_InstanceID].var
    
    
    #ifndef UNITY_DONT_INSTANCE_OBJECT_MATRICES
        #undef UNITY_MATRIX_M
        #undef UNITY_MATRIX_I_M
        #define MERGE_UNITY_BUILTINS_INDEX(X) unity_Builtins##X
       //..省略不重要的东西
        #else
            #define UNITY_MATRIX_M      UNITY_ACCESS_INSTANCED_PROP(unity_Builtins0, unity_ObjectToWorldArray)
            #define UNITY_MATRIX_I_M    UNITY_ACCESS_INSTANCED_PROP(MERGE_UNITY_BUILTINS_INDEX(UNITY_WORLDTOOBJECTARRAY_CB), unity_WorldToObjectArray)
        #endif
    #endif
    //根据unity_InstanceID访问结构数组中的 object to world matrix,world to object matrix。
    
    • 所以当调用Unity自带的API--Graphics.DrawMeshInstanced(最多可以绘制1023个)绘制物体时,若绘制1023个物体,会分三次提交,产生三个batch,图中为四个是因为其中有一个为天空盒的数据。

    • 若要可以通过一次提交绘制无限个物体,则需要使用Graphics.DrawMeshInstancedIndirectUnity接口。同时还需要我们避开使用Constant Buffer,其实可以使用StructuredBuffer代替(缺点是在Opnegl的顶点着色器不支持访问StructuredBuffer,但是Vulkan,Metal,DX都支持),将所需要的数据一次都提交到shader后(这些数据最起码可以产生物体到世界的矩阵,从而将顶点从物体空间变化到世界空间,至于法线,可以使用统意的地表法线),调用DrawCall 命令可以多次绘制(这些DC命令也是存储在CommandBuffer中,一次提交的 )。

    • 由于调用Graphics.DrawMeshInstanced和Graphics.DrawMeshInstancedIndirect接口产生的物体不受视锥剔除和裁剪的影响,所以需要我们自己做一下简单的剔除,若物体很少的话可以在CPU端做一下简单的剔除工作;若很多则可以联合ComputeShader来做,这样做的好处是产生的结果可以直接传送到Shader中。

    • 关于视锥剔除看我这篇

  • SRPBatcher:这应该是自定义渲染管线最大的好处了,使用相同shader变体(也就是Keyword)的物体就可以参与合批,步骤如下:

    • 每当场景种产生一个新的材质时,unity会自动收集材质的属性,并将其保存在GPU中,并且只有属性改变时才会更新。
    • 将每个物体的引擎属性(例如物体到世界矩阵,世界到物体矩阵等等)缓存在GPU中,每次只需要更新,不需要重新创建.
    • 所以SRP Batcher只是降低了Batch的成本。

总结

  • 四者的优先顺序为Static Batch ,SRPBatcher,GPUInstancing,DynamicBatch。
  • 对于大型不移动的物体推荐使用Static Batch;相同且大量的物体使用GPUInstancing,可以在运行时才显示出来;mesh种类不同,但使用了相同的shader变体的情况下使用SRPBatcher;最后在考虑DynamicBatch。