Unity可編程渲染管線(SRP)的底層工作原理

前言

本文是對(duì)Unity可編程渲染管線(Scriptable Render Pipeline)基礎(chǔ)框架的一點(diǎn)梳理和備忘,包含了個(gè)人對(duì)底層實(shí)現(xiàn)的理解以及對(duì)大量官方資料的解讀。如有謬誤或不解,歡迎留言。

1 SRP的頂層結(jié)構(gòu)

為了解決內(nèi)置渲染管線(Built-In Render Pipeline)在應(yīng)對(duì)日新月異的渲染需求時(shí)過于僵硬,不夠靈活的問題,Unity推出了可編程渲染管線(Scriptable Render Pipeline)的概念,按照官方文檔提供的解釋,SRP在頂層設(shè)計(jì)上做了3個(gè)維度的區(qū)分,分別是:

  1. 可編程渲染后端(Scriptable Render Backend),主要由Cpp語言編寫的運(yùn)行時(shí)基礎(chǔ)框架,其本身不可編程,但是向可編程管線的實(shí)現(xiàn)層提供豐富且高效的API;
  2. 核心公共件(Core RP),以C#和ShaderLab語言編寫的一些列公共庫為主,提供不依賴于具體渲染管線的基礎(chǔ)服務(wù);
  3. 渲染管線實(shí)現(xiàn)層(Render Pipelines),是基于上兩層實(shí)現(xiàn)的具體渲染管線解決方案,可供客制化,官方樣板主要有URP和HDRP。

相比開源的“核心公共件”和“渲染管線實(shí)現(xiàn)層”來說“可編程渲染后端”作為運(yùn)行在底層的黑盒,對(duì)我們影藏了大量的數(shù)據(jù)管理和圖形渲染邏輯,如果說“渲染管線實(shí)現(xiàn)層”指揮了管線的具體調(diào)度節(jié)奏,那么“可編程渲染后端”就是調(diào)度后被安排來真正完成具體功能的那一位。在上圖紅框中可見,Unity官方將“后端”區(qū)分為:“context”,“culling”,“draw”和“batch renderer”等節(jié)點(diǎn),對(duì)標(biāo)了Native源碼中四個(gè)重要的功能模塊。我們不妨由此出發(fā)來深入了解SRP的運(yùn)作機(jī)理。首先結(jié)合應(yīng)用層調(diào)用方式和源碼閱讀,簡單總結(jié)這些模塊的功能如下:

  1. Context -> 承載了一次完整的渲染管線提交所需數(shù)據(jù),同時(shí)也提供了各種對(duì)外方法的入口;
  2. Culling -> 負(fù)責(zé)判斷場景內(nèi)所有激活狀態(tài)的Renderer的可見性,過濾出所有可見且合法的渲染對(duì)象;
  3. Draw -> 底層繪制邏輯,負(fù)責(zé)分類整理和排序Renderer,收集和設(shè)置渲染參數(shù),最終提交渲染線程執(zhí)行繪制;
  4. Batch Renderer -> Srp合批渲染器,通過判斷相鄰渲染對(duì)象之間的屬性,篩選和組織對(duì)象進(jìn)行合批處理。

在“渲染管線實(shí)現(xiàn)層”(比如URP)中也可以找到上述核心模塊的“分身”。它們有的直接映射了本體,例如Native中的Context和URP中的Context對(duì)象,還有的則直接或間接觸發(fā)了上述模塊功能:

  1. Context.Cull -> 直接對(duì)應(yīng)了“后端”中的Culling,負(fù)責(zé)渲染對(duì)象的可見性判斷,也負(fù)責(zé)生成RenderQueue隊(duì)列(后續(xù)展開);
  2. Context.Execute ->(如URP中的ExecuteCommandBuffer)負(fù)責(zé)填充Context中的渲染指令隊(duì)列(CommandQueue);
  3. Context.Submit -> 向“后端”Draw 模塊一次性提交所有壓入的渲染指令,在Draw的過程中還會(huì)進(jìn)一步觸發(fā)Batch Renderer,構(gòu)造合批渲染。

下面分別簡述下這3個(gè)重要功能點(diǎn)的內(nèi)部執(zhí)行邏輯:

2 Cull

剔除部分的工作量多寡與場景復(fù)雜度正相關(guān),且場景相機(jī),燈光和陰影貼圖數(shù)量的多寡還可對(duì)部分剔除工作產(chǎn)生倍增或倍減的效果。檢查Profiler發(fā)現(xiàn),在通常情況下耗時(shí)比較突出的剔除工作主要有:(1)陰影剔除(ShadowCulling)和(2)動(dòng)態(tài)場景渲染對(duì)象剔除(SceneDynamicObjectsCulling)。其他可能參與剔除的類別還有:

  1. 靜態(tài)遮擋剔除(Static Occlusion Culling)
  2. 地形剔除(Cull Terrains)
  3. 探針剔除(ReflectionProbe Update)
  4. 燈光剔除(Light Culling)

你能否也在Profiler中看到它們?nèi)Q于你的Unity工程是否預(yù)計(jì)算并存儲(chǔ)了潛在可見集合(Potentially Visible Set: PVS),或使用了Unity原生的地形和反射探針系統(tǒng),亦或是設(shè)置了多光源(Spot和Point Light)。只是即便開啟了上述額外的剔除項(xiàng)目,在一般情況下Culling階段的主要負(fù)擔(dān)還是在陰影場景動(dòng)態(tài)物體剔除上面,下面我們逐一解析下。

2.1 Shadow Culling

首先需要說明一點(diǎn),陰影剔除并不是由Constant.Cull觸發(fā)的,而是由Constant.DrawShadow觸發(fā),只是其從屬于可見性剔除的本質(zhì)沒變,這里就一起說了。

我們知道平行光光源視角通常被設(shè)置為一個(gè)較大的矩形正交投影視錐體,可以覆蓋整個(gè)場景。這樣可以確保任何在光線路徑上的物體都會(huì)生成相應(yīng)的陰影,但只是確保有無投影,受陰影貼圖分辨率影響,必須找到合適的投影范圍才能生成高質(zhì)量的投影。因此在實(shí)際計(jì)算陰影貼圖時(shí),Unity會(huì)考慮攝像機(jī)的視錐體的影響,利用遠(yuǎn)近裁剪面限定了限定平行光光源的矩形正交投影視錐體的尺寸,使矩形投影體永遠(yuǎn)聚焦在熱點(diǎn)區(qū)域附近。之后為了進(jìn)一步縮小計(jì)算范圍,還需要根據(jù)攝像機(jī)視錐體的信息進(jìn)行裁剪,Unity為此計(jì)算了攝像機(jī)視錐體的邊界盒(Bounding Box),然后將該邊界盒擴(kuò)展一定距離,再與聚焦后的矩形正交投影視錐體做交集,形成最終的光源投影范圍,此區(qū)域(K-DOP)一般由6~10塊平面合圍而成。

而所謂的陰影剔除就是先于投影計(jì)算做準(zhǔn)備,主要通過對(duì)6~10塊平面做相交測試,將不在光源投影范圍的物體從投影計(jì)算中剔除出去,此外如果開啟了動(dòng)態(tài)遮擋剔除,Unity也會(huì)利用內(nèi)置的Umbra系統(tǒng)參與計(jì)算(此處暫略)。剔除過程由Jobs系統(tǒng)管理,也就是說是多線程并發(fā)處理的,參考如下Profiler截圖:

圖中紅框標(biāo)出的Shadows.CullShadowCastersDirectional專門剔除被平行光源影響的投影物體,Unity將場景內(nèi)所有參與陰影投影的物體分成一定數(shù)量的組(Group),之后為每一個(gè)光源+投影物體組的組合創(chuàng)建一個(gè)專門的剔除工作任務(wù),由Jobs分派到合適的線程上工作。結(jié)合工程實(shí)例的表現(xiàn)效果可知,陰影剔除總的工作負(fù)載受以下因素影響:

  • 與場景中開啟陰影的光源數(shù)量正相關(guān)。即便場景非常簡單(比如只有1個(gè)投影物體),但有N個(gè)投影光源,Unity仍然會(huì)針對(duì)每個(gè)光源派發(fā)總共N個(gè)Jobs進(jìn)行處理。
  • 與場景中投射陰影的游戲?qū)ο髷?shù)量正相關(guān)。如果對(duì)象數(shù)量很多,Unity會(huì)將它們劃分到不同Group里,這樣就會(huì)產(chǎn)生Group總數(shù) x 投影光源總數(shù)個(gè)Jobs。

一個(gè)簡單結(jié)論:當(dāng)ShadowCull耗時(shí)過高時(shí),最有效的方法就是減少投影物體和光源的數(shù)量。

2.2 Scene Dynamic Object Culling

動(dòng)態(tài)場景渲染對(duì)象剔除的目的是提前過濾掉場景中攝像機(jī)不可見的渲染對(duì)象,為后續(xù)管線流程減負(fù)。注意此處的Dynamic并非指渲染對(duì)象本身的Static屬性,而是在運(yùn)行時(shí)實(shí)時(shí)計(jì)算物體可見性的方案,與需要大量預(yù)計(jì)算的靜態(tài)遮擋剔除技術(shù)相區(qū)別。動(dòng)態(tài)場景渲染對(duì)象剔除包括了視錐剔除動(dòng)態(tài)物體遮擋剔除兩個(gè)方面,其中實(shí)時(shí)的動(dòng)態(tài)物體遮擋剔除(Occlusion Culling)使用內(nèi)建的第三方Umbra系統(tǒng),該系統(tǒng)默認(rèn)是關(guān)閉的,需要手動(dòng)開啟并配合輕度預(yù)計(jì)算(只涉及空間劃分)。關(guān)于這些可見性判斷的具體算法不是本文的主題,不過可以肯定的是,手機(jī)端TBDR管線可有效避免片元因前后遮擋引起的OverHead,且在各種合批技術(shù)的加持下,通過付出不很穩(wěn)定的額外CPU資源去換取少量但穩(wěn)定的渲染資源收集和提交消耗這件事是否值當(dāng)還兩說,需要具體到項(xiàng)目(場景)具體判斷。

