(程序向)Unity3D GPU繪製管線(1)
來自專欄 Unity Graphics148 人贊了文章
標題圖來自大名鼎鼎的Far Cry 5,在這款遊戲中有大量需要重複繪製的物體,如樹木,草地,山石等,如果每個模型都是一次單獨的drawcall,怕是天底下最強的CPU也耐不住這麼折騰,這時候,一些機智的朋友就提出「可以用GPU Instance」,確實如此,GPU Instance在相當的一段時間裡都作為物體重複繪製的好辦法,使用較少的gpu消耗,解放大量的cpu性能,然鵝,這樣的解放卻並不徹底,我們需要更徹底的解放CPU的消耗。
我們知道,在渲染管線中,首先要經過剔除,篩選出會出現在屏幕上的物體,然後將物體的Mesh,Texture,Shader Pass等數據輸入顯存,最後向GPU提出繪製請求。而GPU Instance則降低了輸入數據和剔除繪製請求這兩部分的性能消耗,將許多重複的操作合併到了一起,然而,他卻並沒有解決巨量的剔除,輸入數據等CPU消耗,在Unity中這些消耗不容易被看出來其實很大原因是Unity的多線程優化做的比較完善,這使得Benchmark或Demo中很難暴露出性能的短板,然而,如果我們打開任務管理器會發現CPU的佔用已經非常高了,其次,GPU Instance一般繪製動態物體非常困難,因為大量的矩陣信息都需要重構,如風吹草動等,進一步增加了CPU的消耗。因此,我們可以更大限度的擴展GPU Instance,將更多的操作直接放到GPU,利用好GPU強大的並行能力。
這裡,我們操作兩萬多方塊進行移動,並且每個方塊都受到了直接光的光照:
按照國際慣例,先上源碼地址:
https://github.com/MaxwellGengYF/Unity-Computing-Mesh首先,我們使用Compute Shader完成剔除,並將倖存下來的物體放到一個ComputeBuffer中,然後在Shader中讀這個ComputeBuffer。剔除方面,我們使用bounding box進行frustum culling:這一部分的操作非常簡單,Bounds包括了位置矩陣和extent,即bounding box的大小,矩陣則保存了方塊的位置,角度等重要的信息。然後判斷該方塊是否在面的上方。這裡的plane的xyz存儲著面的法線方向,而w儲存著面距離原點的距離。
接下來就是使用compute shader將6個面分別計算,並累計記錄沒被剔除的物體:
這段代碼同樣非常容易理解,依次讀取allBounds中所有的bounding box,並將6個面代入並運算,最後給沒有被剔除的物體,計算其坐標矩陣並打包到另一個compute buffer中,當然,而_Count在這裡則純粹起到了防止溢出的作用。
我們注意到,在視頻中,還有物體移動的動態模糊效果,說到動態模糊,就不得不提到MotionVectors,因此我們需要一個單獨的函數計算物體上一幀的位置,並輔助之後的MotionVectors Pass:
同樣非常容易理解,這裡隨意的加了一個讓方塊成波浪形移動的sin cos函數,為了驗證我們的motion vectors有沒有成功。
在完成了compute shader之後,我們就需要用腳本來操作compute shader,因為渲染管線程序的特殊性,我們需要較高的擴展能力,因此這裡對程序進行了組件化,組件化在解決框架擴展性問題上的表現有目共睹,Unity官方一直強推的ECS架構正是組件化的一個運用。首先,我們構建幾個組件,其中每個組件都應有自己明確的用處。
第一個組件就是剔除需要用到的數據的集合:
這裡有所有compute shader需要用到的數據和compute shader本身的引用,第二個組件則包含繪製需要的material和commandbuffer,這個組件將直接用來控制渲染部分:
剩下的則是重現一下compute shader中的struct了:
請注意,這裡我們在每個struct中都記錄了一個SIZE變數,用於儲存struct本身的體積,這使得之後確定compute buffer的大小非常方便。
有了組件,就要有控制組件的函數,一般組件化編程中,要盡量使用無返回值的純函數,即靜態的,不依賴除參數以外其他變數的函數,這裡函數量太過冗長,我們就不一一展示,感興趣的朋友可以通過上方的Github地址解讀。
在腳本中,我們使用了兩個CommandBuffer繪製了兩個Pass,分別是GBuffer Pass和MotionVectors Pass,前者通過Deferred Shading管線,將物體的Geometry信息輸出到GBuffer上,後者通過上一幀傳入的MVP矩陣,計算出當前像素點在上一幀的位置。關於繪製,Unity給我們提供了DrawProcedural這個API,它會在屏幕上繪製N個頂點,並且根據傳入的參數決定是光柵化的方法。由於使用了Procedural而非普通Mesh,Shader的方法也要進行修改,不再通過appdata而是通過vertexID和instanceID手動讀取computeBuffer。Deferred Pass的Vertex Shader代碼如下:
Fragment與其他Shader就大抵無異了,這裡每個VertexID是模型頂點的計數,而InstanceID則是GPU Instance的計數,由於DrawProcedural依然是GPU Instance,因此這裡實際非常容易理解。
需要著重描寫的是MotionVectors Pass,這裡在Vertex中輸出當前幀和上一幀的頂點位置信息,並在fragment函數中計算每個像素點的偏移量:
這裡把頂點的位置直接計算到投影空間,這樣在fragment中就不需要更多的運算量。值得一提的是這裡的currentpos,因為SV_POSITION在管線中會被變化,因此我們需要另一個變數來儲存完整的投影坐標,最終計算出的結果就是當前像素點在屏幕上的移動方向。
在這一整輪渲染過程中,GPU已經承擔了剔除,整理,合批,繪製的作用,而CPU在整個過程中唯一需要做的就是將繪製請求提交出去,因此,CPU的消耗幾近於0。然而,在實際的性能測試中,許多時候這套完全GPU繪製的性能消耗,並不高於Unity自帶的GPU Instance,這種情況下一般短板不在於CPU而在於GPU,因此我們在實際項目中解決需求時,需要考慮當前項目的現狀決定選用的技術,否則可能並不會提高,反而會降低運行效率。
推薦閱讀:
※第十一章 場景管理 Part4(11.5 To 11.7)
※從零開始手敲次世代遊戲引擎(五十一)
※Unity3D初學者2D精靈開發基礎
※[GDC15]Parallelizing the Naughty Dog Engine using Fibers
※UE4 如何製作一個釣魚竿