優化移動遊戲效能 | 來自Unity頂級工程師的效能分析、記憶體與程式碼架構小貼士

遊資網 發表於 2021-07-08
Unity
Unity Accelerate Solution 團隊對 Unity 引擎的原始碼瞭如指掌,可幫助客戶們最大限度地利用引擎。團隊的日常工作包括深入剖析客戶專案,搜尋其在速度、穩定性與效率方面有待優化的部分。本次,我們請到了這支 Unity 最為資深的軟體工程師團隊來分享一些移動遊戲優化方面的專業知識。

他們分享了非常多的錦囊妙計,以至於一篇博文很難涵蓋所有內容。因此,我們將推出一個博文系列。作為此係列的首篇文章,我們將著重介紹怎樣藉助效能分析、記憶體優化和程式碼架構來提高遊戲的效能。在未來的幾周內,我們將再發表兩篇文章:一篇討論 UI Physics,另一篇討論音訊和資源、專案配置和圖形。

話不多說,直接開講!

效能分析

優化工作的第一個步驟便是通過效能分析來收集效能資料,這也是移動端優化的第一步。
我們要儘早在目標裝置上進行效能分析,而且要經常分析。

Unity Profiler 可提供應用關鍵的效能資訊,因此是優化必不可少的一部分。儘早對專案進行效能分析,不要拖到發售前。對每一個故障或效能尖峰徹查到底。對你自己的專案效能有一個清晰的認知,可幫助你更輕鬆地發現新問題。

Unity 編輯器內的效能分析可以揭示出遊戲不同系統的相對效能,而在執行裝置上進行分析可讓你獲取更為準確的效能洞察。經常性地在目標裝置上分析開發版。同時為最高配置與最低配置的裝置進行效能分析和優化。

除了 Unity Profiler,你還可以使用 iOS 與 Android 的原生工具來進一步測試引擎在平臺上的表現。

  • 比如 iOS 的 Xcode 和 Instruments
  • 以及 Android 上的 Android Studio 和 Android Profiler

部分硬體更是帶有額外的分析工具(例如 Arm Mobile Studio、Intel VTune 以及 Snapdragon Profiler)。

Unity Profiler:

https://docs.unity3d.com/Manual/Profiler.html

Xcode:

https://developer.apple.com/documentation/xcode/

Instruments:

https://help.apple.com/instruments/mac/current/#/dev7b09c84f5

Android Studio:

https://developer.android.com/studio/intro

Android Profiler:

https://developer.android.com/studio/profile/android-profiler

Arm Mobile Studio:

https://developer.arm.com/tools-and-software/graphics-and-gaming/arm-mobile-studio

Intel VTune:

https://software.intel.com/content/www/us/en/develop/documentation/vtune-help/top.html

Snapdragon Profiler:

https://developer.qualcomm.com/software/snapdragon-profiler

針對性優化

如果遊戲出現效能問題,切忌自行猜測或揣測成因,一定要使用 Unity Profiler 和平臺專屬工具來準確找出卡頓的問題來源。

不過,這裡所說的優化並不都適用於你的應用。在某個專案中適用的方法不一定適用於你的專案。找出真正的效能瓶頸,將精力集中在有實際效用的地方。

瞭解 Unity Profiler 工作原理

Unity Profiler 可幫助你在執行時檢測出卡頓或當機的原因,更好地瞭解特定幀或時間點上發生了什麼。工具預設啟用 CPU 和記憶體監測軌,你也可以根據需要啟用額外的分析模組,包括渲染器、音訊和物理(如極度依賴物理模擬的遊戲或音遊)。

優化移動遊戲效能 | 來自Unity頂級工程師的效能分析、記憶體與程式碼架構小貼士
或使用Unity Profiler來測試應用程式的效能和資源分配

勾選 Development Build 便能為目標裝置構建應用,勾選 Autoconnect Profiler 或者手動關聯分析器,來加快其啟動時間。

優化移動遊戲效能 | 來自Unity頂級工程師的效能分析、記憶體與程式碼架構小貼士

選中需要分析的目標平臺。按下 Record(錄製)按鈕可記錄應用在幾秒鐘內的執行(預設為300幀)。開啟 Unity > Preferences > Analysis > Profiler > Frame Count 介面可修改錄製幀數,最長錄製幀數可以增加到 2000幀。當然更長的錄製幀數會讓 Unity 編輯器佔用更多的 CPU 資源和記憶體,但其在特定情形下的作用非常大。

