[Unity] 物件池的實現 - Practicing of Object Pool

物件池 (Object Pool),是一個普遍用於遊戲的一個設計模式 (pattern),目的是為了重複使用一些頻繁被取用的資源,減少建立、銷毀的運算消耗,在特定情況下是非常重要的優化手段。

我這邊會試著從硬體原理開始,說明物件池的使用。

先說說硬體

所謂的遊戲程式,就是一個將許多圖片、模型、邏輯依照事先的設計展現於螢幕中,一個由 0 與 1 組成的世界。(我就是想講這句話)

許多的程式碼、遊戲資源在構築成螢幕表現前,要先從儲存設備中出發,經過一連串硬體路徑才能抵達螢幕,這邊我們簡化成三個階段:

  • 長期儲存設備:電腦的硬碟、手機的儲存硬體等。
  • 短期儲存設備:一般來說就是記憶體。
  • 實際運算與表現:CPU、GPU等,細節不在本次討論範圍內。

其中,長期儲存設備的反應很慢,一般來說會盡量減少相關的存取次數。如果跟 Unity 做個對應,Resources.Load 相關的 API 便是為了存取位於此處的資源而進行的動作,所以一般會建議盡量減少使用次數,或者不要在重要的時間點執行 (如戰鬥畫面),而是事先做好存取動作 (如讀取畫面)。

短期儲存設備基本上算是運作相當快速了,不過像是記憶體這類的硬體,是硬體中所有的設備共享的一個區塊,所以會有一套管理機制來分配每個軟體或硬體的使用範圍。即使是在單一軟體中,也會有負責記憶體分配與回收的機制,來保障程式的運作,這一部份 Unity 都已經幫我們完成了管理,只要我們不濫用的話

所以說為何需要物件池?

剛剛也說了,記憶體的實際管理 Unity 跟 .NET 都幫我們完成了,只要不濫用則遊戲通常都可以順順的運作下去。

因為很難定義濫用記憶體是怎麼一回事,所以我們直接來說濫用的結果好了,最常見的就是 GC.Collect 會瘋狂被觸發啦!只要有閱讀過 Unity 優化相關資料的人,就會知道這是件可怕的事。

GC.Collect是一個回收記憶體的系統機制,一般來說是累積一定量的待銷毀物件後會自動執行,在執行的瞬間會造成一定的效能開銷,所以頻繁出現的 GC.Collect 會直接造成遊戲的卡頓與劣化。

回到物件池(Object Pool)的目的:減少建立、銷毀動作的次數,因此常作為頻繁被取用 的資源的管理手段,減少建立與銷毀的開銷,減少 GC.Collect 被觸動的頻率。

最常被舉例的情況,大概就是子彈了,不斷重複被發射與擊毀的子彈,而且短時間之內會大量產生,與大量消逝的物件,正是物件池的應用對象。用物件池的取出、收回兩個動作來代替一般物件的建立、消毀,減少記憶體重新分配與回收的頻率。

物件池實作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class GameObjectPool : MonoBehaviour {
[SerializeField] private GameObject m_prefab;
[SerializeField] private int m_initailSize = 5;
private List<GameObject> m_availableObjects = new List<GameObject> ();

private void Awake () {
for (int i = 0; i < m_initailSize; i++) {
GameObject go = Instantiate<GameObject> (m_prefab, this.transform);
m_availableObjects.Add (go); go.SetActive (false);
}
}
public GameObject GetPooledInstance (Transform parent) {
lock (m_availableObjects) {
int lastIndex = m_availableObjects.Count - 1;
if (lastIndex >= 0) {
GameObject go = m_availableObjects[lastIndex];
m_availableObjects.RemoveAt (lastIndex);
go.SetActive (true);
if (go.transform.parent != parent) {
go.transform.SetParent (parent);
}
return go;
} else {
GameObject go = Instantiate<GameObject> (m_prefab, parent);
return go;
}
}
}
public void BackToPool (GameObject go) {
lock (m_availableObjects) {
m_availableObjects.Add (go);
go.SetActive (false);
}
}
}

這是一段簡易的物件池實現,需要一個物件作為掛載體,並在 Inspector 拉進要做為初始物件的 prefab。

  • GetPooledInstance () 會檢查物件池中是否有物件可以取用,有則取出進行回傳,否則會從 prefab 再次實體化一個新物件並回傳。
  • GetPooledInstance () 有個 transform 作為傳入值,根據 Unity 5.3 之後的更新,可以在實體化 Instantiate () 傳入要做為 parent 的 transform,比起後來才 SetParent() 會友更好的效能。
  • BackToPool () 會將物件回收,放回 List 中,並 SetActive (false) 進入待命狀態。

一般來說除了這裡實現的兩個方法,還會實現一個銷毀整個物件池的方法,用來結束物件池的使用。

另外如果接受物件池管理的物件也掛載著 PooledObject 之類的腳本,可以讓整套管理機制更加完整便利,不過這部分我還還在思考構思中,尚未實作。

** 參考連結的 《Object Pools, a Unity C# Tutorial 》一文中,便是有 ObjectPool、PooledObject 兩個腳本合作的物件池版本。

細節補充

這邊說明前文沒提到,關聯性較低,但我想可以藉機說明的事情。

這邊我用硬碟代稱長期儲存設備、記憶體代稱短期儲存設備,由於不同平台間有硬體差異,可能用詞上較不精確,但我想閱讀起來會較容易。

一般來說,除了一開始會自動載入的程式碼、第一個場景,Unity 其他資源 (場景、Resources 資料夾中的檔案) 都要經過存取的動作,將資料從硬碟讀取到記憶體才能使用,這邊舉兩個  API 為例子:

  • SceneManager.LoadScene ()
  • Resources.Load ()

因為這兩個動作執行上很慢,所以當檔案較大、數量較多的時候,會事先進行存取的動作,所以才會有讀取畫面、非同步執行等設計。另外如果知道資源會在後續很快會再用上,不要急於 Resources.UnloadAsset (),而是另行設計暫存手段,減少讀取次數才好。

本文中說到的 GameObject 物件,在硬碟中的身分是 Prefab,也是要經過 Resources.Load () 來取得。而一開始就被 Reference 在場景的 Componenet 上的 Prefab,之所以不需要 Resources.Load (),是因為它們一開始就視為場景的一部份被讀取進來了。

所以,作為第一個場景時因為無法使用讀取畫面等手段讓玩家理解,如果做得太大或者有太多 Componenet、Prefab Reference 在場景中,就會讓遊戲開啟時有段畫面卡住的時間,是要盡量避免的。

而 GameObject.Instantiate () 一般做為將 Prefab 實體化到場景中的動作,實際上是複製一份 Prefab 資料,是一個從記憶體到記憶體的動作,雖然分配上不需多少時間,但是建構物件在場景中,需要 CPU 進行另外的運算來初始化,所以如果 Prefab 太大,有過多的子物件或 Component 需要初始化,依舊是會造成卡頓的,這邊無法透過非同步來優化,只能分隔成較小的 Prefab 來分散負擔。

而銷毀物件 GameObject.Destory() 直接影響的是後來的 GC,便是本文主要內容提及的,也是物件池主要發揮功效的部份了。

雖然這幾點跟物件池沒有直接關係,但是跟物件池背後的目的,以及相關的硬體、Unity 機制有關,便一並簡單地進行了說明。

參考