2020年7月31日 星期五

Mobile 渲染效能分析 (2)

  • 前言
在上一篇,我從硬體角度去分析渲染流程,

並以 Imagination Technologies 的 PowerVR 作為範例,

深入探討如何有效利用裝置機制,讓渲染效能最佳化。

在本篇將從軟體(i.e. Unity3D)視角觀點觀察其渲染機制,

研究效能瓶頸原因,進而找出優化方案。

  • Unity 渲染機制
Unity 對物件的渲染,需要下述三個元件

  1. Mesh : 紀錄物件的形狀,也就是節點資料。
  2. Materail : 描述渲染方法,包含使用的 Shader 和運算所需的參數。
  3. Transform : 決定物件的顯示位置。
Unity 在每呼叫一次 Draw Call 時,GPU 都會從 System memory 去讀取這三個元件資料。



在 Mobile 渲染效能分析 (1) 裡,我提到對於 mobile 而言,

對 System memory 的存取是費時且耗電的一個行為。

因此,如何有效減少 Draw Call 就成了 Mobile渲染優化的一個定石。


Unity 對於減少 Draw Call 的方案,主要有下述三種

  1. Dynamic(Static) Batching
  2. GPU Instancing
  3. SRP Batcher
接下來會針對這三種方案,去一一說明。


  • Dynamic(Static) Batching
既然 Unity 渲染每個物件都會呼叫一次 Draw Call,

那我們可以很直覺地想到如果將所以物件合併成一個之後再渲染,

是不是只需要呼叫一個 Draw Call 就可以了?

這種將物件合併渲染的方法稱作 Dynamic(Static) Batching。

其中 Dynamic 和 Static 的差別只在於物件合併是否再 run-time 時運作。

至於物件要如何合併,

回想我在上面提到物件渲染所需的三個元件 : Mesh、Material 和 Transform。

物件合併就是要把每個物件的的這三個元件按照類別組合再一起。

首先將焦點放在 Mesh 和 Transform 上面,

Mesh 紀錄物件的形狀, Transform 決定物件的顯示位置。

因此,我可以將每個物件的 Mesh 先經過 Transform 的轉換在合併。

這種先轉換座標在合併的好處是我可以省去 Transform 的資料。

如果我將 Mesh 和 Transform 分別合併,我需要儲存所有 Mesh 和 Transform 的記憶體容量。

但我將 Mesh 經 Transform 轉換後在合併,我只需要儲存 Mesh 的記憶體容量(註1)。

Mesh 和 Transform 可以簡單的被合併,但 Material 就沒這麼簡單了。

最主要原因是 Material 包含了 Shader 資料。

試想,你可以簡單的把兩個相異的 float array 合併成一個 float array,

但你要如何把兩段不同的 source code 合併成一個?而且不能有 bug ...

因此 Unity 對於 Dynamic(Static) Batching 的限制是被合併的物件共用相同的 Material,

既然使用了相同的 Material ,就沒有合併的問題。


將物件的 Mesh 合併


從上述的討論可以得知,Dynamic(Static) Batching 是透過找出使用相同 Material 的物件,

將其模型資料合併,來達到減少對 System memory 的存取(i.e. 呼叫 Draw Call)。

這種做法缺點在於需要大量的記憶體空間來儲存合併的模型資料,

對於 Dynamic Batching 來說,甚至要負擔計算合併的開銷,實際上對於效能優化改善有限,

因此在 Unity 5.4 之後提出了 GPU Instancing 作為減少 Draw Call 的另一個方法。


  • GPU Instancing
Dynamic(Static) Batching 提供了一個很好的方向來減少 Draw Call 的呼叫,

但需要大量的記憶體空間來儲存合併後的結果;因此,減少記憶體需求就成了改進的目標。

仔細分析記憶體需求原因是因為儲存了大量的模型節點資料,

所以如果和 Material 一樣,我們限制所有物件共用同一個 Mesh 的話,

是不是能減少對記憶體容量的需求?

答案是可能的,舉個範例來說好了,

我有兩個物件共用一個模型,這個模型的節點數量假設是 1000,

就有 1000 x 3 = 3000 個浮點數(每個節點都是一個三維座標),

如果我使用 Dynamic(Static) Batching 的話我需要 1000 x 3 x 2 = 6000 個浮點數的記憶體需求。

但因為 Mesh 資料是相同的,彼此差別只在 Transform 資料,

因此我只需要合併 Transform 資料,每個 Transform 資料有 16 個浮點數 (= 4x4 Matrix)。

所以總共我需要 1000 x 3 + 16 x 2 = 3032 個浮點數的記憶體需求,節省了將近一半的空間。

這種同時限制共用 Material 和 Mesh ,只合併不同參數資料 (這裡為 Transform)的作法,

