《小米超神》技術總監王嘯予:重度MOBA的優化之路
來自專欄 UWA:簡單優化、優化簡單
今天為大家分享的內容來自UWA DAY 2018技術大會上,嘉賓王嘯予的演講《重度MOBA的優化之路》。他重點分享了開發過程中遇到的各種技術問題以及對應的解決方案,包括:如何通過渲染到紋理的方法來降低一些UI的Draw Call;如何自行構建UI Mesh管理頻繁移動的HUD和小地圖的UI部分;如何優化UI界面中的粒子特效等,實用性較強,極具參考意義。
大家好,我是來自朱雀網路的王嘯予,今天給大家分享我們在研發《小米超神》這款MOBA手游過程中遇到的各種問題及對應的解決方案。
每個遊戲的優化側重點和遊戲類型緊密相關。比如MOBA遊戲,地圖相對固定且全部可見,具有固定視角;同時這類競技性遊戲對實時響應的要求較高,運行時的計算量比較大,類如尋路演算法,這導致MOBA遊戲不能做Level Streaming,必須整體載入;並且幀率平滑性需求要高於最大性能表現,我們需要盡量避免運行時載入資源,最好是全部預載入;最後,MOBA手游是CPU密集的。
本文主講UI和場景:
關於UI:
1. 動態圖集;
2. 自定義的UI Mesh;
3. UI與特效混排的方案。
關於場景:
1. 不可見物體的優化;
2. Crowd渲染優化。
一、UI
1. 動態圖集
所謂動態圖集就是沒有辦法靜態生成的,需要在運行時動態生成的圖集,那麼我們為什麼需要動態圖集?
動態圖集是為了解決遊戲中動態圖片太多的問題,也就是我們沒有辦法預先放在UI上的。下圖案例中可以看到右下角的英雄技能圖標、天賦技能圖標,以及主動使用的物品圖片,均為動態載入。左上角的英雄頭像也是動態載入,而且由於技能之類的圖片太多(畢竟有幾十個英雄),所以沒有辦法打成一張靜態圖集。而如果作為獨立圖片動態載入,就會多十幾個DrawCall。即便是打成多張靜態圖集,也會導致UI渲染的批次被打斷。
解決方案:用動態打圖集的方式。因為我們沒有Unity源碼,所以圖集的分塊演算法參考了這個開源項目 http://davikingcode.com/blog/unity-generate-spritesheets-at-runtime/,這個演算法效率比較不錯,建議大家可以研究一下,它的分塊演算法的思路上本質上類似於BSP。
下圖是按解決方案打出來的動態圖集。大圖集是在遊戲Loading時獲得動態圖片,然後把這些動態圖片渲染到RenderTexture上,用GPU的方式來做可以保證載入的效率。在遊戲中,英雄頭像使用了一張256x256的RenderTexture,而英雄技能、天賦技能和物品圖標使用了一張512x512的RenderTexture。這樣一來,技能面板動態圖標的消耗從12個DrawCall降低到1個DrawCall。而英雄頭像部分,從最多9個DrawCall降低到2個DrawCall,這個結果是因為敵我雙方英雄頭像使用的材質不同。實際操作中,技能面板的動態圖片放在同一個層級里,這樣就只有1個DrawCall,上面的蒙板、邊框零散圖片打成靜態圖集,在不出現穿插的情況下,UGUI也會協助合批。因此通過這種方式大量減少了DrawCall。後面講到的一些點其實也用到了動態圖集。
2. 自定義的UI Mesh
為什麼需要自定義UI Mesh?正如UWA一直以來所說的那樣,UI元素要做到動靜分離。如果動態元素太多怎麼辦?《小米超神》中需要動態變化的UI元素非常多,如果使用UGUI來做會產生大量消耗,主要集中在SendWillRenderCanvases和BuildBatch兩個函數,UGUI本身也會不斷地去重建。
下圖是遊戲中的血條,密集的原因是因為每一波小兵有5個,夾雜著投石車。並且血條是始終顯示的,有些遊戲中只有血量不滿的時候才會顯示,但是《小米超神》要求做成全顯示。並且血條希望實現移動平滑,必須每幀去刷新血條位置。
下圖是遊戲中的小地圖,小地圖上的動態元素也非常多,包括英雄、小兵、野怪、建築、寵物信使、偵察守衛等。不過小地圖的更新頻率會稍慢一點,每三幀更新一次。
這裡的問題是,在動態UI元素如此多,且要不斷地去更新,如何保證效率?最終研發團隊放棄使用UGUI的現成方案,轉而自己構造Mesh,自己構造出來的Mesh使用一個單獨的正交攝像機來繪製,為了方便區分血條、小地圖,我們局部做了區分,實際中是在同一個攝像機下面。
在UI Mesh的構造函數中可以看到是創建了一個GameObject,附加MeshFilter和MeshRenderer,然後再做一些初始化的工作。重點在於自行填充Mesh的三個Buffer:位置、UV和索引。另外為了避免在運行時重複申請內存,在初始化的時候要申請足夠多的頂點。在實際遊戲中用到了多個UI Mesh,總體的頂點數大概在3000左右。
初始化Mesh之後,還要去維護頂點Buffer。在《小米超神》中每個Actor要維護自身的數據,包括頂點位置和UV,並保存它在數組中的索引。舉個例子,一個小兵的血條包含背景底框和前景血條,2個矩形8個頂點,在遊戲中去動態地改變這8個頂點的位置。如果某個Actor不在視野中,那麼把它所有頂點坍縮到一個點就不顯示了。另外,Actor死亡的時候,並不刪除它的數據,而是先設置為不顯示,然後緩存起來準備復用。也就是說無論整場戰鬥創建了多少個角色,實際上血條都是在這個Mesh的Buffer里不斷復用。
下圖是由Actor調用的代碼,BuildQuad是Actor申請數據段,返回Buffer的Index。下面這個是運行時改變頂點位置和UV。所以由於一開始申請了足夠多的頂點,在運行時基本是沒有UI Mesh Rebuild的消耗。
上文提到《小米超神》中用到了多個UI Mesh,主要因為有相互遮擋的需求,比如英雄的血條要在小兵的血條之上。因為渲染UI Mesh的時候沒有開深度,所以如果做在同一個Mesh里就不能保證圖標之間的遮擋關係。如果有分層級的需求,就要把它們分開成多個,並且使用MeshRenderer的SortingOrder來保證渲染順序。在遊戲里,HUD和小地圖分別有4個層級,共8個DrawCall。
為了在一個UI Mesh裡面渲染,必須把圖標拼到一個大圖裡。HUD血條是用UGUI的靜態圖集來做(下圖左圖)。而在小地圖上由於有動態的圖標,包括英雄和信使,以及一些會在小地圖上顯示的英雄技能。採用動態圖集方式,在載入時渲染到一張256x256的大圖裡。
總結,UI Mesh的用處在於避免UI重建,以及減少DrawCall。
3. UI和特效混排
在遊戲中,大量的UI上都嵌入了特效。因為Unity中UI和粒子/Mesh的渲染方式不同,是兩個體系。所以原本將特效嵌入UI界面,遇到排序問題是通過團隊自身管理SortingOrder來解決,當UI面板逐漸增多的時候就變得越來越複雜。因此我們團隊也在思考能否把它們歸到一個體系當中來處理。下圖中的麥克風特效、裝備特效以及技能升級特效,都可以復用。
最終解決方案是把特效渲染到RenderTexture上,在UI組件上使用這張紋理的方式。這種方式的優點在於,由於使用的都是UGUI組件,所以沒有任何的排序問題,可以任意嵌入UI中,並且相同的特效可以在多處UI復用。然而這種方式也有缺點,首先需要多一遍UI Image的繪製,同時引入RenderTexture必然增加了內存佔用。但是對於《小米超神》來說,由於我們在UI裡面嵌入的特效較多,這種方式確實是最佳方案。
需要提醒的是,這種方案不能直接使用默認的半透明混合公式。原因是由於混合順序發生了變化,先渲染到RenderTexture上,再渲染到UI上。以最常見的AlphaBlend為例,在混合順序發生變化的時候,可以看到FinalColor和實際得到的FinalColor有區別,左邊這張圖比右邊這張會明顯偏白,右邊這張才是我們期望得到的效果。
因此將混合公式做了修改調整,以確保結果的正確性。修改方式:首先RenderTexture的ClearColor要改成黑色,Alpha為1。然後是特效的Shader,Blend方式要做修改,Color方面不變,還是Srcalpha Oneminussrcalpha,Alpha部分要改成Zero Oneminussrcalpha,最後是引用RenderTexture的UI Image,Blend方式改成One Srcalpha。不過這只是單次混合的結果正確,如果擴展到多層疊加的一般情況呢?
圖中的C代表Color,A代表Alpha,推導可以得出,即便對於多層混合疊加的一般情況,這種修改方式也適用。
解決了AlphaBlend的問題,另一種在特效中常用的混合方式就是Additive,同樣對Shader的混合公式做了小改動,Color方面保持不變,Alpha改成Zero One。在《小米超神》項目中UI特效只用到了這兩種混合方式,不透明的物體不存在混合順序問題。如果大家的遊戲中有用到更複雜的特效,可以再擴展。
二、場景
1. 不可見物體優化
所謂不可見物體就是攝像機里看不到的物體,這種物體不會被玩家所關心,相對的就可以對它們進行一些優化。
在Unity場景中的GameObject,每幀都會執行附加的Monobehavior組件的Update,包括了Unity自身動畫、粒子系統,以及Component的Update。當物件多的時候,這部分消耗很大。另外,當GameObject位置和朝向發生變化的時候,設置Transform的Position和Rotation會便於所有的子節點調用它們的SetPosition和SetRotation,而且是一個遞歸的樹狀結構,如果GameObject層次比較深,這部分的消耗不能忽略不計。因此可以考慮當物體不可見的時候不做更新。
具體做法是分離Actor的邏輯Transform和對應的GameObject的Transform,把這兩個獨立開來。當邏輯Actor的AABB包圍盒處於視錐體中的時候,才更新GameObject,包括設置它的Transform,否則就將整個GameObject 關掉。這樣就不會調用其組件的Update。這樣做確實降低了更新GameObject和設置Transform的消耗,但是在GameObject的Activate和Deactivate的時候有額外的消耗,這裡要感謝UWA幫我們指出這一點。
從GameObject Activate的消耗曲線可以看到,性能消耗還是相當可觀的,平均有3毫秒左右,峰值可以達到10毫秒,並且數量級很大,整場遊戲當中有1萬多次。原因是頻繁對鏡頭內外的GameObject做開關所導致的性能開銷。
GameObject Deactivate的消耗,也是處在同一量級。這種量級的開銷對於總共只有33毫秒每幀的遊戲來已經很高。所以不能單純通過Activate和Deactivate的方式來降低Update的消耗。
解決辦法是把GameObject上面附加的MonoBehavior對其作了Enable和Disable。包括Renderer的Enable/Disable、Animator、Animation和粒子系統。對於Animator,不能把它Disable掉,因為Animator是個狀態機。因此按照需求把Animator的Update Mode改成Cull Update Transforms甚至是Cull Completely。至於Animation,如果想把它Disable掉,然後Enable的時候又能恢復正常,就必須自行維護Animation的時間。下圖貼出來的代碼是在Enable的時候設置Animation State的時間,然後把權重設成1,這樣才能表現正確。粒子系統同樣,如果選擇Disable要自行管理時間,Enable的時候去設置時間。如果是自寫的MonoBehavior,當然就要根據需求決定是不是要關掉。
優化之後,Activate和Deactivate的數量大幅下降,從曲線來看消耗也明顯減少。再和前面的一萬多次消耗做對比,效果很明顯,表現上也沒有問題。
2. Crowd渲染優化
《小米超神》標榜的是重度MOBA,所謂重度MOBA,其實就是場景大、小兵多、野怪多、光效多。小兵的數量和端游是一樣的,每波5隻,三近戰兩遠程,另外夾雜有投石車。相比而言,更容易出現同屏大量角色的問題。特別需要提醒的是,因為兵都是從基地里出的,所以如果是在基地處爆發團戰的時候,同屏可能會有大量的小兵存在。由此帶來的問題有三個:
- 動畫計算
- 蒙皮的消耗
- 大量的DrawCall
Skinned Mesh無法依靠Unity的Dynamic Batching,但採用骨骼動畫的話這些問題是避免不了。
解決方式就是不用骨骼動畫,採用key Frame Animation, 這個啟發來自於2016年頑皮狗的一篇文章。就是將小兵的每一幀動作的頂點位置都寫入到紋理上,在Vertex Shader階段去採樣獲得頂點位置,這樣既不需要骨骼,也沒有蒙皮的消耗。當然在VS裡面採樣紋理需要手機有VTF的支持,要預先加個判斷,如果硬體不支持的話,就只能Fallback到骨骼動畫。不過大部分GLES 2.0的手機也都支持VTF。
在遊戲中,小兵的頂點數小於1024,所以使用一張512*512的紋理分成兩列來儲存動畫,可以儲存256幀,每秒24幀的話相當於10秒多,保證足夠使用。同時,為了讓動畫的幀與幀之間平滑過渡,避免採樣的時候可能採樣不到關鍵幀,把紋理的Filter Mode要設為Bilinear。
還有一個最大的問題在於浮點精度,原來一個頂點的位置數據是X、Y、Z三個浮點數,顯然遊戲不可能存儲這麼高精度的數據,帶寬也吃不消。於是採用RGBM的方式,將頂點位置壓縮到一張RGBA32的紋理中。從實際表現來看,損失的精度在可接受的範圍內。在這下圖裡左邊的是骨骼動畫的一幀,右邊的是對應的key Frame Animation的一幀,確實有一些精度損失,但是影響不大,特別在手機屏幕較小的情況下。
去掉了骨骼動畫,團隊在Update里去計算當前的動畫時間,換算成動畫幀,在渲染的時候把幀作為材質參數傳進去。
另外一個問題是,很多時候骨骼不僅僅是用來做動畫計算,還作為綁點使用,比如我們射擊的時候,要取得槍口位置,然後在槍口位置生成子彈發射出去,上述方式因為去掉了骨骼,需要一個替代方案。於是在烘培頂點位置的時候把骨骼的關鍵位置同時記錄下來,基本上是一個數組,然後遊戲中需要的時候使用三次施密特曲線來做擬合。這樣就可以大致地還原骨骼的位置,對原有的邏輯不產生影響。
在Shader部分,我分享一段Vertex Shader代碼。這裡主要就是生成UV,然後根據UV到預先烘培好的紋理中去採樣頂點位置。這裡需要知道頂點序號,由於SV_VertexID要到OpenGL ES3.0才有,而VTF雖然不是2.0的標準,但很多2.0的機器都支持了,所以考慮低端設備兼容,把頂點序號存在UV2中。然後根據傳入的動畫幀來決定採樣動畫紋理的U,而V是根據頂點序號來得出。這樣就能得到這一幀的頂點位置。另外除了頂點紋理,團隊還烘培了一張法線紋理,以便於計算光照,法線紋理沒有使用True Color而是RGB壓縮。如果遊戲里小兵不使用動作融合,這裡只傳了一個動畫幀即可,如果使用動作融合,需要傳進一個Float4,包含兩個Frame和權重,然後分別進行採樣再插值,所以這種方法其實是兼容動作融合的。
Nvidia在GDC2003有一篇Batch,Batch,Batch,尤其對MOBA這種CPU負擔重的遊戲而言,降低DrawCall把物件Batch起來就顯得很重要。所以對於OpenGL ES3.0以上的機器來說,既然已經去掉了骨骼,很自然地就要使用GPU Instancing。在物件少的時候GPU Instancing的性能相比起來並沒有優勢,但是物件多了以後,它的性能曲線更平滑。《小米超神》中,小兵分為近戰兵、遠程兵、投石車這三類。因為紅藍雙方小兵的角色模型一致,但紋理不同,為了相同Mesh只用一個Batch來渲染,我們把紋理壓成一張,藍方在左邊,紅方在右邊,使用UV偏移來採樣。那麼在Instancing的時候就需要把動畫幀、UV偏移、顏色等信息作為數組傳入。
這是加了GPU Instancing以後的Vertex Shader,就是對傳入的屬性加了宏,其它不變。
最後可以看一下效果,在用了Key-Frame Animation和GPU Instancing之後,分別把近戰兵,遠程兵和投石車Batch到一起,角色數量最多的小兵就只有3個Batch。不過在使用上也需要衡量,不是所有角色都適合Key-Frame Animation的方式,因為需要額外烘培RGBA的貼圖,一般來說使用在頂點數少,精度要求不那麼高而數量又多的角色上。
以上就是我本次分享的技術內容,基於《小米超神》研發過程中遇到的問題和解決方法的復盤,希望能對大家的研發有所參考。謝謝大家!
推薦閱讀: