記憶體最佳化——
“勿以善小而不為,勿以惡小而為之”
資源記憶體佔用
一、紋理資源
紋理資源可以說是幾乎所有遊戲專案中佔據最大記憶體開銷的資源。一個6萬面片的場景,網格資源最大才不過10MB,但一個2048x2048的紋理,可能直接就達到16MB。因此,專案中紋理資源的使用是否得當會極大地影響專案的記憶體佔用。
那麼,紋理資源在使用時應該注意哪些地方呢?
(1) 紋理格式
紋理格式是研發團隊最需要關注的紋理屬性。因為它不僅影響著紋理的記憶體佔用,同時還決定了紋理的載入效率。一般來說,我們建議開發團隊儘可能根據硬體的種類選擇硬體支援的紋理格式,比如Android平臺的ETC、iOS平臺的PVRTC、Windows PC上的DXT等等。
在使用硬體支援的紋理格式時,你可能會遇到以下幾個問題:
色階問題
由於ETC、PVRTC等格式均為有失真壓縮,因此,當紋理色差範圍跨度較大時,均不可避免地造成不同程度的“階梯”狀的色階問題。因此,很多研發團隊使用RGBA32/ARGB32格式來實現更好的效果。但是,這種做法將造成很大的記憶體佔用。比如,同樣一張1024x1024的紋理,如果不開啟Mipmap,並且為PVRTC格式,則其記憶體佔用為512KB,而如果轉換為RGBA32位,則很可能佔用達到4MB。所以,研發團隊在使用RGBA32或ARGB32格式的紋理時,一定要慎重考慮,更為明智的選擇是儘量減少紋理的色差範圍,使其儘可能使用硬體支援的壓縮格式進行儲存。
ETC1 不支援透明通道問題
在Android平臺上,對於使用OpenGL ES 2.0的裝置,其紋理格式僅能支援ETC1格式,該格式有個較為嚴重的問題,即不支援Alpha透明通道,使得透明貼圖無法直接透過ETC1格式來進行儲存。對此,我們建議研發團隊將透明貼圖儘可能分拆成兩張,即一張RGB24位紋理記錄原始紋理的顏色部分和一張Alpha8紋理記錄原始紋理的透明通道部分。然後,將這兩張貼圖分別轉化為ETC1格式的紋理,並透過特定的Shader來進行渲染,從而來達到支援透明貼圖的效果。該種方法不僅可以極大程度上逼近RGBA透明貼圖的渲染效果,同時還可以降低紋理的記憶體佔用,是我們非常推薦的使用方式。
(2)紋理尺寸
一般來說,紋理尺寸越大,則記憶體佔用越大。所以,儘可能降低紋理尺寸,如果512x512的紋理對於顯示效果已經夠用,那麼就不要使用1024x1024的紋理,因為後者的記憶體佔用是前者的四倍。
(3) Mipmap功能
Mipmap旨在有效降低渲染頻寬的壓力,提升遊戲的渲染效率。但是,開啟Mipmap會將紋理記憶體提升1.33倍。對於具有較大縱深感的3D遊戲來說,3D場景模型和角色我們一般是建議開啟Mipmap功能的,但是在我們的測評專案中,經常會發現部分UI紋理也開啟了Mipmap功能。這其實就沒有必要的,絕大多數UI均是渲染在螢幕最上層,開啟Mipmap並不會提升渲染效率,反倒會增加無謂的記憶體佔用。
(4) Read & Write
一般情況下,紋理資源的“Read & Write”功能在Unity引擎中是預設關閉的。但是,我們仍然在專案深度最佳化時發現了不少專案的紋理資源會開啟該選項。對此,我們建議研發團隊密切關注紋理資源中該選項的使用,因為開啟該選項將會使紋理記憶體增大一倍。
二、網格
網格資源在較為複雜的遊戲中,往往佔據較高的記憶體。
引擎模組自身佔用
引擎自身中存在記憶體開銷的部分紛繁複雜,可以說是由巨量的“微小”記憶體所累積起來的,比如GameObject及其各種Component(最大量的Component應該算是Transform了)、ParticleSystem、MonoScript以及各種各樣的模組Manager(SceneManager、CanvasManager、PersistentManager等)...
上面所指出的引擎各組成部分的記憶體開銷均比較小,真正佔據較大記憶體開銷的是這兩處:WebStream 和 SerializedFile。
其絕大部分的記憶體分配則是由AssetBundle載入資源所致。簡單言之,當您使用new WWW或CreateFromMemory來載入AssetBundle時,Unity引擎會載入原始資料到記憶體中並對其進行解壓,而WebStream的大小則是AssetBundle原始檔案大小 + 解壓後的資料大小 + DecompressionBuffer(0.5MB)。
當專案中存在透過new WWW載入多個AssetBundle檔案,且AssetBundle又無法及時釋放時,WebStream的記憶體可能會很大,這是研發團隊需要時刻關注的。
對於SerializedFile,則是當你使用LoadFromCacheOrDownload、CreateFromFile或new WWW本地AssetBundle檔案時產生的序列化檔案。
對於WebStream和SerializedFile,你需要關注以下兩點:
- 是否存在AssetBundle沒有被清理乾淨的情況。開發團隊可以透過Unity Profiler直接檢視其使用具體的使用情況,並確定Take Sample時AssetBundle的存在是否合理;
- 對於佔用WebStream較大的AssetBundle檔案(如UI Atlas相關的AssetBundle檔案等),建議使用LoadFromCacheOrDownLoad或CreateFromFile來進行替換,即將解壓後的AssetBundle資料儲存於本地Cache中進行使用。這種做法非常適合於記憶體特別吃緊的專案,即透過本地的磁碟空間來換取記憶體空間。
託管堆記憶體佔用
對於目前絕大多數基於Unity引擎開發的專案而言,其託管堆記憶體是由Mono分配和管理的。“託管” 的本意是Mono可以自動地改變堆的大小來適應你所需要的記憶體,並且適時地呼叫垃圾回收(Garbage Collection)操作來釋放已經不需要的記憶體,從而降低開發人員在程式碼記憶體管理方面的門檻。
但是這並不意味著研發團隊可以在程式碼中肆無忌憚地開闢託管堆記憶體,因為目前Unity所使用的Mono版本存在一個很嚴重的問題,即:Mono的堆記憶體一旦分配,就不會返還給系統。這意味著Mono的堆記憶體是隻升不降的。
專案執行時,在場景A中開闢了60MB的託管堆記憶體,而到下一場景B時,只需要使用20MB的託管堆記憶體,那麼Mono中將會存在40MB空閒的堆記憶體,且不會返還給系統。這是我們非常不願意看到的現象,因為對於遊戲(特別是移動遊戲)來說,記憶體的佔用可謂是寸土寸金的,讓Mono毫無必要地鎖住大量的記憶體,是一件非常浪費的事情。
不必要的堆記憶體分配主要來自於以下幾個方面:
1.高頻率地 New Class/Container/Array等。研發團隊切記不要在Update、FixUpdate或較高呼叫頻率的函式中開闢堆記憶體,這會對你的專案記憶體和效能均造成非常大的傷害。做個簡單的計算,假設你的專案中某一函式每一幀只分配100B的堆記憶體,幀率是1秒30幀,那麼1秒鐘遊戲的堆記憶體分配則是3KB,1分鐘的堆記憶體分配就是180KB,10分鐘後就已經分配了1.8MB。如果你有10個這樣的函式,那麼10分鐘後,堆記憶體的分配就是18MB,這期間,它可能會造成Mono的堆記憶體峰值升高,同時又可能引起了多次GC的呼叫。在我們的測評專案中,一個函式在10分鐘內分配上百MB的情況比比皆是,有時候甚至會分配上GB的堆記憶體。
2.Log輸出。我們發現在大量的專案中,仍然存在大量Log輸出的情況。建議研發團隊對自身Log的輸出進行嚴格的控制,僅保留關鍵Log,以避免不必要的堆記憶體分配。
3.UIPanel.LateUpdate。這是NGUI中CPU和堆記憶體開銷最大的函式。它本身只是一個函式,但NGUI的大量使用使它逐漸成為了一個不可忽視規則。該函式的堆記憶體分配和自身CPU開銷,其根源上是一致的,即是由UI網格的重建造成。
4.關於程式碼堆記憶體分配的注意點還有很多,比如String連線、部分引擎API(GetComponent)的使用等等
記憶體標準:
1.150MB總體記憶體標準
2.分配
紋理資源50M
網格資源20M
動畫片段15M
音訊片段15M
Mono記憶體40M
其他10M
這裡未包含較為複雜的字型檔案及TextAsset
記憶體洩露和資源冗餘
造成記憶體不能完全回落的情況有很多,比如資源載入後常駐記憶體以備後續使用、Mono堆記憶體的只升不降等等,這些均可造成記憶體無法完全回落。一般來說,我們推薦的判斷記憶體是否洩漏的方法如下:
一、檢查資源的使用情況,特別是紋理、網格等資源的使用
資源洩漏是記憶體洩露的主要表現形式,其具體原因是使用者對載入後的資源進行了儲存(比如放到Container中),但在場景切換時並沒有將其Remove或Clear,從而無論是引擎本身還是手動呼叫Resources.UnloadUnusedAssets等相關API均無法對其進行解除安裝,進而造成了資源洩露。
二、透過Profiler來檢測WebStream或SerializedFile的使用情況
AssetBundle的管理不當也會造成一定的記憶體洩露,即上一場景中使用的AssetBundle在場景切換時沒有被解除安裝掉,而被帶入到了下一場場景中。對於這種情況,建議直接透過Profiler Memory中的Take Sample來對其進行檢測,透過直接檢視WebStream或SerializedFile中的AssetBundle名稱,即可判斷是否存在“洩露”情況。
三、透過Android PSS/iOS Instrument反饋的App執行緒記憶體來檢視
無效的Mono堆記憶體開銷
目前,Unity所使用的Mono版本中存在一個較大的問題,即記憶體一旦分配,則不會再返回給系統。這就衍生出另外一個問題—— 無效的Mono堆記憶體。它是Mono所分配的堆記憶體,但卻沒有被真正利用上,因此稱之為“無效”。
如何避免或減少過多“無效堆記憶體”的分配
避免一次性堆記憶體的過大分配。Mono的堆記憶體也是“按需”逐步進行分配的。但如果一次性開闢過大堆記憶體,比如New一個較大Container、載入一個過大配置檔案等,則勢必會造成Mono的堆記憶體直接衝高,所以研發團隊對堆記憶體的分配需要時刻注意
避免不必要的堆記憶體開銷。
資源冗餘
“資源冗餘”,是指在某一時刻記憶體中存在兩份甚至多份同樣的資源。導致這種情況的出現主要有兩種原因:
一、AssetBundle打包機制出現問題
同一份資源被打入到多份AssetBundle檔案中。舉個例子,同一張紋理被不同的NPC所使用,同時每個NPC被製作成獨立的AssetBundle檔案,那麼在沒有針對紋理進行依賴打包的前提下,就會出現該張紋理出現在不同的NPC AssetBundle檔案中。當這些AssetBundle先後被載入到記憶體後,記憶體中即會出現紋理資源冗餘的情況。對此,我們建議研發團隊在發現資源冗餘問題後,對相關AssetBundle的製作流程一定要進行檢查。
二、資源的例項化所致
在Unity引擎中,當我們修改了一些特定GameObject的資源屬性時,引擎會為該GameObject自動例項化一份資源供其使用,比如Material、Mesh等。
過多的冗餘資源卻為Resources.UnloadUnusedAssets API的呼叫效率增加了相當大的壓力。