就叫做 GPU Instancing。

這裡衍生思考一下,既然能夠合併 Transform 資料,

這表示也能合併其他資料像是 Materail Parameter。

Material 要共用是因為 Shader 無法合併的關係,但不代表 Materail Parameter 無法被合併。

把 Run Shader 想成 Call Function,Materail Parameters 就可以視為 function 的 input arguments。

因此 Materail Parameter 的合併不會影響到渲染結果。



從上面的討論可以得知,GPU Instancing 和 Particle System 相當的吻合,

Particle System 是將同一個物件(通常是個 Billboard)大量渲染, 

符合了 GPU Instancing 對 Material 和 Mesh 共用的限制。

因此大部分的 GPU Instancing 展示都是用 Particle System 來實現。


  • Scriptable Render Pipeline Batcher

在前面提的兩項效能優化方案,都是避免 Draw Call 的大量呼叫,

減少對 System memory 的存取;但兩種方案都對渲染物件有很大的限制。

回過頭來看,為什麼每一次的 Draw Call 都需要去重新讀取 System memory?

這是因為 Unity 為了要讓渲染能夠更彈性化,

能提供一個跨平台非固定(non-constant)的 buffer 儲存渲染參數,所做的一個讓步[1]。

這樣的結果造成了當 Draw Call 發生,必須要從 System memory 重新讀取所有參數,

即使我只改變了某個特定的參數(ex. mesh 或 material parameter)而已。

這時換個角度來思考,如果只更新被變更的參數,減少從 System memory 讀取的資料量,

是不是能夠提供一個更彈性的渲染優化方案?


Scriptable Render Pipeline Batcher(之後簡稱 SRP Batcher),

顧名思義就是針對 Scriptable Render Pipleline 的一個渲染優化技術(註2)。

可以針對物件渲染時,變更的參數資料單獨更新。

這些變更的參數資料可以是 mesh 或 material parameters,但不包含 Shader。

因此,對渲染物件的限制也就只有要求必須使用同一個 Shader(註3)。

比較起前兩個渲染優化方案提供更大的彈性。




  • 結論
物件的渲染最終還是由硬體裝置輸出,因此如何提供硬體一個更有效率的渲染方式便是使用者

需要去琢磨的部分。

對 Mobile 而言, System memory 的存取往往是效能瓶頸的所在,

對此,通常會從兩個方向去優化

  1. 減少對 System memory 的存取量
  2. 減少對 System memory 的存取次數

方向 1 可以藉由減少模型大小 (減面或減節點)和減少貼圖的解析度,

來降低讀取的數量,縮短讀取時間,減少裝置耗能。

方向 2 Unity 提供了 3 個方案協助使用者減少存取次數。


如何應用上述兩個方向,同時不影響畫面美觀,就是遊戲開發人員最需要面對的課題。


  • References



  • 附註
1.這裡要注意的是因為 Unity 的 Mesh 結構有節點容量的限制,因此有可能無法將所有物件合併     成一個。
   而因為這樣的限制, Dynamic batching 對於物件模型的節點數量有著更嚴格的要求,
   詳細要求可以參考 [2]。

2.SRP Batcher 只能在使用 Scriptable render pipleline 的環境下使用,若使用 Unity 內定的
   渲染環境, SRP Batcher 將無法作用。

3.同一個 Shader 指的是物件 material 引用的 shader file 來源必須是同一個,且擁有相同的
   keyword。Unity 的 Variant System 可以透過 keyword 管理,讓 shader 可以在 rum-time 時做到
  類似 Script 的 #if-#else 一樣切換運作內容。

2020年7月18日 星期六

Mobile 渲染效能分析 (1)

  • 前言
本系列著重在 mobile 的效能優化上,會分成硬體和軟體兩個角度去分析探討效能優化。

本篇探討 mobile 硬體架構,並以 Imagination Technologies 的 PowerVR 晶片為範例,

說明如何有效應用硬體資源,達到效能的最大化。


  • Tile-Base Rendering

在談到 mobile 效能,就必須要說明 Tile-Base Rendering (之後簡稱 TBR)。

我們知道 GPU 上的 on-chip memory (i.e. SRAM, 或 L1 L2 cache) 很小,

對於一個螢幕解析度,假設是 1920 x 1080,這樣大的 Frame Buffer來說,是絕對不夠用的。

因此會使用一個存取速度慢但有比較大的記憶空間(i.e. DRAM)來儲存。

對於 DRAM 的存取,就是效能的最大瓶頸,

不但讀寫速度慢,也需要花費大量的電力去操作。(這也是裝置發熱耗電的原因)

mobile 為了延長使用時間,必須減少對 DRAM 的存取,降低裝置發熱耗電的損失。

