这里写个简单的预制体对象池系统
1.可池化的组件
首先为可以在对象池系统中使用的组件定义一个接口类:
public interface IPoolableComponent {
void Spawned();
void Despawned();
}
IPoolableComponent的方法和IPoolableObject采用的方法完全不同。这次创建的对象是GameObject,与标准对象相比,这些对象要复杂得多,因为它们的大量运行时行为通过Unity引擎处理,而我们对它们的底层访问很少。
GameObject没有提供可以在对象创建时调用的与new()等价的方法,也不能从GameObject类中继承,以实现一个new()方法。创建GameObject的方式是将其放到场景中,或运行时通过GameObject.Instantiate()方法实例化它们,而可以提供的唯一输入只有初始位置和旋转。当然,它们的组件有Awake()回调供开发者定义,它在组件首次产生时调用,但这只是一个组合的对象——不是真正产生或回收的父对象。
因此,由于仅对GameObject类的组件拥有控制权,假设池化的GameObject上附加的组件中至少有一个实现了IPoolableComponent接口类。
每次重新产生池化的GameObject时,都应该在每个实现的组件上调用Spawned()方法,而回收时,则调用相应的Despawned()方法。这提供了在创建和销毁父GameObject时控制数据变量和行为的入口点。
销毁GameObject的行为是微不足道的;通过SetActive()将其active标记设置为false。这将禁用碰撞器和刚体的物理计算,将其从可渲染对象中移除,以及实际上在一个步骤中禁用了GameObject与所有内置的Unity引擎子系统的所有交互。唯一的例外是当前在对象上调用的协程,因为如第2章所述,协程是独立于任何Update()和GameObject活动调用的。因此,需要在这些对象的回收期间调用StopCoroutine()或StopAllCoroutine()。
另外,组件通常也会挂接到自定义的游戏子系统中,因此Despawn()方法让组件有机会在关闭之前处理任何自定义清理。例如,使用Despawn()从第2章定义的消息传递系统中注销。
遗憾的是,成功地重新生成GameObject相对复杂。当对象重新生成时,有很多设置在上次对象被激活时遗留下来,而必须重置以避免行为冲突。与此相关的一个常见问题是刚体的linearVelocity和angularVelocity属性。如果这些值没有在对象重新激活之前明确重置,那么重新生成的新对象会继续使用旧版本回收时设置的速度移动。
内建的组件是密封的,意味着它们不能被继承,这使该问题更复杂。因此为了避免这些问题,可以创建自定义的组件,只要对象被回收时,重置附加的刚体。
public class ResetPooledRigidbodyComponent : MonoBehaviour, IPoolableComponent {
[SerializeField] Rigidbody _body;
public void Spawned() { }
public void Despawned() {
if (_body == null) {
_body=GetComponent<Rigidbody>();
if (_body == null) {
// no Rigidbody!
return;
}
}
_body.velocity=Vector3.zero;
_body.angularVelocity=Vector3.zero;
}
}
注意,执行清除任务的最佳时机是在回收时,因为我们不确定GameObject的IPoolableComponent接口类调用Spawned()方法的顺序。另一个IPoolableComponent不可能在回收时修改对象的速度,但附加到相同对象上的不同IPoolableComponent可能想在Spawned()方法调用时将初始速度设置为某个重要的值。因此,在ResetPooledRigidbodyComponent类的Spawned()方法调用期间执行速度重置,可能导致和其他组件的潜在冲突,产生很奇怪的bug。
实际上,创建不是自包含的可池化组件,并像这样用其他组件进行修补是实现对象池系统的最大危险之一。应该最小化这种设计,并在尝试调试游戏中的奇怪问题时定期验证它们。
为了举例说明,下面定义了一个简单的可池化组件,它使用了第2章中的MessagingSystem类。该组件在每次生成和回收对象时,自动处理一些基本任务:
public void Spawned() {
MessagingSystem.Instance.AttachListener(typeof(MyCustomMessage), this.HandleMyCustomMessage);
}
bool HandleMyCustomMessage(BaseMessage msg) {
MyCustomMessage castMsg=msg as MyCustomMessage;
Debug.Log (string.Format("Got the message! {0}, {1}", castMsg._intValue, castMsg._floatValue));
return true;
}
public void Despawned() {
if (MessagingSystem.IsAlive) {
MessagingSystem.Instance.DetachListener(typeof(MyCustomMessage),
this.HandleMyCustomMessage);
}
}
}
2.预制池系统
了解了对象池系统需要什么,下面就要实现它。要求如下:
- 必须接受请求,从预制、初始位置和初始旋转中生成GameObject:
- 如果已经存在已经回收的版本,应该重新生成第一个可用的对象。
- 如果不存在已经回收的版本,应该从预制体中实例化出新的GameObject
- 在上述两种情况下,都应该在附加到GameObject上的所有IPoolableComponent接口类上调用Spawned()方法。
- 必须接受请求,以回收特定的GameObject。
- 如果对象由对象池系统管理,它应该禁用,并附加到GameObject上的所有IPoolableComponent接口类上调用Despawned()方法。
- 如果对象没有由对象池系统管理,应该报错。
需求相当直接明了,但如果希望使解决方案的性能更好,则需要进行一些调查。首先,对于主要入口点来说,典型的单例是一个很好的选择,因为这个系统应该能从任何地方全局访问:
public static class PrefabPoolingSystem {}
生成对象的主要任务包括接受一个预制引用,指出是否有回收的GameObject从相同的引用中实例化。为此,本质上对象池系统需要为任何给定的预制体引用跟踪两个不同类型的列表:一个是激活的(已经生成的)GameObject列表;另一个是从该预制体中实例化的未激活(回收)的对象列表。该信息最好被抽象到一个独立的类中,将其命名为PrefabPool。
为了最大化该系统的性能(因此,相对于始终只从内存中分配和释放对象,可以实现最大的收益),可以使用一些快速数据结构,以便当发出生成或回收请求时,获取相应的PrefabPool对象。
由于生成GameObject需要给定一个预制体,我们将通过一个数据结构快速将预制体映射到管理它们的PrefabPool。同时,由于回收对象需要给定一个GameObject,我们将通过另一个数据结构,把已经生成的GameObject快速映射到最初生成它们的PrefabPool。满足这两个需求的最好选项是使用一对字典。
接着在PrefabPoolingSystem类中定义这些字典:
public static class PrefabPoolingSystem {
static Dictionary<GameObject,PrefabPool> _prefabToPoolMap=new
Dictionary<GameObject,PrefabPool>();
static Dictionary<GameObject,PrefabPool> _goToPoolMap=new
Dictionary<GameObject,PrefabPool>();
}
接下来,定义在生成对象时发生了什么:
public static GameObject Spawn(GameObject prefab, Vector3 position, Quaternion rotation) {
if (!_prefabToPoolMap.ContainsKey (prefab)) {
_prefabToPoolMap.Add (prefab, new PrefabPool());
}
PrefabPool pool=_prefabToPoolMap[prefab];
GameObject go=pool.Spawn(prefab, position, rotation);
_goToPoolMap.Add (go, pool);
return go;
}
给Spawn()方法提供一个预制体引用、一个初始位置以及一个初始旋转。需要指出预制体属于哪个PrefabPool(如果有的话),使用提供的数据请求它生成新的GameObject,并给请求者返回生成的对象。首先检查预制到池的映射,以确定是否已经存在该预制体的池。如果没有存在,则立刻创建一个池。不管什么情况,接着请求PrefabPool生成新对象。PrefabPool要么重新生成之前回收的对象,要么实例化一个新对象(如果没有任何非激活的实例)。
该类不关心PrefabPool如何创建对象。它只是想通过PrefabPool类生成实例,以便将其添加到GameObject到池的映射中,并将其返回给请求者。
为了便利,也可以定义一个重载版本,将对象放到世界的中心。这对于只存在于场景中但不可见的对象很有用:
public static GameObject Spawn(GameObject prefab) {
return Spawn (prefab, Vector3.zero, Quaternion.identity);
}
上述代码没有真正发生生成和回收。该任务最终在PrefabPool类中实现。
回收需要给定一个GameObject,接着找出哪个PrefabPool在管理它。为此可以迭代PrefabPool对象,检查它们是否包含给定的GameObject。然而,如果最终生成了很多PrefabPool,那么该迭代会花费一些时间。通常最终会有和预制体一样多的PrefabPool对象(至少,只要通过对象池系统管理它们)。大多数项目如果没有几千个,往往也有几百个、几十个不同的预制体。
因此维护GameObject到池的映射,以确保能快速访问最初生成对象的PrefabPool。它还可以用来快速检查给定的GameObject是否由对象池系统管理。以下是回收方法的定义,该方法完成这些任务:
public static bool Despawn(GameObject obj) {
if (!_goToPoolMap.ContainsKey(obj)) {
Debug.LogError (string.Format ("Object {0} not managed by pool
system!", obj.name));
return false;
}
PrefabPool pool=_goToPoolMap[obj];
if (pool.Despawn (obj)) {
_goToPoolMap.Remove (obj);
return true;
}
return false;
}
PrefabPoolingSystem和PrefabPool的Despawn()方法都返回布尔值,它可以用于检查对象释放被成功回收。
最终,由于维护的两个映射,可以快速访问管理给定引用的PrefabPool,此解决方案将针对系统管理的任意数量的预制体进行伸缩。
3.预制池
现在有了一个可以自动处理多个预置池的系统,剩下的唯一工作就是定义该池的行为。如前所述,PrefabPool类应维护两个数据结构:一个用于已从给定的Prefab中实例化的活动(派生)对象,另一个用于非活动(回收的)对象。
从技术上讲,PrefabPoolingSystem类已经维护了一个由PrefabPool管理Prefab的映射,所以实际上可以节省一点内存,方法是让PrefabPool依赖于PrefabPoolingSystem类,让它引用它管理的Prefab。因此,这两个数据结构是PrefabPool需要跟踪的成员变量。
但是,对于每个派生的GameObject,它还必须维护所有IPoolableComponent引用的列表,以便对它们调用Spawned()和Despawned()方法。获取这些引用可能是在运行时执行的一个昂贵操作,所以最好将数据缓存在一个简单的结构中:
public struct PoolablePrefabData {
public GameObject go;
public IPoolableComponent[] poolableComponents;
}
该结构体包含对GameObject以及它所有IPoolableComponent组件的预缓存列表的引用。
现在可以定义PrefabPool类的成员数据:
public class PrefabPool {
Dictionary<GameObject,PoolablePrefabData> _activeList=new
Dictionary<GameObject,PoolablePrefabData>();
Queue<PoolablePrefabData> _inactiveList=new
Queue<PoolablePrefabData>();
}
为了快速找到给定GameObject引用中对应的PoolablePrefabData,用于激活列表的数据结构应该是一个字典。这对于对象回收会很有帮助。
同时,非激活的数据结构定义为一个队列,也可以定义为列表、堆栈或需要定期扩展或收缩的数据结构,因为只需要从组的一端弹出对象,而与它是哪个对象无关。它仅关心取出对象中的一个。队列对于这种情况很有用,因为调用一次Dequeue(),就可以从数据结构中获取并移除对象。
4.生成对象
接下来定义在池系统容器中生成GameObject意味着什么:在某个时刻,PrefabPool会收到一个请求,从给定的预制体中利用给定位置和旋转生成GameObject。首先应该检查是否有该预制体的非激活实例。如果有,就可以将下一个可用对象移出队列,并重新生成它。如果没有,就需要使用GameObject.Instantiate()从预制体中实例化新的GameObject。此时,应该创建PoolablePrefabData对象,来保存GameObject引用,并获取附加到它上面,所有实现了IPoolableComponent的MonoBehaviour列表。
不管是哪种生成方式,现在可以激活GameObject,设置其位置和旋转,并调用它所有IPoolableComponent引用的Spawned()方法。一旦对象重新生成,就可以将它添加到激活对象列表,并返回给请求者。
下面的Spawn()方法定义了这个行为:
public GameObject Spawn(GameObject prefab, Vector3 position, Quaternion
rotation) {
PoolablePrefabData data;
if (_inactiveList.Count > 0) {
data=_inactiveList.Dequeue();
} else {
// instantiate a new object
GameObject newGO=GameObject.Instantiate(prefab, position,
rotation) as GameObject;
data=new PoolablePrefabData();
data.go=newGO;
data.poolableComponents=newGO.GetComponents<IPoolableComponent>();
}
data.go.SetActive (true);
data.go.transform.position=position;
data.go.transform.rotation=rotation;
for(int i=0; i < data.poolableComponents.Length; ++i) {
data.poolableComponents[i].Spawned ();
}
_activeList.Add (data.go, data);
return data.go;
}
5.预先生成实例
由于当PrefabPool用完所有已回收的实例时使用GameObject.Instantiate()新建对象,该系统不能完全消除运行时的对象实例化以及堆内存分配。在当前场景的生命周期中预先生成所需数量的实例非常重要,这样就可以最小化或消除在运行时实例化更多对象的需要。
注意不应该预先生成太多对象。如果在场景中最可能出现的是3或4个爆炸,那么预先生成100个爆炸粒子特效就是一种浪费。相反,生成太少实例将导致过多的运行时内存分配,而该系统的目标是将主要的内存分配推到场景生命周期的开始时刻。需要注意在内存中维护多少个实例,这样就不会浪费不必要的内存空间。
接下来在PrefabPoolingSystem类中定义一个方法,可以用来快速地从Prefab中预先生成给定数量的对象。它基本上是生成N个对象,并立刻回收它们:
public static void Prespawn(GameObject prefab, int numToSpawn) {
List<GameObject> spawnedObjects=new List<GameObject>();
for(int i=0; i < numToSpawn; i++) {
spawnedObjects.Add (Spawn (prefab));
}
for(int i=0; i < numToSpawn; i++) {
Despawn(spawnedObjects[i]);
}
spawnedObjects.Clear ();
}
在场景初始化过程中使用这个方法,来预生成一组在关卡中使用的对象。以下列代码为例:
public class OrcPreSpawner : MonoBehaviour
[SerializeField] GameObject _orcPrefab;
[SerializeField] int _numToSpawn=20;
void Start() {
PrefabPoolingSystem.Prespawn(_orcPrefab, _numToSpawn);
}
}
6.对象的回收
最后,是回收对象。如前所述,这主要包括禁用对象,也需要完成不同的记录任务,并调用所有IPoolableComponent引用的Despawned()方法。
下面是PrefabPool.Despawn()的方法定义:
public bool Despawn(GameObject objToDespawn) {
if (!_activeList.ContainsKey(objToDespawn)) {
Debug.LogError ("This Object is not managed by this object
pool!");
return false;
}
PoolablePrefabData data=_activeList[objToDespawn];
for(int i=0; i < data.poolableComponents.Length; ++i) {
data.poolableComponents[i].Despawned ();
}
data.go.SetActive (false);
_activeList.Remove (objToDespawn);
_inactiveList.Enqueue(data);
return true;
}
首先,验证对象由池管理,接着获取相应的PoolablePrefabData,以访问IPoolableComponent引用列表。一旦在所有引用上调用Despawned(),则禁用对象,将其从激活列表中移除,并将其推入非激活队列中,以便以后重新生成。
7.测试
下面的类定义允许对PrefabPoolingSystem类进行简单的实践测试;它支持3个预制体,并在程序初始化期间为每个预制体预先生成5个实例。可以按下1,2,3或4按键以生成对应类型的实例,接着按下Q,W,E和R按键回收对应类型的一个随机实例:
public class PrefabPoolingTestInput : MonoBehaviour {
[SerializeField] GameObject _orcPrefab;
[SerializeField] GameObject _trollPrefab;
[SerializeField] GameObject _ogrePrefab;
[SerializeField] GameObject _dragonPrefab;
List<GameObject> _orcs=new List<GameObject>();
List<GameObject> _trolls=new List<GameObject>();
List<GameObject> _ogres=new List<GameObject>();
List<GameObject> _dragons=new List<GameObject>();
void Start() {
PrefabPoolingSystem.Prespawn(_orcPrefab, 11);
PrefabPoolingSystem.Prespawn(_trollPrefab, 8);
PrefabPoolingSystem.Prespawn(_ogrePrefab, 5);
PrefabPoolingSystem.Prespawn(_dragonPrefab, 1);
}
void Update () {
if (Input.GetKeyDown(KeyCode.Alpha1)) {SpawnObject(_orcPrefab, _orcs);}
if (Input.GetKeyDown(KeyCode.Alpha2)) {SpawnObject(_trollPrefab, _trolls);}
if (Input.GetKeyDown(KeyCode.Alpha3)) {SpawnObject(_ogrePrefab, _ogres);}
if (Input.GetKeyDown(KeyCode.Alpha4)) {SpawnObject(_dragonPrefab, _dragons);}
if (Input.GetKeyDown(KeyCode.Q)) { DespawnRandomObject(_orcs); }
if (Input.GetKeyDown(KeyCode.W)) { DespawnRandomObject(_trolls); }
if (Input.GetKeyDown(KeyCode.E)) { DespawnRandomObject(_ogres); }
if (Input.GetKeyDown(KeyCode.R)) { DespawnRandomObject(_dragons); }
}
void SpawnObject(GameObject prefab, List<GameObject> list) {
GameObject obj=PrefabPoolingSystem.Spawn (prefab, 5.0f * Random.insideUnitSphere, Quaternion.identity);
list.Add (obj);
}
void DespawnRandomObject(List<GameObject> list) {
if (list.Count == 0) {
// Nothing to despawn
return;
}
int i=Random.Range (0, list.Count);
PrefabPoolingSystem.Despawn(list[i]);
list.RemoveAt(i);
}
}
任何预制体一旦生成5个以上的实例,将需要在内存中实例化一个新的实例,消耗一些内存分配。然而,如果观察Profiler窗口的Memory Area,当仅生成与回收已经存在的实例时,绝对不会发生新的内存分配。
8.预制池和场景加载
该系统还有一个重要警告:由于PrefabPoolingSystem是静态类,它比场景的生命周期更长。这意味着当加载新场景时,池系统的字典尝试维护之前场景中已经池化的实例的引用,但Unity在切换场景时强制销毁这些对象,而不管我们依然保有对它们的引用(除非它们设置为DontDestroyOnLoad()),因此字典将充满空引用。这将为下个场景带来很多严重问题。
因此,应该在PrefabPoolingSystem中创建一个方法,为类似事件重置池系统。下面的代码在新场景加载前调用,为下个场景中的任何Prespawn()调用做好准备:
public static void Reset() {
_prefabToPoolMap.Clear ();
_goToPoolMap.Clear ();
}
注意,如果在场景切换时调用垃圾回收,就不用显式地销毁这些字典引用的PrefabPool对象。因为它们仅引用PrefabPool对象,而它们将在下一次垃圾回收时被回收。如果没有在场景切换之间调用垃圾回收,那么PrefabPool和PooledPrefabData对象将一直存在于内存中。
9.预制池总结
这个池系统为GameObject和预制的运行时内存分配问题提供了一个很好的解决方案,但需要注意以下事项:
需要小心在重生成的物体中正确地重置重要的数据(如刚体速度)。
必须确保不会预先生成太少或太多预制实例。
应该小心IPoolableComponent中Spawned()和Despawned()方法的执行顺序,不要假设它们以特定顺序执行。
在加载新场景时必须调用PrefabPoolingSystem的Reset(),以清除可能不再存在的空引用对象。
还可以实现其他几个特性。如果希望将来扩展这个系统,这些特性就留作学术练习:
在GameObject初始化之后添加到GameObject上的IPoolableComponent不会触发它们的Spawned()或Despawned()方法,因为只在GameObject首次初始化时收集该列表。为了修复此问题,可以修改PrefabPool,在每次调用Spawned()和Despawned()时,获取IPoolableComponent引用。但代价是生成和回收期间有额外的开销。
添加到预制根下的子节点的任何IPoolableComponent不会被统计。为了修复此问题,如果使用预制体更深层级上的组件,可以修改PrefabPool来使用GetComponentsInChildren,但代价是有额外的开销。
已经存在于场景中的预制实例不由池系统管理。可以创建需要附加到此对象的组件,该组件在Awake()回调中通知PrefabPoolingSystem类预制体的存在,并给相应的PrefabPool传入引用。
可以实现一种方法,让IPoolableComponent在获取期间设置优先级,并直接控制它们的spawn()和Despawned()方法的执行顺序。
可以添加计数器,来跟踪对象在非活动列表中相对于整个场景生命周期存在了多长时间,并在关闭期间打印出数据。这可以说明是否提前生成了太多给定的预制体实例。
这个系统不会与将自己设置为DontDestroyOnLoad()的预制实例友好地交互。明智的做法可能是在每个Spawn()调用中添加一个布尔值,以确定对象是否应该持久化,并将它们保存在一个单独的数据结构中,在Reset()期间不清除这些数据结构。
可以更改Spawn()以接受一个参数,该参数允许请求者将定制数据传递给IPoolableObject的Spawn ()函数以进行初始化。这可以使用一个系统,类似于从第2章的消息传递系统的Message类中派生自定义消息对象的方式。