【USparkle專欄】如果你深懷絕技,愛“搞點研究”,樂于分享也博采眾長,我們期待你的加入,讓智慧的火花碰撞交織,讓知識的傳遞生生不息!
這是侑虎科技第1413篇文章,感謝作者 偶爾不帥供稿。歡迎轉發分享,未經作者授權請勿轉載。如果您有任何獨到的見解或者發現也歡迎聯系我們,一起探討。(QQ群:465082844)
作者主頁:
一、技術設計背景
Unity引擎自帶的粒子系統一直是CPU端計算的,這里是指粒子系統以下三大步驟都是在CPU計算。
粒子系統的主要3個開銷大的步驟:
1. 每個發射器每幀創建新粒子實例
2. 每個粒子實例每幀更新粒子位置、顏色等狀態
3. 每個發射器的繪制提交與發射器之間渲染排序
后來硬件的發展GPU提升的更快,而實際項目中常常也是CPU瓶頸居多。所以有了基于ComputeShader與GPUInstance技術的GPU粒子系統。比如Unreal Engine有CPU和GPU 2套,較新版Unity也有VFX。但是選擇自己寫一套主要是這幾個考量。
基礎功能的面板數據
二、單個復雜粒子模式
這種模式雖然游戲內不太常用,但是性能提升最大,也是開發最簡單直觀的。而且GitHub已經有Demo,我就不重復寫這種模式的代碼了。如果覺得我這里說的不夠詳細,沒有基礎代碼部分有點暈的同學可以下載這份很短但完整的源碼。
具體的做法分3個步驟:
1. 在C#腳本中,每幀對這個發射器計算這一幀需要創建的粒子數(根據粒子系統上每秒多少個和 Burst參數),然后需要創建多少個Dispatch、多少個線程數,因為這種模式發射器數量很少,粒子數很大,比如全地圖煙霧、全圖落葉等。所以CPU計算發射數的工作量非常少,沒必要讓GPU計算。
2. 把這種粒子系統看成粒子數量是固定的,比如N,這N就是粒子系統里粒子上限參數。創建長度為N的StructuredBuffer,存放Particle實例信息的Struct。因為每個實例生命結束順序不固定,所以需要一個可用粒子池的AppendBuffer來記錄Particle數組里哪些Index粒子可被拿來復用。
3. 每幀對所有粒子實例更新,每個ComputeShader線程處理一個粒子實例。所以不管當前多少個粒子在渲染都是按N來做的。這種粒子一般都是循環N,基本就是要渲染的全部,只要設置合理,其實并不會浪費不可見粒子的空循環,比再用Buffer管理有效粒子,渲染時再跳轉反而性能更好。
部分關鍵代碼:
Buff內粒子實例數據
粒子數據與可用粒子對象池索引變量
這里需要注意:dead與alive其實對于C#那邊同一份Buffer數據。只是在創建粒子的Kernel里消費,在Init與Update的Kernel里Append,因為死亡或初始化都要把粒子設置為可用,就是把Index還給Buffer。
創建粒子是消耗可用的粒子Index
更新時,如果生命到期就把粒子的Index還給可用Buffer
渲染的時候,數量邏輯一樣按粒子系統的設置maxCount作為InstanceCount。其中不可見的粒子用col=pInst.alive*pInst.color,實現隱藏。這種模式絕大部分時候繪制的粒子數量就接近maxCount,所以基本都是alive=true的,很少空計算。
以下是測試結果渲染20w個粒子,這種性能提升是巨大的。Unity的CPU方案107幀 VS GPU實現方案1661幀。
顏色不同是因為,Demo的作者在對顏色隨生命變化的漸變圖轉圖形時,沒考慮用線性空間導致的,不影響性能對比。
單個復雜粒子CPU/GPU方案幀數對比
左邊是抓幀證明渲染的粒子數量一樣
三、多發射器的簡單粒子
這個模式才是我真正為項目開發的模式,也是更能寫出性能大收益的模式,老老實實的寫很容易負優化。這是因為GPU中的半透明與CPU中的半透明對象很難一起高性能排序,通用引擎為了通用與絕對正確,據我粗略了解,這個問題是無解的(高性能的解),后面會講如何定制優化,先看性能對比。
單獨200個子彈碰撞特效,每個有6個發射器,所以一共1200粒子反射器,但來回切換激活 同時只顯示50%左右(后面按每幀600個粒子更新來算)。Unity CPU版是373FPS,本方案是2461FPS。如果用上個方案的那個GitHub Demo之間做這種,會發現只有100多幀,負優化。所以我沒有拿那個源碼用,而是自己重新設計了一套符合具體項目的方案。
很多發射器實例的模式下
性能對比:Unity CPU粒子(上)
vs 本方案GPU粒子(下)
這是因為單個復雜粒子模式是每個粒子發射器都創建一個含有粒子數據的Buff,每幀通過Dispatch ComputeShader更新這些粒子,也就是說,這樣需要600次Dispatch,性能自然就差了。
所以第一步改進就是申請一個公用的大Buff來存放當前激活的所有發射器的粒子數據。對于這種數據組織一般有2種模式:一種是間接尋址,一種是每個粒子發射器定長數組占用,然后通過Offset獲取自己在Buffer內的數據。
這里采用第二種,每種發射器最多同時存在32個粒子實例,這樣可以滿足大部分戰斗中反復出現的大量及時性特效。但是我們上面說Particles是根據粒子創建死亡維護的對象池,數據是無序的。當時是同一個粒子發射器,一次DrawIndirect,所以不需要在意順序。但現在這個數據里有不同的發射器創建的粒子,渲染時也需要訪問不同的Index來獲取對應數據。所以需要一個RWStructuredBuffer particlesIndexer;來記錄每個發射器,包含的粒子在Particles數組中的Index。每個發射器占32位元素,同樣渲染的時候,需要用另一個RWStructuredBuffer emitterCounter;,這個變量就是用在 DrawMeshInstancedIndirect(Mesh mesh, int submeshIndex, Material material, Bounds bounds, ComputeBuffer bufferWithArgs, int argsOffset); 這個API里的bufferWithArgs,配合后面argsOffset就能實現每個發射器不同的偏移了。
更新函數中,是這樣把當前幀需要渲染的活著的粒子寫入這2個Buffer的。
這樣雖然每幀對粒子的Update在一次Dispatch后就執行完了,但渲染的時候,每個發射器單獨執行DrawCall還是會性能很差。從Nsight工具可以看到非常恐怖的切換Shader次數,時間很快是因為我是3080顯卡,在普通顯卡中這個性能是不具備現實可用性的。
每個粒子發射器一次DrawCall的GPU切換情況
四、半透明排序與合批渲染
這是整個技術的關鍵所在也是最大的矛盾點,目前的DrawIndirect API每次調用都只能傳一個AABB,引擎會根據這個AABB中心參與場景里其他對象進行排序,所以一次DrawIndirect繪制的所有粒子擁有同一個順序,要么全部在某對象前,要么全部在某對象后渲染?,F在每個粒子發射器單獨一個DrawCall的情況下排序正常了(和Unity自帶CPU粒子一樣,逐發射器排序正常,不考慮多個發射器之間逐粒子排序),但性能不行。
如果所有同材質發射器合并成一個DrawCall,那么排序又會不正常,因為它們中間出現場景的半透明對象無法穿插到這個DrawCall里。這也是為什么Unity的GPUInstance文章都是不拿半透明做例子,因為Opaque的排序不正確不影響畫面效果,有Depth保證最終順序。透明材質是沒有寫Depth的,除非用了深度剝離技術。但這說遠了,一般不會這樣做的,所以如何合批是重點。
先看下Unity本身是如何合批粒子的,經過簡單測試就能發現,如果ab是相同的粒子發射器的不同實例,c是不同的粒子反射器,ab距離靠近,而c在ab前或在ab后,那么只有2個DrawCall;如果c在ab中間就會有3個DrawCall。所以引擎是排序后才把相鄰的又相同的反射器合批渲染。但我們渲染數據是在GPU,如果讓CPU排序后要合批,則需要搬運Buffer內數據后合并到一起,很復雜且要改引擎。如果在GPU內排序更不可能,GPU內只能粒子自己排序,無法與場景上對象排序,這些對象都在CPU。所以通用引擎很難解決這個問題。
但做定制開發就輕松多了。首先觀察下這些項目中的特效,同一種特效總是出現在世界空間位置相機的地方,比如一個人開槍的特效總是在他槍口附近,而子彈的碰撞特效又總是在前方某個位置,不同的玩家是不同的,所以只要用玩家ID+粒子發射器Prefab種類做Key 來分組,Key相同的一次性渲染就可以了。但這個性能很高,需要犧牲精確度,比如同一個人在玻璃后開幾槍,再跑玻璃前面開幾槍,那么先創建出的玻璃后的粒子也會一起渲染到玻璃上面。但是這問題不大,因為這些特效都是0.5秒之內就消失的,不會長期停留在跑動和下次開槍時,但墻上的彈孔是個特例他們會停留30秒,所以這個方案不好。
另一個更好的方法是根據世界空間把1立方米內的相同粒子發射器Prefab的所有粒子做一次Draw,因為位置很靠近所以它們按同一個位置參與排序基本是正確的,比較簡單的是用long類型把這些信息計算到一起且不重復。假設這里場景范圍是正負5000米,全部合批發射器用這個管理 Dictionary activeEmitterTypes;。
根據位置與發射器類型計算合批渲染的編號
分組發射器數據結構
最后介紹該方案的主要數據。因為改用這種合批,這里有和上面修改的地方。
按類型與空間合批渲染的更新方式
該方案的主要數據
最后看下最終落地效果,從原來開槍掉18幀變成只掉5幀,至此優化幾輪的開槍降幀問題終于有點穩住了,之前是根本不能與CSGO相比,他們優化的太好了。
最終落地項目
連發35(常見彈夾)后降幀對比
五、GPU的優化
這個GPU粒子主要功能是優化CPU瓶頸,關于GPU的性能優化順便提下,開火會有大量重疊的多層的大屏幕面積的火焰、煙霧,導致Overdraw問題非常大,觀察CSGO與COD有幾個簡單優化技巧:
文末,再次感謝jackie 偶爾不帥的分享,作者主頁:,如果您有任何獨到的見解或者發現也歡迎聯系我們,一起探討。(QQ群:465082844)
近期精彩回顧