重用对象
对象池
原文地址:https://catlikecoding.com/unity/tutorials/object-management/reusing-objects/
销毁形状。
自动化创建和销毁。
构建一个简单的GUI。
使用分析器跟踪内存分配。
使用对象池来回收形状。
这是关于Object Management系列的第三篇教程。它添加了销毁形状的功能,以及重用形状的方法。
本教程使用Unity 2017.4.4f1制作。
1. 销毁对象
如果我们只能创造形状,那么它们的数量就会不断增加,直到我们开始一款新游戏。但几乎总是当游戏创造出某些内容时,它也可以被摧毁。所以让我们实现销毁形状的可能性。
1.1 销毁的按键
已经有一个创建形状的键,所以添加一个销毁形状的键是有意义的。为Game添加一个Key变量。虽然D看起来是一个合理的默认值,但它是用于移动的常见WASD键配置的一部分。让我们用X来代替,它是取消或终止的常用符号,在大多数键盘上位于C旁边。
public KeyCode createKey = KeyCode.C;
public KeyCode destroyKey = KeyCode.X;
1.2 销毁随机形状
在Game中添加一个DestroyShape方法来处理一个形状的销毁。就像我们创造随机形状一样,我们也销毁随机形状。这是通过为形状列表选择一个随机索引,并使用Destroy方法销毁相应的对象来完成的。
void DestroyShape () {
int index = Random.Range(0, shapes.Count);
Destroy(shapes[index]);
}
但这只在当前存在形状的情况下有效。这可能不是事实,因为还没有形状创建或加载,或者所有现有的已经被销毁。因此,只有当列表中至少包含一个形状时,我们才能销毁一个形状。如果没有,destroy命令将不会执行任何操作。
void DestroyShape () {
if (shapes.Count > 0) {
int index = Random.Range(0, shapes.Count);
Destroy(shapes[index]);
}
}
Destroy处理游戏对象,一个组件,或一个资产。为了摆脱整个形状对象而不仅仅是它的Shape组件,我们必须明确地销毁组件所包含的游戏对象。我们可以通过组件的gameObject属性来访问它。
Destroy(shapes[index].gameObject);
现在我们的DestroyShape方法已经生效了,当玩家按下销毁键时就可以在更新中调用它。
void Update () {
if (Input.GetKeyDown(createKey)) {
CreateShape();
}
else if (Input.GetKeyDown(destroyKey)) {
DestroyShape();
}
…
}
1.3 保持列表正确
我们现在能够创建和销毁对象。然而,当试图破坏多个形状时,您可能会得到一个错误。MissingReferenceException: The object of type ‘Shape’ has been destroyed but you are still trying to access it.(类型为“Shape”的对象已被销毁,但您仍试图访问它。)
出现这个错误是因为尽管我们已经销毁了一个形状,但我们还没有将它从形状列表中移除。因此,列表仍然包含对已销毁游戏对象组件的引用。它们仍然存在于内存中,以一种僵尸般的状态存在。当试图销毁这样的对象第二次,Unity报告一个错误。
解决方案是正确地避免对我们刚刚破坏的形状的引用。因此,在破坏一个形状之后,将它从列表中移除。这可以通过调用列表的RemoveAt方法来完成,并使用要删除元素的索引作为参数。
void DestroyShape () {
if (shapes.Count > 0) {
int index = Random.Range(0, shapes.Count);
Destroy(shapes[index].gameObject);
shapes.RemoveAt(index);
}
}
1.4 高效去除
虽然这种方法可行,但它并不是从列表中删除元素的最有效方法。因为列表是有序的,删除一个元素会在列表中留下空白。从概念上讲,这种差距很容易消除。被移除元素的相邻元素只是彼此相邻。
但是,List类是用数组实现的,因此不能直接操作邻居关系。相反,间隙可以通过将下一个元素移到这个间隙中来消除,因此它直接位于被删除的元素之前的元素之后。这样就把差距向列表的末尾移动了一步。重复这个过程,直到这个缺口从列表的末尾消失。
但是我们不关心我们所记录的形状的顺序。所以这些元素的移动是不需要的。虽然我们不能从技术上避免它,但我们可以通过手动抓取最后一个元素并将其放在被销毁元素的位置,从而跳过几乎所有的工作,有效地将缺口传送到列表的末尾。然后删除最后一个元素。
void DestroyShape () {
if (shapes.Count > 0) {
int index = Random.Range(0, shapes.Count);
Destroy(shapes[index].gameObject);
int lastIndex = shapes.Count - 1;
shapes[index] = shapes[lastIndex];
shapes.RemoveAt(lastIndex);
}
}
2. 持续的创造和销毁
每次创造和销毁一个形状并不是快速填充或消除游戏的方法。如果我们想要不断地创造和销毁它们呢?我们可以通过一遍又一遍地快速按压按键来做到这一点,但这很快就会让人厌倦。让我们使其自动化。
形状应该以什么速度创建?我们将使其可配置。这次我们不会通过检查器来控制它。相反地,我们将让它成为游戏本身的一部分,这样玩家就可以根据自己的喜好改变速度。
2.1 GUI
为了控制创建速度,我们将在场景中添加一个图形用户界面(GUI)。GUI需要画布,这可以通过GameObject / UI / canvas创建。这就为场景添加了两个新的游戏对象。首先是画布本身,然后是一个事件系统,它可以与画布进行交互。
两个对象都有多个组件,但我们不需要考虑它们的细节。我们可以使用它们,不需要改变任何东西。默认情况下,画布作为覆盖层,呈现在游戏窗口的场景顶部,在屏幕空间。
虽然屏幕空间画布在逻辑上并不存在于3D空间中,但它仍然会显示在场景窗口中。这允许我们编辑它,但这很难在场景窗口在3D模式下做。GUI并没有与场景摄像机对齐,它的比例是每像素一个单位,所以它最终会像场景中的一个巨大的平面。当编辑GUI时,你通常会将场景窗口切换到2D模式,你可以通过工具栏左侧的2D按钮进行切换。
2.2 创建速度标签
在添加关于创造速度的控制之前,我们将添加一个标签告诉玩家这是关于什么。我们通过GameObject / UI / text添加一个文本对象,并将其命名为Creation Speed Label。它会自动成为画布的子元素。事实上,如果我们没有画布,当我们制作文本对象时就会自动创建一个。
GUI对象的功能和所有其他游戏对象一样,除了它们有一个Rect Transform 组件,它扩展了常规Transform组件。它不仅控制对象的位置、旋转和缩放,还控制其矩形大小、枢轴点和锚点。
锚点控制GUI对象相对于父容器的位置定位的方式,以及它如何对父容器的大小变化作出反应。让我们把这个标签放在游戏窗口的左上方。为了让它保持在那里,不管我们最终使用的是什么窗口大小,将其锚定在左上角。您可以通过单击Anchor正方形并选择弹出的适当选项来做到这一点。还将显示的文本更改为Creation Speed。
将标签放置在画布的左上角,在它和游戏窗口的边缘之间留下一点空白。
2.3 创建速度滑块
我们将使用一个滑块来控制创建速度。通过GameObject / UI / Slider添加一个。这将创建多个对象的层次结构,这些对象一起形成GUI滑块小部件。命名它的本地根对象Creation Speed Slider。
将滑块放在标签的正下方。默认情况下,它们具有相同的宽度,并且标签在文本下方有大量的空白空间。所以你可以把滑块拖到标签的底部边缘,它会紧挨着它。
滑块的本地根对象的滑块组件有一堆设置,我们将保留它们的默认值。我们唯一要改变的是它的Max Value,它定义了最大的创建速度,以每秒创建的形状表示。我们把它设为10。
2.4 设置创建速度
滑块已经工作了,你可以在播放模式中调整它。但它还没有影响到任何东西。我们必须先给Game增加一个创建速度,所以有些地方需要改变。我们会给它一个默认的公共CreationSpeed属性。
public float CreationSpeed { get; set; }
滑块的检查器底部有一个*On Value Changed (Single)框。这表示在滑块的值更改后调用的方法或属性的列表。On Value Changed后面的(Single)*表示被更改的值是一个浮点数。当前列表为空。通过单击框底部的+按钮来改变这种情况。
事件列表现在只包含一个条目。它有三个配置选项。第一个设置控制什么时候应该激活这个条目。它默认设置为Runtime Only,这是我们想要的。下面是一个设置游戏对象的字段。将Game对象的引用拖到它上面。这允许我们选择附加到目标对象的组件的方法或属性。现在我们可以使用第三个下拉列表,选择Game,然后在顶部的Dynamic float标题下选择CreationSpeed。
第四个选项是零输入字段? 您从静态参数列表中选择CreationSpeed时,就会发生这种情况。顾名思义,它允许您配置一个固定值作为参数,而不是动态滑块值。您必须使用动态选项。
2.5 连续创建形状
为了使持续创建成为可能,我们必须跟踪创作的进度。为此,在Game中添加一个float字段。当这个值达到1时,应该创建一个新形状。
float creationProgress;
进度在Update中增加,通过添加从最后一帧开始的时间,这可以通过Time.deltaTime获得。进度的快慢由时间增量乘以创建速度来控制。
void Update () {
…
creationProgress += Time.deltaTime * CreationSpeed;
}
每次creationProgress达到1时,我们必须将其重置为零并创建一个形状。
creationProgress += Time.deltaTime * CreationSpeed;
if (creationProgress == 1f) {
creationProgress = 0f;
CreateShape();
}
但是我们不太可能得到进度值恰好为1的结果。相反,我们会超出一定程度。所以我们应该检查是否大于等于1。然后我们将进度减少1,保存额外的进度。所以时间并不精确,但我们不会放弃额外的进度。
creationProgress += Time.deltaTime * CreationSpeed;
if (creationProgress >= 1f) {
creationProgress -= 1f;
CreateShape();
}
然而,自上一帧以来,我们可能取得了很大的进展,最终得到了2、3甚至更多的值。这可能发生在帧速率下降,创建速度较大的时候。为了确保我们尽可能快地赶上,将if语句更改为while语句。
creationProgress += Time.deltaTime * CreationSpeed;
while (creationProgress >= 1f) {
creationProgress -= 1f;
CreateShape();
}
现在,你可以让游戏以每秒10个形状的速度,定期创建新的形状。如果您想关闭自动创建过程,只需将滑块设置为零。
2.6 连续销毁形状
接下来,重复我们为创建滑块所做的所有工作,现在做一个销毁滑块。创建另一个标签和滑块,这是最快的方法,复制现有的,向下移动它们,并重命名它们。
然后添加一个DestructionSpeed属性,并将销毁滑块连接到它。如果你复制了创建滑块,只需更改它的目标属性。
public float DestructionSpeed { get; set; }
最后,添加用于跟踪销毁进度的代码。
float creationProgress, destructionProgress;
…
void Update () {
…
creationProgress += Time.deltaTime * CreationSpeed;
while (creationProgress >= 1f) {
creationProgress -= 1f;
CreateShape();
}
destructionProgress += Time.deltaTime * DestructionSpeed;
while (destructionProgress >= 1f) {
destructionProgress -= 1f;
DestroyShape();
}
}
游戏现在能够同时自动创建和销毁形状。如果两者设置为相同的速度,形状的数量大致保持不变。为了让创建和销毁以令人愉悦的方式同步,你可以稍微调整其中一个的速度,直到它们的进程对齐或交替。
我怎样才能去掉场景窗口中的画布? 当不在GUI上工作时,在场景窗口中显示画布是很烦人的。你可以通过编辑器右上角的Layers菜单来隐藏它或特定图层上的任何东西。默认情况下,所有GUI对象都在UI层上,您可以通过切换其眼镜按钮使其不可见。这会影响场景窗口,但不会影响游戏窗口。
3. 对象池
每次实例化对象时,都必须分配内存。每当一个对象被销毁,它所使用的内存就必须被回收。但回收不会立即发生。有一个垃圾收集进程,它偶尔会运行以清理所有内容。这是一个昂贵的过程,因为它必须根据是否有对象仍然持有对它的引用,来确定哪些对象真正不再有效地存在。因此,使用的内存数量会增长一段时间,直到它被认为太多,然后识别不可访问的内存并重新提供。如果涉及到很多内存块,这可能会导致游戏的帧率大幅下降。
虽然重用低级内存很困难,但重用更高级别的对象要容易得多。如果我们从不破坏游戏对象,而是回收它们,那么垃圾收集过程就不需要运行。
3.1 分析
为了了解内存分配的数量和时间,你可以使用Unity的分析器窗口,根据Unity版本,你可以通过Window / Profiler或Window / Analysis / Profiler打开这个窗口。它可以在播放模式下记录大量信息,包括CPU和内存使用情况。
在积累了一些形状后,让游戏以最大的创建和销毁速度运行一段时间。然后在分析器的数据图上选择一个点,这将暂停游戏。当选择CPU部分时,选中的帧的所有高级调用都显示在图的下方。您可以根据内存分配对调用进行排序,内存分配在GC Alloc列中显示。
在大多数帧中,总分配是零。但是当一个形状在那个帧中被实例化时,你会在顶部看到一个分配内存的条目。你可以展开该条目以查看Game.Update负责实例化的调用。
在每次运行的编辑器中,分配的字节数可能有所不同。这款游戏并没有像独立版本那样进行优化,而且编辑器本身也会影响分析。通过创建一个独立的开发构建,并让它自动连接到编辑器进行分析,可以获得更好的数据。
创建构建,运行它一段时间,然后在编辑器中检查分析器数据。
这个分析数据不受编辑器的影响,尽管我们仍然在使用一个必须收集和发送分析数据的开发构建。
3.2 回收利用
因为我们的形状是简单的游戏对象,它们不需要太多内存。尽管如此,持续不断的新实例化流最终将触发垃圾收集过程。为了防止这种情况,我们必须重用形状,而不是破坏它们。所以每次游戏销毁一个形状时,我们都应该将其送回工厂进行回收。
回收形状是可行的,因为它们在使用时不会发生太大的变化。它们得到随机的变换、材质和颜色。如果要进行更复杂的调整——比如添加或删除组件,或添加子对象——那么回收就不可行了。为了支持这两种情况,让我们向ShapeFactory添加一个切换来控制它是否回收。回收对我们当前的游戏是可能的,所以通过检查器启用它。
[SerializeField]
bool recycle;
3.3 把形状放入池中
当一个形状被回收,我们把它放在一个储备池。然后,当请求一个新形状时,我们可以从这个池中获取一个现有的形状,而不是默认创建一个新形状。只有当池为空时,我们才需要实例化一个新形状。对于工厂可以生产的每种形状类型,我们需要一个单独的池,因此给它一个形状列表数组。
using System.Collections.Generic;
using UnityEngine;
[CreateAssetMenu]
public class ShapeFactory : ScriptableObject {
…
List<Shape>[] pools;
…
}
添加一个创建池的方法,即为prefabs数组中的每个条目创建一个空列表。
void CreatePools () {
pools = new List<Shape>[prefabs.Length];
for (int i = 0; i < pools.Length; i++) {
pools[i] = new List<Shape>();
}
}
在Get方法开始时,检查是否启用了回收。如果是,请检查是否存在相关的存储池。如果没有,则此时创建池。
public Shape Get (int shapeId = 0, int materialId = 0) {
if (recycle) {
if (pools == null) {
CreatePools();
}
}
Shape instance = Instantiate(prefabs[shapeId]);
instance.ShapeId = shapeId;
instance.SetMaterial(materials[materialId], materialId);
return instance;
}
3.4 从池中检索对象
实例化形状并设置其ID的现有代码现在应该只在不回收时使用。否则,应该从池中检索实例。为了使这成为可能,必须在决定如何获取实例之前声明instance变量。
Shape instance;
if (recycle) {
if (pools == null) {
CreatePools();
}
}
else {
instance = Instantiate(prefabs[shapeId]);
instance.ShapeId = shapeId;
}
instance.SetMaterial(materials[materialId], materialId);
当启用回收时,我们必须从正确的池中提取实例。我们可以使用形状ID作为池索引。然后从池中获取一个元素并激活它。这是通过在它的游戏对象上调用SetActive方法,并使用true作为参数来实现的。然后把它从池中取出。因为我们不关心池中元素的顺序,我们可以只获取最后一个元素,这是最有效的。
Shape instance;
if (recycle) {
if (pools == null) {
CreatePools();
}
List<Shape> pool = pools[shapeId];
int lastIndex = pool.Count - 1;
instance = pool[lastIndex];
instance.gameObject.SetActive(true);
pool.RemoveAt(lastIndex);
}
else {
instance = Instantiate(prefabs[shapeId]);
}
但这只有在池中有东西时才有可能发生,所以要检查一下。
List<Shape> pool = pools[shapeId];
int lastIndex = pool.Count - 1;
if (lastIndex >= 0) {
instance = pool[lastIndex];
instance.gameObject.SetActive(true);
pool.RemoveAt(lastIndex);
}
如果没有,我们别无选择,只能创建一个新的形状实例。
if (lastIndex >= 0) {
instance = pool[lastIndex];
instance.gameObject.SetActive(true);
pool.RemoveAt(lastIndex);
}
else {
instance = Instantiate(prefabs[shapeId]);
instance.ShapeId = shapeId;
}
为什么使用列表而不是栈? 因为列表可以在播放模式下重新编译,而栈则不行。Unity不序列化栈。您可以使用栈来代替,但是列表也可以。
3.5 回收一个对象
要利用池,工厂必须有办法回收不再需要的形状。这是通过添加带有形状参数的公共Reclaim方法来实现的。该方法还应该首先检查是否启用了回收,如果启用了,则在执行其他操作之前确保池存在。
public void Reclaim (Shape shapeToRecycle) {
if (recycle) {
if (pools == null) {
CreatePools();
}
}
}
在Get中创建池还不够吗? 如果循环在播放模式中从未被切换,那么这就足够了,因为形状必须在被回收之前被回收。在Reclaim中这样做,就可以在游戏模式中切换回收,这使它更容易进行实验。
现在我们确定了池的存在,通过使用其形状ID作为池索引,可以将回收的形状添加到正确的池中。
public void Reclaim (Shape shapeToRecycle) {
if (recycle) {
if (pools == null) {
CreatePools();
}
pools[shapeToRecycle.ShapeId].Add(shapeToRecycle);
}
}
此外,回收的形状必须被停用,这现在代表销毁。
pools[shapeToRecycle.ShapeId].Add(shapeToRecycle);
shapeToRecycle.gameObject.SetActive(false);
但当回收不启用时,形状应该被真实地摧毁。
if (recycle) {
…
}
else {
Destroy(shapeToRecycle.gameObject);
}
3.6 回收代替销毁
工厂不能强制将形状返回给它。这取决于Game通过在DestroyShape调用Reclaim而不是Destroy,使回收成为可能。
void DestroyShape () {
if (shapes.Count > 0) {
int index = Random.Range(0, shapes.Count);
//Destroy(shapes[index].gameObject);
shapeFactory.Reclaim(shapes[index]);
int lastIndex = shapes.Count - 1;
shapes[index] = shapes[lastIndex];
shapes.RemoveAt(lastIndex);
}
}
当开始新游戏时也是如此。
void BeginNewGame () {
for (int i = 0; i < shapes.Count; i++) {
//Destroy(shapes[i].gameObject);
shapeFactory.Reclaim(shapes[i]);
}
shapes.Clear();
}
确保Game运行良好,在归还后仍然不会销毁形状。这会导致错误。所以这并不是一种万无一失的技术,程序员必须遵守规则。只有从工厂获得的形状才应该返回给它,而不需要显著地更改它们。虽然可以销毁形状,但这将使回收成为不可能。
3.7
不管是否启用回收,游戏的玩法都是一样的,但你可以通过观察层级窗口看到不同之处。当创建和销毁以相同的速度发生时,您将看到形状将变得活跃和不活跃,而不是被创建和销毁。游戏物体的总数在一段时间后会变得稳定。只有当特定形状类型的池为空时,才会创建一个新实例。游戏运行时间越长,这种情况发生的频率越低,除非创建速度高于销毁速度。
您还可以使用分析器来验证内存分配发生的频率是否大大降低。它们并没有被完全消除,因为有时还需要创造新的形状。此外,有时在对象回收时分配内存。这种情况的发生有两个原因。首先,池列表有时需要增长。其次,要禁用一个对象,我们必须访问gameObject属性。这将在属性第一次检索到游戏对象的引用时分配少量内存。所以这只发生在每个形状第一次被回收的时候。
下一个教程是多场景。