英文原文:https://thegamedev.guru/unity-addressables/pooling/
如果您打算跳入 Unity Addressables 对象池,请小心。 您最好确保对象池不为空。
在之前的帖子中,我向您展示了如何在游戏中无限制地加载内容。
嗯,当然有限制,但如果你在实施资产内容管理系统方面做得很好,你就不太可能达到这些限制。 如果你听从我的建议,我很确定你会做对的。
但到目前为止,我可以说我们遗漏了一个重要的拼图。 我的意思是,我们遗漏了很多,但今天我想关注一个非常具体的问题:延迟。
什么是延迟?
延迟是从开始到完成某件事所花费的时间。 这是我们通常想要避免的某种延迟。
例如,您在用微波炉烹制爆米花时会遇到延迟问题。 在那里,您启动微波炉并等待 3 到 5 分钟。 而且我们想马上吃爆米花,所以这种延迟很糟糕。
当我们进入游戏领域时,事情变得比煮爆米花更糟糕。
在游戏中,毫秒很重要。 超过 20 毫秒的一切都会让竞争性多人游戏变得更加不公平。
但在这篇文章中,我们不是在谈论多人游戏。 我们将讨论使用 Addressables for Unity 加载和显示资产时遇到的延迟。
实际上,我们会为此做点什么。
我们将实现一个简单的 Unity Addressables Pooling 系统。
内容
- 一级开发人员:简单的 Unity Addressables 加载
- 二级开发人员:Unity Addressables Pooling
- 三级开发人员:Smart Unity Addressables Pooling
- 性能表现
- 自动池化
- 自动池化
一级开发人员:简单的 Unity Addressables 加载
是的,我知道。 我们已经这样做过好几次了。
我们采用一个预制件,将其标记为可寻址,然后将其分配给一个脚本,该脚本会在需要时加载预制件。
与基于直接引用的传统资产管理工作流程相比,这给您带来了巨大的好处。 简而言之,使用 Addressables 可以让你……
- 加载时间更短 : 满足玩家对加载时间的要求
- 微小的迭代时间 : 大大减少编辑器的播放和部署时间
- 消除内存压力 : 定位更多设备 减少崩溃
- 出售您的下一个 DLC : 几乎零努力地计划、制作和运送您的 DLC
要阅读更多相关信息,请访问我关于 Unity Addressables Benefits: 3 Ways to Save Your Game 的介绍性文章。
在这篇博文中,我将坚持展示我极其复杂的示例项目设置。
哦,没关系,它只是一个通过 Addressables API 实例化的预制件……
这在大多数情况下都适用于任何游戏。
然而…
这个加载和实例化过程有一些延迟。 Unity 必须获取所需的资产包,加载预制件及其依赖项并实例化。
加载过程应远低于 1 毫秒。
但是当我们给这个对象增加更多的复杂性时,事情就会变得一团糟。 如果我们添加动画师、粒子系统、刚体等,Unity 最终肯定会从我们这里偷走 10 毫秒。 激活这些组件可能会花费大量时间。
如果资产包是通过网络提供的,但它们还没有准备好,那么我们说的是几秒钟,甚至几分钟。
如果在生成最终 Boss 时玩家已经到达地牢的尽头,您的游戏会有多恐怖?
这是我的猜测:既有利可图又可怕。
Unity 中的一个典型解决方案依赖于添加对象池。
您可以在网上找到许多适用于 Unity 的对象池。 问题是,它们还没有为 Addressables 做好准备。
但是现在,你会得到一个。
二级开发人员:Unity Addressables 对象池
让我在这里警告您:对于池化系统的需求因项目而异。
在这里,我将为您提供一个简单的系统,您可以根据自己的需要进行调整。
这就是您希望从这个池化系统中得到的:
隐藏你的延迟 : 快速激活和停用
很少的编程开销 : 简单的 API:获取和返回
更少的内存压力 : 如果池被禁用,请释放内存。 如果启用池,则取回内存。
如果您想知道:是的,我重新使用了上一节中的图标。 这里很忙。
在我们进入代码之前,我将向您展示我准备的测试。
1.预热异步池
到目前为止,预制件及其内容尚未加载到内存中。 池已启用并根据 Addressables 加载预制件。
然后,它实例化几个对象并将它们全部停用,付出 Awake、Start、OnEnable 和 OnDisable 的代价。
到目前为止,预制件内容在内存中。
2. 帮助我们的游戏玩法:从池中取出一个物品
用户通过同步方法 Take() 从池中取出一个项目并将其放在场景中的某个位置。
用户支付激活 (OnEnable) 时间,这取决于他们预制件的复杂性。
3.节省CPU时间:将物品归还到池中
用户厌倦了他们的新玩具并将其放回池中。
池将其停用并将其置于其层次结构下,付出 OnDisable 的代价。
4.释放内存:disable 池
一段时间后,我们知道我们将不再需要这个项目。
我们禁用池,因此它会释放所有使用的内存,即使间接引用仍然存在于池中。
这种方法的优势依赖于内存管理。 我们决定支付内存价格。
使用传统的 Unity 对象池,我们一直在支付内存开销,即使预制件从未实例化。现在,代码看起来如何?
public class GamedevGuruPoolUserTest : MonoBehaviour
{
[SerializeField] private AssetReference assetReferenceToInstantiate = null;
IEnumerator Start()
{
var wait = new WaitForSeconds(8f);
// 1. Wait for pool to warm up.
yield return wait;
// 2. Take an object out of the pool.
var pool = GamedevGuruPool.GetPool(assetReferenceToInstantiate);
var newObject = pool.Take(transform);
// 3. Return it.
yield return wait;
pool.Return(newObject);
// 4. Disable the pool, freeing resources.
yield return wait;
pool.enabled = false;
// 5. Re-enable pool, put the asset back in memory.
yield return wait;
pool.enabled = true;
}
}
这是一段非常正常的测试代码。
如果有任何相关的内容需要提及,请参阅第 13 行。为什么我们要寻找将我们的资产传递给 GetPool 的池?
这背后的想法是,您可能需要多个池,每种资产类型一个,因此您需要一种方法来识别您想要访问的池。
我不是特别喜欢访问静态变量的静态方法,但您应该根据游戏的需要调整代码。
顺便说一句,您不需要自己复制所有代码。 我准备了一个您可以免费访问的存储库。
访问 GitHub 存储库
池本身的代码如何?
namespace GamedevGuru
{
public class GamedevGuruPool : MonoBehaviour
{
public bool IsReady { get { return loadingCoroutine == null; } }
[SerializeField] private int elementCount = 8;
[SerializeField] private AssetReference assetReferenceToInstantiate = null;
private static Dictionary<object, GamedevGuruPool> allAvailablePools = new Dictionary<object, GamedevGuruPool>();
private Stack<GameObject> pool = null;
private Coroutine loadingCoroutine;
public static GamedevGuruPool GetPool(AssetReference assetReference)
{
var exists = allAvailablePools
.TryGetValue(assetReference.RuntimeKey, out GamedevGuruPool pool);
if (exists)
{
return pool;
}
return null;
}
public GameObject Take(Transform parent)
{
Assert.IsTrue(IsReady, $"Pool {name} is not ready yet");
if (IsReady == false) return null;
if (pool.Count > 0)
{
var newGameObject = pool.Pop();
newGameObject.transform.SetParent(parent, false);
newGameObject.SetActive(true);
return newGameObject;
}
return null;
}
public void Return(GameObject gameObjectToReturn)
{
gameObjectToReturn.SetActive(false);
gameObjectToReturn.transform.parent = transform;
pool.Push(gameObjectToReturn);
}
void OnEnable()
{
Assert.IsTrue(elementCount > 0, "Element count must be greater than 0");
Assert.IsNotNull(assetReferenceToInstantiate, "Prefab to instantiate must be non-null");
allAvailablePools[assetReferenceToInstantiate.RuntimeKey] = this;
loadingCoroutine = StartCoroutine(SetupPool());
}
void OnDisable()
{
allAvailablePools.Remove(assetReferenceToInstantiate);
foreach (var obj in pool)
{
Addressables.ReleaseInstance(obj);
}
pool = null;
}
private IEnumerator SetupPool()
{
pool = new Stack<GameObject>(elementCount);
for (var i = 0; i < elementCount; i++)
{
var handle = assetReferenceToInstantiate.InstantiateAsync(transform);
yield return handle;
var newGameObject = handle.Result;
pool.Push(newGameObject);
newGameObject.SetActive(false);
}
loadingCoroutine = null;
}
}
}
我知道,有点长,但我想把它贴在这里,这样我就可以解释发生了什么。
就像我之前说的,在第 14 行中,我们为您提供了合适的池,正如在本文中我们的目标是每个预制件都有一个池。 我们为此使用运行时Key,这是我们用来识别可寻址资产的字符串。 其他变体可以包括使用泛型和枚举来代替使用单个池对象。
在第 30-33 行中,我们从池中取出一个对象,我们将其作为父对象,然后激活它。 您可能希望向该函数添加更多参数,例如位置和旋转。
我们在第 41-43 行做相反的事情。 就像孩子叛逆离开家一个小时后才回来一样,我们接受了它。 我们停用它并将其父级返回到我们的游戏对象池。
然后是在第 52 行和第 60 行预热池并将其清空的时候了。我们通过实例化和停用 8 个预制件来预热池。 最后,我们调用 Addressables.ReleaseInstance 来释放内存。
这里的策略很明确:当我们怀疑我们需要它时启用池,并在我们不需要时禁用/销毁它。
三级开发人员:智能 Unity Addressables 对象池
Unity Addressables Pooling 系统有很多变体。
这完全取决于您的游戏目标。
性能
例如,您可以优先考虑性能。 如果是这种情况,您当然不希望在 pool 的 Take 和 Return 调用中activate/deactivate整个游戏对象。
activate 非常昂贵。 你想要的是 enable/disable 某些组件,例如渲染器、动画器、画布等。你将停止支付绘制调用而不支付激活时间。
您还可以避免的是过多的设置父级,因为我们也为此付出了高昂的代价。
如果是这种情况,您可能想要使用 PerformancePool。
自动池化
如果不需要性能并且您宁愿节省时间,您也可以让您的生活更轻松并且仍然可以获得池化的好处。
您可以选择 AutomaticPool 组件,它会为您处理加载和卸载预制件。 更有趣的是,它会在用户不需要预制件的情况下经过一段时间后释放所有内存。
如果您对这些即插即用组件感兴趣,您会很高兴知道它们包含在忙碌的开发人员课程的可寻址中。
高技能的游戏开发人员和我将开始冲刺,以转变和提升我们在 Unity 中制作游戏的方式
加入我们,寻求精通游戏性能。
你觉得这篇文章怎么样? 发表评论以分享您使用 Addressables 的经验。