因此在硬體上,將 Frame Buffer 分成很多個 tile (區塊),

這些 tile 小到可以被 SRAM 儲存,GPU 可以直接對這個 Tile 做讀寫,

等不再使用後,在傳送到 DRAM 去儲存。

試想,一個 1920 x 1080 的 Frame Buffer,

要填滿整個畫面,會需要對 DRAM 做 1920 x 1080 次的存取。

假設每個 Tile 10 x 10,先寫入 Tile 在回傳 DRAM 的話,

要填滿整個畫面,只需要做 192 x 108 次的存取。

實際上, Tile 的大小會根據裝置設備來決定。

但可以知道的是,這種做法可以確實地降低對 DRAM 的存取。(註1)

這種將 Frame Buffer 分割成數個 Tile 分批進行處理的作法,

就叫做 Tiled-Base Render。

Tile-Base Render 示意圖


  • Tile-Base Deffered Rendering

TBR 架構看似完美,但實際上仍有問題存在。

假設 畫面中,有 Sphere 和 Cube 要被渲染,渲染順序是先 Sphere 之後 Cube。

首先 Sphere 經過 Tiler(註2)計算後,被分配在 Tile A 之內。

根據上面所述 TBR 流程,這時 GPU 需要先複製 Tile A 到 Tile Buffer,

渲染之後寫回 Frame Buffer 儲存。

但渲染下一個 Cube 時,經過 Tiler 計算時發現也是被分配到 Tile A 內。

這時悲劇就發生了,GPU 必須要重複渲染 Sphere 時一樣的動作,

將 Tile A 再一次複製到 Tile Buffer 內,最後在寫回 Frame Buffer。


為了避免上述的悲劇,必須要確定寫入每個 Tile 的資料都已經結束了,

(以上例來說,Sphere 和 Cube 都計算完了) 才能覆蓋回 Frame Biffer 裡面。

這種將覆蓋回去的動作延遲(Defered)到最後的 TBR 流程,

稱作 Tile-Base Deffered Rendering (之後簡稱 TBDR)。


在實作上,通常會在 System memory 裡,分配一個空間( Frame Data)

去儲存每個 Tile 裡面經過 Vertex Shader 計算後的節點資料,

當 Tile 要覆蓋 Frame Buffer 時,再從 Frame Data 取出節點資料做 Pixel Shader 的處理。


資料來源 :  OpenGL Insights [1]


  • PowerVR GPU 架構

看完上述對於 TBDR 架構的討論,接下來談談 Imagination Technologies 的 PowerVR 晶片。


PowerVR GPU 架構,資料來源 : Introduction to PowerVR for Developers [3]



TBDR 是 Imagination Technologies 的專利(註3),

PowerVR 就是根據 TBDR 架構製作的圖形裝置,

整個渲染流程可以分成兩個部分:

  1. Vertex Processing (Tiler)
  2. Per-Tile Rasterization (Renderer)

  • Vertex Processing (Tiler)

Tiler 流程,資料來源 : Introduction to PowerVR for Developers [3]


在每個 Frame,圖形裝置會將渲染物件的節點經過 Vertex Shader 的轉換,

將節點轉換到螢幕空間。

之後會通過 Tiler,將轉換後的節點分配到指定的 Tile,

並冊列清單儲存所有這個 Tile 內的節點資料,然後儲存在 Parameter Buffer 內。

這個 Parameter Buffer 是一個位於 System memory 內的儲存空間。

根據官方文件在 Parameter Buffer 內的資料都是經過特殊壓縮處理,

只占用極小的資料量,加快資料的傳輸。


  • Per-Tile Rasterization (Renderer)

Renderer 流程,資料來源 : Introduction to PowerVR for Developers [3]


裝置會重複 Vertex Processing 的動作直到需要更新 Frame Buffer 時,

才會執行 Renderer (ex. swap back and front buffer 或呼叫特殊指令 glflush、glfinish 等)。

當執行 Renderer 時,會以 Tilie 為單位執行 Renderer。

首先,會讀取該 Tile 在 Parameter Buffer 的節點清單資料。

然後透過 Image Synthesis Processor (之後簡稱 ISP) 將節點清單資料做 

Hidden Suface Removal(隱藏面遞除,簡稱 HSR)。

HSR 透過 Depth Buffer 和 Stencil Buffer 決定那些面不顯示,

找出畫面最前面的資料(i.e. 最靠近攝影機)。

接著做 Texture and Shading Processor(簡稱 TSP,i.e. Pixel Shader 或 Fragment Shader) 。

完成渲染之後,最後在寫入 Frame Buffer。


  • PowerVR 架構的疑問和推論

在看完官方提供了 PowerVR 的架構說明[3],

