Unity基础教程系列(三)——复用对象(Object Pools)

2020-09-14 15:27:50 浏览数 (1)

目录

1 销毁对象1.1 销毁物体的快捷键1.2 销毁随机形状1.3 保持列表正确1.4 高效清除2 持续的创造与销毁2.1 GUI2.2 创建速度标签2.3 创建Speed滑动条2.4 设置创建速度2.5 继续形状的创建2.6 继续形状销毁3 对象池3.1 分析3.2 回收再利用3.3 形状池3.4 从池中检索一个对象3.5 回收对象3.6 用回收代替销毁3.7 在Action里进行回收收起

本文重点: 1、销毁形状 2、自动创建和销毁 3、构建简单的GUI 4、使用Profiler追踪内存分配 5、使用对象池回收形状

这是关于对象管理系列的第三篇教程。它增加了破坏形状的能力,以及复用它们的方法。

本教程是CatLikeCoding系列的一部分,原文地址见文章底部。“原创”标识意为原创翻译而非原创教程。

本教程使用Unity 2017.4.4f1编写。

(回收各种形状的展示)

1 销毁对象

如果我们只能创造形状,那么它们的数量只会增加,直到我们开始一个新的游戏为止。但大部分的时候,当一些物体在游戏中被创建时,它也应该可以被销毁。现在让我们让销毁形状变为可能。

1.1 销毁物体的快捷键

创建形状已经有了一个键,所以添加一个键来销毁一个形状是有意义的。为游戏添加一个key变量。虽然D似乎是一个合理的默认值,但它一般用于移动的,常见WASD键配置的一部分。这里我们用X来代替,它是一个表示取消或终止的常用符号,并且在大多数键盘上,它挨着C。

(配置创建和销毁的快捷键)

1.2 销毁随机形状

在Game中添加一个DestroyShape方法来处理一个形状的销毁。就像我们创造随机形状一样,我们也销毁随机形状。这是通过为形状列表选择一个随机索引并使用Destroy方法销毁相应的对象来完成的。

但这只在当前有形状的情况下有效。再很多时候,对象可能还没有创建或加载,或者所有现有的对象都已经被销毁了。所以我们只能在列表中包含至少一个形状时销毁它。否则,destroy命令将什么也不做。

销毁可以作用在游戏对象、组件或资产上。要删除整个shape对象而不仅仅是它的shape组件,所以我们必须明确地销毁该组件所属的游戏对象。可以通过组件的gameObject属性访问它。

既然我们的DestroyShape方法是有效的,那么当玩家按下destroy键时就可以在Update中调用它。

1.3 保持列表正确

我们现在能够创建和销毁对象。然而,当试图销毁多个形状时,你很可能会得到一个错误。

MissingReferenceException: The object of type 'Shape' has been destroyed but you are still trying to access it.

发生此错误的原因是,虽然我们已经销毁了一个形状,但没有从形状列表中删除它。因此,列表仍然包含对已销毁游戏对象组件的引用。它们仍然存在于内存中,以一种僵尸的状态。当第二次试图销毁该对象的时候,Unity会报告这个错误。

解决方法是正确地去掉对我们刚刚销毁的形状的引用。因此,在销毁一个形状之后,将其从列表中删除。这可以通过调用列表的RemoveAt方法来实现,并将要删除的元素的索引作为参数。

1.4 高效清除

虽然这种方法可以达到目的,但它不是从列表中删除元素的最有效方法。因为列表是有序的,所以删除一个元素会在列表中留下空白。从概念上讲,这种差距是很容易消除的。即让被删除元素的相邻元素成为彼此的邻居元素。

(移除D元素的示意)

但是,List类是用数组实现的,因此不能直接操作邻居关系。相反,间隙是通过将下一个元素移到这个间隙中来消除的,因此它直接出现在被删除的元素之前的元素之后。这会将间隙向列表的末尾移动了一步。需要重复这个过程,直到间隙从列表末尾消失。

(慢速移除,按顺序移除)

但我们其实不关心我们要追踪的形状的顺序。所以所有这些元素的转移过程都是不需要的。虽然我们不能从技术上避免它,但我们可以通过手动抓取最后一个元素并将其放在被破坏元素的位置来跳过几乎所有的工作,有效地将间隙传送到列表的末尾。然后删除最后一个元素。

(快速移除,不需要保证顺序)

2 持续的创造与销毁

