Object Pool 的小練習.

廢言

一直在用 pooling 的插件, 因為是插件基於管理上的原因不能直接改寫以致經常要規避一些插件的缺憾,
Object pool 算是經常用又沒有自己寫過的東西. 這天有空就來把以前遇到的 Pooling 問題整理一下做個少插件.
以下是小弟的 Object pool 的寫法.
細節在代碼中已加入註解, 或許還有其他人能提出一些我從來沒想過的用法, 歡迎討論.

功能

甚麼是物件池 (Object Pool), 為常用的衍生物 (Token) 進行顯示管理, 規避經常產生及刪除物件所造成的資源瓶頸…
三個基本 Public API

  • Spawn(GameObject, Vector3, Quaternion, Transform)
  • Despawn(GameObject)
  • IsSpawned(GameObject)

一個主體功能

  • Preloading

概念

以 TokenCache 為概念中心, 利用 Queue, Hashset 進行衍生物(Token)管理.

  • Queue 作為 deactive token 的暫存 list, 利用 FIFO 的特性直接拿取閑置最久的 Token 作為下個 active token.
  • Hashset 作為 active token, 可以快速的檢查是否正在使用.

在程序中 m_CacheDict 使用 private setter 作為初始化的手段是因為初始點不確定的做法.
因為第一個衍生物可以是 preload 生成或者由外部程序呼叫被而被立即產生的.


而最後的 ISpawnObject interface, 則是保留日後擴展 Token 上的寫法. OnSpawned / OnDespawn 等作為 callback 的接口.

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

namespace Kit
{
	public class ObjectPool : MonoBehaviour, System.IDisposable
	{
		[SerializeField] PrefabPreloadSetting[] m_PrefabPreloadSettings = { };

		#region Data structure
		private bool m_IsDisposed;

		/// <summary>Preload Setting</summary>
		[System.Serializable]
		public class PrefabPreloadSetting : PrefabSetting
		{
			[Header("Preload")]
			[Tooltip("After Awake(), trigger auto preload in ? second.")]
			public float m_PreloadDelay = 0f;
			[Tooltip("The interval between each preload elements, distribute the performace overhead during GameObject.Instantiate")]
			public int m_PreloadFramePeriod = 0;
			[Tooltip("Auto preload prefab(s) base on giving amount")]
			public int m_PreloadAmount = 1;
			[Tooltip("Keep prefab instance's Awake on their first spawn, instead of force disable on preload")]
			public bool m_KeepAwakeOnFirstSpawn = true;
		}

		/// <summary>Setting for spawn/despawn prefab behavior</summary>
		[System.Serializable]
		public class PrefabSetting
		{
			[Header("Reference")]
			[Tooltip("Name used for spawning the preloaded prefab(s), e.g. across network")]
			public string m_Name = string.Empty;
			public GameObject m_Prefab;
			[Header("Rule")]
			[Tooltip("The maximum amount of the current pool.")]
			public int m_PoolLimit = 100;
			[Tooltip("Will block any further spawn request, if current pool was reach maximum limit.")]
			public bool m_CullOverLimit = true;
			[Tooltip("Ensure the token(s) will re-parent under current pool on hierachy level.")]
			public bool m_ReturnToPoolAfterDespawn = true;

			internal void Validate()
			{
				if (m_Name.Length == 0 && m_Prefab != null)
					m_Name = m_Prefab.name;
			}
		}

		/// <summary>Internal memory cache to handle spawn flow and keep instance reference</summary>
		private struct TokenCache
		{
			public PrefabSetting setting;
			public Queue<GameObject> deactiveObjs;
			public HashSet<GameObject> activeObjs;
			public int totalCount => deactiveObjs.Count + activeObjs.Count;
			public TokenCache(PrefabSetting setting)
			{
				this.setting = setting;
				deactiveObjs = new Queue<GameObject>(setting.m_PoolLimit);
				activeObjs = new HashSet<GameObject>();
			}
			internal void Clear()
			{
				foreach (var token in activeObjs)
					token?.SetActive(false);
				activeObjs.Clear();
				deactiveObjs.Clear();
			}
		}

		private Dictionary<GameObject, TokenCache> _cacheDict = null;
		private Dictionary<GameObject /* prefab */, TokenCache> m_CacheDict
		{
			get
			{
				if (_cacheDict == null)
					_cacheDict = new Dictionary<GameObject, TokenCache>(10);
				return _cacheDict;
			}
		}

		/// <summary>The table to increase the speed to tracking the token and it's prefab group.</summary>
		private Dictionary<GameObject /* token */, GameObject /* prefab */> m_AllSpawnedObjs = new Dictionary<GameObject, GameObject>();
		#endregion // Data structure