由于每一幀等待Cull的目標(biāo)是全場景中激活的渲染對(duì)象,數(shù)量可觀,如果從數(shù)據(jù)流的角度出發(fā),Scene Dynamic Object Culling還是有許多說之處的。首先基于每一個(gè)場景Unity都維護(hù)了一個(gè)叫SceneDynamicObjects的隊(duì)列,它裝載了所有處于激活狀態(tài)下的渲染對(duì)象引用 ,與此同時(shí)它們?cè)陉?duì)列中的下標(biāo)又構(gòu)成了另一個(gè)重要的數(shù)據(jù)隊(duì)列IndexList 。我們知道整個(gè)Culling過程是由Jobs System負(fù)責(zé)規(guī)劃和派發(fā)的,視負(fù)載不同前后可能有多組線程參與計(jì)算,每個(gè)線程實(shí)際負(fù)責(zé)IndexList上的一個(gè)區(qū)段,線程內(nèi)遍歷這段IndexList,對(duì)每個(gè)讀取到的渲染對(duì)象應(yīng)用可見性判斷算法,這就構(gòu)成了一組Cull Job,而多組這樣的Jobs之后還會(huì)再追加一次Combine Job,從下面Porfiler截圖中可以得到印證:

借用官方講座的截圖(下圖),多組Cull Job運(yùn)行在獨(dú)立線程中(對(duì)應(yīng)一種顏色),線程內(nèi)部訪問的IndexList數(shù)據(jù)段彼此獨(dú)立,不產(chǎn)生競太,當(dāng)執(zhí)行完可見性判斷邏輯后,Cull Job丟棄沒有通過的索引,余下索引回填到數(shù)組中,同時(shí)保證向隊(duì)列前端對(duì)齊靠緊:

由于Cull Job的這種工作方式,必然導(dǎo)致它們的產(chǎn)出數(shù)組在IndexList內(nèi)部是不連續(xù)的,Unity利用追加的Combine Job如下圖所示這般重新規(guī)劃整理List,過程就不再贅述了。

2.3 Execute RenderQueue

經(jīng)過Combine之后獲得的是可見渲染對(duì)象的索引隊(duì)列,而這些渲染對(duì)象(Renderer)的實(shí)例在內(nèi)存中的分布肯定是不連續(xù)的。我們知道Jobs系統(tǒng)為了提高并發(fā)運(yùn)算效率,在派發(fā)多線程任務(wù)時(shí)會(huì)要求將所有待處理數(shù)據(jù)盡可能處理成連續(xù)排布的形式,于是便有了隨后的ExecuteRenderQueueJob(該過程同樣由多線程執(zhí)行),目標(biāo)是將各種引用類型的對(duì)象展平成值類型,同時(shí)對(duì)齊排列到一整片連續(xù)內(nèi)存中,Unity從這里開始引入了2個(gè)新的概念:

  • RenderNode -> 扁平化渲染對(duì)象(Renderer)后的值類型結(jié)構(gòu)體,包含渲染所需的一切信息(MaterialData,LayeringData,LightMapST,LightMapIndex,RendererType,RendererPriority,CastShadow,ReceiveShadow,ProbeUsage,DynamicOcc,RenderingLayerMask,StaticBatchInfo,etc...);
  • RenderNodeQueue -> 由RenderNode組成的數(shù)據(jù)隊(duì)列,用于保證數(shù)組元素在內(nèi)存上是連續(xù)的。

所以ExecuteRenderQueueJob過程也很簡單:遍歷IndexList,找到并讀取對(duì)應(yīng)Renderer,然后將數(shù)據(jù)展開到RenderNode結(jié)構(gòu)上,依序?qū)懭隦enderNodeQueue。如下圖所示,到目前為止隊(duì)列中RenderNode的前后順序由Cull后的IndexList排列屬性決定,Culling過程會(huì)隨機(jī)剔除部分對(duì)象,而Culling前的原始IndexList又由前文提及的維護(hù)了全場景渲染對(duì)象的SceneDynamicObjects隊(duì)列決定,該隊(duì)列內(nèi)Renderer前后排列順序則由各自的初始化時(shí)機(jī)決定,故可以認(rèn)為,Unity并不在意RenderNode隊(duì)列里各個(gè)Node在邏輯上的亂序狀態(tài)。

RenderNodeQueue是Cull部分的終點(diǎn),也是實(shí)際渲染的起點(diǎn),事實(shí)上如果把渲染一幀畫面比作烹飪一桌菜肴,那么整個(gè)Culling過程就好似飯店后廚在制備酒席前的備料階段,場景內(nèi)的食材(原始渲染對(duì)象)經(jīng)過洗凈去皮(Cull掉不需要不可見部分)以及切段分盤盛放(格式化和扁平化數(shù)據(jù)結(jié)構(gòu)),最終一排排整齊羅列在工作臺(tái)上(RenderNodeQueue)。

3 Execute

Context.Execute相對(duì)簡單,它只負(fù)責(zé)以Command的形式收集來自應(yīng)用層的渲染指令,處理類似任務(wù)的前端接口還有:

  • CommandBuffer.Blit
  • CommandBuffer.DrawMesh
  • ScriptableRenderContext.DrawRenderers
  • ScriptableRenderContext.DrawShadows

雖然接口名給人一種即時(shí)執(zhí)行的暗示,但它們本質(zhì)都是向SRP底層提供的指令隊(duì)列中填充不同內(nèi)容的Command指令。

Unity共有三種不同類型的Command,它們分別是:

  1. ShadowDrawingSettings:對(duì)應(yīng)DrawShadowCommands隊(duì)列
  2. DrawRenderersCommand:對(duì)應(yīng)DrawRenderersCommands隊(duì)列
  3. RenderingCommandBuffer:對(duì)應(yīng)CommandBuffers隊(duì)列

此外Unity底層還單獨(dú)維護(hù)了一個(gè)的用于記錄全局先后順序的隊(duì)列,叫做Commands,類型是:dynamic_array<Command> ,每當(dāng)用戶向指令隊(duì)列添加新的Command時(shí),這個(gè)隊(duì)列也會(huì)添加一份該Command的引用,具體流程參考下圖:

Submit前,幾乎所有的繪制或渲染接口都是調(diào)用向上述隊(duì)列中添加指令對(duì)象,指令內(nèi)容由Command對(duì)象記錄,指令順序被Commands隊(duì)列保持。繼續(xù)套用烹飪酒席的例子做類比的話,Execute階段是酒店收集客戶菜單的過程,菜單中的每道菜對(duì)應(yīng)了一個(gè)獨(dú)立的Command指令,決定了所需備料的種類和烹飪方法;而菜單中菜品的先后順序也被固定了下來,以確保冷盤(前菜),正餐和甜點(diǎn)的上菜順序不會(huì)錯(cuò)亂。

4 Submit

Submit的作用是向SRP底層一次性提交Context中的所有渲染指令,驅(qū)動(dòng)真正的“繪制”和“渲染”邏輯。該過程的CPU消耗對(duì)應(yīng)了主線程Profiler中的ScriptableRenderContext.Submit條目,我們可以在Scriptable Render Loop下找到它。Submit之后,后廚就收到了客戶的訂單(Commands隊(duì)列),于是便開始依序遍歷訂單中每一個(gè)菜品(Command),在主線程內(nèi)逐個(gè)處理它們。

我們知道不同的“菜肴”會(huì)對(duì)食材的種類和炒制的方法有不同的要求,渲染指令同樣會(huì)對(duì)渲染對(duì)象和渲染管線有不同的過濾條件和配置要求,以常見的“菜品”DrawRenderersCommand為例(就是負(fù)責(zé)DrawOpaque或者DrawTransparent的那個(gè)),它需要將執(zhí)行分解為2個(gè)階段進(jìn)行:

  1. 數(shù)據(jù)準(zhǔn)備階段(PrepareDrawRenderers)
  2. 數(shù)據(jù)執(zhí)行階段(ExecuteDrawRenderers)

4.1 數(shù)據(jù)準(zhǔn)備階段

數(shù)據(jù)準(zhǔn)備階段(PrepareDrawRenderers)的CPU消耗對(duì)應(yīng)了Profiler中的RenderLoop.Prepare(準(zhǔn)備階段)和RenderLoop.Sort(排序階段)兩個(gè)條目:

  1. RenderLoop.Prepare -> 基于渲染指令自身的屬性和影響范圍,挑選出參與該指令的細(xì)粒度渲染對(duì)象。類比烹飪菜肴的話,可以看做是從全部備料中選出當(dāng)前菜品所需的部分,同時(shí)加工備料,使成為適合下鍋烹飪的形態(tài)。
  2. RenderLoop.Sort -> 對(duì)挑選出的細(xì)粒度渲染對(duì)象進(jìn)行排序,以確定它們進(jìn)入合批通道的先后順序。做類比的話,相當(dāng)于確定各項(xiàng)處理后食材的下鍋順序。

RenderLoop.Prepare到底做了什么還是有必要深究一下的。我們知道經(jīng)過Culling過程后全場景可見的Renderer信息被展平在了名為RenderNode的內(nèi)存上,可以籠統(tǒng)的讓RenderNode對(duì)象與游戲場景中每一個(gè)可見的Renderer對(duì)應(yīng)上,但是光到Renderer這一層還不是最細(xì)粒度的渲染對(duì)象,精確包含全部渲染要素的最小集合是ShaderPass,所以Unity需要在數(shù)據(jù)準(zhǔn)備階段(Prepare)進(jìn)一步細(xì)化和過濾,具體參考下圖:

(1個(gè))RenderNode -> (1個(gè)或多個(gè))Material -> (1個(gè)或多個(gè))Pass

在RenderLoop.Prepare階段,所有被梳理出來包含了單個(gè)ShaderPass全部數(shù)據(jù)的對(duì)象叫做ScriptableLoopObjectData,后文簡稱“ObjectData”。緊接著Unity會(huì)基于ObjectData的屬性和當(dāng)前DrawRendererCommand的具體過濾需求進(jìn)行二次過濾,簡介篩選條件如下:

  1. LayerMask
  2. RenderingLayerMask -> 對(duì)應(yīng)不同(Universal)Renderer(和RendererAsset設(shè)置)
  3. MotionVectorPassRequested
  4. ShaderTagID -> 對(duì)應(yīng)“Lit”,“SimpleLit”和“UnLit”等內(nèi)置或用戶手動(dòng)設(shè)置的TagID

至于RenderLoop.Sort階段,自然是負(fù)責(zé)將ObjectData對(duì)象按規(guī)則排序,底層邏輯中ScriptableLoopObjectData是一個(gè)相對(duì)輕量化的結(jié)構(gòu)體(struct),屬于值類型,具體定義如下:

struct ScriptableLoopObjectData
{
    RenderObjectData            data;                //記錄有參與比較的各種變量
    const SharedMaterialData*   sharedMaterial;      //指針 -> 指向材質(zhì)類
    const ShaderLab::Pass*      pass;                //指針 -> 指向Pass
    UInt32                      passIndex;           //值類型索引
    UInt32                      passOrder;           //值類型優(yōu)先級(jí)
};

復(fù)雜且與Sorting無關(guān)的數(shù)據(jù)會(huì)被Unity直接存放到指針中,而需要在排序比較中反復(fù)使用的參數(shù)則全部被整合成了值類型的數(shù)據(jù)結(jié)構(gòu)(參考RenderObjectData)。

影響排序的主要因素如下:

SortSortingLayer,    // global sorting layer(全局級(jí))
SortRenderQueue,         // material render queue(材質(zhì)級(jí))
SortBackToFront,         // 基于從后往前的規(guī)則
SortQuantizedFrontToBack, // 基于從前往后(量化)的規(guī)則 -> 有利于TBDR優(yōu)化Overhead
SortOptimizeStateChanges, // 優(yōu)化排序以提高效率,綜合考慮了: static batching,  lightmaps, material sort key, geometry ID
SortCanvasOrder,          // Canvas系統(tǒng)內(nèi),在距離相同前提下的 sort priority 
SortRendererPriority,    // renderer priority (當(dāng)render queue不可區(qū)分時(shí)使用)

比如,一個(gè)不透明物體的規(guī)則通常由以下幾種排序條件組成:

SortCommonOpaque = SortSortingLayer | 
        SortRenderQueue | 
        SortQuantizedFrontToBack | 
        SortOptimizeStateChanges | 
        SortCanvasOrder

比較的順序如下,可以理解為一旦某個(gè)比較節(jié)點(diǎn)得出結(jié)果(非相同)則立即返回結(jié)果:

  1. SortSortingLayer(全局SortingLayer)
  2. SortRenderQueue(材質(zhì)上的RenderQueue)
  3. SortRendererPriority(SRP專用,作為RenderQueue相等前提下的備用)
  4. SortBackToFront(依相機(jī)連續(xù)距離從后向前排序,半透明物體使用)
  5. SortQuantizedFrontToBack(依相機(jī)離散距離從前向后排序,不透明物體使用)
  6. SortOptimizeStateChanges(SRP Batcher兼容性排序優(yōu)化,讓能一起B(yǎng)atch的排序到一起)
  7. SortCanvasOrder(畫布順序)
  8. NodeIndex Or PassOrder

基于以上分析可以,像PrepareSort這類邏輯簡單,可獨(dú)立拆分,同時(shí)又面對(duì)海量同類數(shù)據(jù)的工作非常適合多線程并發(fā)執(zhí)行,事實(shí)上也是如此,Unity在多組Worker上執(zhí)行Prepare操作,其產(chǎn)出(一段ObjectData隊(duì)列)則被后起的多條Sort線程消費(fèi),參考下圖:

Sort這類計(jì)算密集型的工作非常適合多線程(多核)執(zhí)行,上圖中Unity將Sort任務(wù)拆分成了87個(gè)實(shí)例共運(yùn)行在11個(gè)線程中,累積總耗時(shí)達(dá)到1.44ms,實(shí)際耗時(shí)在多線程優(yōu)化下僅有總耗時(shí)的不到一成。換言之,對(duì)于核心數(shù)量偏少偏弱的(中低端)移動(dòng)平臺(tái)來說,控制渲染對(duì)象的總量(即便有合批加持)仍然很有必要。

4.2 數(shù)據(jù)執(zhí)行階段

準(zhǔn)備好數(shù)據(jù)后我們正式進(jìn)入數(shù)據(jù)執(zhí)行階段(ExecuteDrawRenderers),它在SRP語境中對(duì)應(yīng)了Profiler中RenderLoop.DrawSRPBatcher條目。 由于SRPBatcher的出色性能,工程默認(rèn)開啟了該項(xiàng)優(yōu)化,對(duì)應(yīng)下圖箭頭處:

在準(zhǔn)備完數(shù)據(jù)之后,Unity手上有經(jīng)過了細(xì)化且完成了排序的ObjectData隊(duì)列,大廚現(xiàn)在需要依次序?qū)⑦@些深加工過的食材投入鍋中烹飪,針對(duì)一道菜肴來說所需投放食材總量是固定的,如果每種食材各自需要加熱的時(shí)長也是已知的,那么相比于一份份加入食材,將能夠同時(shí)烹飪的食材一起入鍋,這樣做既能減少食材投放總批次,又能縮短烹飪總時(shí)長,從而加快出菜速度,間接提高了飯點(diǎn)的翻臺(tái)率。

好了,從我們拙劣的類比小故事回到RenderLoop.DrawSRPBatcher中來,隊(duì)列中每個(gè)ObjectData元素都記錄有是否兼容SRP Batcher的標(biāo)識(shí)符,Unity接下來要干的是根據(jù)排序結(jié)果將彼此相鄰且都兼容SRP合批的ObjectData對(duì)象打包投喂給SRP合批處理模塊(SRP Batcher);對(duì)于不兼容的ObjectData,則打包投喂給傳統(tǒng)合批渲染模塊(Standard/Legency Batcher)。至于什么是“不兼容”,官方手冊(cè)上有明確的闡述,可以簡練概括如下:

  1. 渲染對(duì)象使用的SubShader必須是兼容SRPBatcher的,這對(duì)應(yīng)了一些具體的ShaderLab編寫規(guī)則
  2. 沒有用戶在運(yùn)行時(shí)自行添加的額外材質(zhì)屬性(CustomProps),這對(duì)應(yīng)了不能使用MaterialPropertyBlocks;
  3. 早期版本(2019.3之前)只支持MeshRenderer,之后又追加支持了SkinnedMeshRenderer,所有其他Renderer都不支持;

備注1:在開啟SRPBatch模式前提下,如果材質(zhì)激活了實(shí)例化material->enableInstancing == true,Unity仍然會(huì)按照SRP Batch的方式嘗試合批處理,因?yàn)楣俜皆谧约旱臏y試demo中發(fā)現(xiàn)SRPBatch總是比管線自動(dòng)執(zhí)行的GPUInstancing效率高。

備注2:RenderLoop.DrawSRPBatcher默認(rèn)是運(yùn)行在主線程上的,如果開啟PlayerSettings->Graphics Jobs則會(huì)激活多線程模式:Unity會(huì)將ObjectData隊(duì)列按照可分派線程數(shù)進(jìn)行劃分,再把DrawSRPBatcher連同一部分隊(duì)列中的ObjectData通過Jobs丟給包含主線程在內(nèi)的諸多的核心計(jì)算。這些線程可以通過渲染線程同步向底層Gfx API發(fā)請(qǐng)求,也可以由執(zhí)行線程自身異步發(fā)送請(qǐng)求,前提是目標(biāo)圖形接口支持異步調(diào)用:常見的Gfx Device,諸如D3D11, OpenGLES,Vulkan,Metal這些都是支持(兼容)多線程的(Threadable)。只是請(qǐng)注意,即便在最新的2023.3版Unity官方文檔上,Graphics Jobs仍然是處于測試狀態(tài)(Experimental)的功能,網(wǎng)上也有一些開啟后遇到的兼容性問題和顯示bug(甚至crash),使用時(shí)需三思。

Unity管這段粗略的“合批兼容性”分流叫做“Dispatch Prepare”,具象化過程可參考如下圖示:

4.2.1 合批規(guī)則

每個(gè)成功被歸類到SRP batcher內(nèi)的ObjectData并不一定真能與其他同伴合批成功,Unity將進(jìn)一步基于如下規(guī)則做判斷,一旦合批失敗則SRP Batch打斷,Unity會(huì)將之前積累的合批對(duì)象打包提交給Gfx Device,并由其組織提交DrawCall,同時(shí)形成新一次的SetPassCall。

SRPBatchBreakDifferentShader,         //不同Shader
SRPBatchBreakCauseMultiPassShader,    //不同Pass
SRPBatchKeywordsChange,               //不同KeywordSet
SRPBatchMaterialNeedDeviceStateChange,//不同Material的管線相關(guān)Porperties設(shè)置