該分析器採用標記框架,可分析以 ProfileMarkers(如MonoBehaviour的Start或Update方法,或特定API呼叫)劃分出的程式碼執行時。在使用 Deep Profiling 時,Unity 可以分析出每次函式呼叫的開始與結尾,準確地呈現出導致應用效能放緩的程式碼部分。

ProfileMarkers:

https://docs.unity.cn/ScriptReference/Unity.Profiling.ProfilerMarker.html

Deep Profiling:

https://docs.unity.cn/cn/current/Manual/ProfilerWindow.html

優化移動遊戲效能 | 來自Unity頂級工程師的效能分析、記憶體與程式碼架構小貼士
你可以藉助Timeline檢視來明確應用最為依賴的是CPU還是GPU

在分析遊戲時,我們建議同時分析效能高峰與幀平均成本。在分析幀率過低的應用時,較為有效的方法是分析並優化每一幀中執行成本較高的程式碼。在尖峰處首先分析繁重的運算(如物理、AI、動畫)和垃圾資料收集。

點選視窗中的某幀,接著使用 Timeline 或 Hierarchy 檢視進行分析:

  • 圖片 Timeline 可顯示特定幀耗時的視覺化圖表,幫助你直觀地看到各項活動以及不同執行緒之間的關係。你可使用該選項來了解專案主要依賴的是 CPU 還是 GPU。
  • Hierarchy 將顯示分組的 ProfileMarkers 層級,並以毫秒(Time ms'總耗時'和Self ms‘自執行耗時’)為單位對樣本進行排序。你還可以數出幀上函式的 Calls 呼叫以及記憶體清理(GC Alloc)的次數。

優化移動遊戲效能 | 來自Unity頂級工程師的效能分析、記憶體與程式碼架構小貼士
Hierarchy檢視允許按照耗時長短對ProfileMarkers進行排序

注意,在優化任意專案之前,一定要儲存 Profiler 的 .data 檔案,這樣你就能在修改後比較優化前後的不同了。剖析、優化和比較,清空再重複,如此迴圈往復來提高效能。

Profiler Analyzer

該工具可以彙總多幀 Profiler 資料,由使用者來挑選出那些問題較大的幀。如果你想了解專案更改後 Profiler 的相應改變,可使用 Compare 檢視分別載入和比較兩個資料集,從而完成測試與優化。Profile Analyzer 可在 Unity Package Manager 中下載。

Profile Analyzer:

https://docs.unity3d.com/Packages/[email protected]/manual/index.html

優化移動遊戲效能 | 來自Unity頂級工程師的效能分析、記憶體與程式碼架構小貼士
Profiler Analyzer可以很好地補充Profiler,可以進一步深入分析幀與標記資料

為每幀設定一個時間預算

你可以設立一個目標幀率,為每幀劃定一個時間預算。理想情況下,一個以 30 fps 執行的應用每幀應占有約 33.33 毫秒(1000毫秒/30幀)。同樣地,60 fps 每幀約為 16.66 毫秒。

裝置可以在短時間內超過預算(如過場動畫或載入過程中),但絕不能長時間如此。

裝置溫度優化

對於移動裝置而言,長時間佔用最大時間預算可能會導致裝置過熱,作業系統可能會啟動 CPU 與 GPU 降頻保護。我們建議每幀僅佔用約 65% 的時間預算,保留一定的散熱時間。常見的幀預算為:30 fps 為每幀 22 毫秒,60 fps 為每幀 11 毫秒。

大多數移動裝置不像桌面裝置那樣有主動散熱功能,因此環境溫度可以直接影響效能。

如果裝置發熱嚴重,Profiler 可能會察覺並彙報這塊效能低下的部分,即使其只是暫時性問題。為了應對分析時裝置過熱,分析應分成小段進行。這樣便能允許裝置散熱、模擬出真實的執行條件。我們的建議是,在進行效能分析前後,預留 10-15 分鐘用於裝置散熱。

分清 GPU 與 CPU 依賴程度

Profiler 可在 CPU 耗時或 GPU 耗時超出幀預算髮出警告,它將彈出下方以 Gfx 為字首的標記:

  • Gfx.WaitForCommands 標記表示渲染執行緒正在等待主執行緒完成,後者可能出現了效能瓶頸。
  • 而 Gfx.WaitForPresent 表示主執行緒正在等待 GPU 遞交渲染幀。

記憶體分析

Unity 會採取自動化記憶體管理來處理由使用者生成的程式碼與指令碼。值型別本地變數等小型資料會被分配到記憶體堆疊中,大型資料和永續性儲存資料則會被分配到託管記憶體中。