一次创造和销毁一个形状并不是增加或减少游戏内容的快速方法。如果我们想要不断地创造和摧毁它们呢?当然可以通过一遍又一遍地快速按键来实现,但这样做很快就会让人疲倦。所以我们把它自动化。

形状应该以什么速度创建呢?我们将其设置为可配置。这次我们不打算通过Unity检视器来控制它。相反,我们将使它成为游戏本身的一部分,这样玩家就可以根据自己的喜好改变速度。

2.1 GUI

为了控制创建速度,我们将向场景添加图形用户界面(GUI)。GUI需要画布,可以通过GameObject/ UI / Canvas创建画布。这会将两个新游戏对象添加到场景中。首先是画布本身,然后是一个事件系统,让它们之间可以进行交互。

(Canvas and event system 对象)

两个对象都有多个组件,但我们不需要关心它们的细节。直接用它们本来的样子,不做任何改变。默认情况下,画布设置为一个Overlay,会渲染在游戏窗口的场景的最上层。

虽然屏幕空间的画布逻辑上不是存在于3D空间中,但它仍然会显示在场景窗口中。这允许我们编辑它,但当场景窗口处于3D模式时,这很难做到。GUI与场景摄像机模式不一致,而且它的比例是每像素一个单位,因此它就像场景中的某个地方放置的一个巨大的平面。当编辑GUI时,你可以将场景窗口切换到2D模式,可以通过工具栏左侧的2D按钮进行切换。

(场景窗口上 2D模式)

2.2 创建速度标签

在添加用于创建速度的控件之前,我们先添加一个标签,告诉玩家它是干什么的。为此,我们通过GameObject/ UI / Text添加文本对象并将其命名为Creation Speed Label。它会自动成为Canvas的子节点。实际上,如果没有Canvas的话,它会在创建文本对象时会自动创建一个。

(创建speed的标签对象)

GUI对象的功能和所有其他游戏对象一样,除了它们有一个Rect Transform组件,它扩展了常规Transform组件。不仅控制对象的位置、旋转和缩放,还控制它的矩形大小、枢轴点和锚点。

锚控制GUI对象相对于其父容器的位置,以及它对其父容器的大小变化的反应。我们把标签放在游戏窗口的左上角。无论最终的窗口大小如何,如果要保持它相对位置不变,可以将其锚定在左上角。你可以通过点击锚点并选择弹出的适当选项来做到这一点。然后将显示的文本更改为Creation Speed。

(锚点设置为左上)

将标签放置在画布的左上角,在它和游戏窗口的边缘之间留一点空白。

(放置在Canvas的左上角)

2.3 创建Speed滑动条

我们将使用滑块控制速度创建。通过GameObject/ UI / Slider 添加一个滑动条。这会创建多个对象的层次结构,这些层次结构一起构成一个GUI滑块小部件。将其本地根对象命名为Creation Speed Slider。

(创建Speed的滑块层次结构)

将滑块直接放置在标签下方。默认情况下,它们具有相同的宽度,并且标签在文本下面有足够的空白空间。你可以将滑块向上拖动到标签的底部边缘它会吸附到它的旁边。

(放置滑动条)

Slider局部Root对象的Slider组件有一些设置,保留它们的默认值。我们唯一要改变的是它的最大值,它定义了最大创建速度。设为10。

(最大值设置为10)

2.4 设置创建速度

滑块已经生效了,你可以在播放模式调整它。但它还没有影响到任何东西。必须先给游戏添加一个创造速度,以便对一些东西进行控制和改变。我们给它一个默认的公共CreationSpeed属性。

滑块的检查器底部有一个改变值(单个)的框。它表示在滑块的值更改后调用的一列方法或属性。Value Changed后面的(Single)表示被更改的值是一个浮点数。当前列表为空。通过单击方框底部的 按钮来修改。

(没有连接的滑块)

事件列表现在只包含一个条目。它有三个配置选项。第一个设置控制何时激活该条目。它默认设置为运行时,这正是我们想要的。下面是一个设置游戏对象的字段。将游戏对象的引用拖放到上面。这允许我们选择附加到目标对象的组件的方法或属性。现在我们可以使用第三个下拉列表,选择Game。

(滑动条链接到属性)

我得到了一个输入字段,但第四个选项是0? 当你从静态参数列表中选择CreationSpeed时,就会发生这种情况。顾名思义,这允许你配置一个固定值作为参数,而不是动态滑块的值。你必须使用动态选项而不是静态选项。

2.5 继续形状的创建