稍微解釋一下,合批失敗情況最多的是KeywordsChange,原因有很多,比如對(duì)于一段Shader代碼在C#中顯式的修改Keywords:

Shader shader = Shader.Find("MyShader");
string[] keywords = new string[] { "KEYWORD1", "KEYWORD2" };
ShaderUtil.SetShaderKeywords(shader, keywords);

再比如,用戶通過修改Renderer等組件的面板設(shè)置,也可能造成前后渲染物體的內(nèi)置關(guān)鍵詞(builtIn keywords)不同,內(nèi)置Keywords大致如下:

  • Light keywords
  • Shadow keywords
  • lightmapping keywords
  • fog keywords
  • other builtin keywords
    • EmissionMap
    • VertexLightOn
    • SoftParticlesOn
    • HDROn
    • LODFadeCrossfade

還有,如果前后渲染對(duì)象的RendererType不同,也必定會(huì)間接的影響到內(nèi)置Shader關(guān)鍵詞,渲染類型大致如下,但是對(duì)于SRP Batch來說只有Mesh和SkinnedMesh這兩類:

  • RendererMesh //支持SRP
  • RendererSkinnedMesh //支持SRP
  • RendererSprite
  • RendererTilemap
  • RendererTrail
  • RendererLine
  • RendererParticleSystem
  • RendererBillboard
  • RendererSpriteMask
  • RendererSpriteShape
  • RendererVFX

合批失敗的其他原因:

  1. MultiPassShader
    -> 對(duì)應(yīng)了出現(xiàn)相同SubShader但是屬于不同Pass的情況;
  2. DifferentShader
    -> 對(duì)應(yīng)了不同的Shader或者不同SubShader的情況;
  3. MaterialNeedDeviceStateChange
    -> 說明前后2個(gè)渲染對(duì)象的材質(zhì)面板選項(xiàng)中,存在了某些能夠影響到渲染狀態(tài)(blend mode,depth/stencil settings等)的差異設(shè)置;
4.2.2 合批循環(huán)

說完合批失敗的原因后我們?cè)賮砜纯淳唧w的合批循環(huán)流程,可簡述如下:

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在循環(huán)開始前會(huì)先創(chuàng)建一個(gè)srp batch對(duì)象,之后開始從隊(duì)列中取出并解析第一個(gè)ObjectData_1。由于沒有第零個(gè)ObjectData可以合批,初次合批兼容性測試必然導(dǎo)致合批失敗,進(jìn)而觸發(fā)新一輪合批開始。這個(gè)過程中Unity依據(jù)ObjectData_1的特性執(zhí)行一次SetPass,該操作對(duì)應(yīng)了Profiler中的ApplyShaderPass,其本質(zhì)是部分渲染參數(shù)在CPU端的整合和拷貝和上傳(后續(xù)詳解)。這之后Unity繼續(xù)推進(jìn)循環(huán),開始檢查第二個(gè)ObjectData_2是否可以和上一個(gè)(ObjectData_1)對(duì)象合批,判斷合批的依據(jù)不再贅述。如果可以合批,則將此ObjectData_2對(duì)象納入合批集合(srp batch對(duì)象)中,繼續(xù)推進(jìn)迭代。如果合批失敗,則本輪srp batch尋找合批對(duì)象的過程到此為止,Unity會(huì)先觸發(fā)一次向底層GfxDevice的數(shù)據(jù)提交,內(nèi)容是batcher內(nèi)部積攢的全部ObjectData,這個(gè)過程對(duì)應(yīng)了Profiler中的BatchRenderer.Flush,簡言之,Flush主要職責(zé)是把同批次ObjectData進(jìn)一步展開成(臨時(shí)的)數(shù)據(jù)緩沖,通過管道交給渲染線程。最后在下一輪開始合批前,Unity還需要以當(dāng)前渲染對(duì)象(既破壞了合批的那個(gè)ObjectData_2)作為藍(lán)本,觸發(fā)新一輪的SetPass,至此完成了一整輪循環(huán)。

為了提高渲染效率,我們肯定期望Unity每次合批的渲染對(duì)象越多越好,但是從SRP Batch循環(huán)機(jī)制的現(xiàn)實(shí)出發(fā),參與合批的ObjectData是從隊(duì)列中依次被取出的,合批失敗后立即提交先前的緩存對(duì)象,沒有所謂的:“跳過當(dāng)前渲染對(duì)象,嘗試在隊(duì)列后方繼續(xù)搜索可合批ObjectData”這種邏輯存在。那么為了能夠增加合批成功的幾率,我們就需要在數(shù)據(jù)準(zhǔn)備階段利用好排序規(guī)則中各種可控的標(biāo)簽,將能夠彼此合批的渲染對(duì)象規(guī)劃到隊(duì)列中相鄰的位置,同時(shí)又不破壞最終繪制圖像的前后層關(guān)系。

從如下Profiler截圖中可知,RenderLoop.DrawSRPBatcher的主要開銷都在ApplyShaderPassFlush這2個(gè)方法上面(占比上分析):

事實(shí)上在相同渲染對(duì)象前提下,ApplyShaderPassFlush執(zhí)行次數(shù)越少,對(duì)應(yīng)的合批效果越好,從而Unity執(zhí)行渲染效率就越高。雖然絕大多數(shù)情況下我們只需要知道如何提高合批成功率(通過優(yōu)化材質(zhì)和提交順序等外部手段)就能達(dá)成提高渲染效率的目的,但是知其然知其所以然,如果能夠掌握這兩個(gè)方法內(nèi)部的具體執(zhí)行邏輯,想必也能幫我們了解渲染管線底層優(yōu)化的方向,激發(fā)我們?cè)趯?shí)際性能優(yōu)化過程中的思考深度。

4.2.3 ApplyShaderPass

ApplyShaderPass的主要任務(wù)(在主線程上)一言以蔽之是:收集、整理以及(向GPU)提交一次SRP Batch過程中Shader Program用到的所有“公共”性質(zhì)的資源和配置。

展開來解釋是這樣的:所謂“收集”是指Unity讀取當(dāng)前正在合批的GPUProgram(類似Shader Program的抽象),解析其中羅列的各項(xiàng)(為了完成渲染)必備的要素和資源清單,提取其中“公共”部分的過程。由此可見這里的公共是相對(duì)于單個(gè)Shader代碼而言的,基于同一份Shader創(chuàng)建的不同材質(zhì)所依賴資源并不屬于“公共”,只有諸如ShadowMap,LightMap,ViewToProj,Time等等資源屬于Shader共有。所謂“整理”是指使資源的布局(Layout)合規(guī),默認(rèn)情況下圖像API提供的Uniform Block內(nèi)數(shù)據(jù)布局是依賴于應(yīng)用層實(shí)現(xiàn)的,比如常見布局有packed,shared和std140各自都規(guī)定了數(shù)據(jù)緩沖的對(duì)齊標(biāo)準(zhǔn)和量化單位等復(fù)雜要求,Unity在收集完畢各種常量屬性(Constant Property)之后,向Gfx Device提交之前,可能需要對(duì)部分?jǐn)?shù)據(jù)進(jìn)行補(bǔ)丁修正(Patching)。那么具體有哪些資源和配置是需要在ApplyShaderPass時(shí)期做收集整理和提交操作的呢?這包含了三個(gè)方面,我們一一羅列如下:

  1. 確定渲染邏輯,系統(tǒng)基于從屬于材質(zhì)的GPUProgram搜尋適合要求的SubProgram:每個(gè)SubProgram對(duì)應(yīng)ShaderPass中的一種特定KeywordSet組合(或者叫Shader Variant)。Unity將關(guān)鍵詞組合作為Key,將編譯后的Shader數(shù)據(jù)緩存到LookupTable中,隨著程序的運(yùn)行,LookupTable逐漸擴(kuò)容的同時(shí)還會(huì)加速SubProgram的獲取,另一方面,如果沒有找到緩存數(shù)據(jù),Unity則會(huì)在當(dāng)前GPUProgram所支持的Keywords組合中尋找最合適“候補(bǔ)者”,候補(bǔ)者需要與目標(biāo)ShaderPass+Keywords組合在滿足閾值的前提下最為接近。如果目標(biāo)SubProgram或候補(bǔ)者并未加載和編譯,Unity同步加載和編譯它們。
  2. 資源數(shù)據(jù)準(zhǔn)備和提交:當(dāng)確定了具體的SubProgram,Unity就知道目標(biāo)Shader具體需要哪些公共資源屬性,它們主要由:常量緩存參數(shù)(CBParameters),紋理參數(shù)(TextureParameters),緩沖參數(shù)(BufferParameters)以及采樣器參數(shù)(SamplerParamters)這四大類構(gòu)成。其中常量緩存主要對(duì)應(yīng)了系統(tǒng)內(nèi)置的 CBUFFER_START(Name) ... CBUFFER_END 代碼段以及其中定義的一系列類型參數(shù),由Float,Vector和Matrix這三個(gè)基本類型及其數(shù)組類型組成;紋理參數(shù)定義了紋理在GPU端的綁定ID(TextureID)以及其他少量信息,諸如紋理索引(SlotID)和采樣器索引(SamplerUnit);緩沖參數(shù)與各種系統(tǒng)或用戶定義好的ComputeBuffer相關(guān);最后是采樣器參數(shù),由于當(dāng)下主流圖形API都支持紋理和采樣器分開定義,所以如果Shader使用到了單獨(dú)定義的采樣器時(shí),就需要將Shader中某個(gè)采樣器Symbol關(guān)聯(lián)到某個(gè)具體的采樣器索引(SamplerUnit),同時(shí)配置合適的采樣器狀態(tài)(SamplerState)。先提一嘴,在開啟渲染線程的情況下,目前所有提及的資源的準(zhǔn)備過程(收集整理)和提交過程是分布執(zhí)行的,這點(diǎn)在后面渲染線程部分會(huì)詳細(xì)展開。
  3. 配置管線狀態(tài),既“Apply Device States”,它負(fù)責(zé)告知底層管線當(dāng)前Batch所需的Blend、Depth、Stencil和Raster等狀態(tài)。

