您想找什么?
Hero background image
使用对象池提升 Unity 中 C# 脚本的性能

通过在 Unity 项目中实现常见的游戏编程设计模式,您可以有效地构建和维护干净、有序且可读的代码库。设计模式不仅可以减少重构和测试时间,还可以加快入职和开发流程,为游戏、开发团队和业务的发展奠定坚实的基础。

不要将设计模式视为可以复制并粘贴到代码中的最终解决方案,而应将其视为额外的工具,如果使用得当,它可以帮助您构建更大、可扩展的应用程序。

本页介绍了对象池以及它如何帮助提高游戏的性能。它包含如何在项目中实现 Unity 内置对象池系统的示例。

这里的内容基于免费电子书 《使用游戏编程模式提升你的代码》,它解释了众所周知的设计模式并分享了在你的 Unity 项目中使用它们的实际示例。

Unity 游戏编程模式系列中的其他文章可在 Unity 最佳实践 中心找到,或者单击以下链接:

4-2 层次结构
理解 Unity 中的对象池

对象池是一种设计模式,它可以通过减少 CPU 运行重复创建和销毁调用所需的处理能力来提供性能优化。相反,通过对象池,现有的游戏对象可以被反复重用。

对象池的关键功能是提前创建对象并将其存储在池中,而不是根据需要创建和销毁它们。当需要一个对象时,它会从池中取出并使用,当不再需要时,它会返回到池中而不是被销毁。

上图说明了对象池的一个常见用例,即从炮塔发射射弹。让我们一步一步地解析这个例子。

对象池模式不是创建然后销毁,而是使用一组已初始化的对象,这些对象保持在已停用的池中并等待。然后,该模式会预先实例化游戏开始前特定时刻所需的所有对象。应在玩家不会注意到卡顿的适当时间激活池,例如在加载屏幕期间。

一旦池中的游戏对象被使用,它们就会被停用并准备在游戏再次需要它们时使用。当需要一个对象时,您的应用程序不需要首先实例化它。相反,它可以从池中请求它,激活和停用它,然后将其返回到池中而不是销毁它。

这种模式可以减少运行垃圾收集所需的内存管理繁重工作的成本,如下一节所述。

Unity Profiling 电子书
内存分配

在了解如何利用对象池的示例之前,让我们先简单看一下它有助于解决的根本问题。

池技术不仅有助于减少实例化和销毁操作所花费的 CPU 周期。它还通过减少对象创建和销毁的开销来优化内存管理,这需要分配和释放内存以及调用构造函数和析构函数。

Unity 中的托管内存

Unity 的 C# 脚本环境提供了托管内存系统。它有助于管理内存的释放,因此您不需要通过代码手动请求它。内存管理系统还可以帮助保护内存访问,确保释放不再使用的内存,并防止访问对您的代码无效的内存。

Unity 使用 垃圾收集器 从应用程序和 Unity 不再使用的对象中回收内存。然而,这也会影响运行时性能,因为分配托管内存会耗费 CPU 的时间,并且 垃圾收集 (GC) 可能会阻止 CPU 执行其他工作,直到其完成任务为止。

每次在 Unity 中创建新对象或销毁现有对象时,都会分配和释放内存。这就是对象池发挥作用的地方:它减少了由于垃圾收集峰值而可能导致的卡顿。由于内存分配,GC 峰值通常伴随着大量对象的创建或销毁。除了过早的垃圾收集之外,该过程还会导致内存碎片,从而更难找到可用的连续内存区域。

通过停用和激活相同的现有对象来回收它们,您可以创建一种效果,例如在屏幕外发射数百发子弹,而实际上,您只是禁用并回收它们。

在我们的高级分析指南中了解有关内存管理的更多信息

对象池示例项目
Using UnityEngine.Pool

虽然您可以创建自己的自定义系统来实现对象池,但是 Unity 中有一个内置的 ObjectPool 类,您可以使用它在项目中有效地实现此模式(在 Unity 2021 LTS 及更高版本中可用)。

