前言
本文是对Unity可编程渲染管线(Scriptable Render Pipeline)基础框架的一点梳理和备忘,包含了个人对底层实现的理解以及对大量官方资料的解读。如有谬误或不解,欢迎留言。
1 SRP的顶层结构
为了解决内置渲染管线(Built-In Render Pipeline)在应对日新月异的渲染需求时过于僵硬,不够灵活的问题,Unity推出了可编程渲染管线(Scriptable Render Pipeline)的概念,按照官方文档提供的解释,SRP在顶层设计上做了3个维度的区分,分别是:
- 可编程渲染后端(Scriptable Render Backend),主要由Cpp语言编写的运行时基础框架,其本身不可编程,但是向可编程管线的实现层提供丰富且高效的API;
- 核心公共件(Core RP),以C#和ShaderLab语言编写的一些列公共库为主,提供不依赖于具体渲染管线的基础服务;
- 渲染管线实现层(Render Pipelines),是基于上两层实现的具体渲染管线解决方案,可供客制化,官方样板主要有URP和HDRP。
相比开源的“核心公共件”和“渲染管线实现层”来说“可编程渲染后端”作为运行在底层的黑盒,对我们影藏了大量的数据管理和图形渲染逻辑,如果说“渲染管线实现层”指挥了管线的具体调度节奏,那么“可编程渲染后端”就是调度后被安排来真正完成具体功能的那一位。在上图红框中可见,Unity官方将“后端”区分为:“context”,“culling”,“draw”和“batch renderer”等节点,对标了Native源码中四个重要的功能模块。我们不妨由此出发来深入了解SRP的运作机理。首先结合应用层调用方式和源码阅读,简单总结这些模块的功能如下:
- Context -> 承载了一次完整的渲染管线提交所需数据,同时也提供了各种对外方法的入口;
- Culling -> 负责判断场景内所有激活状态的Renderer的可见性,过滤出所有可见且合法的渲染对象;
- Draw -> 底层绘制逻辑,负责分类整理和排序Renderer,收集和设置渲染参数,最终提交渲染线程执行绘制;
- Batch Renderer -> Srp合批渲染器,通过判断相邻渲染对象之间的属性,筛选和组织对象进行合批处理。
在“渲染管线实现层”(比如URP)中也可以找到上述核心模块的“分身”。它们有的直接映射了本体,例如Native中的Context和URP中的Context对象,还有的则直接或间接触发了上述模块功能:
- Context.Cull -> 直接对应了“后端”中的Culling,负责渲染对象的可见性判断,也负责生成RenderQueue队列(后续展开);
- Context.Execute ->(如URP中的ExecuteCommandBuffer)负责填充Context中的渲染指令队列(CommandQueue);
- Context.Submit -> 向“后端”Draw 模块一次性提交所有压入的渲染指令,在Draw的过程中还会进一步触发Batch Renderer,构造合批渲染。
下面分别简述下这3个重要功能点的内部执行逻辑:
2 Cull
剔除部分的工作量多寡与场景复杂度正相关,且场景相机,灯光和阴影贴图数量的多寡还可对部分剔除工作产生倍增或倍减的效果。检查Profiler发现,在通常情况下耗时比较突出的剔除工作主要有:(1)阴影剔除(ShadowCulling)和(2)动态场景渲染对象剔除(SceneDynamicObjectsCulling)。其他可能参与剔除的类别还有:
- 静态遮挡剔除(Static Occlusion Culling)
- 地形剔除(Cull Terrains)
- 探针剔除(ReflectionProbe Update)
- 灯光剔除(Light Culling)
你能否也在Profiler中看到它们取决于你的Unity工程是否预计算并存储了潜在可见集合(Potentially Visible Set: PVS),或使用了Unity原生的地形和反射探针系统,亦或是设置了多光源(Spot和Point Light)。只是即便开启了上述额外的剔除项目,在一般情况下Culling阶段的主要负担还是在阴影和场景动态物体剔除上面,下面我们逐一解析下。
2.1 Shadow Culling
首先需要说明一点,阴影剔除并不是由Constant.Cull触发的,而是由Constant.DrawShadow触发,只是其从属于可见性剔除的本质没变,这里就一起说了。
我们知道平行光光源视角通常被设置为一个较大的矩形正交投影视锥体,可以覆盖整个场景。这样可以确保任何在光线路径上的物体都会生成相应的阴影,但只是确保有无投影,受阴影贴图分辨率影响,必须找到合适的投影范围才能生成高质量的投影。因此在实际计算阴影贴图时,Unity会考虑摄像机的视锥体的影响,利用远近裁剪面限定了限定平行光光源的矩形正交投影视锥体的尺寸,使矩形投影体永远聚焦在热点区域附近。之后为了进一步缩小计算范围,还需要根据摄像机视锥体的信息进行裁剪,Unity为此计算了摄像机视锥体的边界盒(Bounding Box),然后将该边界盒扩展一定距离,再与聚焦后的矩形正交投影视锥体做交集,形成最终的光源投影范围,此区域(K-DOP)一般由6~10块平面合围而成。
而所谓的阴影剔除就是先于投影计算做准备,主要通过对6~10块平面做相交测试,将不在光源投影范围的物体从投影计算中剔除出去,此外如果开启了动态遮挡剔除,Unity也会利用内置的Umbra系统参与计算(此处暂略)。剔除过程由Jobs系统管理,也就是说是多线程并发处理的,参考如下Profiler截图:
图中红框标出的Shadows.CullShadowCastersDirectional专门剔除被平行光源影响的投影物体,Unity将场景内所有参与阴影投影的物体分成一定数量的组(Group),之后为每一个光源+投影物体组的组合创建一个专门的剔除工作任务,由Jobs分派到合适的线程上工作。结合工程实例的表现效果可知,阴影剔除总的工作负载受以下因素影响:
- 与场景中开启阴影的光源数量正相关。即便场景非常简单(比如只有1个投影物体),但有N个投影光源,Unity仍然会针对每个光源派发总共N个Jobs进行处理。
- 与场景中投射阴影的游戏对象数量正相关。如果对象数量很多,Unity会将它们划分到不同Group里,这样就会产生Group总数 x 投影光源总数个Jobs。
一个简单结论:当ShadowCull耗时过高时,最有效的方法就是减少投影物体和光源的数量。
2.2 Scene Dynamic Object Culling
动态场景渲染对象剔除的目的是提前过滤掉场景中摄像机不可见的渲染对象,为后续管线流程减负。注意此处的Dynamic并非指渲染对象本身的Static属性,而是在运行时实时计算物体可见性的方案,与需要大量预计算的静态遮挡剔除技术相区别。动态场景渲染对象剔除包括了视锥剔除和动态物体遮挡剔除两个方面,其中实时的动态物体遮挡剔除(Occlusion Culling)使用内建的第三方Umbra系统,该系统默认是关闭的,需要手动开启并配合轻度预计算(只涉及空间划分)。关于这些可见性判断的具体算法不是本文的主题,不过可以肯定的是,手机端TBDR管线可有效避免片元因前后遮挡引起的OverHead,且在各种合批技术的加持下,通过付出不很稳定的额外CPU资源去换取少量但稳定的渲染资源收集和提交消耗这件事是否值当还两说,需要具体到项目(场景)具体判断。
由于每一帧等待Cull的目标是全场景中激活的渲染对象,数量可观,如果从数据流的角度出发,Scene Dynamic Object Culling还是有许多说之处的。首先基于每一个场景Unity都维护了一个叫SceneDynamicObjects的队列,它装载了所有处于激活状态下的渲染对象引用 ,与此同时它们在队列中的下标又构成了另一个重要的数据队列IndexList 。我们知道整个Culling过程是由Jobs System负责规划和派发的,视负载不同前后可能有多组线程参与计算,每个线程实际负责IndexList上的一个区段,线程内遍历这段IndexList,对每个读取到的渲染对象应用可见性判断算法,这就构成了一组Cull Job,而多组这样的Jobs之后还会再追加一次Combine Job,从下面Porfiler截图中可以得到印证:
借用官方讲座的截图(下图),多组Cull Job运行在独立线程中(对应一种颜色),线程内部访问的IndexList数据段彼此独立,不产生竞太,当执行完可见性判断逻辑后,Cull Job丢弃没有通过的索引,余下索引回填到数组中,同时保证向队列前端对齐靠紧:
由于Cull Job的这种工作方式,必然导致它们的产出数组在IndexList内部是不连续的,Unity利用追加的Combine Job如下图所示这般重新规划整理List,过程就不再赘述了。
2.3 Execute RenderQueue
经过Combine之后获得的是可见渲染对象的索引队列,而这些渲染对象(Renderer)的实例在内存中的分布肯定是不连续的。我们知道Jobs系统为了提高并发运算效率,在派发多线程任务时会要求将所有待处理数据尽可能处理成连续排布的形式,于是便有了随后的ExecuteRenderQueueJob(该过程同样由多线程执行),目标是将各种引用类型的对象展平成值类型,同时对齐排列到一整片连续内存中,Unity从这里开始引入了2个新的概念:
- RenderNode -> 扁平化渲染对象(Renderer)后的值类型结构体,包含渲染所需的一切信息(MaterialData,LayeringData,LightMapST,LightMapIndex,RendererType,RendererPriority,CastShadow,ReceiveShadow,ProbeUsage,DynamicOcc,RenderingLayerMask,StaticBatchInfo,etc...);
- RenderNodeQueue -> 由RenderNode组成的数据队列,用于保证数组元素在内存上是连续的。
所以ExecuteRenderQueueJob过程也很简单:遍历IndexList,找到并读取对应Renderer,然后将数据展开到RenderNode结构上,依序写入RenderNodeQueue。如下图所示,到目前为止队列中RenderNode的前后顺序由Cull后的IndexList排列属性决定,Culling过程会随机剔除部分对象,而Culling前的原始IndexList又由前文提及的维护了全场景渲染对象的SceneDynamicObjects队列决定,该队列内Renderer前后排列顺序则由各自的初始化时机决定,故可以认为,Unity并不在意RenderNode队列里各个Node在逻辑上的乱序状态。
RenderNodeQueue是Cull部分的终点,也是实际渲染的起点,事实上如果把渲染一帧画面比作烹饪一桌菜肴,那么整个Culling过程就好似饭店后厨在制备酒席前的备料阶段,场景内的食材(原始渲染对象)经过洗净去皮(Cull掉不需要不可见部分)以及切段分盘盛放(格式化和扁平化数据结构),最终一排排整齐罗列在工作台上(RenderNodeQueue)。
3 Execute
Context.Execute相对简单,它只负责以Command的形式收集来自应用层的渲染指令,处理类似任务的前端接口还有:
- CommandBuffer.Blit
- CommandBuffer.DrawMesh
- ScriptableRenderContext.DrawRenderers
- ScriptableRenderContext.DrawShadows
虽然接口名给人一种即时执行的暗示,但它们本质都是向SRP底层提供的指令队列中填充不同内容的Command指令。
Unity共有三种不同类型的Command,它们分别是:
- ShadowDrawingSettings:对应DrawShadowCommands队列
- DrawRenderersCommand:对应DrawRenderersCommands队列
- RenderingCommandBuffer:对应CommandBuffers队列
此外Unity底层还单独维护了一个的用于记录全局先后顺序的队列,叫做Commands,类型是:dynamic_array<Command> ,每当用户向指令队列添加新的Command时,这个队列也会添加一份该Command的引用,具体流程参考下图:
在Submit前,几乎所有的绘制或渲染接口都是调用向上述队列中添加指令对象,指令内容由Command对象记录,指令顺序被Commands队列保持。继续套用烹饪酒席的例子做类比的话,Execute阶段是酒店收集客户菜单的过程,菜单中的每道菜对应了一个独立的Command指令,决定了所需备料的种类和烹饪方法;而菜单中菜品的先后顺序也被固定了下来,以确保冷盘(前菜),正餐和甜点的上菜顺序不会错乱。
4 Submit
Submit的作用是向SRP底层一次性提交Context中的所有渲染指令,驱动真正的“绘制”和“渲染”逻辑。该过程的CPU消耗对应了主线程Profiler中的ScriptableRenderContext.Submit条目,我们可以在Scriptable Render Loop下找到它。Submit之后,后厨就收到了客户的订单(Commands队列),于是便开始依序遍历订单中每一个菜品(Command),在主线程内逐个处理它们。
我们知道不同的“菜肴”会对食材的种类和炒制的方法有不同的要求,渲染指令同样会对渲染对象和渲染管线有不同的过滤条件和配置要求,以常见的“菜品”DrawRenderersCommand为例(就是负责DrawOpaque或者DrawTransparent的那个),它需要将执行分解为2个阶段进行:
- 数据准备阶段(PrepareDrawRenderers)
- 数据执行阶段(ExecuteDrawRenderers)
4.1 数据准备阶段
数据准备阶段(PrepareDrawRenderers)的CPU消耗对应了Profiler中的RenderLoop.Prepare(准备阶段)和RenderLoop.Sort(排序阶段)两个条目:
- RenderLoop.Prepare -> 基于渲染指令自身的属性和影响范围,挑选出参与该指令的细粒度渲染对象。类比烹饪菜肴的话,可以看做是从全部备料中选出当前菜品所需的部分,同时加工备料,使成为适合下锅烹饪的形态。
- RenderLoop.Sort -> 对挑选出的细粒度渲染对象进行排序,以确定它们进入合批通道的先后顺序。做类比的话,相当于确定各项处理后食材的下锅顺序。
RenderLoop.Prepare到底做了什么还是有必要深究一下的。我们知道经过Culling过程后全场景可见的Renderer信息被展平在了名为RenderNode的内存上,可以笼统的让RenderNode对象与游戏场景中每一个可见的Renderer对应上,但是光到Renderer这一层还不是最细粒度的渲染对象,精确包含全部渲染要素的最小集合是ShaderPass,所以Unity需要在数据准备阶段(Prepare)进一步细化和过滤,具体参考下图:
(1个)RenderNode -> (1个或多个)Material -> (1个或多个)Pass
在RenderLoop.Prepare阶段,所有被梳理出来包含了单个ShaderPass全部数据的对象叫做ScriptableLoopObjectData,后文简称“ObjectData”。紧接着Unity会基于ObjectData的属性和当前DrawRendererCommand的具体过滤需求进行二次过滤,简介筛选条件如下:
- LayerMask
- RenderingLayerMask -> 对应不同(Universal)Renderer(和RendererAsset设置)
- MotionVectorPassRequested
- ShaderTagID -> 对应“Lit”,“SimpleLit”和“UnLit”等内置或用户手动设置的TagID
至于RenderLoop.Sort阶段,自然是负责将ObjectData对象按规则排序,底层逻辑中ScriptableLoopObjectData是一个相对轻量化的结构体(struct),属于值类型,具体定义如下:
struct ScriptableLoopObjectData
{
RenderObjectData data; //记录有参与比较的各种变量
const SharedMaterialData* sharedMaterial; //指针 -> 指向材质类
const ShaderLab::Pass* pass; //指针 -> 指向Pass
UInt32 passIndex; //值类型索引
UInt32 passOrder; //值类型优先级
};
复杂且与Sorting无关的数据会被Unity直接存放到指针中,而需要在排序比较中反复使用的参数则全部被整合成了值类型的数据结构(参考RenderObjectData)。
影响排序的主要因素如下:
SortSortingLayer, // global sorting layer(全局级)
SortRenderQueue, // material render queue(材质级)
SortBackToFront, // 基于从后往前的规则
SortQuantizedFrontToBack, // 基于从前往后(量化)的规则 -> 有利于TBDR优化Overhead
SortOptimizeStateChanges, // 优化排序以提高效率,综合考虑了: static batching, lightmaps, material sort key, geometry ID
SortCanvasOrder, // Canvas系统内,在距离相同前提下的 sort priority
SortRendererPriority, // renderer priority (当render queue不可区分时使用)
比如,一个不透明物体的规则通常由以下几种排序条件组成:
SortCommonOpaque = SortSortingLayer |
SortRenderQueue |
SortQuantizedFrontToBack |
SortOptimizeStateChanges |
SortCanvasOrder
比较的顺序如下,可以理解为一旦某个比较节点得出结果(非相同)则立即返回结果:
- SortSortingLayer(全局SortingLayer)
- SortRenderQueue(材质上的RenderQueue)
- SortRendererPriority(SRP专用,作为RenderQueue相等前提下的备用)
- SortBackToFront(依相机连续距离从后向前排序,半透明物体使用)
- SortQuantizedFrontToBack(依相机离散距离从前向后排序,不透明物体使用)
- SortOptimizeStateChanges(SRP Batcher兼容性排序优化,让能一起Batch的排序到一起)
- SortCanvasOrder(画布顺序)
- NodeIndex Or PassOrder
基于以上分析可以,像Prepare和Sort这类逻辑简单,可独立拆分,同时又面对海量同类数据的工作非常适合多线程并发执行,事实上也是如此,Unity在多组Worker上执行Prepare操作,其产出(一段ObjectData队列)则被后起的多条Sort线程消费,参考下图:
Sort这类计算密集型的工作非常适合多线程(多核)执行,上图中Unity将Sort任务拆分成了87个实例共运行在11个线程中,累积总耗时达到1.44ms,实际耗时在多线程优化下仅有总耗时的不到一成。换言之,对于核心数量偏少偏弱的(中低端)移动平台来说,控制渲染对象的总量(即便有合批加持)仍然很有必要。
4.2 数据执行阶段
准备好数据后我们正式进入数据执行阶段(ExecuteDrawRenderers),它在SRP语境中对应了Profiler中RenderLoop.DrawSRPBatcher条目。 由于SRPBatcher的出色性能,工程默认开启了该项优化,对应下图箭头处:
在准备完数据之后,Unity手上有经过了细化且完成了排序的ObjectData队列,大厨现在需要依次序将这些深加工过的食材投入锅中烹饪,针对一道菜肴来说所需投放食材总量是固定的,如果每种食材各自需要加热的时长也是已知的,那么相比于一份份加入食材,将能够同时烹饪的食材一起入锅,这样做既能减少食材投放总批次,又能缩短烹饪总时长,从而加快出菜速度,间接提高了饭点的翻台率。
好了,从我们拙劣的类比小故事回到RenderLoop.DrawSRPBatcher中来,队列中每个ObjectData元素都记录有是否兼容SRP Batcher的标识符,Unity接下来要干的是根据排序结果将彼此相邻且都兼容SRP合批的ObjectData对象打包投喂给SRP合批处理模块(SRP Batcher);对于不兼容的ObjectData,则打包投喂给传统合批渲染模块(Standard/Legency Batcher)。至于什么是“不兼容”,官方手册上有明确的阐述,可以简练概括如下:
- 渲染对象使用的SubShader必须是兼容SRPBatcher的,这对应了一些具体的ShaderLab编写规则;
- 没有用户在运行时自行添加的额外材质属性(CustomProps),这对应了不能使用MaterialPropertyBlocks;
- 早期版本(2019.3之前)只支持MeshRenderer,之后又追加支持了SkinnedMeshRenderer,所有其他Renderer都不支持;
备注1:在开启SRPBatch模式前提下,如果材质激活了实例化material->enableInstancing == true
,Unity仍然会按照SRP Batch的方式尝试合批处理,因为官方在自己的测试demo中发现SRPBatch总是比管线自动执行的GPUInstancing效率高。
备注2:RenderLoop.DrawSRPBatcher默认是运行在主线程上的,如果开启PlayerSettings->Graphics Jobs
则会激活多线程模式:Unity会将ObjectData队列按照可分派线程数进行划分,再把DrawSRPBatcher连同一部分队列中的ObjectData通过Jobs丢给包含主线程在内的诸多的核心计算。这些线程可以通过渲染线程同步向底层Gfx API发请求,也可以由执行线程自身异步发送请求,前提是目标图形接口支持异步调用:常见的Gfx Device,诸如D3D11, OpenGLES,Vulkan,Metal这些都是支持(兼容)多线程的(Threadable)。只是请注意,即便在最新的2023.3版Unity官方文档上,Graphics Jobs仍然是处于测试状态(Experimental)的功能,网上也有一些开启后遇到的兼容性问题和显示bug(甚至crash),使用时需三思。
Unity管这段粗略的“合批兼容性”分流叫做“Dispatch Prepare”,具象化过程可参考如下图示:
4.2.1 合批规则
每个成功被归类到SRP batcher内的ObjectData并不一定真能与其他同伴合批成功,Unity将进一步基于如下规则做判断,一旦合批失败则SRP Batch打断,Unity会将之前积累的合批对象打包提交给Gfx Device,并由其组织提交DrawCall,同时形成新一次的SetPassCall。
SRPBatchBreakDifferentShader, //不同Shader
SRPBatchBreakCauseMultiPassShader, //不同Pass
SRPBatchKeywordsChange, //不同KeywordSet
SRPBatchMaterialNeedDeviceStateChange,//不同Material的管线相关Porperties设置
稍微解释一下,合批失败情况最多的是KeywordsChange,原因有很多,比如对于一段Shader代码在C#中显式的修改Keywords:
Shader shader = Shader.Find("MyShader");
string[] keywords = new string[] { "KEYWORD1", "KEYWORD2" };
ShaderUtil.SetShaderKeywords(shader, keywords);
再比如,用户通过修改Renderer等组件的面板设置,也可能造成前后渲染物体的内置关键词(builtIn keywords)不同,内置Keywords大致如下:
- Light keywords
- Shadow keywords
- lightmapping keywords
- fog keywords
- other builtin keywords
- EmissionMap
- VertexLightOn
- SoftParticlesOn
- HDROn
- LODFadeCrossfade
还有,如果前后渲染对象的RendererType不同,也必定会间接的影响到内置Shader关键词,渲染类型大致如下,但是对于SRP Batch来说只有Mesh和SkinnedMesh这两类:
- RendererMesh //支持SRP
- RendererSkinnedMesh //支持SRP
- RendererSprite
- RendererTilemap
- RendererTrail
- RendererLine
- RendererParticleSystem
- RendererBillboard
- RendererSpriteMask
- RendererSpriteShape
- RendererVFX
合批失败的其他原因:
- MultiPassShader
-> 对应了出现相同SubShader但是属于不同Pass的情况; - DifferentShader
-> 对应了不同的Shader或者不同SubShader的情况; - MaterialNeedDeviceStateChange
-> 说明前后2个渲染对象的材质面板选项中,存在了某些能够影响到渲染状态(blend mode,depth/stencil settings等)的差异设置;
4.2.2 合批循环
说完合批失败的原因后我们再来看看具体的合批循环流程,可简述如下:
SrpBatcher batcher;
ObjectData currentObj;
for (index = 0; index < batchableObjQueue.size; ++index)
{
ObjectData obj = batchableObjQueue[index];
...
if (obj.IsNotBatchableWith( currentObj ))
{
currentObj = obj;
batcher.Flush()
...
batcher.ApplyShader( currentObj );
}
else
{
batcher.Add( obj );
}
}
batcher.Flush();
Unity在循环开始前会先创建一个srp batch对象,之后开始从队列中取出并解析第一个ObjectData_1。由于没有第零个ObjectData可以合批,初次合批兼容性测试必然导致合批失败,进而触发新一轮合批开始。这个过程中Unity依据ObjectData_1的特性执行一次SetPass,该操作对应了Profiler中的ApplyShaderPass,其本质是部分渲染参数在CPU端的整合和拷贝和上传(后续详解)。这之后Unity继续推进循环,开始检查第二个ObjectData_2是否可以和上一个(ObjectData_1)对象合批,判断合批的依据不再赘述。如果可以合批,则将此ObjectData_2对象纳入合批集合(srp batch对象)中,继续推进迭代。如果合批失败,则本轮srp batch寻找合批对象的过程到此为止,Unity会先触发一次向底层GfxDevice的数据提交,内容是batcher内部积攒的全部ObjectData,这个过程对应了Profiler中的BatchRenderer.Flush,简言之,Flush主要职责是把同批次ObjectData进一步展开成(临时的)数据缓冲,通过管道交给渲染线程。最后在下一轮开始合批前,Unity还需要以当前渲染对象(既破坏了合批的那个ObjectData_2)作为蓝本,触发新一轮的SetPass,至此完成了一整轮循环。
为了提高渲染效率,我们肯定期望Unity每次合批的渲染对象越多越好,但是从SRP Batch循环机制的现实出发,参与合批的ObjectData是从队列中依次被取出的,合批失败后立即提交先前的缓存对象,没有所谓的:“跳过当前渲染对象,尝试在队列后方继续搜索可合批ObjectData”这种逻辑存在。那么为了能够增加合批成功的几率,我们就需要在数据准备阶段利用好排序规则中各种可控的标签,将能够彼此合批的渲染对象规划到队列中相邻的位置,同时又不破坏最终绘制图像的前后层关系。
从如下Profiler截图中可知,RenderLoop.DrawSRPBatcher的主要开销都在ApplyShaderPass和Flush这2个方法上面(占比上分析):
事实上在相同渲染对象前提下,ApplyShaderPass和Flush执行次数越少,对应的合批效果越好,从而Unity执行渲染效率就越高。虽然绝大多数情况下我们只需要知道如何提高合批成功率(通过优化材质和提交顺序等外部手段)就能达成提高渲染效率的目的,但是知其然知其所以然,如果能够掌握这两个方法内部的具体执行逻辑,想必也能帮我们了解渲染管线底层优化的方向,激发我们在实际性能优化过程中的思考深度。
4.2.3 ApplyShaderPass
ApplyShaderPass的主要任务(在主线程上)一言以蔽之是:收集、整理以及(向GPU)提交一次SRP Batch过程中Shader Program用到的所有“公共”性质的资源和配置。
展开来解释是这样的:所谓“收集”是指Unity读取当前正在合批的GPUProgram(类似Shader Program的抽象),解析其中罗列的各项(为了完成渲染)必备的要素和资源清单,提取其中“公共”部分的过程。由此可见这里的公共是相对于单个Shader代码而言的,基于同一份Shader创建的不同材质所依赖资源并不属于“公共”,只有诸如ShadowMap,LightMap,ViewToProj,Time等等资源属于Shader共有。所谓“整理”是指使资源的布局(Layout)合规,默认情况下图像API提供的Uniform Block内数据布局是依赖于应用层实现的,比如常见布局有packed,shared和std140各自都规定了数据缓冲的对齐标准和量化单位等复杂要求,Unity在收集完毕各种常量属性(Constant Property)之后,向Gfx Device提交之前,可能需要对部分数据进行补丁修正(Patching)。那么具体有哪些资源和配置是需要在ApplyShaderPass时期做收集整理和提交操作的呢?这包含了三个方面,我们一一罗列如下:
- 确定渲染逻辑,系统基于从属于材质的GPUProgram搜寻适合要求的SubProgram:每个SubProgram对应ShaderPass中的一种特定KeywordSet组合(或者叫Shader Variant)。Unity将关键词组合作为Key,将编译后的Shader数据缓存到LookupTable中,随着程序的运行,LookupTable逐渐扩容的同时还会加速SubProgram的获取,另一方面,如果没有找到缓存数据,Unity则会在当前GPUProgram所支持的Keywords组合中寻找最合适“候补者”,候补者需要与目标ShaderPass+Keywords组合在满足阈值的前提下最为接近。如果目标SubProgram或候补者并未加载和编译,Unity同步加载和编译它们。
- 资源数据准备和提交:当确定了具体的SubProgram,Unity就知道目标Shader具体需要哪些公共资源属性,它们主要由:常量缓存参数(CBParameters),纹理参数(TextureParameters),缓冲参数(BufferParameters)以及采样器参数(SamplerParamters)这四大类构成。其中常量缓存主要对应了系统内置的 CBUFFER_START(Name) ... CBUFFER_END 代码段以及其中定义的一系列类型参数,由Float,Vector和Matrix这三个基本类型及其数组类型组成;纹理参数定义了纹理在GPU端的绑定ID(TextureID)以及其他少量信息,诸如纹理索引(SlotID)和采样器索引(SamplerUnit);缓冲参数与各种系统或用户定义好的ComputeBuffer相关;最后是采样器参数,由于当下主流图形API都支持纹理和采样器分开定义,所以如果Shader使用到了单独定义的采样器时,就需要将Shader中某个采样器Symbol关联到某个具体的采样器索引(SamplerUnit),同时配置合适的采样器状态(SamplerState)。先提一嘴,在开启渲染线程的情况下,目前所有提及的资源的准备过程(收集整理)和提交过程是分布执行的,这点在后面渲染线程部分会详细展开。
- 配置管线状态,既“Apply Device States”,它负责告知底层管线当前Batch所需的Blend、Depth、Stencil和Raster等状态。
对于ApplyShaderPass还有3点额外的补充:
其一,如果在C#端的渲染指令(比如DrawRenderer指令)中设置了replacementShader,那么为了能得到正确的GpuProgram,Unity会在执行ApplyShaderPass期间判断渲染对象Shader上标记的renderType是否与cmd中记录的renderType一致,进而选择是否触发“替换Shader”的逻辑。常见的renderType有“Opaque”,“Transparent”等,关于replacementShader可参考官方文档。
其二,如果C#端的渲染指令cmd不带有replacementShader标识符,也不在多线程上执行(关闭Graphic Jobs),那么会触发Unity对ApplyShahderPass的快速缓存机制,通过暂存(Recording)每次SetPass时系统向底层Gfx Device提交的指令缓冲(Cmd in CommandQueue),从而获得在遇到相同ShaderPass时快速执行ApplyShahderPass的能力。
其三是关于多线程问题,如果我们关闭Render Thread(取消PlayerSettings->Multithreaded rending的勾选状态),那么包含ApplayShaderPass和Flush在内的所有向底层Gfx API发起请求的方法都将在当前的工作线程上直接处理。一般而言当前工作线程就是主线程(Main Thread)。对于开启Multithreaded rending的情况,我们挪到讲完Flush之后再说。
4.2.4 Flush
Flush是渲染大循环结束前的临门一脚,此时所有公共数据已由ApplyShahderPass完成了提交,且参与本次合批的具体渲染对象也已经确定,因此(在主线程上)Flush要做的是将这些实例的私有渲染相关数据收集整合起来,以一定格式写入连续内存,最终再(通过线程间管道)提交给底层Gfx Device来执行。数据收集本身并没有什么特别的地方,数据就在每个ObjectData及其对应的RenderNode中,取来便是,可说的是所需数据的具体类型和用途,以及整个内存开辟和填充的过程。
想象一下,一方面底层接口要求传入的数据以一定格式排布在一整段连续的内存中,另一方面成百上千个实例的私有渲染数据所需的内存占用并不少。那么为了提高内存读写效率,避免多段内存之间的连续拷贝,Unity就需要预计算出足够放下全部数据的空间大小,再一次性向系统申请到手。在这段连续内存中,Unity根据预计算结论,划分出不同的子区块用来对应不同的渲染数据队列,队列长度一般同当次合批的渲染对象个数相当,渲染数据大致可分为五类,主要是各种实例间信息,存储格式以基础值类型为主,但是也会使用指针索引处理复杂且庞大数据结构。下面具体讲讲这五类数组对象:
- Array<BuildInSystemCBuffer>
BuildInSystemCBuffer是一块数据对齐(float4的整数倍)的连续内存,记录了渲染对象自身的一些系统级的内置常量,叫做Per-Object buffer data,Unity官方已经给出了数据种类和布局,参考下表所示。
Per-Object buffer data是SRP Batch与Standary Batch的主要区别点:一方面Unity使用“专用代码”更新和提交这些逐渲染对象的系统级信息,从而一定程度上提高了CPU端运行效率(后文展开);另一方面Unity通过规范结构,特别是布局的先后顺序,从而让数据消费端能够仅依靠地址偏移读取任何处于激活状态的内容,优化了向GPU端绑定数值(Value)的效率;最后Unity还允许裁剪掉数据布局中所有处于尾部的无用区块(非激活状态),尽可能减少BuildInSystemCBuffer的内存空间和ConstantBuffer空间占用。
关于最后一点可以展开解释一下:已知Unity会先分析数据结构来确定所需开辟的内存空间大小,但这不是通过简单的sizeof(BuildInSystemCBuffer)来实现的,而是针对Per-Object buffer data专门分析其Shader使用的Feature状况,确定在上表中最后一个使用的数据对象是谁,再依据这个数据对象的偏移决定总的数据结构尺寸。
举个例子,假如某个渲染对象的Shader仅使用了unity_ObjectToWorld
和unity_SHAr
这两个变量,那么Unity在预处理该Shader时就会认为它使用了“Space block feature”和“Spherical Harmonic block feature”这两个特征,相对应的BuildInSystemCBuffer内部与空间和球谐关联的数据区段就会被填充,其他区段则直接略过,最终该渲染对象的系统内置常量缓存将会占用的大小等于 sizeof(float4) * 20
的内存空间。
如上图所示,这里有个取巧的地方,由于同一批次的渲染对象所使用的Shader以及激活的KeywordSets必然相同,因此它们使用的BuiltIn Feature也必然相同,这就导致整个Array<BuildInSystemCBuffer>
中的元素实际Size是相等的,从PerObjectLargeBuffer的角度看,现在任何一个渲染对象的任何一个激活Feature都能够通过简单的偏移计算获得。
- Array<GfxBatchMesh>
SRP Batch允许不同Mesh进行合批,因此每当合批阶段中当前对象和上一个对象的Mesh不同时,Unity就会创建并写入一个新的GfxBatchMesh数据对象。请放心它的里面并没有成堆的Index数组和Vertex数组,网格数据是以指针的形式存在,最终对应到GPU显存中的一段数据,因此并不会涉及庞大的数据转移和拷贝,除了一种情况以外:当缓存在MeshBuffer中的顶点通道(Vertex Channel)数量不满足实际上渲染顶点时的需要的数目,打个比方,如果当前缓存的buffer中没有任何一套“ShaderChannelTexCoords”,但是找到的GPUProgram又要求需要有“ShaderChannelTexCoord_0”,那么就必须为此(在CPU端)创建一套完整的纹理通道#0数组,数组长度与Mesh的顶点数一致,因此可能需要开辟大量内存,而Channel数据的初始化也是在这时执行的。
- Array<DrawBuffersRange>
这是一个和Static Batch相关的数据类型,可以这样理解:静态合批要求Unity在离线状态下,为满足合批条件的复数个渲染对象额外烘焙出一份包含了全部合批对象的Mesh资源(主要是顶点资源),系统在Culling阶段仍然使用渲染对象各自原有的网格对象、变换矩阵和包围盒进行可见性剔除,随后所有可见的部分输送到数据准备阶段进行拆解和排序,Unity尽可能保证所有满足静态合批的对象在排序后是紧密相邻的(对应排序时的SortOptimizeStateChanges符号位)。同其他参与SRP Batch的渲染对象一样,Static Batch对象也会进入RenderLoopDrawSRPBatcher大循环,进而被batcher捕获和处理。对于仅满足SRP Batch的对象,不同的Mesh会被区分处理,其数据由上文提及的GfxBatchMesh结构管理;而Static Batch的渲染对象必然共享了一个合并后的顶点超集(一般仅在GPU端),但是经过Cull和Sort后,余下成功静态合批的渲染对象很可能只对应了顶点超集中的某几个部分, 这就需要DrawBuffersRange数据结构帮忙整编它们了,结构名中的Range指的就是一段映射到合批对象的连续顶点区段。
事实上在满足某些条件(后文会展开)的情况下,Static Batch合批成功的一组渲染实例只会触发一个或很少的几个DrawCall,因此能成功参与静态合批的物体在渲染特性上必须高度一致,很多细微的特性差异就能破坏Static Batch,使合批退化成普通的SRP Batch。导致合批失败的差异或规则可总结如下:
- 若StaticBatch对象具有MotionVector,LightProbe,ProbeVolume,ReflectionProbe,MultiLight等特性(Feature);
- 若StaticBatch对象之间使用了不同的Material;
- 若LightMapIndex不同;
- 若InternalMeshID不同; //对应SubMeshIndex不同
- 若开启了LODFade;
- 若不是MeshRenderer;
- 若静态合批到了不同的超集中;
- 若顶点数据中的availableChannels不同;
- 若顶点数超过了最大值;
总之,静态合批相比SRP合批会严格许多。
- Array<GfxTetureParam>
和GfxBatchMesh结构类似,GfxTextureParam结构内仅存放了类型是Uint32的TextureID以及纹理下标等数据,只有当前后渲染对象的材质发生变化时,Unity才会遍历当前渲染对象的全部渲染阶段(RenderStage:主要包含Vertex,Geo,Hull,Domain和Fragment等可编程阶段),提取相应的GPUProgram,并从中获取所需的纹理信息(张数和应用),完成纹理参数配置。
- Array<PerMaterialCB>
所有的PerMaterialCB早在场景加载完毕后的头几帧就已经完成了填充和上传,因为Unity内部会为访问过的渲染对象以场景为单位做缓存,在第一次加载完场景并执行完场景剔除(Culling)之后,如果Unity发现缓存为空,就会触发一系列的创建操作(GetOrCreateSharedRendererScene)用来创建和填充当前场景的RenderNode,通俗的说就是初始化当前场景中的所有激活的渲染对象。以MeshRenderer为例,初始化的过程中会调用到一个回调“PrepareMeshRenderNodes”,它的一项任务就是遍历所有绑定到当前MeshRenderer身上的Material,提取其中的材质参数(Mat Prop Value),最后提交给底层Gfx API上传GPU,同时自身也持有对应Buffer在GPU端的引用,这个Buffer就是PerMaterialCB。
关于PerMaterialCB还有两点值得一提:
- Unity要求所有涉及PerMaterialCB的写操作全部在主线程中执行;
- PerMaterialCB中可包含0个或多个由用户定义的ConstantBuffer,一个典型的CB是材质球上的各种Properties,同一个名字的CB之下所有数据以键值对存放,Key对应了ShaderFastName,是一个由string转换而来的UINT字段,同时CB内部以float4的长度进行数据对齐,且只支持“Float”,“Vector”和“Matrix”等类型和它们的数组形式。诸如“Texture”和“ComputeBuffer”之类的引用类数据另行存储,不在PerMatericalCB中。
5 渲染线程
ApplyShahderPass和Flush在向底层Gfx API发起请求之前的整个工流(大多数情况下)是在主线程上完成的,唯有在底层支持异步Gfx API,且开启了Graphics Jobs的情况下,ApplyShahderPass和Flush的工作才由多条Jobs线程分担执行。
渲染线程则不同,它是完全独立于Graphics Jobs之外的概念,Unity设计渲染线程的目的在于将非图形设备向代码和图形设备向代码解耦,前者在主线程上工作,后者完全由渲染线程接管。大部分情况下(Unity默认)工程是开启渲染线程的,你也可以通过PlayerSettings->Multithreaded rending选项框进行确认,届时Unity在构造Gfx Device的抽象层“Gfx Device Client”时会单独起一个叫做Render Thread的线程作为消费者,一方面通过CommandQueue时刻监听来自主线程生产者发送的指令和数据,一方面负责和底层Gfx API交互。如下图所示:
Device Client对外封装有完整的图像接口,对内则持有真正的图像设备(Real Device),至于你的系统在运行时会初始化出什么样的Real Device,一般是由用户配置的Player-Settings->Graphics-API
列表决定的,如果缺失了这部分用户配置,Unity则依据当前Platform提供的首选项自动选择。在渲染线程模式下,所有对外图形接口都被统一封装到了Uniform Graphics API名下,应用层调度这些对外接口所生产的指令和数据则会依序压入指令队列(CommandQueue),队列另一端是作为消费者的渲染线程,确切的说是名为GfxDeviceWorker的工作模块,负责解析和运行指令,并在需要时向Real Device发起请求,接受响应。
Unity也可以不开启渲染线程,在此模式下Worker和CommandQueue并不存在,所有对外的图形API会直接与真正的图像接口设备对接,此时底层图形API的调用者和Device Client代理层的调用者都工作在相同的线程上(一般为主线程)。
回到渲染线程来,当主线程的ApplyShahderPass和Flush向渲染线程发起请求,能够触发渲染线程对应的执行逻辑,这点可以从Profiler中看到端倪:
说是端倪,主要由于Unity内置打点信息是缺位的,我们从Profiler出发并不能直观的看到ApplayShaderPass所激发的渲染线程逻辑(暂命名为ApplyGpuProgram)位于何处,但是基于主线程的串行特性以及只有一个渲染线程和一条指令队列的事实,我们不难推测出ApplyGpuProgram(对应上图中蓝色方框)应该位于由Flush触发的渲染线程逻辑DrawBuffersBatchMode(对应上图中红色箭头)之前。整体上看,主线程向CommandQueue的提交顺序决定了渲染线程的工作顺序。
5.1 再说ApplayShaderPass
5.1.1 主线程中的ApplayShaderPass
ApplayShaderPass中消耗占比最大的是数据处理部分,并且贯穿了主线程和渲染线程,下面我们先基于主线程中的数据准备(PrepareValues)部分看看一共涉及了哪些数据,Unity又是如何处理它们的。
PrepareValues方法作为入口,它的入参“buffer”对象通过CommandQueue创建,是所有待收集数据的目的地,下面观察上图右侧,自上而下依序准备(Prepare)了六个方面的数据,它们分别是:
- Value (Default)
-> 关联Shader内定义的一个装有“公共”常量的CBUFFER_START/END
代码段,一般为“UnityLighting”或“UnityPerCamera”,内部cbIndex = -1
,对应了一段在GPU中已经开辟好的UBO,数值类型包含:Float
,Vector
,Matrix
及其它们的数组; - Value (Extra)
-> 与Value (Default) 类似,如果存在则关联第2~N组CBUFFER_START/END
代码段,可以是“UnityShadows”,“MainLightShadows”,"AdditionalLightShadows",“UnityFog”等,也可以是用户定义的其他不随材质变化,只与Shader关联的常量参数,它们的内部cbIndex > 0
; - Texture
-> 一系列绑定到Shader上的纹理数据结构,结构包含纹理引用TextureID
,用于确定GPU内存中某一特定纹理格式资源; - ComputeBuffer
-> 一系列绑定到Shader上的数据缓冲(Buffer)结构,包含ComputeBufferID
,用于确定GPU内存中某一特定的SSBO; - Sampler
-> 一系列绑定到Shader上的采样器结构,定义了采样器的状态和名字; - ConstantBuffer
-> 一系列绑定到Shader上的常量缓存结构,用户可通过Material.SetConstantBuffer
添加。需要注意的是,如果底层Gfx Device选择了OpenGLES,那么Material.SetConstantBuffer
接口会失效。
注意这些数据向buffer的填充顺序是固定的,后续渲染线程在提取数据对象时,会默认这个固定的填充顺序,再配合预设的数据头标识符“head”以及数据尾标识符“end”,Unity就可以在渲染线程中非常高效的读取(解码)buffer中的数据。
5.1.2 渲染线程中的ApplayShaderPass
流程参考下图:
可以看到,渲染线程会直接向Gfx API提交(Apply)从buffer中解码出来的各项渲染参数和资源引用,执行顺序和“数据准备时(Prepare)”保持一致。这里着重讲一下Value,简单说一次ApplyValueParameters方法的调用填充了Shader中一段CBUFFER_START/END代码段,由于是常量缓冲,在底层这些数据需要用到Upload操作,以OpenGLES API为例对应了glBufferSubData指令,其语义是将数据上传到GPU中指定UBO的指定偏移上并覆盖,不涉及Buffer的开辟( 如果要开辟新的缓冲需要用到glBufferData指令)。此外向GPU装填ValueParamters时还有数据对齐的要求,目的是消除不同Gfx API对ConstantBuffer数据读写时在格式上的区别,常见的有对齐标准有packed,shared和std140,Unity默认使用std140,于是当我们在Shader中定义如下常量缓冲时:
CBUFFER_START(myCB)
float SomeFancyData[1023]; //受数组下标描述符只有10bit长度,且需要预留一个值表他意,故Array对象最大支持1023=1<<10-1长度
...
CBUFFER_END
在实际提交给Gfx Device执行数据上传前,Unity会对每个元素打补丁:
for (UInt16 i = 0; i < numVals; ++i) //numVals == 1023
{
temp[i * 4 + 0] = SomeFancyData[i];
temp[i * 4 + 1] = 0;
temp[i * 4 + 2] = 0;
temp[i * 4 + 3] = 0;
}
因为std140对齐标准要求CB中每个数组的元素必须与Vector4
对齐。同理,Unity在处理Matrix4x4
数组时就得将每个矩阵拆解成4个Vector4
元素,使得总长度变为MatriceArray.size() * 4
,有趣的是拆分后的数组总长度不受1023的上限影响,但是极限CB的尺寸会来到约64KB
大小(1023个Matrix4x4
),如果底层图形API对CB有最大限制(比如16KB
),则可能会导致数据截断。配置常量缓冲中的元素还有一些其他可注意事项,比如下面这两条节选自Unity官方文档的建议:
- 用
float4
或者float4x4
替代float3
或者float3x3
,因为float4
在所有的Gfx API中都是一种布局,但是float3
不是。 - 在CBUFFER代码段内声明元素时,将它们按照尺寸从大往小排列,如先
float4
,再float2
,最后float
,好处也是能够消除不同Gfx API底层之间的差异。
总之std140通过提前将数据对齐,规范结构,可以消除不同图形接口的兼容性问题,使得系统在只消耗少量额外内存和CPU时钟的前提下大幅提高管线的数据读写和编解码效率,这里就不深入展开了。
除了ApplyValue以外我们还能看到ApplyTexutre,ApplyComputeBuffer,ApplySampler和ApplyConstantBuffer等方法,这些方法面对的资源参数一般只涉及少量引用类型的数据,故而应用(Apply)资源的本质只是将少量数据引用绑定(Binding)到正确的名字上而已,不涉及元素对齐和海量数据上传,因此效率相对较高。
当然也不是说简单到完全没有坑,比如ApplyConstantBuffer,在Unity当前材质体系下,如果我们想要将Shader中的某个Name绑定到另一个在别处定义并创建好的Constant Buffer的话,首先需要以如下方式在Shader中定义CB对象(HLSL):
cbuffer myConstantBuffer {
float4x4 matWorld;
float4 vObjectPosition;
float arrayIndex;
}
然后需要在C#端通过Material.SetConstantBuffer
或者MaterialPropertyBlock.SetConstantBuffer
告知Unity你希望哪个自定义常量缓冲实例(对应ComputerBuffer或GraphicsBuffer)与名字为myConstantBuffer的对象进行绑定。
但是想要正确使用好这个ConstantBuffer,你还需要处理好三个DrawBack:
- 兼容性问题:不是所有Gfx Device支持通过ComputerBuffer或GraphicsBuffer的方式直接向Shader中的
cbuffer
对象赋值,比如OpenGL\OpenGLES就不行; - 符号对齐问题:赋值成功也可能存在CBuffer内分布的数据与Shader内声明的常量缓冲变量不能一一对应的情况,这点视不同Gfx API而不同,Unity无法帮我们消除这种潜在的变量配对问题,我们需要依靠前文提及的“按照尺寸从大往小排列”规则手动消除这种影响;
- 视硬件制造商不同,ConstantBuffer与StructuredBuffer相比可能会有更高的读写效率(因为数据被Alloc在更加接近计算核心的高速Cache上),因此其资源总量是相对受限的,此外不同Gfx API对单个CB的尺寸也有大小的限制。
Unity的官方建议是,在ConstantBuffer中尽可能只存放小尺寸的table:
“ The very short version is that a "ConstantBuffer" is a special term for a small table of assorted values, whereas Buffer and StructuredBuffer are for arrays of the same type.”
5.2 再说Flush
与ApplayShaderPass类似,Flush在主线程内的工作主要是收集和填充buffer,既一段由CommandQueue开辟(指定)的内存段,只是Flush专注的是各种PerMaterialData以及System Built-In Object Data数据的收集和填充。
Flush在渲染线程上的工作模块可以在Profiler中直接找到,叫“DrawBuffersBatchMode”,与ApplyGpuProgram的CB数据对齐+资源上传,以及对纹理,采样器和缓冲等引用资源的绑定等操作类似,DrawBuffersBatchMode需要负责:
- builtInCB的绑定(一个batch一次),
- perMaterialCB的绑定(材质变化时执行一次),
- perMaterialTexture的绑定(材质变化时执行一次),
- 网格资源的整理和绑定(一般情况是一个batchInstance一次*),
- 向底层图形API发起DrawCall指令,
- 以及在最后回收和释放本次Batch的CPU端临时缓存。
至于DrawCall的总次数一般与参与合批的渲染对象数量一致,但是在开启静态合批的前提下(BuiltIn-Instance另说),实际DrawCall的数量很可能会小于(甚至远远小于)成功进入一次Srp Batch的渲染对象数目。这是因为同一类静态合批对象使用了预烘焙的全量Mesh作为几何阶段的数据来源,而Unity会将全量Mesh中顶点索引相邻的两个或多个静态合批对象看做是逻辑上的单个对象执行DrawCall。
打个比方,假如一组5
个能够彼此静态合批的渲染对象{1,2,4,5,7}
通过了Culling和Sorting后又被依照这个顺序送入了一次Srp Batch中。再假设渲染对象代表的数字恰好对应了它们在全量Mesh中使用的网格顶点缓存(VertexBuffer)范围所处位置,数字相邻则位置也相邻,那么Unity的静态合批就会将渲染对象{1}
及其使用的[A, B]
段顶点与渲染对象{2}
及其使用的[B+1, C]
区段顶点合并成[A, C]
顶点范围,使得在Mesh的角度上将{1}
和{2}
视作一个渲染对象。由此可见,这组5
个渲染对象最终只会出发3次DrawCall,分别是:{1,2}
,{4,5}
和{7}
。
而如果Cull和Sort后原本的5个对象按照{1,4,2,5,7}
的顺序被投入到SRP Batch,那么由于没有相邻的对象可以整合Mesh顶点,最终将会执行5
次DrawCall,每个对象一次。
6 Standard Batch vs SRP Batch
个人认为可以从三个主要方面去理解它们的不同,分别是“合批判断逻辑”,“合批循环”以及“PerMaterialCBuffer提交逻辑”。
6.1 合批判断逻辑的不同
与SRP Batch合批规则相比,传统合批需要满足更加严格的条件,简单整理如下:
BatchBreakCauseMultipleForwardLights, //ForwardAdd类型的Pass不能合批
BatchBreakCauseDifferentMaterials, //不同的材质不能合批
BatchBreakCauseMultiPassShader, //材质相同,但是使用的Pass不同也不能合批
BatchBreakCauseOddNegativeScaling, //遇到Transform.scale.xyz中有1维或3维变量是负数的不能合批
BatchBreakCauseDifferentShadowReceiving, //接受阴影和不接受阴影的物体之间不能合批
BatchBreakCauseDifferentForwardLights, //前向渲染管线中不同的MainLight不能合批
BatchBreakCauseDifferentLightingLayersInDeferred, //延迟渲染中不同的LightingLayer(记录在Stencil中)不能合批
BatchBreakCauseDifferentCastShadowSettings, //渲染Shadow过程中遇到不同的ShadowSettings
BatchBreakCauseDifferentShaderCasterHashes, //渲染Shadow过程中遇到不同的ShadowCaster Pass
BatchBreakCauseShaderDisablesBatching, //Shader不支持Batching的自然不能合批
BatchBreakCauseDifferentCustomPropHashes, //相同材质和Pass,但是材质关联的属性数值不同也不能合批
BatchBreakCauseNonInstanceablePropSet, //后续要走(或不走)Intance流程而打断合批
BatchBreakCauseLightmapped, //Lightmap使用的TexArray不同或者Index不同
BatchBreakCauseDifferentLightProbes, //前后不同的LightProbe
BatchBreakCauseDifferentProbeOcclusions, //前后不同的ProbeOcclusion
BatchBreakCauseDifferentReflectionProbes, //前后不同的反射探针
BatchBreakCauseInstancingReachedMaxBatchSize, //超过最大Batch数,这个数目前可以认为是uint32的最大表示值
BatchBreakCauseMotionVectors, //如果开启了逐物体的MotionVector,则不能合批
为方便对比,我把SRP Batch合批失败的情况放在了下面:
SRPBatchBreakDifferentShader, //不同Shader
SRPBatchBreakCauseMultiPassShader, //不同Pass
SRPBatchKeywordsChange, //不同KeywordSet
SRPBatchMaterialNeedDeviceStateChange,//不同Material的管线相关Porperties设置
由此可见,想要合批成功,不光合批对象的材质要完全一样,很多系统内置(Built-In)的常量缓冲数据都要一致才行。
6.2 合批循环的不同
两种Batch对待合批对象的处理流程存在较大差异,参考如下对比流程图:
传统Batch在循环处理每一个对象的过程中,不论是否可以合批总是会进行大量的写Buffer操作,不难发现目标Buffer指向的大多是“Unity系统内置逐对象变量”,而且Buffer与Buffer之间彼此独立,内存上是不连续。作为对比,右侧的SRP Batch只有很少(2个)变量参数需要逐渲染对象设置,本身宽松的合批逻辑在理论上也允许更多的ObjectData合并到一起,最后Flush时统一由专职代码逻辑处理“Unity系统内置逐对象变量”(BuiltInCB)的内容,保证同一批对象的Per-Object buffer data在内存上连续且对齐,方便GPU一次性提交,同时也方便了GPU端使用offset获取具体数据。
6.3 Per Material CBuffer提交逻辑的不同
在前文介绍SRP Batch的Flush函数时我们已经从其填充Array<PerMaterialCB>
的方式了解到,在一开始导入Renderer的过程中,Unity引擎会判断是否开启了SRP Batch,如果开启则触发材质常量参数的提前收集并立即提交给底层Gfx API,因此只要材质的相关属性不发生变化(没有使用C#代码动态修改Material各项属性参数),我们可以认为GPU显存中的某块持久化内存中常驻有该材质的关联数据(Per Material Param)。
另一方面,传统Batch会通过对应的Flush方法(参考下图),对每一个ObjectData(图中对应了BatchInstanceData)执行一遍ApplySharedNodeCustomProps方法,其通过Gfx API提供的CommandQueue,将系统层收集的用户定义的材质常量参数(对应下图中的ShaderPropertySheet)提交给渲染线程,并进一步上传到GPU(对应下图红框中的写操作)。逐对象数据的上传过程每一个渲染帧都会发生。
6.4 重新解读官方对比图
我们再来审视一下广为流传的官方对比图,你可能会发现SRP Batch图例中的一些问题:事实上SRP Batch并不能只通过两次Binding就提交DrawCall,因为系统任然需要收集整理和上传渲染对象的各种“built in data”,使其成为GPU显存中的一段CBuffer,然后才能从容的“Bind with offset”,只不过这些操作不是在合批循环中执行的(对应了下图中的浅红和浅蓝色块),而是放在了下图类似“SetShaderPass”的附近。
下面一组对比图同样来自官方文档,主要从数据流角度出发,SRP Batch将不同更新频率的数据做了区分(Built-In和Per-Material),各自使用专职代码处理,数据位于GPU缓存的不同区域。
6.5 关于SRP Batcher所以高效的结论
末尾,参考官方的建议,我们确认SRP Batcher之所以高效主要依赖于以下两点:
- 每一个材质相关的参数(perMaterialCB)都提前进行了持久化,保存在了GPU常量缓存中,取用时只负责绑定对象即可;
- 相比于传统模式将材质和模型数据混杂在一起处理,SRP Batch使用了优化过的专职代码处理引擎内置数据(System Built-In Data)和逐材质属性(Per Material Data),其中引擎内置数据分布在连续内存中,可以依靠offset取用,方便GPU进行优化调度。