對(duì)于ApplyShaderPass還有3點(diǎn)額外的補(bǔ)充:

其一,如果在C#端的渲染指令(比如DrawRenderer指令)中設(shè)置了replacementShader,那么為了能得到正確的GpuProgram,Unity會(huì)在執(zhí)行ApplyShaderPass期間判斷渲染對(duì)象Shader上標(biāo)記的renderType是否與cmd中記錄的renderType一致,進(jìn)而選擇是否觸發(fā)“替換Shader”的邏輯。常見的renderType有“Opaque”,“Transparent”等,關(guān)于replacementShader可參考官方文檔。

其二,如果C#端的渲染指令cmd不帶有replacementShader標(biāo)識(shí)符,也不在多線程上執(zhí)行(關(guān)閉Graphic Jobs),那么會(huì)觸發(fā)Unity對(duì)ApplyShahderPass的快速緩存機(jī)制,通過暫存(Recording)每次SetPass時(shí)系統(tǒng)向底層Gfx Device提交的指令緩沖(Cmd in CommandQueue),從而獲得在遇到相同ShaderPass時(shí)快速執(zhí)行ApplyShahderPass的能力。

其三是關(guān)于多線程問題,如果我們關(guān)閉Render Thread(取消PlayerSettings->Multithreaded rending的勾選狀態(tài)),那么包含ApplayShaderPassFlush在內(nèi)的所有向底層Gfx API發(fā)起請(qǐng)求的方法都將在當(dāng)前的工作線程上直接處理。一般而言當(dāng)前工作線程就是主線程(Main Thread)。對(duì)于開啟Multithreaded rending的情況,我們挪到講完Flush之后再說。

4.2.4 Flush

Flush是渲染大循環(huán)結(jié)束前的臨門一腳,此時(shí)所有公共數(shù)據(jù)已由ApplyShahderPass完成了提交,且參與本次合批的具體渲染對(duì)象也已經(jīng)確定,因此(在主線程上)Flush要做的是將這些實(shí)例的私有渲染相關(guān)數(shù)據(jù)收集整合起來,以一定格式寫入連續(xù)內(nèi)存,最終再(通過線程間管道)提交給底層Gfx Device來執(zhí)行。數(shù)據(jù)收集本身并沒有什么特別的地方,數(shù)據(jù)就在每個(gè)ObjectData及其對(duì)應(yīng)的RenderNode中,取來便是,可說的是所需數(shù)據(jù)的具體類型和用途,以及整個(gè)內(nèi)存開辟和填充的過程。

想象一下,一方面底層接口要求傳入的數(shù)據(jù)以一定格式排布在一整段連續(xù)的內(nèi)存中,另一方面成百上千個(gè)實(shí)例的私有渲染數(shù)據(jù)所需的內(nèi)存占用并不少。那么為了提高內(nèi)存讀寫效率,避免多段內(nèi)存之間的連續(xù)拷貝,Unity就需要預(yù)計(jì)算出足夠放下全部數(shù)據(jù)的空間大小,再一次性向系統(tǒng)申請(qǐng)到手。在這段連續(xù)內(nèi)存中,Unity根據(jù)預(yù)計(jì)算結(jié)論,劃分出不同的子區(qū)塊用來對(duì)應(yīng)不同的渲染數(shù)據(jù)隊(duì)列,隊(duì)列長度一般同當(dāng)次合批的渲染對(duì)象個(gè)數(shù)相當(dāng),渲染數(shù)據(jù)大致可分為五類,主要是各種實(shí)例間信息,存儲(chǔ)格式以基礎(chǔ)值類型為主,但是也會(huì)使用指針?biāo)饕幚韽?fù)雜且龐大數(shù)據(jù)結(jié)構(gòu)。下面具體講講這五類數(shù)組對(duì)象:

  1. Array<BuildInSystemCBuffer>

BuildInSystemCBuffer是一塊數(shù)據(jù)對(duì)齊(float4的整數(shù)倍)的連續(xù)內(nèi)存,記錄了渲染對(duì)象自身的一些系統(tǒng)級(jí)的內(nèi)置常量,叫做Per-Object buffer data,Unity官方已經(jīng)給出了數(shù)據(jù)種類和布局,參考下表所示。

Per-Object buffer data是SRP Batch與Standary Batch的主要區(qū)別點(diǎn):一方面Unity使用“專用代碼”更新和提交這些逐渲染對(duì)象的系統(tǒng)級(jí)信息,從而一定程度上提高了CPU端運(yùn)行效率(后文展開);另一方面Unity通過規(guī)范結(jié)構(gòu),特別是布局的先后順序,從而讓數(shù)據(jù)消費(fèi)端能夠僅依靠地址偏移讀取任何處于激活狀態(tài)的內(nèi)容,優(yōu)化了向GPU端綁定數(shù)值(Value)的效率;最后Unity還允許裁剪掉數(shù)據(jù)布局中所有處于尾部的無用區(qū)塊(非激活狀態(tài)),盡可能減少BuildInSystemCBuffer的內(nèi)存空間和ConstantBuffer空間占用。

關(guān)于最后一點(diǎn)可以展開解釋一下:已知Unity會(huì)先分析數(shù)據(jù)結(jié)構(gòu)來確定所需開辟的內(nèi)存空間大小,但這不是通過簡單的sizeof(BuildInSystemCBuffer)來實(shí)現(xiàn)的,而是針對(duì)Per-Object buffer data專門分析其Shader使用的Feature狀況,確定在上表中最后一個(gè)使用的數(shù)據(jù)對(duì)象是誰,再依據(jù)這個(gè)數(shù)據(jù)對(duì)象的偏移決定總的數(shù)據(jù)結(jié)構(gòu)尺寸。

舉個(gè)例子,假如某個(gè)渲染對(duì)象的Shader僅使用了unity_ObjectToWorldunity_SHAr這兩個(gè)變量,那么Unity在預(yù)處理該Shader時(shí)就會(huì)認(rèn)為它使用了“Space block feature”和“Spherical Harmonic block feature”這兩個(gè)特征,相對(duì)應(yīng)的BuildInSystemCBuffer內(nèi)部與空間和球諧關(guān)聯(lián)的數(shù)據(jù)區(qū)段就會(huì)被填充,其他區(qū)段則直接略過,最終該渲染對(duì)象的系統(tǒng)內(nèi)置常量緩存將會(huì)占用的大小等于 sizeof(float4) * 20的內(nèi)存空間。

如上圖所示,這里有個(gè)取巧的地方,由于同一批次的渲染對(duì)象所使用的Shader以及激活的KeywordSets必然相同,因此它們使用的BuiltIn Feature也必然相同,這就導(dǎo)致整個(gè)Array<BuildInSystemCBuffer>中的元素實(shí)際Size是相等的,從PerObjectLargeBuffer的角度看,現(xiàn)在任何一個(gè)渲染對(duì)象的任何一個(gè)激活Feature都能夠通過簡單的偏移計(jì)算獲得

  1. Array<GfxBatchMesh>

SRP Batch允許不同Mesh進(jìn)行合批,因此每當(dāng)合批階段中當(dāng)前對(duì)象和上一個(gè)對(duì)象的Mesh不同時(shí),Unity就會(huì)創(chuàng)建并寫入一個(gè)新的GfxBatchMesh數(shù)據(jù)對(duì)象。請(qǐng)放心它的里面并沒有成堆的Index數(shù)組和Vertex數(shù)組,網(wǎng)格數(shù)據(jù)是以指針的形式存在,最終對(duì)應(yīng)到GPU顯存中的一段數(shù)據(jù),因此并不會(huì)涉及龐大的數(shù)據(jù)轉(zhuǎn)移和拷貝,除了一種情況以外:當(dāng)緩存在MeshBuffer中的頂點(diǎn)通道(Vertex Channel)數(shù)量不滿足實(shí)際上渲染頂點(diǎn)時(shí)的需要的數(shù)目,打個(gè)比方,如果當(dāng)前緩存的buffer中沒有任何一套“ShaderChannelTexCoords”,但是找到的GPUProgram又要求需要有“ShaderChannelTexCoord_0”,那么就必須為此(在CPU端)創(chuàng)建一套完整的紋理通道#0數(shù)組,數(shù)組長度與Mesh的頂點(diǎn)數(shù)一致,因此可能需要開辟大量內(nèi)存,而Channel數(shù)據(jù)的初始化也是在這時(shí)執(zhí)行的。

  1. Array<DrawBuffersRange>

