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