为了使持续的创建成为可能,我们必须跟踪创建的进程。为此添加一个float字段到游戏中。当该值达到1时,创建一个新形状。

通过添加从最后一帧开始的时间,在Update中增加进度,该时间可以通过time . deltatime获得。进展有多快是由时间增量乘以创造速度来控制的。

每当creationProgress达到1时,我们必须将其重置为零并创建一个形状。

但是,我们不太可能得到一个恰好为1的进度值。相反,我们会超出一些量。所以我们应该检查是否至少有1个。然后我们将进度减少1,节省额外的进度。时间可能并不准确,但我们不会放弃额外的进度。

但是,有可能由于自上一帧以来已经获得了非常大的进度,所以我们最终得到的值为2,3,甚至更多。这可能发生在帧速率下降的时候。结合高创建速度,为了确保我们尽可能快地赶上进度,可以将if语句更改为while语句。

你现在可以让游戏创建一个规则的新形状流,在一个理想的速度高达10个形状每秒。如果你想关闭自动创建过程,只需将滑块设置回零。

2.6 继续形状销毁

接下来,重复我们为创建滑块所做的所有工作,但现在为销毁滑块。创建另一个标签和滑块,复制现有的标签和滑块,将它们向下移动并重命名,这样做的速度最快。

(创建和销毁滑块)

然后添加一个DestructionSpeed属性,并将销毁滑块连接到它。如果你复制了创建滑块,你只需要改变它的目标属性。

(销毁滑块 链接属性)

最后,添加用于跟踪销毁进程的代码。

游戏现在可以同时自动创建和破坏形状。如果两者都设置为相同的速度,形状的数量大致保持不变。为了让创造和销毁以一种令人愉快的方式同步,你可以稍微调整一个的速度,直到它们的进程一致或交替。

(最大速度下创建和销毁对象)

怎样才能在场景窗口中去掉画布? 当不在GUI上工作时,在场景窗口中显示画布是很烦人的。ni 可以通过编辑器右上角的Layers菜单隐藏它或特定层上的任何其他内容。默认情况下,所有GUI对象都在UI层上,你可以通过切换其眼睛按钮使其不可见。这会影响场景窗口,但不会影响游戏窗口。

(隐藏UI层)

3 对象池

每次实例化一个对象时,都必须分配内存。每次一个对象被销毁时,它使用的内存都必须被回收。但回收不会立即发生。偶尔会运行一个垃圾收集过程来清理所有东西。这是一个代价高昂的过程,因为它必须根据是否有对象仍然持有对它的引用来确定哪些对象实际上不再有效地被引用。

因此,使用的内存数量会增长一段时间,直到它被系统认为占用的太多了,然后不可访问的内存会被识别出来进行回收并再次可用。如果涉及到很多内存块,这可能会导致游戏中的帧速率显著下降。

显然重用低级内存比较困难,但在更高级别重用对象要容易得多。如果我们不销毁游戏对象,而是回收它们,那么就不需要运行垃圾收集过程。

3.1 分析

要了解发生多少内存分配以及何时进行分配,你可以使用Unity的profiler 窗口,你可以根据Unity版本通过Window/ Profiler或Window/ Analysis / Profiler打开该窗口。在运行模式下,它可以记录很多信息,包括CPU和内存使用情况。

在积累了一些形状后,让游戏以最大的创造和销毁速度运行一段时间。然后在profiler 的数据图上选择一个点,它将暂停游戏。当选择CPU部分时,所选帧的所有高级调用将显示在图的下面。你可以按内存分配对调用进行排序,内存分配显示在GC Alloc列中。

在大多数帧中,总分配为零。但是,当在该框架中实例化一个形状时,你将在顶部看到一个分配内存的条目。可以展开该条目以查看Game.Update。它负责实例化的更新。

(创建形状的数据分析)

在每次运行期间,编辑器中分配的字节数可能不同。游戏并没有像独立构建那样得到优化,编辑器本身也会影响性能分析。通过创建独立的开发构建,并将其自动连接到编辑器进行分析,可以获得更好的数据。

(构建设置中开启development build 进行 profiling)

创建构建,运行一段时间,然后在编辑器中检查profiler数据。

(分析一个standalone 构建)

这个分析数据不会受编辑器的影响,但其实我们仍在处理一个必须收集和发送分析数据的开发模式。

3.2 回收再利用

因为我们的形状是简单的游戏对象,它们并不需要太多的内存。但尽管如此,一个不断的新实例化流最终将触发垃圾收集过程。为了防止这种情况,我们需要重用形状,而不是破坏它们。所以每次游戏会破坏一个形状,而不是我们应该把它们送回工厂回收。