這是一個(gè)和Static Batch相關(guān)的數(shù)據(jù)類型,可以這樣理解:靜態(tài)合批要求Unity在離線狀態(tài)下,為滿足合批條件的復(fù)數(shù)個(gè)渲染對(duì)象額外烘焙出一份包含了全部合批對(duì)象的Mesh資源(主要是頂點(diǎn)資源),系統(tǒng)在Culling階段仍然使用渲染對(duì)象各自原有的網(wǎng)格對(duì)象、變換矩陣和包圍盒進(jìn)行可見性剔除,隨后所有可見的部分輸送到數(shù)據(jù)準(zhǔn)備階段進(jìn)行拆解和排序,Unity盡可能保證所有滿足靜態(tài)合批的對(duì)象在排序后是緊密相鄰的(對(duì)應(yīng)排序時(shí)的SortOptimizeStateChanges符號(hào)位)。同其他參與SRP Batch的渲染對(duì)象一樣,Static Batch對(duì)象也會(huì)進(jìn)入RenderLoopDrawSRPBatcher大循環(huán),進(jìn)而被batcher捕獲和處理。對(duì)于僅滿足SRP Batch的對(duì)象,不同的Mesh會(huì)被區(qū)分處理,其數(shù)據(jù)由上文提及的GfxBatchMesh結(jié)構(gòu)管理;而Static Batch的渲染對(duì)象必然共享了一個(gè)合并后的頂點(diǎn)超集(一般僅在GPU端),但是經(jīng)過Cull和Sort后,余下成功靜態(tài)合批的渲染對(duì)象很可能只對(duì)應(yīng)了頂點(diǎn)超集中的某幾個(gè)部分, 這就需要DrawBuffersRange數(shù)據(jù)結(jié)構(gòu)幫忙整編它們了,結(jié)構(gòu)名中的Range指的就是一段映射到合批對(duì)象的連續(xù)頂點(diǎn)區(qū)段。

事實(shí)上在滿足某些條件(后文會(huì)展開)的情況下,Static Batch合批成功的一組渲染實(shí)例只會(huì)觸發(fā)一個(gè)或很少的幾個(gè)DrawCall,因此能成功參與靜態(tài)合批的物體在渲染特性上必須高度一致,很多細(xì)微的特性差異就能破壞Static Batch,使合批退化成普通的SRP Batch。導(dǎo)致合批失敗的差異或規(guī)則可總結(jié)如下:

  1. 若StaticBatch對(duì)象具有MotionVector,LightProbe,ProbeVolume,ReflectionProbe,MultiLight等特性(Feature);
  2. 若StaticBatch對(duì)象之間使用了不同的Material;
  3. 若LightMapIndex不同;
  4. 若InternalMeshID不同; //對(duì)應(yīng)SubMeshIndex不同
  5. 若開啟了LODFade;
  6. 若不是MeshRenderer;
  7. 若靜態(tài)合批到了不同的超集中;
  8. 若頂點(diǎn)數(shù)據(jù)中的availableChannels不同;
  9. 若頂點(diǎn)數(shù)超過了最大值;

總之,靜態(tài)合批相比SRP合批會(huì)嚴(yán)格許多。

  1. Array<GfxTetureParam>

GfxBatchMesh結(jié)構(gòu)類似,GfxTextureParam結(jié)構(gòu)內(nèi)僅存放了類型是Uint32的TextureID以及紋理下標(biāo)等數(shù)據(jù),只有當(dāng)前后渲染對(duì)象的材質(zhì)發(fā)生變化時(shí),Unity才會(huì)遍歷當(dāng)前渲染對(duì)象的全部渲染階段(RenderStage:主要包含Vertex,Geo,Hull,Domain和Fragment等可編程階段),提取相應(yīng)的GPUProgram,并從中獲取所需的紋理信息(張數(shù)和應(yīng)用),完成紋理參數(shù)配置。

  1. Array<PerMaterialCB>

所有的PerMaterialCB早在場景加載完畢后的頭幾幀就已經(jīng)完成了填充和上傳,因?yàn)閁nity內(nèi)部會(huì)為訪問過的渲染對(duì)象以場景為單位做緩存,在第一次加載完場景并執(zhí)行完場景剔除(Culling)之后,如果Unity發(fā)現(xiàn)緩存為空,就會(huì)觸發(fā)一系列的創(chuàng)建操作(GetOrCreateSharedRendererScene)用來創(chuàng)建和填充當(dāng)前場景的RenderNode,通俗的說就是初始化當(dāng)前場景中的所有激活的渲染對(duì)象。以MeshRenderer為例,初始化的過程中會(huì)調(diào)用到一個(gè)回調(diào)“PrepareMeshRenderNodes”,它的一項(xiàng)任務(wù)就是遍歷所有綁定到當(dāng)前MeshRenderer身上的Material,提取其中的材質(zhì)參數(shù)(Mat Prop Value),最后提交給底層Gfx API上傳GPU,同時(shí)自身也持有對(duì)應(yīng)Buffer在GPU端的引用,這個(gè)Buffer就是PerMaterialCB。

關(guān)于PerMaterialCB還有兩點(diǎn)值得一提:

  1. Unity要求所有涉及PerMaterialCB的寫操作全部在主線程中執(zhí)行;
  2. PerMaterialCB中可包含0個(gè)或多個(gè)由用戶定義的ConstantBuffer,一個(gè)典型的CB是材質(zhì)球上的各種Properties,同一個(gè)名字的CB之下所有數(shù)據(jù)以鍵值對(duì)存放,Key對(duì)應(yīng)了ShaderFastName,是一個(gè)由string轉(zhuǎn)換而來的UINT字段,同時(shí)CB內(nèi)部以float4的長度進(jìn)行數(shù)據(jù)對(duì)齊,且只支持“Float”,“Vector”和“Matrix”等類型和它們的數(shù)組形式。諸如“Texture”和“ComputeBuffer”之類的引用類數(shù)據(jù)另行存儲(chǔ),不在PerMatericalCB中。

5 渲染線程

ApplyShahderPassFlush在向底層Gfx API發(fā)起請(qǐng)求之前的整個(gè)工流(大多數(shù)情況下)是在主線程上完成的,唯有在底層支持異步Gfx API,且開啟了Graphics Jobs的情況下,ApplyShahderPassFlush的工作才由多條Jobs線程分擔(dān)執(zhí)行。

渲染線程則不同,它是完全獨(dú)立于Graphics Jobs之外的概念,Unity設(shè)計(jì)渲染線程的目的在于將非圖形設(shè)備向代碼和圖形設(shè)備向代碼解耦,前者在主線程上工作,后者完全由渲染線程接管。大部分情況下(Unity默認(rèn))工程是開啟渲染線程的,你也可以通過PlayerSettings->Multithreaded rending選項(xiàng)框進(jìn)行確認(rèn),屆時(shí)Unity在構(gòu)造Gfx Device的抽象層“Gfx Device Client”時(shí)會(huì)單獨(dú)起一個(gè)叫做Render Thread的線程作為消費(fèi)者,一方面通過CommandQueue時(shí)刻監(jiān)聽來自主線程生產(chǎn)者發(fā)送的指令和數(shù)據(jù),一方面負(fù)責(zé)和底層Gfx API交互。如下圖所示:

Device Client對(duì)外封裝有完整的圖像接口,對(duì)內(nèi)則持有真正的圖像設(shè)備(Real Device),至于你的系統(tǒng)在運(yùn)行時(shí)會(huì)初始化出什么樣的Real Device,一般是由用戶配置的Player-Settings->Graphics-API列表決定的,如果缺失了這部分用戶配置,Unity則依據(jù)當(dāng)前Platform提供的首選項(xiàng)自動(dòng)選擇。在渲染線程模式下,所有對(duì)外圖形接口都被統(tǒng)一封裝到了Uniform Graphics API名下,應(yīng)用層調(diào)度這些對(duì)外接口所生產(chǎn)的指令和數(shù)據(jù)則會(huì)依序壓入指令隊(duì)列(CommandQueue),隊(duì)列另一端是作為消費(fèi)者的渲染線程,確切的說是名為GfxDeviceWorker的工作模塊,負(fù)責(zé)解析和運(yùn)行指令,并在需要時(shí)向Real Device發(fā)起請(qǐng)求,接受響應(yīng)。

Unity也可以不開啟渲染線程,在此模式下Worker和CommandQueue并不存在,所有對(duì)外的圖形API會(huì)直接與真正的圖像接口設(shè)備對(duì)接,此時(shí)底層圖形API的調(diào)用者和Device Client代理層的調(diào)用者都工作在相同的線程上(一般為主線程)。

回到渲染線程來,當(dāng)主線程的ApplyShahderPassFlush向渲染線程發(fā)起請(qǐng)求,能夠觸發(fā)渲染線程對(duì)應(yīng)的執(zhí)行邏輯,這點(diǎn)可以從Profiler中看到端倪:

說是端倪,主要由于Unity內(nèi)置打點(diǎn)信息是缺位的,我們從Profiler出發(fā)并不能直觀的看到ApplayShaderPass所激發(fā)的渲染線程邏輯(暫命名為ApplyGpuProgram)位于何處,但是基于主線程的串行特性以及只有一個(gè)渲染線程和一條指令隊(duì)列的事實(shí),我們不難推測出ApplyGpuProgram(對(duì)應(yīng)上圖中藍(lán)色方框)應(yīng)該位于由Flush觸發(fā)的渲染線程邏輯DrawBuffersBatchMode(對(duì)應(yīng)上圖中紅色箭頭)之前。整體上看,主線程向CommandQueue的提交順序決定了渲染線程的工作順序。

5.1 再說ApplayShaderPass

5.1.1 主線程中的ApplayShaderPass

ApplayShaderPass中消耗占比最大的是數(shù)據(jù)處理部分,并且貫穿了主線程和渲染線程,下面我們先基于主線程中的數(shù)據(jù)準(zhǔn)備(PrepareValues)部分看看一共涉及了哪些數(shù)據(jù),Unity又是如何處理它們的。