垃圾資料收集器會定期識別並刪除未被使用的託管記憶體,這個自動流程在檢查堆的物件時可能導致遊戲卡頓或執行放緩。

這裡,優化記憶體便是指關注託管記憶體的分配與刪除時機,將記憶體垃圾回收的影響降到最低。詳情請在 Understanding the managed heap 中瞭解。

Understanding the managed heap:

https://docs.unity.cn/cn/current/Manual/BestPracticeUnderstandingPerformanceInUnity4-1.html

優化移動遊戲效能 | 來自Unity頂級工程師的效能分析、記憶體與程式碼架構小貼士
Memory Profiler中的幀資料記錄、檢視與比較

Memory Profiler

Memory Profiler 屬於一個獨立的分析模組,可以擷取託管資料堆記憶體的狀態,幫助你識別出資料碎片化和記憶體洩漏等問題。

在 Tree Map 檢視中點選一個變數便可跟蹤其在記憶體原生物件上的狀態。你可在此處找出由紋理過大或資源重複載入而導致的常見記憶體消耗問題。

通過以下連結瞭解如何使用 Unity 的 Memory Profiler 優化記憶體佔用。

Memory Profiler:

https://docs.unity3d.com/Packages/[email protected]/manual/index.html

降低記憶體垃圾回收(GC)對效能的影響

Unity 使用的是 Boehm-Demers-Weiser 垃圾回收器 ,它會中止主執行緒程式碼執行,在垃圾回收工作完成後再讓其恢復執行。

請注意,部分多餘的託管記憶體分配會造成 GC 耗能高峰:

  • Strings(字串):在 C# 中,字串屬於引用型別,而非值型別。我們需要減少不必要的字串建立或更改操作,儘量避免解析 JSON 和 XML 等由字串組成的資料檔案,將資料儲存於 ScriptableObjects,或以 MessagePack 或 Protobuf 等格式儲存。如果你需要在執行時構建字串,可使用 StringBuilder 類。
  • Unity 函式呼叫:部分函式會涉及託管記憶體分配。我們需要快取陣列引用,避免在迴圈進行中進行陣列的記憶體分配,且儘量使用那些不會產生垃圾回收的函式。比如使用 GameObject.CompareTag,而不是使用 GameObject.tag 手動比對字串(因為返回一個新字串會產生垃圾資料)。
  • Boxing(打包):避免在引用型別變數處傳入值型別變數,因為這樣做會導致系統建立一個臨時物件,在背地裡將值型別轉換為物件型別(如int i = 123; object o = i ),從而產生垃圾回收的需求。儘量使用正確的型別覆寫來傳入想要的值型別。泛型也可用於型別覆寫。
  • Coroutines(協同程式):雖然 yield 不會產生垃圾回收,但新建 WaitForSeconds 物件會。我們可以快取並複用 WaitForSeconds 物件,不必在 yield 中再度建立。
  • LINQ 與 Regular Expressions(正規表示式):這兩種方法都會在後臺的資料打包期間產生垃圾回收。如果需要追求效能,請儘量避免使用 LINQ 和正規表示式,轉而使用 for 迴圈和列表來建立陣列。

Boehm-Demers-Weiser 垃圾回收器:

https://www.hboehm.info/gc/

定時處理垃圾回收

如果你確定垃圾回收帶來的卡頓不會影響遊戲特定階段的體驗,你可以使用 System.GC.Collect 來啟動垃圾資料收集。

請在 Understanding Automatic Memory Management(自動化記憶體管理)中瞭解怎樣妥善地使用這項功能。

Understanding Automatic Memory Management:

https://docs.unity.cn/cn/current/Manual/UnderstandingAutomaticMemoryManagement.html

使用增量式垃圾回收(Incremental GC)分散垃圾回收

增量式垃圾回收不會在程式執行期間長時間地中斷執行,而會將總負荷分散到多幀,形成零碎的收集流程。如果垃圾資料收集對效能產生了較大的影響,可以嘗試啟用這個選項來降低 GC 的處理高峰。你可以使用 Profile Analyzer 來檢驗此功能的實際作用。

優化移動遊戲效能 | 來自Unity頂級工程師的效能分析、記憶體與程式碼架構小貼士
使用增量垃圾回收來降低GC處理高峰

程式設計和程式碼架構

Unity 的 PlayerLoop 包含許多可與引擎核心互動的函式。該結構包含一些負責初始化和每幀更新的系統,所有指令碼都將依靠 PlayerLoop 來生成遊戲體驗。