回收形状是可行的,因为它们在使用过程中不会改变太多。它们有随机的transform、材质和颜色。如果进行了更复杂的调整,比如添加或删除组件,或者添加子对象,那么回收就不可行了。为了支持这两种情况,让我们添加一个Switch到ShapeFactory来控制它是否回收。回收对于我们当前的游戏是可能的,所以可以通过检查器启用它。

(Factory 开启了recycling)

3.3 形状池

当一个形状被回收时,我们把它放在一个备用池中。然后,当被要求创建一个新形状时,我们可以从这个池中获取一个现有的形状,而不是在默认情况下创建一个新形状。只有当池为空时,我们才需要实例化一个新形状。我们需要为工厂能够生产的每种形状类型提供一个单独的池,因此给它一个形状列表数组。

添加一个创建池的方法,即prefabs数组中的每个条目都有一个空列表。

在Get方法开始时,检查是否启用了回收。如果是,检查池是否存在。如果没有,则此时创建池。

3.4 从池中检索一个对象

实例化形状并设置其ID的现有代码现在应该只在不回收时使用。否则,应该从池中检索实例。要实现这一点,必须在决定如何获取实例之前声明实例变量。

启用回收功能后,我们必须从正确的池中提取实例。我们可以使用形状ID作为池索引。然后从该池中获取一个元素,然后将其激活。这是通过在其游戏对象上调用SetActive方法(以true作为参数)来完成的。然后将其从池中删除。因为我们不在乎池中元素的顺序,所以我们可以直接抓最后一个元素,这是最有效的。

但这只有在池中有东西时才可能,所以检查一下。

如果没有,我们别无选择,只能创建一个新的shape实例。

为什么使用列表而不是堆栈? 因为列表可以在播放模式下重新编译,而堆栈则不能。Unity不会序列化堆栈。 你可以使用堆栈代替,但是列表工作很好。

3.5 回收对象

要使用这些池,工厂必须有一种方法来回收不再需要的形状。这可以通过添加带有形状参数的公共回收方法来实现。此方法还应该首先检查是否启用了回收,如果启用了,则在执行其他操作之前确保池存在。

在Get中创建池还不够吗? 如果回收从来没有在播放模式下进行切换,那么这就足够了,因为一个形状必须在可被回收的时候再进行回收。通过在Reclaim 中这样做,你就可以在游戏模式中切换回收,这让你更容易尝试。

现在我们已经确定了池的存在,可以将回收的形状添加到正确的池中,方法是使用其形状ID作为池索引。

此外,回收的形状必须停用,这代表已经销毁。

但如果不进行回收利用,它的形状应该被真正地摧毁。

3.6 用回收代替销毁

工厂不能强制将形状返回给它。通过调用回收而不是在DestroyShape中调用Destroy,让回收的决定权转嫁于Game。

在开始一个新游戏的时候也是如此。

确保Game运行良好,并且在归还后不会销毁形状。这有可能导致错误。所以这不是一种万无一失的技术,是程序员必须要注意的。只有从工厂得到的形状应该返回到它,而不是显著改变他们。虽然有可能销毁这些形状,但这样就无法回收了。

3.7 在Action里进行回收

不管回收是否被启用,游戏都是一样的,你可以通过观察层级窗口来看到区别。当创建和销毁以相同的速度进行时,你会看到形状将会活跃(激活)和不活跃,而不是被创建和销毁。游戏对象的总数将在一段时间后变得稳定。只有当特定形状类型的池为空时,才会创建一个新的实例。游戏运行的时间越长,这种情况就越少发生,除非创建速度高于销毁速度。

(混合了活动和不活动的对象列表)

你还可以使用分析器来验证内存分配发生的频率是否大大降低。内存分配并不会被完全消除,发生这种情况有两个原因,因为有时仍然需要创建新的形状。此外,有时在回收对象时也会分配内存。。首先,池列表有时需要增长。其次,要停用一个对象,我们必须访问gameObject属性。这在属性第一次检索游戏对象的引用时分配了一点内存。所以这只会发生在每个形状第一次被循环利用的时候。

下一个教程是多场景。

欢迎扫描二维码,查看更多精彩内容。点击 阅读原文 可以跳转原教程。

本文翻译自 Jasper Flick的系列教程

原文地址:

https://catlikecoding.com/unity/tutorials

0 人点赞