PrepareValues方法作為入口,它的入?yún)ⅰ癰uffer”對(duì)象通過CommandQueue創(chuàng)建,是所有待收集數(shù)據(jù)的目的地,下面觀察上圖右側(cè),自上而下依序準(zhǔn)備(Prepare)了六個(gè)方面的數(shù)據(jù),它們分別是:

  1. Value (Default)
    -> 關(guān)聯(lián)Shader內(nèi)定義的一個(gè)裝有“公共”常量的CBUFFER_START/END代碼段,一般為“UnityLighting”或“UnityPerCamera”,內(nèi)部cbIndex = -1,對(duì)應(yīng)了一段在GPU中已經(jīng)開辟好的UBO,數(shù)值類型包含:FloatVector,Matrix及其它們的數(shù)組;
  2. Value (Extra)
    -> 與Value (Default) 類似,如果存在則關(guān)聯(lián)第2~N組CBUFFER_START/END代碼段,可以是“UnityShadows”,“MainLightShadows”,"AdditionalLightShadows",“UnityFog”等,也可以是用戶定義的其他不隨材質(zhì)變化,只與Shader關(guān)聯(lián)的常量參數(shù),它們的內(nèi)部cbIndex > 0
  3. Texture
    -> 一系列綁定到Shader上的紋理數(shù)據(jù)結(jié)構(gòu),結(jié)構(gòu)包含紋理引用TextureID,用于確定GPU內(nèi)存中某一特定紋理格式資源;
  4. ComputeBuffer
    -> 一系列綁定到Shader上的數(shù)據(jù)緩沖(Buffer)結(jié)構(gòu),包含ComputeBufferID,用于確定GPU內(nèi)存中某一特定的SSBO;
  5. Sampler
    -> 一系列綁定到Shader上的采樣器結(jié)構(gòu),定義了采樣器的狀態(tài)和名字;
  6. ConstantBuffer
    -> 一系列綁定到Shader上的常量緩存結(jié)構(gòu),用戶可通過Material.SetConstantBuffer添加。需要注意的是,如果底層Gfx Device選擇了OpenGLES,那么Material.SetConstantBuffer接口會(huì)失效。

注意這些數(shù)據(jù)向buffer的填充順序是固定的,后續(xù)渲染線程在提取數(shù)據(jù)對(duì)象時(shí),會(huì)默認(rèn)這個(gè)固定的填充順序,再配合預(yù)設(shè)的數(shù)據(jù)頭標(biāo)識(shí)符“head”以及數(shù)據(jù)尾標(biāo)識(shí)符“end”,Unity就可以在渲染線程中非常高效的讀?。ń獯a)buffer中的數(shù)據(jù)。

5.1.2 渲染線程中的ApplayShaderPass

流程參考下圖:

可以看到,渲染線程會(huì)直接向Gfx API提交(Apply)從buffer中解碼出來的各項(xiàng)渲染參數(shù)和資源引用,執(zhí)行順序和“數(shù)據(jù)準(zhǔn)備時(shí)(Prepare)”保持一致。這里著重講一下Value,簡單說一次ApplyValueParameters方法的調(diào)用填充了Shader中一段CBUFFER_START/END代碼段,由于是常量緩沖,在底層這些數(shù)據(jù)需要用到Upload操作,以O(shè)penGLES API為例對(duì)應(yīng)了glBufferSubData指令,其語義是將數(shù)據(jù)上傳到GPU中指定UBO的指定偏移上并覆蓋,不涉及Buffer的開辟( 如果要開辟新的緩沖需要用到glBufferData指令)。此外向GPU裝填ValueParamters時(shí)還有數(shù)據(jù)對(duì)齊的要求,目的是消除不同Gfx API對(duì)ConstantBuffer數(shù)據(jù)讀寫時(shí)在格式上的區(qū)別,常見的有對(duì)齊標(biāo)準(zhǔn)有packed,shared和std140,Unity默認(rèn)使用std140,于是當(dāng)我們?cè)赟hader中定義如下常量緩沖時(shí):

CBUFFER_START(myCB)
    float SomeFancyData[1023]; //受數(shù)組下標(biāo)描述符只有10bit長度,且需要預(yù)留一個(gè)值表他意,故Array對(duì)象最大支持1023=1<<10-1長度
    ...
CBUFFER_END

在實(shí)際提交給Gfx Device執(zhí)行數(shù)據(jù)上傳前,Unity會(huì)對(duì)每個(gè)元素打補(bǔ)?。?/p>

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;
}

因?yàn)閟td140對(duì)齊標(biāo)準(zhǔn)要求CB中每個(gè)數(shù)組的元素必須與Vector4對(duì)齊。同理,Unity在處理Matrix4x4數(shù)組時(shí)就得將每個(gè)矩陣拆解成4個(gè)Vector4元素,使得總長度變?yōu)?code>MatriceArray.size() * 4,有趣的是拆分后的數(shù)組總長度不受1023的上限影響,但是極限CB的尺寸會(huì)來到約64KB大?。?023個(gè)Matrix4x4),如果底層圖形API對(duì)CB有最大限制(比如16KB),則可能會(huì)導(dǎo)致數(shù)據(jù)截?cái)?。配置常量緩沖中的元素還有一些其他可注意事項(xiàng),比如下面這兩條節(jié)選自Unity官方文檔的建議:

  1. float4或者float4x4替代float3或者float3x3,因?yàn)?code>float4在所有的Gfx API中都是一種布局,但是float3不是。
  2. 在CBUFFER代碼段內(nèi)聲明元素時(shí),將它們按照尺寸從大往小排列,如先float4,再float2,最后float,好處也是能夠消除不同Gfx API底層之間的差異。

總之std140通過提前將數(shù)據(jù)對(duì)齊,規(guī)范結(jié)構(gòu),可以消除不同圖形接口的兼容性問題,使得系統(tǒng)在只消耗少量額外內(nèi)存和CPU時(shí)鐘的前提下大幅提高管線的數(shù)據(jù)讀寫和編解碼效率,這里就不深入展開了。

除了ApplyValue以外我們還能看到ApplyTexutre,ApplyComputeBuffer,ApplySampler和ApplyConstantBuffer等方法,這些方法面對(duì)的資源參數(shù)一般只涉及少量引用類型的數(shù)據(jù),故而應(yīng)用(Apply)資源的本質(zhì)只是將少量數(shù)據(jù)引用綁定(Binding)到正確的名字上而已,不涉及元素對(duì)齊和海量數(shù)據(jù)上傳,因此效率相對(duì)較高。

當(dāng)然也不是說簡單到完全沒有坑,比如ApplyConstantBuffer,在Unity當(dāng)前材質(zhì)體系下,如果我們想要將Shader中的某個(gè)Name綁定到另一個(gè)在別處定義并創(chuàng)建好的Constant Buffer的話,首先需要以如下方式在Shader中定義CB對(duì)象(HLSL):

cbuffer myConstantBuffer {
    float4x4 matWorld;
    float4 vObjectPosition; 
    float arrayIndex;
}

然后需要在C#端通過Material.SetConstantBuffer或者MaterialPropertyBlock.SetConstantBuffer告知Unity你希望哪個(gè)自定義常量緩沖實(shí)例(對(duì)應(yīng)ComputerBuffer或GraphicsBuffer)與名字為myConstantBuffer的對(duì)象進(jìn)行綁定。

但是想要正確使用好這個(gè)ConstantBuffer,你還需要處理好三個(gè)DrawBack:

  1. 兼容性問題:不是所有Gfx Device支持通過ComputerBuffer或GraphicsBuffer的方式直接向Shader中的cbuffer對(duì)象賦值,比如OpenGL\OpenGLES就不行;
  2. 符號(hào)對(duì)齊問題:賦值成功也可能存在CBuffer內(nèi)分布的數(shù)據(jù)與Shader內(nèi)聲明的常量緩沖變量不能一一對(duì)應(yīng)的情況,這點(diǎn)視不同Gfx API而不同,Unity無法幫我們消除這種潛在的變量配對(duì)問題,我們需要依靠前文提及的“按照尺寸從大往小排列”規(guī)則手動(dòng)消除這種影響;
  3. 視硬件制造商不同,ConstantBuffer與StructuredBuffer相比可能會(huì)有更高的讀寫效率(因?yàn)閿?shù)據(jù)被Alloc在更加接近計(jì)算核心的高速Cache上),因此其資源總量是相對(duì)受限的,此外不同Gfx API對(duì)單個(gè)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在主線程內(nèi)的工作主要是收集和填充buffer,既一段由CommandQueue開辟(指定)的內(nèi)存段,只是Flush專注的是各種PerMaterialData以及System Built-In Object Data數(shù)據(jù)的收集和填充。

Flush在渲染線程上的工作模塊可以在Profiler中直接找到,叫“DrawBuffersBatchMode”,與ApplyGpuProgram的CB數(shù)據(jù)對(duì)齊+資源上傳,以及對(duì)紋理,采樣器和緩沖等引用資源的綁定等操作類似,DrawBuffersBatchMode需要負(fù)責(zé):

  1. builtInCB的綁定(一個(gè)batch一次),
  2. perMaterialCB的綁定(材質(zhì)變化時(shí)執(zhí)行一次),
  3. perMaterialTexture的綁定(材質(zhì)變化時(shí)執(zhí)行一次),
  4. 網(wǎng)格資源的整理和綁定(一般情況是一個(gè)batchInstance一次*),
  5. 向底層圖形API發(fā)起DrawCall指令,
  6. 以及在最后回收和釋放本次Batch的CPU端臨時(shí)緩存。

至于DrawCall的總次數(shù)一般與參與合批的渲染對(duì)象數(shù)量一致,但是在開啟靜態(tài)合批的前提下(BuiltIn-Instance另說),實(shí)際DrawCall的數(shù)量很可能會(huì)小于(甚至遠(yuǎn)遠(yuǎn)小于)成功進(jìn)入一次Srp Batch的渲染對(duì)象數(shù)目。這是因?yàn)橥活愳o態(tài)合批對(duì)象使用了預(yù)烘焙的全量Mesh作為幾何階段的數(shù)據(jù)來源,而Unity會(huì)將全量Mesh中頂點(diǎn)索引相鄰的兩個(gè)或多個(gè)靜態(tài)合批對(duì)象看做是邏輯上的單個(gè)對(duì)象執(zhí)行DrawCall。

打個(gè)比方,假如一組5個(gè)能夠彼此靜態(tài)合批的渲染對(duì)象{1,2,4,5,7}通過了Culling和Sorting后又被依照這個(gè)順序送入了一次Srp Batch中。再假設(shè)渲染對(duì)象代表的數(shù)字恰好對(duì)應(yīng)了它們?cè)谌縈esh中使用的網(wǎng)格頂點(diǎn)緩存(VertexBuffer)范圍所處位置,數(shù)字相鄰則位置也相鄰,那么Unity的靜態(tài)合批就會(huì)將渲染對(duì)象{1}及其使用的[A, B]段頂點(diǎn)與渲染對(duì)象{2}及其使用的[B+1, C]區(qū)段頂點(diǎn)合并成[A, C]頂點(diǎn)范圍,使得在Mesh的角度上將{1}{2}視作一個(gè)渲染對(duì)象。由此可見,這組5個(gè)渲染對(duì)象最終只會(huì)出發(fā)3次DrawCall,分別是:{1,2},{4,5}{7}

而如果Cull和Sort后原本的5個(gè)對(duì)象按照{1,4,2,5,7}的順序被投入到SRP Batch,那么由于沒有相鄰的對(duì)象可以整合Mesh頂點(diǎn),最終將會(huì)執(zhí)行5次DrawCall,每個(gè)對(duì)象一次。

6 Standard Batch vs SRP Batch

個(gè)人認(rèn)為可以從三個(gè)主要方面去理解它們的不同,分別是“合批判斷邏輯”,“合批循環(huán)”以及“PerMaterialCBuffer提交邏輯”。

6.1 合批判斷邏輯的不同

與SRP Batch合批規(guī)則相比,傳統(tǒng)合批需要滿足更加嚴(yán)格的條件,簡單整理如下:

BatchBreakCauseMultipleForwardLights,      //ForwardAdd類型的Pass不能合批
BatchBreakCauseDifferentMaterials,         //不同的材質(zhì)不能合批
BatchBreakCauseMultiPassShader,            //材質(zhì)相同,但是使用的Pass不同也不能合批
BatchBreakCauseOddNegativeScaling,         //遇到Transform.scale.xyz中有1維或3維變量是負(fù)數(shù)的不能合批
BatchBreakCauseDifferentShadowReceiving,   //接受陰影和不接受陰影的物體之間不能合批
BatchBreakCauseDifferentForwardLights,     //前向渲染管線中不同的MainLight不能合批
BatchBreakCauseDifferentLightingLayersInDeferred,    //延遲渲染中不同的LightingLayer(記錄在Stencil中)不能合批
BatchBreakCauseDifferentCastShadowSettings,    //渲染Shadow過程中遇到不同的ShadowSettings
BatchBreakCauseDifferentShaderCasterHashes,    //渲染Shadow過程中遇到不同的ShadowCaster Pass
BatchBreakCauseShaderDisablesBatching,         //Shader不支持Batching的自然不能合批
BatchBreakCauseDifferentCustomPropHashes,      //相同材質(zhì)和Pass,但是材質(zhì)關(guān)聯(lián)的屬性數(shù)值不同也不能合批
BatchBreakCauseNonInstanceablePropSet,         //后續(xù)要走(或不走)Intance流程而打斷合批
BatchBreakCauseLightmapped,                    //Lightmap使用的TexArray不同或者Index不同
BatchBreakCauseDifferentLightProbes,           //前后不同的LightProbe
BatchBreakCauseDifferentProbeOcclusions,       //前后不同的ProbeOcclusion
BatchBreakCauseDifferentReflectionProbes,      //前后不同的反射探針
BatchBreakCauseInstancingReachedMaxBatchSize,  //超過最大Batch數(shù),這個(gè)數(shù)目前可以認(rèn)為是uint32的最大表示值
BatchBreakCauseMotionVectors,                  //如果開啟了逐物體的MotionVector,則不能合批

為方便對(duì)比,我把SRP Batch合批失敗的情況放在了下面:

SRPBatchBreakDifferentShader,         //不同Shader
SRPBatchBreakCauseMultiPassShader,    //不同Pass
SRPBatchKeywordsChange,               //不同KeywordSet
SRPBatchMaterialNeedDeviceStateChange,//不同Material的管線相關(guān)Porperties設(shè)置

由此可見,想要合批成功,不光合批對(duì)象的材質(zhì)要完全一樣,很多系統(tǒng)內(nèi)置(Built-In)的常量緩沖數(shù)據(jù)都要一致才行。

6.2 合批循環(huán)的不同

兩種Batch對(duì)待合批對(duì)象的處理流程存在較大差異,參考如下對(duì)比流程圖:

傳統(tǒng)Batch在循環(huán)處理每一個(gè)對(duì)象的過程中,不論是否可以合批總是會(huì)進(jìn)行大量的寫B(tài)uffer操作,不難發(fā)現(xiàn)目標(biāo)Buffer指向的大多是“Unity系統(tǒng)內(nèi)置逐對(duì)象變量”,而且Buffer與Buffer之間彼此獨(dú)立,內(nèi)存上是不連續(xù)。作為對(duì)比,右側(cè)的SRP Batch只有很少(2個(gè))變量參數(shù)需要逐渲染對(duì)象設(shè)置,本身寬松的合批邏輯在理論上也允許更多的ObjectData合并到一起,最后Flush時(shí)統(tǒng)一由專職代碼邏輯處理“Unity系統(tǒng)內(nèi)置逐對(duì)象變量”(BuiltInCB)的內(nèi)容,保證同一批對(duì)象的Per-Object buffer data在內(nèi)存上連續(xù)且對(duì)齊,方便GPU一次性提交,同時(shí)也方便了GPU端使用offset獲取具體數(shù)據(jù)。

6.3 Per Material CBuffer提交邏輯的不同

在前文介紹SRP Batch的Flush函數(shù)時(shí)我們已經(jīng)從其填充Array<PerMaterialCB>的方式了解到,在一開始導(dǎo)入Renderer的過程中,Unity引擎會(huì)判斷是否開啟了SRP Batch,如果開啟則觸發(fā)材質(zhì)常量參數(shù)的提前收集并立即提交給底層Gfx API,因此只要材質(zhì)的相關(guān)屬性不發(fā)生變化(沒有使用C#代碼動(dòng)態(tài)修改Material各項(xiàng)屬性參數(shù)),我們可以認(rèn)為GPU顯存中的某塊持久化內(nèi)存中常駐有該材質(zhì)的關(guān)聯(lián)數(shù)據(jù)(Per Material Param)。

另一方面,傳統(tǒng)Batch會(huì)通過對(duì)應(yīng)的Flush方法(參考下圖),對(duì)每一個(gè)ObjectData(圖中對(duì)應(yīng)了BatchInstanceData)執(zhí)行一遍ApplySharedNodeCustomProps方法,其通過Gfx API提供的CommandQueue,將系統(tǒng)層收集的用戶定義的材質(zhì)常量參數(shù)(對(duì)應(yīng)下圖中的ShaderPropertySheet)提交給渲染線程,并進(jìn)一步上傳到GPU(對(duì)應(yīng)下圖紅框中的寫操作)。逐對(duì)象數(shù)據(jù)的上傳過程每一個(gè)渲染幀都會(huì)發(fā)生。

6.4 重新解讀官方對(duì)比圖

我們?cè)賮韺徱曇幌聫V為流傳的官方對(duì)比圖,你可能會(huì)發(fā)現(xiàn)SRP Batch圖例中的一些問題:事實(shí)上SRP Batch并不能只通過兩次Binding就提交DrawCall,因?yàn)橄到y(tǒng)任然需要收集整理和上傳渲染對(duì)象的各種“built in data”,使其成為GPU顯存中的一段CBuffer,然后才能從容的“Bind with offset”,只不過這些操作不是在合批循環(huán)中執(zhí)行的(對(duì)應(yīng)了下圖中的淺紅和淺藍(lán)色塊),而是放在了下圖類似“SetShaderPass”的附近。

下面一組對(duì)比圖同樣來自官方文檔,主要從數(shù)據(jù)流角度出發(fā),SRP Batch將不同更新頻率的數(shù)據(jù)做了區(qū)分(Built-In和Per-Material),各自使用專職代碼處理,數(shù)據(jù)位于GPU緩存的不同區(qū)域。

6.5 關(guān)于SRP Batcher所以高效的結(jié)論

末尾,參考官方的建議,我們確認(rèn)SRP Batcher之所以高效主要依賴于以下兩點(diǎn):

  1. 每一個(gè)材質(zhì)相關(guān)的參數(shù)(perMaterialCB)都提前進(jìn)行了持久化,保存在了GPU常量緩存中,取用時(shí)只負(fù)責(zé)綁定對(duì)象即可;
  2. 相比于傳統(tǒng)模式將材質(zhì)和模型數(shù)據(jù)混雜在一起處理,SRP Batch使用了優(yōu)化過的專職代碼處理引擎內(nèi)置數(shù)據(jù)(System Built-In Data)和逐材質(zhì)屬性(Per Material Data),其中引擎內(nèi)置數(shù)據(jù)分布在連續(xù)內(nèi)存中,可以依靠offset取用,方便GPU進(jìn)行優(yōu)化調(diào)度。

Ref

  1. Src Code
  2. SRP的簡單架構(gòu)
  3. UnitySRP原理初探
  4. Offical Manual
最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容