paint-brush
如何使用 ObjectPool 模式增强您的游戏项目? (统一指南)经过@jokeresdeu
906 讀數
906 讀數

如何使用 ObjectPool 模式增强您的游戏项目? (统一指南)

经过 Les Dibrivniy16m2023/05/10
Read on Terminal Reader

太長; 讀書

对象池是99%游戏项目优化的基础。它基于一个基本原则:在完成其任务后,对象不会被删除,而是被移动到一个单独的环境中,并且可以被检索和重用。 Les Dibrivniy 解释了为什么这种模式是必不可少的。
featured image - 如何使用 ObjectPool 模式增强您的游戏项目? (统一指南)
Les Dibrivniy HackerNoon profile picture
0-item
1-item

大家好,我叫 Oles Dibrivniy,是Keiki的一名 Unity 开发人员。我们在 EdTech 和 GameDev 的交叉点上创建产品——用于儿童发展的应用程序和网络产品。现在我们的产品有超过四百万的用户。

如果有人在面试时问你在游戏开发中使用的编程模式,你首先应该提到的是 ObjectPool。它基于一个基本原则:在完成其任务后,对象不会被删除,而是被移动到一个单独的环境中,并且可以被检索和重用。

这种模式直接影响用户对应用程序的看法,这就是它至关重要的原因。它应该是99%的游戏项目优化的基础。

这篇文章可能与编程大师无关,但对于初学者来说反之亦然。在这里我将通过例子来解释为什么这个模式是必不可少的。


没有 ObjectPool 怎么办?

首先我们分析一下没有ObjectPool的项目案例,从设置入手。我们有一个相对简单的场景,主角和两个敌人投掷火球:

让我们考虑一下火球本身会是什么样子。田野

Rigidbody2D
_speed
将负责火球的运动;这
OnTriggerEnter2D
方法将在碰撞后起作用——带有 FireBall 组件的对象将被销毁:

 public class FireBall : MonoBehaviour 2 { 3 [SerializeField] private Rigidbody2D _rigidbody; 4 [SerializeField] private float _speed; 5   
6 public void FlyInDirection(Vector2 flyDirection) 7 { 8 _rigidbody.velocity = flyDirection * _speed; 9 } 10   
11 private void OnTriggerEnter2D(Collider2D other) 12 { 13 Destroy(gameObject); 14 } 15 }

敌人看起来也很简单:

 public class Enemy : MonoBehaviour 2 { 3 [SerializeField] private FireBall _fireBall; 4 [SerializeField] private Transform _castPoint; 5   
6 public void Cast() 7 { 8 FireBall fireBall = Instantiate(_fireBall, _castPoint.position, Quaternion.identity); 9 fireBall.FlyInDirection(transform.right); 10 } 11 }

你可以打电话给

Cast
从项目或动画事件中的任何点开始的方法。我们有以下流程:敌人制造了一个火球并朝他注视的方向发射。火球在到达途中的第一个障碍物时被摧毁。

这种方法看似最优,但却是最糟糕的选择之一。让我们逐点分析一切:

1. 设备的CPU和内存使用效率低下。

 Instantiate
Destroy
是昂贵的操作:每隔几秒调用它们会导致延迟和滞后。在使用智能手机等小工具时,这一点尤其明显,因为在这些小工具中,保存每个字节的内存都是必要的。您可以打开 Unity Profiler 并在 Hierarchy 窗口的搜索字段中键入 Instantiate,以查看这些操作对游戏来说有多“痛苦”。会有以下内容:

当然,我已经将每个敌人一次制造的火球数量增加到 500 个,以最大限度地提高戏剧效果。然而,很容易想象项目具有更频繁地创建和删除的对象——尤其是在处理 UI 元素或粒子时。

这些过程在运行时不断发生。在舞台上生成数百个对象后,由于持续的内存分配,玩家可能会遇到明显的帧数下降。

2. 你创造的一切都必须被销毁和清理。

Destroy
方法从场景中移除游戏对象,并将其发送到垃圾收集器。您无法控制收集器何时或如何处理它。顺便一提,
 Destroy
很鬼鬼祟祟的。它删除了游戏对象本身,但它的组件可以继续单独存在。如果另一个对象链接到此组件,则可能会发生这种情况。例如,它可能是对某个事件的订阅。

3.代码控制。不同的类负责创建和销毁一个对象,项目中可以有几十个。查找创建或删除的内容和位置有时并不是一项微不足道的任务——我现在对控制层次结构中的对象保持沉默。