可以理解其效能擁有高評價的原因主要可以歸類成下列三點:

  1. 基於 TBR 架構,減少對 Frame Buffer 的存取次數,達到低耗電
  2. Deffered Shading,減少 Overdraw 帶來的效能衝擊
  3. 由硬體做物件排序 (i.e. HSR),可以更高速的決定那些面不顯示(註4)

從上述原因可以確定,PowerVR 對於不透明物件的渲染有極大的優勢。



但是一般遊戲很難完全不使用半透明 (ex. UI 、 特效 等...),

因此對於透明物件的渲染仍是有其必要性。

根據 PowerVR 對於效能優化的建議 [4],在非必要的情況下,

使用 Alpha Blend 取代 Alpha Test 對於效能有比較大的幫助。

但瀏覽網路卻會發現有些文章提出 Alpha Blend 對於效能的影響高於 Alpha Test。

這裡就衍生了兩個問題

  1. 為什麼官方會說 Alpha Blend 的效能比較好
  2. 為什麼在網路上會出現 Alpha Test 效能高於 Alpha Blend 的訊息

因此,本章節將對於兩個相反的事實去探討原因。

  • Alpha Blend

首先必須了解 PowerVR 對透明物件的處理流程,

根據 [5] 的描述,可以得知會對物件做 HSR,

當 HSR 沒有通過時(i.e. 被其他物件遮蔽), 

ISP 會繼續從 Parameter Buffer 找出下一個節點資料做 HSR,

這個時候流程和渲染不透明物件一樣。

但當透明物件通過了 HSR (i.e. 沒有被其他物件遮蔽),

ISP 會中斷運作,並渲染在這個時間點最上層的模型(i.e. 呼叫 Pixel Shader),

將結果儲存在 On-Chip 的 Colour Buffer,

之後在執行透明物件的渲染,從 Colour Buffer 取出背景顏色做 Alpha Blend。

Alpha Blend 的結果在回存到 Colour Buffer 做更新。

當完成更新後,ISP 將重新啟動,繼續從 Parameter Buffer 找出下一個節點資料做 HSR。

中斷 ISP 的目的是要確保渲染順序,讓半透明的渲染能夠正確。

根據上述的描述可以得知,由於背景資料是儲存在 On-Chip 的 Colour buffer 內,

所以相對於直接存取 Frame Buffer 的 Immediate-Mode Rendering,

Alpha Blend 的效率會比較高。

但仍然有 Overdraw 的問題 (講白話就是好一點,但用太多還是有效能問題)。

而且要中斷 ISP,停下來等待半透明渲染也會造成一些額外的效能消耗。


  • Alpha Test

Alpha Test 和 Alpha Blend 也是一樣,首先經過 HSR 處理。

如果被遮蔽,就同不透明物件一樣,

如果沒有被遮蔽,就會執行 TSP (i.e. Pixel shader),找出像素的透明值,

作為是否更新 Z Buffer 的判定。

由上述的說明可以得知,Alpha Test 只是在判斷是否更新 Z Buffer。

注意,這裡並不更新 Colour Buffer 的內容(註5),

這表示還是有 Overdraw 的問題,可以從下述的兩個狀況來分析

  1. 如果最後物件沒有被遮蔽,在寫入 Tile Buffer 時還是要再一次計算渲染結果
  2. 如果最後物件被遮蔽,在計算是否更新 Z軸時已經呼叫一次 TSP 了

無論是哪種狀況,該位置的 Pixel 都要做最少兩次以上的 TSP。

比較和 Alpha Blend 的差別,可以發現 Alpha Test 的 TSP 結果可能是個虛耗(i.e.沒有意義的執行)

Alpha Blend 的 TSP 執行一定會反映到最後畫面結果。

但 Alpha Test 可能會被之後的物件遮蔽,導致白作了一次的 TSP 計算。

因此,可以理解官方建議使用 Alpha Blend 取代 Alpha Test 的原因。


但 Alpha Test 就這麼百害而無一益嗎? 其實也不盡然。

在上面提到 Alpha Test 有虛耗的問題是因為之後可能會被其他物件所遮蔽。

但如果調整渲染順序讓 ISP 按照物件距離攝影機(畫面)由近到遠去執行 HSR,

使 Alpha Test 直接被 HSR 剔除,或確保通過 HSR 之後就不會被其他物件遮蔽。

將 Overdraw 的影響壓到最小,是否對效能有幫助?

tri-Ace Inc. 在 CEDEC 2013 [6] 的演講,就提出了這樣的報告。

tri-Ace Inc. 針對當時 6種常見的 mobile GPU ,

測試渲染不透明物件和 Alpha Test 物件的並存交叉渲染,測試結果如下