		#region U3D Cycle
		private void OnValidate()
		{
			for (int i = 0; i < m_PrefabPreloadSettings.Length; i++)
				m_PrefabPreloadSettings[i].Validate();
		}
		
		private void Awake()
		{
			TriggerPreloadHandler();
		}

		private void OnDestroy()
		{
			Dispose();
		}
		#endregion // U3D Cycle

		#region Preload Token
		private void TriggerPreloadHandler()
		{
			for (int i = 0; i < m_PrefabPreloadSettings.Length; i++)
			{
				if (m_PrefabPreloadSettings[i].m_Prefab == null)
					Debug.LogError($"Fail to preload index {i}, missing prefab", this);
				else
					StartCoroutine(PreloadHandler(m_PrefabPreloadSettings[i]));
			}
		}
		private IEnumerator PreloadHandler(PrefabPreloadSetting setting)
		{
			if (ReferenceEquals(setting.m_Prefab, null))
				throw new UnityException("Handle null check on higher level.");
			else if (setting.m_PreloadDelay > 0f)
				yield return new WaitForSecondsRealtime(setting.m_PreloadDelay);

			bool prefabActiveState = setting.m_Prefab.activeSelf;
			if (setting.m_KeepAwakeOnFirstSpawn && prefabActiveState)
				setting.m_Prefab.SetActive(false);

			LocateOrCreateCache(setting.m_Prefab, setting, out TokenCache cache);

			int cnt = Mathf.Min(setting.m_PreloadAmount, setting.m_PoolLimit);
			while (cache.totalCount < cnt) // Async spawning parallel maintain preload amount 
			{
				// Create instance for prefab.
				GameObject go = CreateToken(cache, transform.position, transform.rotation, transform);
				go.SetActive(false);
				cache.deactiveObjs.Enqueue(go);

				int countFrame = setting.m_PreloadFramePeriod;
				while (countFrame-- > 0)
					yield return null;
			}

			if (setting.m_KeepAwakeOnFirstSpawn)
				setting.m_Prefab.SetActive(prefabActiveState);
		}

		private GameObject CreateToken(TokenCache cache, Vector3 position, Quaternion rotation, Transform parent = null)
		{
			GameObject go = GameObject.Instantiate(cache.setting.m_Prefab, position, rotation, parent);
			go.name += $" #{cache.totalCount + 1:0000}";
			return go;
		}

		private void LocateOrCreateCache(GameObject prefab, PrefabSetting setting, out TokenCache cache)
		{
			if (prefab != null && setting != null && prefab != setting.m_Prefab)
				throw new UnityException($"Invalid prefab setting for this asset {prefab}");
			if (!m_CacheDict.TryGetValue(prefab, out cache))
			{
				// cache not found, create one.
				if (setting == null || setting.m_Prefab == null)
				{
					// spawn on demend, but setting not found.
					Debug.LogWarning($"Fail to locate {nameof(PrefabPreloadSetting)} for {prefab}, fallback default setting.", this);
					cache = new TokenCache(new PrefabPreloadSetting() { m_Prefab = prefab });
				}
				else
				{
					// spawn on demend, without using preload setting.
					cache = new TokenCache(setting);
				}
				m_CacheDict.Add(prefab, cache);
			}
			else if (setting != null)
			{
				// override setting
				// Case : execution order issue. Spawn() call early then Awake();
				cache.setting = setting;
				m_CacheDict[prefab] = cache;
			}
		}
		#endregion // Preload Token

		#region Pooling Core
		public GameObject Spawn(string prefabName, Vector3 position, Quaternion rotation, Transform parent = null)
		{
			if (m_IsDisposed)
			{
				Debug.LogError($"{GetType().Name} : Unable to {nameof(Spawn)} {prefabName} when disposed.", this);
				return null;
			}
			foreach (var cache in m_CacheDict.Values)
				if (cache.setting.m_Name.Equals(prefabName))
					return InternalSpawn(cache, position, rotation, parent, null);
			return null;
		}
		public GameObject Spawn(GameObject prefab, Vector3 position, Quaternion rotation, Transform parent = null, PrefabSetting setting = null)
		{
			if (m_IsDisposed)
			{
				Debug.LogError($"{GetType().Name} : Unable to {nameof(Spawn)} {prefab} when disposed.", this);
				return null;
			}
			else if (prefab == null)
				throw new UnityException("Fail to spawn Null prefab.");

			LocateOrCreateCache(prefab, setting, out TokenCache cache);
			return InternalSpawn(cache, position, rotation, parent, setting);
		}