让我们将 ObjectPool 集成到项目中!

定义问题后,让我们继续解决问题。正如我前面提到的,ObjectPool 模式操作的原理很简单:对象完成工作后,它不会被删除,而是隐藏在“池”中。可以从中检索和重用该对象:

一个实体将负责创建、重用和销毁一个对象——我们称它为

ObjectPool
.我们可以让它看起来像这样来处理火球:

 public class ObjectPool : MonoBehaviour 2 { 3 [SerializeField] private FireBall _fireBallPrefab; 4   
5 private readonly List <FireBall> _freeFireBalls = new List <FireBall>(); 6   
7 public FireBall GetFireBall() 8 { 9 FireBall fireBall; 10       if (_freeFireBalls.Count > 0 ) 11 { 12 fireBall = _freeFireBalls[ 0 ]; 13 _freeFireBalls. Remove(fireBall);
14 } 15       else
16 { 17 fireBall = Instantiate(_fireBallPrefab, transform); 18 } 19       return fireBall; 20 } 21   
22 private void ReturnFireBall(FireBall fireBall) 23 { 24 _freeFireBalls.Add(fireBall); 25 } 26 }

_freeFireBalls
列表出现在这段代码中。我们将在那里存储已经完成工作的创建的火球。敌人现在看起来像这样:

 public class Enemy : MonoBehaviour 2 { 3 [SerializeField] private ObjectPool _objectPool; 4 [SerializeField] private Transform _castPoint; 5   
6 public void Cast() 7 { 8 FireBall fireBall = _objectPool.GetFireBall(); 9 fireBall.transform.position = _castPoint.position; 10 fireBall.FlyInDirection(transform.right); 11 } 12 }

问题的根源:我们怎样才能把火球放回池子里?我们不能依靠敌人;他不知道火球什么时候会被摧毁。我们也不想提供关于火球的知识

ObjectPool
,因为它会创建不必要的连接。

我希望你注意到我做了

ReturnFireBall
方法私有。因此,我们将使用一种基本的 C# 模式 — 观察者及其实现 —事件。火球现在看起来像这样:

 public class FireBall : MonoBehaviour 2 { 3 [SerializeField] private Rigidbody2D _rigidbody; 4 [SerializeField] private float _speed; 5   
6 public event Action<FireBall> Destroyed; 7   
8 public void FlyInDirection(Vector2 flyDirection) 9 { 10 _rigidbody.velocity = flyDirection * _speed; 11 } 12   
13 private void OnTriggerEnter2D(Collider2D other) 14 { 15 Destroyed?.Invoke(this); 16 } 17 }

 ObjectPool
将订阅
Destroyed
将对象传递给世界后的事件:

 public class ObjectPool : MonoBehaviour 2 { 3 [SerializeField] private FireBall _fireBallPrefab; 4 private readonly List <FireBall> _freeFireBalls = new List <FireBall>(); 5   
6 public FireBall GetFireBall() 7 { 8 FireBall fireBall; 9       if (_freeFireBalls.Count > 0 ) 10 { 11 fireBall = _freeFireBalls[ 0 ]; 12 _freeFireBalls. Remove(fireBall);
13 } 14       else
15 { 16 fireBall = Instantiate(_fireBallPrefab, transform); 17 } 18 fireBall.Destroyed += ReturnFireBall; 19       return fireBall; 20 } 21   
22 private void ReturnFireBall(FireBall fireBall) 23 { 24 fireBall.Destroyed -= ReturnFireBall; 25 _freeFireBalls.Add(fireBall); 26 } 27 }

它仍然是将对象池挂在对象上

Enemy
组件——恭喜!垃圾收集器将休息,直到您移动到另一个场景或关闭游戏。我们在其基本实现中有 ObjectPool,但我们可以改进它。

接口和泛型:与 ObjectPool 的完美匹配

FireBall
不是我们将在对象池中运行的唯一对象类型。你必须单独写一个
ObjectPool
每个都可以与其他类型一起工作。这将扩展代码库并降低代码的可读性。所以,让我们使用仿制品和仿制药。

我们运行的每个对象

ObjectPool
必须绑定到特定类型。它们必须独立于特定的实现进行处理。保持池将处理的对象的基本继承层次结构是必不可少的。他们将继承自
MonoBehaviour
, 至少。让我们使用 IPoolable 接口:

 public interface IPoolable 2 { 3 GameObject GameObject { get ; } 4 event Action<IPoolable> Destroyed; 5 void Reset (); 6 }

我们继承

FireBall
从中:

 public class FireBall : MonoBehaviour, IPoolable 2 { 3 [SerializeField] private Rigidbody2D _rigidbody; 4 [SerializeField] private float _speed; 5 public GameObject GameObject => gameObject; 6   
7 public event Action<IPoolable> Destroyed; 8   
9 private void OnTriggerEnter2D(Collider2D other) 10 { 11       Reset (); 12 } 13 public void Reset () 14 { 15 Destroyed?.Invoke(this); 16 } 17   
18 public void FlyInDirection(Vector2 flyDirection) 19 { 20 _rigidbody.velocity = flyDirection * _speed; 21 } 22 }

最后的任务是教

ObjectPool
与任何对象一起工作。 IPoolable 是不够的,因为 Instantiate 只能创建对象的副本
gameObject
财产。我们将使用
Component
;每个这样的对象都继承自此类。
 MonoBehaviour
也继承自它。结果,我们将得到以下内容
ObjectPool
:

 public class ObjectPool<T> where T : Component, IPoolable 2 { 3 private readonly List <IPoolable> _freeObjects; 4 private readonly Transform _container; 5 private readonly T _prefab; 6   
7 public ObjectPool(T prefab) 8 { 9 _freeObjects = new List <IPoolable>(); 10 _container = new GameObject().transform; 11 _container. name = prefab.GameObject. name ; 12 _prefab = prefab; 13 } 14   
15 public IPoolable GetFreeObject() 16 { 17 IPoolable poolable; 18       if (_freeObjects.Count > 0 ) 19 { 20 poolable = _freeObjects[ 0 ] as T; 21 _freeObjects. RemoveAt(0);
22 } 23       else
24 { 25 poolable = Object.Instantiate(_prefab, _container); 26 } 27 poolable.GameObject.SetActive(true); 28 poolable.Destroyed += ReturnToPool; 29       return poolable; 30 } 31   
32 private void ReturnToPool(IPoolable poolable) 33 { 34 _freeObjects.Add(poolable); 35 poolable.Destroyed -= ReturnToPool; 36 poolable.GameObject.SetActive(false); 37 poolable.GameObject.transform.SetParent(_container); 38 } 39 }

现在我们可以创建一个

ObjectPool
对于任何满足继承条件的对象
Component
和 IPoolable。我删除了
ObjectPool
继承自
MonoBehaviour
,从而减少我们将挂在对象上的组件数量。

有一个问题需要解决。多个敌人可以同时出现在舞台上,并且每个敌人都会产生相同的火球。如果他们都经历同样的事情就好了

ObjectPool
— 永远不会有太多的资源节省!让一个类请求特定的对象类型也很方便。这样的类将接管对象的生成、控制和最终处置。此选项是管理每个
ObjectPool
单独在项目中是具有挑战性的。

完成基本要求

ObjectPool
,我们需要在场景加载时生成一定数量的对象。为避免任何资源消耗问题,我们将包含一个加载窗口。让我们现在开始实施吧。

我们将创建一个额外的实体

PoolTask
在最终执行
ObjectPool
.此类将控制从一个预制件创建的对象的工作:

 public class PoolTask 2 { 3 private readonly List <IPoolable> _freeObjects; 4 private readonly List <IPoolable> _objectsInUse; 5 private readonly Transform _container; 6   
7 public PoolTask(Transform container) 8 { 9 _container = container; 10 _objectsInUse = new List <IPoolable>(); 11 _freeObjects = new List <IPoolable>(); 12 } 13   
14 public void CreateFreeObjects<T>(T prefab, int count) where T : Component, IPoolable 15 { 16       for (var i = 0 ; i < count; i++) 17 { 18 var poolable = Object.Instantiate(prefab, _container); 19 _freeObjects.Add(poolable); 20 } 21 } 22   
23 public T GetFreeObject<T>(T prefab) where T : Component, IPoolable 24 { 25 T poolable; 26       if (_freeObjects.Count > 0 ) 27 { 28 poolable = _freeObjects[ 0 ] as T; 29 _freeObjects. RemoveAt(0);
30 } 31       else
32 { 33 poolable = Object.Instantiate(prefab, _container); 34 } 35 poolable.Destroyed += ReturnToPool; 36 poolable.GameObject.SetActive(true); 37 _objectsInUse.Add(poolable); 38       return poolable; 39 } 40   
41 public void ReturnAllObjectsToPool() 42 { 43 foreach (var poolable in _objectsInUse) 44 poolable. Reset (); 45 } 46   
47 public void Dispose() 48 { 49 foreach (var poolable in _objectsInUse) 50 Object.Destroy(poolable.GameObject); 51       
52 foreach (var poolable in _freeObjects) 53 Object.Destroy(poolable.GameObject); 54 } 55   
56 private void ReturnToPool(IPoolable poolable) 57 { 58 _objectsInUse. Remove(poolable);
59 _freeObjects.Add(poolable); 60 poolable.Destroyed -= ReturnToPool; 61 poolable.GameObject.SetActive(false); 62 poolable.GameObject.transform.SetParent(_container); 63 } 64 }

PoolTask 将具有额外的功能:

1. 跟踪我们释放到世界中的对象,以便在必要时销毁它们或在特定时刻将它们放回池中;

2.生成预定数量的免费对象。

最后,让我们创建一个 ObjectPool,它将满足我们所有的需求,并完全接管对象的控制和生成:

 public class ObjectPool 2 { 3 private static ObjectPool _instance; 4 public static ObjectPool Instance => _instance ??= new ObjectPool(); 5   
6 private readonly Dictionary<Component, PoolTask> _activePoolTasks; 7 private readonly Transform _container; 8  
9 private ObjectPool() 10 { 11 _activePoolTasks = new Dictionary<Component, PoolTask>(); 12 _container = new GameObject().transform; 13 _container. name = nameof(ObjectPool); 14 } 15   
16 public void CreateFreeObjects<T>(T prefab, int count) where T : Component, IPoolable 17 { 18       if (!_activePoolTasks.TryGetValue(prefab, out var poolTask)) 19 AddTaskToPool(prefab, out poolTask); 20       
21 poolTask.CreateFreeObjects(prefab, count); 22 } 23   
24 public T GetObject<T>(T prefab) where T : Component, IPoolable 25 { 26       if (!_activePoolTasks.TryGetValue(prefab, out var poolTask)) 27 AddTaskToPool(prefab, out poolTask); 28       return poolTask.GetFreeObject(prefab); 29 } 30   
31 public void Dispose() 32 { 33 foreach (var poolTask in _activePoolTasks.Values) 34 poolTask.Dispose(); 35 } 36   
37 private void AddTaskToPool<T>(T prefab, out PoolTask poolTask) where T : Component, IPoolable 38 { 39 var taskContainer = new GameObject 40 { 41           name = $ "{prefab.name}_pool" , 42 transform = 43 { 44 parent = _container 45 } 46 }; 47 poolTask = new PoolTask(taskContainer.transform); 48 _activePoolTasks.Add(prefab, poolTask); 49 } 50 }

Singleton 的使用可能会引起您的注意——这只是一个示例。您可以自定义项目中的用法——它可以运行

ObjectPool
通过构造函数或通过 Zenject 注入。

我们有我们的最终版本

Cast
中的方法
Enemy
:

 public class Enemy : MonoBehaviour 2 { 3 [SerializeField] private FireBall _prefab; 4 [SerializeField] private Transform _castPoint; 5   
6 public void Cast() 7 { 8 FireBall fireBall = ObjectPool.Instance.GetObject(_prefab); 9 fireBall.transform.position = _castPoint.position; 10 fireBall.FlyInDirection(transform.right); 11 } 12 }

因为使用泛型,我们得到了一个我们需要立即处理的类型的对象。

 ObjectPool
将根据预制件对内部任务进行分组——如果有多个
FireBall
组件,池将正确处理它们并为您提供正确的组件。这种方法将有助于为游戏场景生成任何对象。

但是,在使用 UI 元素时要小心:在具有不同的父变换之间移动对象时

localScale
, 这
localScale
对象本身会发生变化。如果您的项目中有自适应 UI,使用画布组件进行转换将改变它们的
localScale
取决于扩展名。我建议你做这个简单的操作:

 poolable.GameObject.transform.localScale = Vector2. on e ;

您可以在其他选项中使用 3 个脚本:

 ObjectPool
,
 PoolTask
, 和
IPoolable
.所以请随意将它们添加到您的项目中并 100% 使用对象池模式!