在分析時,你會在 PlayerLoop 下看到使用者使用的程式碼(Editor程式碼則位於EditorLoop下)。

優化移動遊戲效能 | 來自Unity頂級工程師的效能分析、記憶體與程式碼架構小貼士
Profiler將顯示在整個引擎執行過程中的自定義指令碼、設定和圖形

優化移動遊戲效能 | 來自Unity頂級工程師的效能分析、記憶體與程式碼架構小貼士

通過以下連結瞭解 PlayerLoop 和 指令碼生命週期 。

PlayerLoop:

https://docs.unity.cn/ScriptReference/LowLevel.PlayerLoop.html

指令碼生命週期:

https://docs.unity.cn/cn/2020.3/Manual/ExecutionOrder.html

你可以使用以下技巧和竅門來優化指令碼。

深入理解 Unity PlayerLoop

我們需要掌握 Unity 幀迴圈的執行順序 。每個 Unity 指令碼都會按照預定的順序執行事件函式,這要求我們瞭解 Awake、Start、Update 以及其他執行週期相關函式之間的區別。

請在 Script Lifecycle Flowchart(指令碼生命週期流程圖)中瞭解函式的執行順序。

Script Lifecycle Flowchart:

https://docs.unity.cn/cn/2020.3/Manual/ExecutionOrder.html

降低每幀的程式碼量

有許多程式碼並非要在每幀上執行,這些不必要的邏輯完全可以在 Update、LateUpdate 和 FixedUpdate 中刪去。這些事件函式可以儲存那些必須每幀更新的程式碼,任何無須每幀更新的邏輯都不必放入其中,只有在相關事物發生變化時,這些邏輯才需被執行。

如果必須要使用 Update,可以考慮讓程式碼每隔 n 幀執行一次。這種劃分執行時間的方法也是一種將繁重工作負荷化整為零的常見技術。在下方例子中,ExampleExpensiveFunction 將每隔三幀執行一次。

  1. private int interval = 3;

  2. void Update()
  3. {
  4.     if (Time.frameCount % interval == 0)
  5.     {
  6.         ExampleExpensiveFunction();
  7.     }
  8. }
複製程式碼

避免在 Start/Awake 中加入繁重的邏輯

當首個場景載入時,每個物件都會呼叫如下函式:

  • Awake
  • OnEnable
  • Start

在應用完成第一幀的渲染前,我們須避免在這些函式中執行繁重的邏輯。否則,應用的載入時間會出乎意料地長。

請在 Order of execution for event functions(事件函式的執行順序)中詳細瞭解首個場景的載入。

Order of execution for event functions:

https://docs.unity.cn/cn/2020.3/Manual/ExecutionOrder.html

避免加入空事件

即使是空的 MonoBehaviours 也會佔用資源,因此我們應該刪除空的 Update 及 LateUpdate 方法。

如果你想用這些方法進行測試,請使用預處理指令(preprocessor directives):

  1. #if UNITY_EDITOR
  2. void Update()
  3. {
  4. }
  5. #endif
複製程式碼

如此一來,在編輯器中的 Update 測試便不會對構建版本造成不良的效能影響。

刪去 Debug Log 語句

Log 宣告(尤其是在Update、LateUpdate及FixedUpdate中)會拖慢效能,因此我們需要在構建之前禁用 Log 語句。

你可以用預處理指令編寫一條 Conditional 屬性來輕鬆禁用 Debug Log。比如下方這種的自定義類:

Conditional 屬性:

https://docs.microsoft.com/en-us/dotnet/api/system.diagnostics.conditionalattribute?view=net-5.0

  1. public static class Logging
  2. {
  3.     [System.Diagnostics.Conditional("ENABLE_LOG")]
  4.     static public void Log(object message)
  5.     {
  6.         UnityEngine.Debug.Log(message);
  7.     }
  8. }
複製程式碼

優化移動遊戲效能 | 來自Unity頂級工程師的效能分析、記憶體與程式碼架構小貼士
新增自定義預處理指令可以實現指令碼的切分

用自定義類生成 Log 資訊時,你只需在 Player Settings 中禁用 ENABLE_LOG  預處理指令,所有的 Log 語句便會一下子消失。

使用雜湊值、避免字串

Unity 底層程式碼不會使用字串來訪問 Animator、Material 和 Shader 屬性。出於提高效率的考慮,所有屬性名稱都會被雜湊轉換成屬性 ID,用作實際的屬性名稱。