		private GameObject InternalSpawn(TokenCache cache, Vector3 position, Quaternion rotation, Transform parent = null, PrefabSetting setting = null)
		{
			GameObject go = null;
			bool notEnoughToken = cache.deactiveObjs.Count == 0;
			if (notEnoughToken)
			{
				bool overLimit = cache.totalCount >= cache.setting.m_PoolLimit && cache.setting.m_CullOverLimit;
				if (!overLimit)
				{
					// Spawn on demend
					go = CreateToken(cache, position, rotation, parent);
				}
			}
			else
			{
				go = cache.deactiveObjs.Dequeue();
				if (go.transform.parent != parent)
					go.transform.SetParent(parent);
				go.transform.SetPositionAndRotation(position, rotation);
			}

			if (go != null)
			{
				cache.activeObjs.Add(go);
				m_AllSpawnedObjs.Add(go, cache.setting.m_Prefab);
				go.SetActive(true);
				go.BroadcastMessage(nameof(ISpawnObject.OnSpawned), this, SendMessageOptions.DontRequireReceiver);
			}
			return go;
		}

		/// <summary>Check if the giving gameobject was spawned by this spawn pool</summary>
		/// <param name="go">giving gameobject</param>
		/// <returns></returns>
		public bool IsSpawned(GameObject go)
		{
			return m_IsDisposed ? false : m_AllSpawnedObjs.ContainsKey(go);
		}

		public void Despawn(GameObject go)
		{
			if (m_IsDisposed)
				return;
			else if (go == null)
			{
				Debug.LogError("Despawn null reference unit.", this);
				return;
			}
			if (m_AllSpawnedObjs.TryGetValue(go, out GameObject prefabKey))
			{
				TokenCache cache = m_CacheDict[prefabKey];
				m_AllSpawnedObjs.Remove(go);
				cache.activeObjs.Remove(go);
				cache.deactiveObjs.Enqueue(go);
				go.SetActive(false);
				if (cache.setting.m_ReturnToPoolAfterDespawn)
					go.transform.SetParent(transform);
				go.BroadcastMessage(nameof(ISpawnObject.OnDespawned), this, SendMessageOptions.DontRequireReceiver);
			}
			else
			{
				Debug.LogError($"{GetType().Name} : {go} was not manage by {GetType().Name}");
			}
		}
		#endregion // Pooling Core

		#region Dispose
		public void Dispose()
		{
			if (!m_IsDisposed)
			{
				foreach (TokenCache cache in m_CacheDict.Values)
					cache.Clear();
				m_CacheDict.Clear();
				m_AllSpawnedObjs.Clear();
				System.GC.SuppressFinalize(this);
				m_IsDisposed = true;
			}
		}
		#endregion // Dispose
	}

	/// <summary>An interface to allow spawned object to receive the following callback during Spawn/Despawn flow.</summary>
	public interface ISpawnObject
	{
		/// <summary>Will boardcast to token(s) after spawn flow.</summary>
		/// <param name="pool">Handling pool reference</param>
		void OnSpawned(ObjectPool pool);

		/// <summary>Will boardcast to token(s) after despawn flow.</summary>
		/// <param name="pool">Handling pool reference</param>
		void OnDespawned(ObjectPool pool);
	}
}

另外一段作為 Test case 的程序碼

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class RandomSpawn : MonoBehaviour
{
	public GameObject m_Prefab;
	public Kit.ObjectPool pool;
	public Transform[] m_Parents = { };

	public float m_Radius = 3f;
	public Vector2 m_Range = Vector2.up;
	private List<GameObject> m_SpawnedGo = new List<GameObject>();

	private void OnEnable()
	{
		StartCoroutine(PeriodicUpdate());
	}

	private IEnumerator PeriodicUpdate()
	{
		while (this.enabled)
		{
			if (Random.value > .5f && m_SpawnedGo.Count > 0)
			{
				int pt = Random.Range(0, m_SpawnedGo.Count);
				pool.Despawn(m_SpawnedGo[pt]);
				m_SpawnedGo.RemoveAt(pt);
			}
			else
			{
				int pt = Random.Range(0, m_Parents.Length);
				GameObject go = pool.Spawn(m_Prefab, Random.insideUnitSphere * m_Radius, Quaternion.identity, m_Parents.Length > 0 ? m_Parents[pt] : null);
				if (go != null)
					m_SpawnedGo.Add(go);
			}
			float second = Random.Range(m_Range.x, m_Range.y);
			yield return new WaitForSeconds(second);
		}
	}
}

One Comment

  1. Pingback: ObjectPool – Clonefactor

發佈留言

發佈留言必須填寫的電子郵件地址不會公開。 必填欄位標示為 *

*

這個網站採用 Akismet 服務減少垃圾留言。進一步瞭解 Akismet 如何處理網站訪客的留言資料