首先,讓不透明物件和 Alpha Test 物件交互從攝影機前向後方重疊排列。

接著根據三種渲染順序規則作測試,紀錄其 FPS 結果,將結果列於下方圖表。

其中,淺藍色是由後往前,紅色是由前往後(不透明開始),

深藍是由前往後(Alpha Test 物件開始)。

雖然結果都很類似,但我將焦點放在 PowerVR,

可以觀察到由後往前的效能最差(淺藍),可以推斷對 Alpha Test 物件虛耗了不少 TSP 計算。

由前往後(不透明開始)的效能最好(紅色),因為後面的所有物件都在 HSR 被剔除。

由前往後(Alpha Test)的效能(深藍)比由前往後(不透明開始)的效能(紅色)稍微低一點,

因為對最前面的 Alpha Test 物件作兩次 TSP(一次更新Z Buffer、一次更新畫面結果)。

從結果可以得知如果能聰明的排序渲染順序,

Alpha Test 帶來的效能衝擊並不會有想像中的嚴重。

這也可以解釋為什麼網路上有些文章會提出 Alpha Test 效能優於 Alpha Blend.

這些文章的測試方法都是用到大量的 Alpha Test (Alpha Blend) 物件去做渲染,然後比較 FPS。

因為 Alpha Test 會遮蔽後面的物件,所以不是所有的物件都會執行 TSP。

而 Alpha Blend 則是所有物件的 TSP都被執行,當然效能會低於 Alpha Test 結果。


  • 結論

綜合上述分析,從硬體架構優化渲染效能可以歸納出下列幾個準則

  1. 盡可能不要使用半透明或 Alpha Test 物件,減少 Overdraw
  2. 不透明、半透明和 Alpha Test 物件的渲染順序不要交叉渲染 (ex. 不透明 -> 半透明 -> 不透明),讓相同種類的物件集中渲染,避免中斷ISP 的運作,增加等待 TSP 和 Blend 的時間;當有必要同時對三種物件渲染時,官方推薦 [4] 的順序是 不透明 -> Alpha Test -> 半透明,這種順序可以讓 HSR 的效用發揮到最大。
  3. 當開始渲染 Frame Buffer 時,若不需要使用 Frame Buffer 的資料 (i.e.上一個 Frame 的畫面結果),記得呼叫 glClear 指令;由於 TBR 架構會複製 Frame Buffer 到 On-Chip Memory, glClear 指令可以減少這個複製動作,增加運作效能。

本篇的討論就到此結束,下一篇會從軟體(i.e. 程式編寫)的角度去分析如何優化系統效能。








  • 附註
1.TBR 的目的在於減少因為對 Frame Buffer 的存取而消耗大量的電力,
   因此目前主流的 mobile GPU 晶片都是採用 TBR 架構。
   但桌上型 PC 沒有耗電問題,所以是直接對 Frame Buffer 操作,
   可以省去 TBR 的 Tile 管理(包括分配、讀、寫和壓縮) 的負擔。
   這種直接對 Frame Buffer 存取的做法稱作 Immediate-Mode Rendering。

2.泛指對模型節點的處理,包含座標轉換、 Clip 、 Tile 分配等,

3.Imagination Technologies 認為 TBR 的概念裡並沒有提到延遲寫入 Frame Buffer,
   因此認為 PowerVR 的 HSR 才是真正實作出 TBDR 的晶片。
   但對 ARM 而言, TBR 的 Tiler 本身已經是一個延遲寫入 Frame Buffer 的概念,
   因此沒有特地提出 TBDR 這個架構。
   事實上,ARM 的 Mali 使用了 Forward Pixel Killing 的技術,
   達成和 PowerVR 的 HSR 一樣來減少 Overdraw 的發生。
   所以 TBR 和 TBDR 的差異就看各廠商怎麼解讀。

4.HSR 的計算是使用 On-Chip 的儲存空間,因此可以快速更新計算結果。

5.Alpha Test 是否更新 Colour Buffer 我並沒有找到相關的文獻來證明我的推論,
   我之所以判斷沒有更新 Colour Buffer 主要根據 下列兩個論點:
   a. 根據 [3] 提示的 Renderer 流程圖,Alpha Test 完成後箭頭指向 HSR 而沒有指向 Colour Buffer
   b. 根據 [4] 如果有更新 Colour Buffer,官方應該不會建議使用 Alpha Blend 取代 Alpha Test,
       因為 Alpha Test 會更新 Z Buffer,可增加遮蔽面積。
  
   



2020年7月8日 星期三

Scriptable Render Pipeline (2)


  • 前言
在上一個章節,

我完成了一個簡單只顯示背景天空的 Scriptable Render Pipeline (之後簡稱 SRP),

在本章節,將接續處理前一章節沒有討論的部分,也就是物件渲染的處理。


  • Culling
