喵的Unity游戏开发之路 - 对象复用
如果丢失格式、图片或视频,请查看原文:https://mp.weixin.qq.com/s/pPQ8LTMfsM3J08_ACtu9MA
前言
很多童鞋没有系统的Unity3D游戏开发基础,也不知道从何开始学。为此我们精选了一套国外优秀的Unity3D游戏开发教程,翻译整理后放送给大家,教您从零开始一步一步掌握Unity3D游戏开发。 本文不是广告,不是推广,是免费的纯干货!本文全名:喵的Unity游戏开发之路 - 对象管理 - 对象复用 - 对象池
销毁形状。
自动创建和销毁。
构建一个简单的GUI。
使用事件探查器跟踪内存分配。
使用对象池回收形状。
这是有关对象管理的系列教程中的第三篇。它增加了销毁形状的能力,然后提供了重用形状的方法。
本教程使用Unity 2017.4.4f1制作。
销毁对象
如果我们只能创建形状,那么它们的数量只能增加,直到开始新游戏。但是几乎总是在游戏中创建某些东西时也可以将其销毁。因此,让我们有可能破坏形状。
破坏的钥匙
已经有一个创建形状的关键点,因此添加一个关键点以销毁它也很有意义。为此Game添加一个关键变量。尽管D似乎是一个合理的默认值,但它是用于移动的通用WASD键位配置的一部分。让我们改用X,它是取消或终止的通用符号,在大多数键盘上都位于C旁边。
public KeyCode createKey = KeyCode.C; public KeyCode destroyKey = KeyCode.X;
销毁随机形状
给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
方法已经可以使用了,当玩家按下销毁键时Update调用它。
void Update () {
if (Input.GetKeyDown(createKey)) {
CreateShape();
}
else if (Input.GetKeyDown(destroyKey)) {
DestroyShape();
}
…
}
保持清单正确
现在,我们能够创建和销毁对象。但是,当尝试破坏多个形状时,您可能会得到一个错误:MissingReferenceException:类型'Shape'的对象已被破坏,但您仍在尝试访问它。
发生错误的原因是,尽管我们已经破坏了形状,但尚未将其从shapes
列表中删除。因此,该列表仍然包含对销毁游戏对象的组件的引用。它们仍然以僵尸状存在于内存中。再次尝试销毁此类对象时,Unity报告错误。
解决方案是正确摆脱对我们刚破坏的形状的引用。因此,销毁形状后,将其从列表中删除。这可以通过调用列表的RemoveAt
方法来完成,将要删除的元素的索引作为参数。
void DestroyShape () {
if (shapes.Count > 0) {
int index = Random.Range(0, shapes.Count);
Destroy(shapes[index].gameObject);
shapes.RemoveAt(index);
}
}
高效清除
尽管此方法有效,但这并不是从列表中删除元素的最有效方法。由于列表是有序的,因此删除一个元素会在列表中留下空白。从概念上讲,这种差距很容易消除。被删除元素的相邻元素只是彼此成为邻居。
但是,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);
}
}
不断的创造与破坏
一次创建和销毁形状并不是填充或填充游戏的快速方法。如果我们要不断创建和销毁它们怎么办?我们可以通过一次又一次快速地按下按键来做到这一点,但这很快就会变得很烦人。因此,让它自动化。
应该以什么速度创建形状?我们将使其可配置。这次我们不会通过检查器进行控制。取而代之的是,我们将其设置为游戏本身的一部分,以便玩家可以随心所欲地改变速度。
图形用户界面
为了控制创建速度,我们将向场景添加图形用户界面(GUI)。GUI需要画布,可以通过GameObject / UI / Canvas创建画布。这将两个新游戏对象添加到场景中。首先是画布本身,然后是一个事件系统,它可以与它进行交互。
这两个对象都有多个组成部分,但是我们不必理会它们的细节。我们可以按原样使用它们,而无需进行任何更改。默认情况下,画布充当叠加层,在屏幕空间的游戏窗口中的场景顶部渲染。
尽管从逻辑上讲,屏幕空间画布在3D空间中不存在,但它仍显示在场景窗口中。这使我们可以对其进行编辑,但是在场景窗口处于3D模式时很难做到这一点。GUI与场景摄影机不对齐,其比例为每个像素一个单位,因此它最终像场景中某个地方的巨大平面一样。编辑GUI时,通常将场景窗口切换为2D模式,可以通过其工具栏左侧的2D按钮进行切换。
创建速度标签
在添加用于创建速度的控件之前,我们将添加一个标签,告诉播放器有关的内容。为此,我们通过GameObject / UI / Text添加文本对象并将其命名为Creation Speed Label。它会自动成为画布的子代。实际上,如果没有画布,则在创建文本对象时会自动创建一个画布。
GUI对象的功能类似于所有其他游戏对象,除了它们具有Rect Transform组件之外,该组件扩展了常规的Transform组件。它不仅控制对象的位置,旋转和比例,还控制其矩形大小,枢轴点和锚点。
锚点控制GUI对象如何相对于其父容器定位,以及它如何响应其父容器的大小变化。让我们将标签放在游戏窗口的左上方。无论最终使用什么窗口大小,要使其保持在该位置,请将其锚点设置在左上方。您可以通过单击“ 锚点”正方形并选择弹出的适当选项来执行此操作。还将显示的文本更改为Creation Speed。
将标签放置在画布的左上角,在标签和游戏窗口边缘之间留一点空白。
创作速度滑块
我们将使用滑块控制创建速度。通过GameObject / UI / Slider添加一个。这将创建多个对象的层次结构,这些层次结构一起构成一个GUI滑块小部件。将其本地根对象命名为Creation Speed Slider。
将滑块直接定位在标签下方。默认情况下,它们具有相同的宽度,并且标签在文本下方有足够的空白空间。因此,您可以将滑块向上拖动到标签的底部边缘,它将紧贴标签边缘。
Slider
滑块的本地根对象的组件具有许多设置,我们将保留它们的默认值。我们唯一要更改的是其“ 最大值”,它定义了最大创建速度,以每秒创建的形状表示。让我们将其设置为10。
设定创作速度
滑块已经可以使用,您可以在播放模式下对其进行调整。但这还没有任何影响。首先,我们必须给Game增加创建速度,因此需要进行一些更改。我们给它一个默认的公共CreationSpeed
属性。
public float CreationSpeed { get; set; }
滑块的检查器底部有一个“更改值(单个)”框。这表示在滑块的值更改后被调用的方法或属性的列表。On Value Changed后面的(单个)表示更改的值是浮点型。当前列表为空。通过单击框底部的+按钮更改此设置。
现在,事件列表包含一个条目。它具有三个配置选项。第一个设置控制何时应激活此条目。默认情况下,将其设置为“ 仅运行时”。在其下方是用于设置应定位的游戏对象的字段。将对我们的Game对象的引用拖到其上。这使我们可以选择附加到目标对象的组件的方法或属性。现在,我们可以使用第三个下拉列表,选择Game,然后在Dynamic float标头下的顶部选择CreationSpeed。
我有一个零输入字段作为第四个选项?
从“ 静态参数”列表中选择CreationSpeed时,就会发生这种情况。顾名思义,它允许您配置固定值以用作参数,而不是动态滑块值。您必须改为使用动态选项。
连续造型
为了使连续创建成为可能,我们必须跟踪创建进度。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();
}
现在,您可以让游戏以所需的速度创建规则的新形状流,速度高达每秒十个形状。如果要关闭自动创建过程,只需将滑块设置回零即可。
连续形状破坏
接下来,重复我们对创建滑块所做的所有工作,但现在对销毁滑块进行重复。创建另一个标签和滑块,最快的方法是复制现有标签和滑块,将其向下移动并重命名。
然后添加一个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上工作时,将画布显示在场景窗口中可能会很烦人。您可以通过编辑器右上角的“ 层”菜单将其(或特定层上的任何其他层)隐藏。默认情况下,所有GUI对象都位于UI层上,您可以通过切换其眼睛按钮使其不可见。这会影响场景窗口,但不会影响游戏窗口。
对象池
每次实例化对象时,都必须分配内存。而且,每当对象被销毁时,就必须回收其使用的内存。但是开垦并不会立即发生。有一个垃圾收集过程有时会运行以清理所有内容。这是一个昂贵的过程,因为它必须根据是否还有任何引用来确定哪些对象实际上不再有效地存在。因此,已使用的内存量会增长一段时间,直到被认为很多为止,然后无法访问的内存将被识别并再次可用。如果涉及许多内存块,这可能会导致游戏中的显着帧频下降。
虽然重用低级内存很困难,但在更高级别重用对象要容易得多。如果我们从不销毁游戏对象,而是回收它们,那么垃圾收集过程就不需要运行。
剖析
要了解发生多少内存分配以及何时进行分配,可以使用Unity的探查器窗口,您可以根据Unity版本通过Window / Profiler或Window / Analysis / Profiler打开该窗口。在播放模式下,它可以记录很多信息,包括CPU和内存使用情况。
在积累了一些形状之后,让游戏以最大的创建和破坏速度运行一段时间。然后在探查器的数据图上选择一个点,这将暂停游戏。选择“ CPU”部分时,所选框架的所有高级调用均显示在图形下方。您可以按内存分配对调用进行排序,这在GC Alloc列中显示。
在大多数帧中,总分配为零。但是,当在该帧中实例化形状时,您会在顶部看到一个分配内存的条目。您可以展开该条目以查看Game.Update
负责实例化的调用。
在运行期间,编辑器中分配的字节数可以不同。游戏没有像独立版本那样进行优化,编辑器本身也会影响分析。通过创建独立的开发构建,并使其自动连接到编辑器进行概要分析,可以获得更好的数据。
创建内部版本,运行一段时间,然后在编辑器中检查探查器数据。
尽管我们仍在与必须收集和发送概要分析数据的开发版本一起工作,但是此概要分析数据不受编辑器的影响。
回收利用
由于我们的形状是简单的游戏对象,因此不需要太多内存。尽管如此,持续不断的新实例化流最终将触发垃圾回收过程。为防止这种情况,我们必须重用形状而不是破坏形状。因此,每次游戏破坏形状时,我们都应该将它们退回工厂进行回收。
回收形状是可行的,因为它们在使用时不会发生太大变化。他们得到随机的变换,材质和颜色。如果进行了更复杂的调整(例如添加或删除组件或添加子对象),那么回收将不可行。为了同时支持这两种情况,让我们添加一个切换开关ShapeFactory
来控制是否回收。当前我们的游戏可以回收,因此请通过检查器启用它。
[SerializeField]
bool recycle;
合并形状
回收形状时,我们将其放入备用池中。然后,当要求提供新形状时,我们可以从该池中获取现有形状,而不是默认情况下创建新形状。仅当池为空时,我们才必须实例化新形状。对于工厂可以生产的每种形状类型,我们都需要一个单独的池,因此为它提供一个形状列表数组。
-
using System.Collections.Generic;
using UnityEngine;
-
[
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;
}
从池中检索对象
实例化形状并设置其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不会序列化堆栈。您可以改用堆栈,但是列表可以正常工作。
回收对象
要使用这些池,工厂必须有一种方法来回收不再需要的形状。这可以通过添加带有shape参数的公共方法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);
}
回收而不是销毁
工厂无法强制要求将形状返回给它。它是由Game
使回收可能,通过调用Reclaim
,而不是Destroy
在DestroyShape
。
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
播放效果良好,并且在将形状归还后仍不会破坏形状。那会导致错误。因此,这不是一个简单的技术,程序员必须表现出来。仅将从工厂获得的形状退还给它,而无需对其进行重大更改。尽管可以破坏形状,但无法回收。
行动中的回收
尽管无论是否启用回收功能,游戏都仍然发挥相同的作用,但是您可以通过观察层次结构窗口来看到差异。当创建和销毁以相同的速度发生时,您会看到形状将变为活动和不活动状态,而不是被创建和销毁。一段时间后,游戏对象的总量将变得稳定。仅当特定形状类型的池为空时,才会创建新实例。除非创建速度高于销毁速度,否则游戏运行的时间越长,发生频率就越低。
您还可以使用事件探查器来验证内存分配发生的频率要少得多。尚未完全消除它们,因为有时仍必须创建新形状。另外,有时回收对象时会分配内存。发生这种情况有两个原因。首先,池列表有时需要增长。其次,要停用对象,我们必须访问该gameObject
属性。在属性第一次检索对游戏对象的引用时,这会分配一点内存。因此,只有在每种形状第一次被回收时才会发生这种情况。
下一个教程是“ 多场景”。
资源库(Repository)
https://bitbucket.org/catlikecodingunitytutorials/object-management-03-reusing-objects
往期精选
Unity3D游戏开发中100+效果的实现和源码大全 - 收藏起来肯定用得着
喵的Unity游戏开发之路 - 从入门到精通的学习线路和全教程
声明:发布此文是出于传递更多知识以供交流学习之目的。若有来源标注错误或侵犯了您的合法权益,请作者持权属证明与我们联系,我们将及时更正、删除,谢谢。
原作者:Jasper Flick
原文:
https://catlikecoding.com/unity/tutorials/object-management/reusing-objects/
翻译、编辑、整理:MarsZhou
More:【微信公众号】 u3dnotes