解決 Unity 的遊戲停頓或 lag 的可能方案

Unity 在手機或掌機等效能有限的平台上,如果沒有進行優化,當遊戲複雜到一定的程度就無法保證幀數可以穩定,開始出現各種卡頓,影響玩家體驗。 一般出現卡頓,都可以依照原因對症下藥、進行優化,盡量增加玩家的優良體驗,以及減少對設備的負擔。因為在社群上參與了些討論,就想將 Coroutine 跟非同步這兩個在 Unity 中常用的運算優化手段做個紀錄。 **這篇文章是單純的想法心得與資料閱讀筆記,沒有舉出實作的驗證。

遊戲可能的卡頓原因

遊戲會卡頓的原因百百種,基本上開啟 profiler 分析原因之後再進行優化才能精準的解決問題。不過在卡頓出現之前、尚在開發階段時,便就預期可能造成效能問題的部分,提早進行處理則再好不過了。 其中一種造成卡頓的原因,是 MonoBehaviour 無法在一幀的時間之內完成運算工作,導致幀與幀之間的時間拉長、FPS 下降,其中常見的細部原因又可以分成:

  • 過多的邏輯運算在 Update() 之中,導致 CPU 花了太多時間,才將流程交棒給畫面更新。
  • 從儲存設備取用資料花上過多時間,導致邏輯運算浪費許多時間在等待資源。如場景切換、Prefab 讀取。
  • 某個邏輯運算的步驟需要其他流程先完成,所以花了許多時間在等待。如等待網路回應、接收伺服器資料。
  • CPU 跟記憶體 IO 被大量其他運算占用,導致 MonoBehaviour 暫時停擺。如同時大量的 GC 產生。
  • 其他。

上面提到的前三個原因,在開發階段較容易察覺,提早進行卡頓的預防;第四個或者其他未知的原因,則可能到了開發後期才會逐漸凸顯。

針對過多邏輯運算或等待的可能解決方法

有一定的 Unity 開發經驗後,會發現某些特定的動作特別容易造成卡頓。則可以使用各種方法,預防與降低卡頓出現的可能,其中常常使用的便是 協程(Coroutine) 以及 非同步設計(Asynchronous Programming)

協程 (Coroutine)

首先,Coroutine 不是多執行緒的一種,所有的運算都還是在主執行緒 (main thread) 中進行,所以並不會降低主執行緒的負擔。Coroutine 的效果是將本來在一幀 (frame,一個 Update) 之間的工作,分散到數個 Update 之中去執行,避開畫面因為等待而卡頓的情形。 在 Unity 之中的 Coroutine 是依附在 MonoBehaviour  之下的,Unity 會在每一次的 Update 流程中去執行 Coroutine 的工作,這點可以在 Unity Script Lifecycle Flowchart 中看到:

不過正因為 Coroutine 依賴著啟用它的 MonoBehaviour,所以當所掛載的物件因為特定原因消失,或者 Component 被關閉或銷毀,都會使 Coroutine 的工作終止甚至遺失。 必要的時候,可以建立一個 DontDestroy GameObject 專門執行 Coroutine,或者使用套件來進行 Coroutine 的派遣:

Coroutine 的各種 yield

Coroutine 的設計中會使用到各種 yield,其中功能也不盡相同:

  • yield return null 下一幀再繼續後續工作。
  • yield return 0 也是下一幀再繼續,不過有額外的 boxing,所以 不要用
  • yield return new WaitForSeconds(float seconds) 從下一幀開始,當經過時間 大於等於 呼叫的秒數後再繼續工作,由於是依賴於 Update 的時間,所以時間的精確度無法信賴。
  • yield return new WaitForEndOfFrame() 可以在 Script Lifecycle Flowchart 看到,其他 yield 都是在 Update() 及 LateUpdate() 之間執行,只有這個是在畫面更新後執行。

非同步設計 (Asynchronous Programming)

非同步設計是真正的 多執行緒的一種。不同於一般多執行續要手動管理執行續的開始與結束,非同步設計是反覆利用 .NET 底層準備好的 Thread Pool 進行工作,因此不需要手動管理執行緒資源的釋放,使用上更為方便彈性。 不過 Unity 對於非同步有限制存在,有些 API 的動作必須在 主執行緒 (main thread) 才能使用,否則會出現 “xxx can only be called from the Main Thread” 的錯誤,在這其中有一些 Thread-safe 的議題存在。例如你無法在非同步的結果回傳中執行 GameObject.Find()。 Unity 提供了許多內建的非同步方法可以直接使用:

  • SceneManager.LoadSceneAsync
  • Resources.LoadAsync
  • AssetBundle.LoadFromFileAsync
  • 其他更多,請自行挖掘官方文件

會有這些內建的非同步方法存在,無非就是這些動作很容易造成遊戲的卡頓甚至停頓,其中大部分是與儲存設備中的資源讀取有關。 而 .NET 中所提供的非同步方法,我只有使用過 TCPClient/Socket 相關的 BeginConnect、BeginReceive、BeginSend 等網路連線相關的方法。 如果加上自行設計非同步方法來使用,更可以將一些需要大量的遊戲邏輯從主執行緒中抽離,對於遊戲的效能優化是另一種選擇。 ** 非同步方法中 Async 與 Begin 關鍵字不只是名稱上不同,更是因為採用了不同的非同步設計模型,這部分我也尚未完全理解透徹,待來日再跟大家分享。

兩個方法的一些小總結

Coroutine 是相當簡單易用的手段,用於分散每個 Update 的負擔,但畢竟負擔還是在主執行續之上,因此不是大量使用就會使效能變好。不過也因為依舊在主執行續上執行工作,所以使用 Unity API 上沒有太大的限制。 Coroutine 有時也可以利用於遊戲邏輯上,讓一些橫跨於數個 Update() 的邏輯 (如計時) 更容易被設計出來。 非同步設計是將工作移出主執行緒來運作,對於遊戲的流暢有相當大的幫助,不過使用上的複雜度較高,也無法使用全部的 Unity API,因此不是任何時候皆適用。

最後

並不是所有的卡頓都可以透過這兩個方法解決,甚至不適當的使用這些方法也會製造卡頓的出現。很多時候遊戲卡頓的原因都是因應每個專案的不同而有不一樣的對應手段,這兩個方法只是在開發初期可以簡單避開許多明顯的效能問題。 通過分析與解明遊戲運作的各個階段與機制,找出最適當的實作方式,才能榨乾硬體設備的效能,做出華麗且體驗流暢的遊戲設計!(雖然手機會因此很燙) 遊戲優化是個深入且有趣的議題,還望這篇文章能帶給大家幫助。 (其實也是擔心查了一堆資料,未來的自己會遺忘細節而做的筆記)