在渲染物件之前,先從效能方面來考慮,我會希望能夠避開渲染不再畫面內的物件,

因此,我要做的第一步就是找出這些畫面外的物件,並過濾掉他們。

由於物件是否在畫面外是根據攝影機位置,所以我可以從攝影機取得判斷 Culling 的參數。

void CameraRender(ScriptableRenderContext context, Camera camera)
{
    ScriptableCullingParameters cullingParameters;
    camera.TryGetCullingParameters(out cullingParameters);
}

根據 Unity Documentation 的說明,我可以用 TryGetCullingParameters 的回傳值,

判斷攝影機是否作用中。

如果回傳 false,我可以不做渲染來節省效能。

void CameraRender(ScriptableRenderContext context, Camera camera)
{
    ScriptableCullingParameters cullingParameters;
    if (!camera.TryGetCullingParameters(out cullingParameters))
            return;
}


取得了判斷 Culling 的參數後,我就可以對物件作 Culling。

在 Scriptable Render Pipeline (1) 裡,

提到了所有物件的資料都被封裝在 ScriptableRenderContext 這個結構裡。

實際上,ScriptableRenderContext 提供了 Cull  這個函數,幫助我做物件遞除。

所以接下來我要做的事就是呼叫 Cull 函數去得到物件 Culling 的結果。

void CameraRender(ScriptableRenderContext context, Camera camera)
{
    ScriptableCullingParameters cullingParameters;
    if (!camera.TryGetCullingParameters(out cullingParameters))
        return;

    CullingResults cullingResults = context.Cull(ref cullingParameters);
}


如果有看過我在 3D 渲染基礎 這篇文章提到的內容,應該會知道 GPU 在做渲染時,

也會做一次物件裁切(clip),既然 GPU 也做了一次,為什麼我又在這裡做一次?

這個原因可以用下圖說明



由於渲染物件的資料在CPU,

若要交由GPU去做 Clipping 就需要將物件資料由CPU傳送到GPU。

這個動作比起直接在CPU做 Culling 多了一個傳送資料的過程。

然而效能的瓶頸往往都是發生在資料傳輸的這個階段。

因此,在CPU就決定物件是否被渲染會是一個比較好的選擇。


  • 渲染順序


決定好那些物件要被渲染時,接下來就是要考慮物件渲染順序。

在這裡,我先只考慮渲染不透明的物件。

和實作 Culling 時一樣,先從效能開始考慮。

在談到效能時,相信時常會聽到 Overdarw 這個名詞,

Overdraw 簡單來說,就是在同一個 frame 裡,對畫面的某一點 pixel 做了兩次以上的渲染。

試想在畫面上,每一個 pixel 只會看到一個顏色,

因此可以合理的認為每一個 pixel 只會做一次渲染。

當某一點 pixel 做了兩次以上的渲染,這就表示除了最後一次的渲染以外都是沒有作用的。

因為這些渲染結果都不會出現在畫面上。


那麼在什麼情況下會出現 Overdraw,當渲染順序不正確時,就有可能發生

假設我渲染兩個 Sphere,一個紅色、一個藍色,

紅色在後面(離攝影機較遠),藍色在前面(離攝影機較近)。

我設定渲染順序為:從離攝影機遠的到離攝影機近的

這時開啟 Frame Debugger 觀察可以看到下圖結果



其中,粉紅色的部分做了兩次渲染 (先紅在藍)



接著,我試著把渲染的順序做了變更,

將渲染順序改成:從離攝影機近的到離攝影機遠的。

同樣地,再開啟 Frame Debugger 觀察可以看到下圖結果



兩個 Sphere 的重疊部分因為 Z-buffer test 不會再做一次渲染,

因此在這個情況下畫面每個 pixel 都最多只做一次渲染。

效能上自然比前者來的好。


回到 SRP ,基於上述討論結果,我希望能設定渲染順序是由前往後。

Unity 提供了 SortingSettings 結構讓我設定渲染順序

void DrawOpaque(ScriptableRenderContext context, Camera camera)
{
    SortingSettings sortingSettings = new SortingSettings(camera);
    sortingSettings.criteria = SortingCriteria.CommonOpaque;
}

其中, SortingCriteria.CommonOpaque 就是指定物件的渲染是由前往後。



  • 使用指定的 Shader 進行渲染


如果有打開過 Unity 內建的 Standard Shader,一定會發現裡面寫了密密麻麻的內容,

再仔細觀察,會發現這些內容被區分成數個 Pass。

事實上,Unity並不會一次執行 Shader 內的所有 Pass,

而是視 Unity 環境的 Render Path 設定(ex. forward 或 Deferred),

在適當的階段,執行指定的 Pass。