让我们通过 Github 上提供的这个 示例项目 来看一下如何使用 UnityEngine.Pool API 来利用内置对象池系统。进入 Github 页面后,转到 Assets>7 Object Pool>Scripts>ExampleUsage2021 获取文件。

注意:您可以查看 Unity Learn的本教程 ,查看 Unity 早期版本中对象池的示例。

此示例由一个炮塔组成,当按下鼠标按钮时,炮塔会快速发射射弹(默认设置为每秒 10 枚射弹)。每个射弹都会穿过屏幕,当其离开屏幕时就需要被销毁。正如上一节所述,如果没有对象池,这会对 CPU 和内存管理造成相当大的拖累。

通过使用对象池,看起来好像有数百颗子弹被发射到屏幕外,但实际上,它们只是被一遍又一遍地禁用和回收。

示例脚本中的代码有助于确保池大小足够大以显示并发活动对象,从而掩盖相同对象不断被重用的事实。

如果您使用过 Unity 的粒子系统,那么您对对象池就有第一手的经验。粒子系统组件包含最大粒子数量的设置。这将循环利用可用的粒子,防止效果超过最大数量。对象池的工作原理类似,但可以使用您选择的任何游戏对象 (GameObject)。

解压 RevisedGun.cs

我们来通过 Assets>7 Object Pool>Scripts>ExampleUsage2021查看 Github demo 中 RevisedGun.cs 代码。

首先要注意的是包含池命名空间:

using UnityEngine.Pool;

通过使用 UnityEngine.Pool API,您可以获得一个基于堆栈的 ObjectPool 类来使用对象池模式跟踪对象。根据您的需要,您还可以使用 CollectionPool 类(List、HashSet、Dictionary 等)

然后,您可以针对枪支射击特性应用特定设置,包括要生成的预制件(名为 ProjectilePrefab,类型为 RevisedProjectile)。

ObjectPool 接口引用自 RevisedProjectile.cs(下一节将进行说明),并在 Awake 函数中进行初始化。

私有无效唤醒()

{
objectPool = new ObjectPool<RevisedProjectile>(CreateProjectile,

OnGetFromPool, OnReleaseToPool,

OnDestroyPooledObject,collectionCheck,defaultCapacity,maxSize);
}

如果您探索 ObjectPool<T0> 构造函数,您会发现它包含在以下情况下设置某些逻辑的有用功能:

首先创建一个池项目来填充池

从池中取出一件物品

将物品放回池中

销毁池对象(例如,如果达到最大限制)

请注意,内置的 ObjectPool 类还包含默认和最大池大小的选项,后者是池中存储的最大项目数。当您调用 Release 时它会被触发,如果池已满,它会被销毁。

让我们看看代码示例如何采取几个操作,指定 Unity 应如何根据您的具体用例有效地处理对象池。

首先,传递 createFunc,用于在池为空时创建一个新实例,在本例中是实例化新配置文件预制件 (Prefab) 的 CreateProjectile()。

私人 RevisedProjectile CreateProjectile()

{
RevisedProjectile projectileInstance = Instantiate(projectilePrefab);
projectileInstance.ObjectPool = objectPool;

return projectileInstance;
}

当您请求 GameObject 的实例时,OnGetFromPool 会被调用,因此您默认启用从池中获取的 GameObject。

私有 void OnGetFromPool(RevisedProjectile pooledObject)

{
pooledObject.gameObject.SetActive(true);
}

当 GameObject 不再需要并将其返回到池时,将使用 OnReleaseToPool - 在此示例中,它只是再次停用它的问题。

私有 void OnReleaseToPool(RevisedProjectile pooledObject)

{
pooledObject.gameObject.SetActive(false);
}

当超出允许的最大池项目数时,将调用 OnDestroyPooledObject。由于池已满,该对象将被销毁。

私有 void OnDestroyPooledObject(RevisedProjectile pooledObject)

{
Destroy(pooledObject.gameObject);
}