在 Animator、Material 或 Shader 上使用 Set 或 Get 方法時,我們便可以利用整數值而非字串。後者還需經過一次雜湊處理,並沒有整數值那麼直接。

使用 Animator.StringToHash 來轉換 Animator 屬性名稱,用 Shader.PropertyToID 來轉換 Material 和 Shader 屬性名稱。

Animator.StringToHash:

https://docs.unity.cn/ScriptReference/Animator.StringToHash.html

Shader.PropertyToID:

https://docs.unity.cn/ScriptReference/Shader.PropertyToID.html

選擇正確的資料結構

由於資料結構每幀可能會迭代上千次,因此其結構對效能有著較大的影響。如果你不清楚資料集合該用 List、Array 還是 Dictionary 表示,可以參考 C# 的 MSDN 資料結構指南來選擇正確的結構。

MSDN 資料結構指南:

https://docs.microsoft.com/en-us/dotnet/standard/collections/?redirectedfrom=MSDN

避免在執行時新增元件

在執行時呼叫 AddComponent 會佔用一定的執行成本,Unity 必須檢查元件是否有重複或依賴項。

當元件已經配置完成,Instantiating a Prefab(例項化預製件)一般來說效能更強。

Instantiating a Prefab:

https://docs.unity.cn/cn/current/Manual/Prefabs.html

快取 GameObjects 和元件

呼叫 GameObject.Find、GameObject.GetComponent 和 Camera.main(2020.2以下的版本)會產生較大的執行負擔,因此這些方法不適合在 Update 中呼叫,而應在 Start 中呼叫並快取。

下方例子展示了一種低效率的 GetComponent 多次呼叫:

  1. void Update()
  2. {
  3.     Renderer myRenderer = GetComponent<Renderer>();
  4.     ExampleFunction(myRenderer);
  5. }
複製程式碼

其實 GetComponent 的結果會被快取,因此只需呼叫一次即可。快取的結果完全可在 Update 中重複使用,不必再度呼叫 GetComponent。


  1. private Renderer myRenderer;

  2. void Start()
  3. {
  4.     myRenderer = GetComponent<Renderer>();
  5. }

  6. void Update()
  7. {
  8.     ExampleFunction(myRenderer);
  9. }
複製程式碼

物件池(Object Pool)

Instantiate(例項化)和 Destroy(銷燬)方法會產生需要垃圾回收資料、引發垃圾回收(GC)的處理高峰,且其執行較為緩慢。與其經常性地例項化和銷燬 GameObjects(如射出的子彈),不如使用物件池將物件預先儲存,再重複地使用和回收。

物件池:

https://en.wikipedia.org/wiki/Object_pool_pattern

優化移動遊戲效能 | 來自Unity頂級工程師的效能分析、記憶體與程式碼架構小貼士
在這個例子中,ObjectPool建立了20個PlayerLaser例項供重複使用

在遊戲特定時間點(如顯示選單畫面時)建立可複用的例項,來降低 CPU 處理高峰的影響,再用一個集合來形成“物件池”。在遊戲期間,例項可在需要時啟用/禁用,用完後可返回到池中,不必再進行銷燬。

優化移動遊戲效能 | 來自Unity頂級工程師的效能分析、記憶體與程式碼架構小貼士
PlayerLaser物件池目前尚未啟用,正等待玩家射擊

這一來你就可以減少託管記憶體分配的次數、防止產生垃圾回收的問題。

使用 ScriptableObjects(可程式設計物件)

固定不變的值或配置資訊可以儲存在 ScriptableObject 中,不一定得儲存於 MonoBehaviour。ScriptableObject 可由整個專案訪問,一次設定便可應用於專案全域性,但它並不能直接關聯到 GameObject 上。

我們可在 ScriptableObject 中用欄位來儲存值或設定,然後在 MonoBehaviours 中引用該物件。

優化移動遊戲效能 | 來自Unity頂級工程師的效能分析、記憶體與程式碼架構小貼士
用作“Inventory(物品欄)”的ScriptableObject可儲存多個遊戲物件的設定

下方的 ScriptableObject 欄位可有效防止多次 MonoBehaviour 例項化產生的資料重複。

請參考 ScriptableObjects 文件瞭解如何使用。

ScriptableObjects:

https://docs.unity.cn/cn/current/Manual/class-ScriptableObject.html

來源:Unity官方平臺
原文:https://mp.weixin.qq.com/s/XNxa0oeW25R_mwCgKWp11w