在這裡,我想將這個機制導入客製的 SRP 裡,

為了達到這個目的,我需要執行兩個動作


  1. 為 Shader 內的 Pass 命名,讓 SRP 能夠識別 Shader 內的所有 Pass。
  2. 告訴 SRP 執行 Shader 內的哪個 Pass。


首先,要將 Shader 內的 Pass 命名, 可以在 Pass 內 Tags 的 LightMode 欄位設定。

Shader "DemoPipelineShader"
{
    Properties
    {
        // 省略
    }
    SubShader
    {
        Pass
        {
            Tags { "LightMode"="DemoPipelineOnly" }
            CGPROGRAM
            // 省略
            ENDCG
        }
    }
}

在上面,我指定 Pass 的名稱為 DemoPipelineOnly。

接下來,我要告訴 SRP 讓所有可被渲染物件使用的 Shader,

都只執行 DemoPipelineOnly 這個 Pass。

Unity 提供了 DrawingSettings 結構來進行這樣的設定。

void DrawOpaque(ScriptableRenderContext context, Camera camera)
{
    SortingSettings sortingSettings = new SortingSettings(camera);
    sortingSettings.criteria = SortingCriteria.CommonOpaque;

    DrawingSettings drawingSettings = new DrawingSettings();
    drawingSettings.SetShaderPassName(0, new ShaderTagId("DemoPipelineOnly"));
    drawingSettings.sortingSettings = sortingSettings;
}

到了這裡,只有叫做 DemoPipelineOnly 的 Pass 才會被渲染。

這代表著如果物件使用的 Shader 沒有  DemoPipelineOnly 這個 Pass ,

就算物件在可視範圍內,也不會被渲染。

如果, Shader 內的 Pass 沒有指定名稱,那這個 Pass 會被預設命名為 SRPDefaultUnlit。



  • 過濾可視物件


完成了渲染順序和渲染內容的設定後,接下來就是要對這些可被渲染的物體進行條件過濾。

為什麼需要作條件過濾?

在使用 Unity 時,為了達到某些效果,會需要用到兩個以上的 Camera 去進行渲染。

這時會希望某些物件,只由特定的 Camera 執行渲染。

(常見的有 UI,或一些不受濾鏡影響的特效。)

因此希望可以透過條件過濾去指定物件被哪個 Camera 渲染。

常見的方法是設定物件 Layer, 然後用 Camera 的 cullingMask 參數做過濾。

Unity 提供了 FilteringSettings 結構來進行這樣的設定

void DrawOpaque(ScriptableRenderContext context, Camera camera)
{
    SortingSettings sortingSettings = new SortingSettings(camera);
    sortingSettings.criteria = SortingCriteria.CommonOpaque;

    DrawingSettings drawingSettings = new DrawingSettings();
    drawingSettings.SetShaderPassName(0, new ShaderTagId("DemoPipelineOnly"));
    drawingSettings.sortingSettings = sortingSettings;

    FilteringSettings filteringSettings = new FilteringSettings();
    filteringSettings.renderQueueRange = new RenderQueueRange(0, 2500);
    filteringSettings.renderingLayerMask = 1;
    filteringSettings.layerMask = camera.cullingMask;
}

其中, filteringSettings.layerMask 就直接引用 Camera 的 cullingMask 設定,

作為物件渲染的過濾條件。

附帶一提的是,我可以不只用物件的 layer 做條件判斷,

我也可以使用物件使用 shader 的 render queue 範圍來判斷。

在上面的程式碼,我設定 render queue 的範圍要在 0 ~ 2500 之間,才能被渲染。

這提供了更多的彈性,讓我設定客製渲染流程。



  • 渲染物件


當完成了上述所有設定,就可以執行物件渲染的動作了。

和天空盒一樣,物件渲染也是被包裝在 ScriptableRenderContext 結構內。

void DrawOpaque(ScriptableRenderContext context, Camera camera)
{
    SortingSettings sortingSettings = new SortingSettings(camera);
    sortingSettings.criteria = SortingCriteria.CommonOpaque;

    DrawingSettings drawingSettings = new DrawingSettings();
    drawingSettings.SetShaderPassName(0, new ShaderTagId("DemoPipelineOnly"));
    drawingSettings.sortingSettings = sortingSettings;

    FilteringSettings filteringSettings = new FilteringSettings();
    filteringSettings.renderQueueRange = new RenderQueueRange(0, 2500);
    filteringSettings.renderingLayerMask = 1;
    filteringSettings.layerMask = camera.cullingMask;

    context.DrawRenderers(cullingResults, ref drawingSettings, ref filteringSettings);
}

上面程式碼,就是渲染物件所需要的基礎設定。

我把這段程式碼和 Scriptable Render Pipeline (1) 的部分做結合。