collectionChecks 用于初始化 IObjectPool,当您尝试释放已经返回到池管理器的 GameObject 时将引发异常,但此检查仅在编辑器中执行。通过关闭它,您可以节省一些 CPU 周期,但是,存在返回已重新激活的对象的风险。

顾名思义,defaultCapacity 是包含元素的堆栈/列表的默认大小,因此也是您想要预先提交的内存分配量。maxPoolSize 将是堆栈的最大大小,并且创建的池化游戏对象绝不会超过此大小。这意味着如果您将物品放回已满的池中,该物品将被销毁。

然后,在 FixedUpdate() 中,您将获得一个池对象,而不是每次运行发射子弹的逻辑时都实例化一个新的射弹。

RevisedProjectile bulletObject = objectPool.Get();

就这么简单。

解压 RevisedProjectile.cs

现在让我们看一下 RevisedProjectile.cs 脚本。

除了设置对 ObjectPool的引用(这使得将对象释放回池更加方便)之外,还有一些有趣的细节。

timeoutDelay 用于跟踪射弹何时被“使用”并可以再次返回到游戏池 - 默认情况下在三秒后发生这种情况。

Deactivate() 函数激活了一个名为DeactivateRoutine(float delay)的协程,它不仅会用objectPool.Release(this)将弹丸释放回池中,还会重置移动的Rigidbody速度参数。

该过程解决了“脏物品”的问题:过去使用过的物品,由于其状态不佳而需要重置。

正如您在此示例中看到的,UnityEngine.Pool API 使得对象池的设置变得高效,因为您不必从头开始重建模式,除非您有特定的用例。

您并不局限于仅限于 GameObjects。池化是一种性能优化技术,用于重用任何类型的 C# 实体:游戏对象 (GameObject)、实例化预制件 (Prefab)、C# 字典等。Unity 为其他实体提供了一些替代池类,例如提供对字典支持的 DictionaryPool<T0,T1> 和提供对 HashSet 支持的 HashSetPool<T0>。在 文档中了解有关这些内容的更多信息。

LinkedPool 使用链接列表来保存对象实例集合以供重用,这可能导致更好的内存管理(取决于您的情况),因为您只将内存用于实际存储在池中的元素。

将其与 ObjectPool 进行比较,后者仅使用底层的 C# 堆栈和 C# 数组,因此包含一大块连续的内存。缺点是,与在 ObjectPool 中相比,您在 LinkedPool 中为每个项目花费更多的内存和更多的 CPU 周期来管理此数据结构,在 ObjectPool 中您可以利用 defaultSize 和 maxSize 来配置您的需求。

博客封面
实现对象池的其他方法

对象池的使用方式因应用程序而异,但当武器需要发射多个射弹时,这种模式通常出现,如前面的例子所示。

一个好的经验法则是在每次实例化大量对象时对代码进行分析,因为这样可能会导致垃圾收集高峰。如果您检测到明显的峰值,导致您的游戏存在卡顿的风险,请考虑使用对象池。请记住,由于需要管理池的多个生命周期,对象池可能会增加代码库的复杂性。此外,您还可能会通过创建过多过早的池而最终保留游戏并不一定需要的内存。

如前所述,除了本文中的示例之外,还有其他几种方法可以实现对象池。一种方法是创建您自己的可根据您的需要进行定制的实现。但是您需要注意类型和线程安全的复杂性,以及定义自定义对象分配/释放。

令人高兴的是,Unity Asset Store 提供了一些很好的替代方案来节省您的时间。

更多 Unity 编程高级资源

电子书《 通过游戏编程模式提升你的代码》提供了一个简单的自定义对象池系统的更全面的示例。Unity Learn还提供了对象池的介绍(您可以 在此处找到),以及使用 2021 LTS 中新的内置对象池系统的 完整教程

所有高级技术电子书和文章均可在Unity 最佳实践中找到中心。电子书也可以在文档中的高级最佳实践页面。

您喜欢本文吗?
是的!
还行。