protected override void Render(ScriptableRenderContext context, Camera[] cameras)
{        
    for (int i = 0; i < cameras.Length; ++i)
    {
        CameraRender(context, cameras[i]);
    }

    context.Submit();
}

void CameraRender(ScriptableRenderContext context, Camera camera)
{
    ScriptableCullingParameters cullingParameters;
    if (!camera.TryGetCullingParameters(out cullingParameters))
        return;

    cullingResults = context.Cull(ref cullingParameters);

    ClearRenderBuffer(cmdBuff, context, camera);
    context.SetupCameraProperties(camera);

    if (camera.clearFlags == CameraClearFlags.Skybox)
        context.DrawSkybox(camera);

    DrawOpaque(context, camera);
}

OK,畫面可以正常顯示,使用的 Shader 也可以正常執行。

看似正常,但其實有一個地方需要再改進。

在這裡,我是先呼叫 DrawSkybox 渲染天空盒之後,再呼叫 DrawOpaque 渲染不透明物件。

但是還記得我在本章節渲染順序裡,提到關於 Overdraw 的內容嗎?

這樣的執行順序會導致不透明物件和天空盒重疊的部分產生 Overdraw。

所以,為了避免 Overdraw ,我需要將呼叫 DrawSkybox 和 DrawOpaque 的順序顛倒,

才能得到更好的效能。

因此,本章節最後的程式碼如下所示:

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Rendering;

public class DemoPipeline : UnityEngine.Rendering.RenderPipeline
{
    CommandBuffer cmdBuff;

    CullingResults cullingResults;

    public DemoPipeline()
    {
        cmdBuff = new CommandBuffer();
    }

    protected override void Render(ScriptableRenderContext context, Camera[] cameras)
    {        
        for (int i = 0; i < cameras.Length; ++i)
        {
            CameraRender(context, cameras[i]);        
        }

        context.Submit();
    }

    void CameraRender(ScriptableRenderContext context, Camera camera)
    {
        ScriptableCullingParameters cullingParameters;
        if (!camera.TryGetCullingParameters(out cullingParameters))
            return;

        cullingResults = context.Cull(ref cullingParameters);

        ClearRenderBuffer(cmdBuff, context, camera);
        context.SetupCameraProperties(camera);

        DrawOpaque(context, camera);

        if (camera.clearFlags == CameraClearFlags.Skybox)
            context.DrawSkybox(camera);        
    }

    void ClearRenderBuffer(CommandBuffer cmd, ScriptableRenderContext context, Camera cam)
    {
        CameraClearFlags clearFlags = cam.clearFlags;

        bool isClearDepth = true;
        bool isClearColor = true;

        switch (clearFlags)
        {
            case CameraClearFlags.Depth:
                isClearColor = false;
                break;

            case CameraClearFlags.Nothing:
                isClearDepth = false;
                isClearColor = false;
                break;
        }

        cmd.ClearRenderTarget(isClearDepth, isClearColor, cam.backgroundColor);
        context.ExecuteCommandBuffer(cmd);
        cmd.Clear();
    }

    void DrawOpaque(ScriptableRenderContext context, Camera camera)
    {
        SortingSettings sortingSettings = new SortingSettings(camera);
        sortingSettings.criteria = SortingCriteria.CommonOpaque;

                
        DrawingSettings drawingSettings = new DrawingSettings();
        drawingSettings.SetShaderPassName(0, new ShaderTagId("DemoPipelineOnly"));
        drawingSettings.sortingSettings = sortingSettings;
        drawingSettings.enableInstancing = true;
                
        FilteringSettings filteringSettings = new FilteringSettings();
        filteringSettings.renderQueueRange = new RenderQueueRange(0, 2500);
        filteringSettings.renderingLayerMask = 1;
        filteringSettings.layerMask = camera.cullingMask;

        context.DrawRenderers(cullingResults, ref drawingSettings, ref filteringSettings);
    }
}



  • 結論
本章節簡單的介紹如何撰寫一個渲染不透明物件的客製 SRP,

在這裡,我特別強調不透明物件是因為這是針對 overdraw 的考量。

但是如果要渲染透明物件的話,overdraw 將是無法避免得一個問題。

這也就是透明物件對效能造成影響的主要原因。

但遺憾的是,一般遊戲很難不用到透明物件的。

如何有效在畫面效果和效能間取得平衡,永遠是遊戲製作上的一個必考題。

在本章節,不討論如何撰寫支援透明物件渲染的客製 SRP。

因為作法和渲染不透明一樣,只是需要注意執行順序要在渲染天空盒之後,

渲染的物件順序要由後往前(離攝影機遠到離攝影機近),剛好跟不透明渲染順序相反。