Unity基础教程系列——对象管理(二)对象多样化(Fabricating Shapes)

2020-09-08 16:21:21 浏览数 (1)

本文重点: 1、为形状创建一个工厂 2、保存和加载形状的id 3、支持多个材质和随机颜色 4、启用GPU实例化

这是关于对象管理系列的第二篇教程。在这一部分中,我们将添加对不同材质和颜色的多种形状的支持,同时保持游戏向后兼容,即兼容游戏的前一个版本。

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

本教程使用Unity 2017.4.1f1编写。

(这些立方体在游戏结束之后仍然能“幸存”)

1 形状工厂

本教程的目标是让我们的游戏更有趣,通过允许创建其他的形状,而不仅仅是白色的立方体。就像位置、旋转和缩放一样,我们将在玩家每次生成一个新形状时随机创建形状。

1.1 形状类

我们需要具体说明游戏会生成什么样的东西。游戏本身只会产生形状,而不是通常的可持久化对象。因此应该创建一个新的Shape类,它表示几何3D形状。目前来说它只是继承自PersistableObject,尚没有添加任何新东西。

从Cube预制件中删除PersistableObject组件,并为其提供Shape组件。它不能同时具有两者,因为我们为PersistableObject提供了DisallowMultipleComponent属性,该属性也会作用于Shape。

(带有Shape组件的Cube)

但这破坏了游戏对象对预制件的引用。但因为Shape也是一个PersistableObject,所以我们可以重新分配它。

(Game中的Prefab重新获得引用)

1.2 多个不同的形状

创建一个默认的球体和胶囊对象,给它们每人一个Shape组件,并把它们也变成预制件。这些是我们的游戏将支持的其他形状。

(球体和胶囊体形状预制)

那圆柱体呢? 当然,你还可以添加一个圆柱体对象,但我省略了它,因为圆柱体没有自己的碰撞器类型。相反,他们使用的是一个胶囊碰撞器,但并不适合。这虽然不是现在的问题,但以后可能会成为问题。

1.3 工厂Asset

当前,Game只能生成一件事,因为它仅具有对预制件的引用。要支持所有三种形状,将需要三个预制引用。这需要三个字段,但这并不灵活。更好的方法是使用数组。当然,也许以后我们会想出另一种方式来创建形状。但这只会让Game变得更加复杂,因为它还需要负责用户输入,跟踪对象并触发保存和加载等。

为了简化Game,我们将在他们自己的类中支持形状的创建。此类就像工厂一样,按需创建形状,而其用户不必知道如何制作这些形状,甚至不必知道有多少种不同的选择。我们将此类命名为ShapeFactory。

工厂的唯一责任是交付形状实例。它不需要位置,旋转或缩放,也不需要Update方法来更改其状态。因此,它不必是组件,不需要将其附加到游戏对象上。相反,它可以单独存在,不是作为特定场景的一部分,而是作为项目的一部分。换句话说,它是一种资产。可以继承自ScriptableObject而不是MonoBehaviour。

我们现在有了一个自定义资产类型。为了将这样的资产添加到我们的项目中,我们必须为它添加一个条目到Unity的菜单中。最简单的方法是将CreateAssetMenu属性添加到类中。

不现在可以通过资产创建形状工厂来创建我们的工厂。但只需要一个。

(形状工厂资产)

为了让我们的工厂了解形状预制件,可以给它一个shape[]预制件数组字段。我们不希望这个字段是公开的,因为它的内部工作不应该公开给其他类。所以要保密。为了让数组在检查器中显示并被Unity保存,可以添加SerializeField属性给它。

字段出现在检查器中之后,将所有三个形状预置拖放到它上面,这样对它们的引用就会被添加到数组中。确保立方体是第一个元素。第二个元素使用球体,第三个元素使用胶囊。

(带有各个预制件引用的工厂)

1.4 获取形状

要使工厂发挥作用,必须有一种方法可以从它获得shape实例。给它一个公共Get方法。客户端可以通过形状标识符参数指出它想要的形状类型。为此,我们将简单地使用一个整数。

为什么不使用枚举? 这当然是可以的,所以你可以这样做。但是我们并不真正关心在代码中确定确切的形状类型,所以整数可以正常工作。这使得仅通过更改工厂的数组内容就可以控制所支持的形状,而无需更改任何代码。

我们可以直接使用标识符作为索引来查找适当的形状预置,实例化它,并返回它。这意味着0代表立方体,1代表球体,2代表胶囊。即使我们以后改变了工厂的工作方式,我们也必须确保这个标识保持不变,以保持向后兼容。

除了请求一个特定的形状之外,我们还可以通过GetRandom方法从工厂获得一个随机的形状实例。我们可以用随机。范围方法随机选择一个索引。

它不应该是随机的吗?范围是(0,prefab.Length- 1)

Unity是随机的。带整型参数的范围方法使用独占最大值。输出范围从最小到最大- 1。这样做是因为典型的用例期望得到一个随机数组索引,这正是我们在这里所做的。

请注意,Random.Range使用float参数的范围会包含最大值。

1.5 获取形状(原文就是重复标题)

因为我们现在在游戏中创建形状,所以可以显式地将它的列表重命名为shapes。也就是说,凡是写了object的地方,都用shapes代替。最简单的方法是使用代码编辑器的重构功能来更改字段的名称,它将负责在使用它的任何地方对其进行重命名。

然后将列表的项类型更改为Shape。

接下来,删除预制字段,并添加一个shapeFactory字段来保存对形状工厂的引用。

在CreateObject中,我们现在将通过调用shapeFactory创建一个任意形状。GetRandom取代实例化一个显式预置。

也重命名一下实例的变量,这样我们处理的是一个shape实例,而不是之前的预置引用,这样表述会非常明确。同样,你可以使用重构来快速且一致地重命名变量。

当加载时,我们现在还必须使用形状工厂。在这种情况下,我们不能使用随机的形状。之前我们只使用过立方体,因此我们应该获取立方体,这是通过调用shapeFactory.Get(0)来完成的。

同样,这里也明确地说明,我们处理的是一个实例。

(Game现在用factory代替预制件)

在给游戏提供我们工厂的引用之后,它现在将在每次玩家生成新的形状时创建随机形状,而不是总是获得立方体。

(创建随机形状)

2 记住形状

虽然现在可以创建三个不同的形状,但是此信息尚未保存。因此,每次加载已保存的游戏时,最终只能得到立方体。这对于以前保存的游戏是正确的,但对于我们添加了对多种形状的支持后保存的游戏却不正确。我们还必须添加对保存不同形状的功能支持,理想情况下,它应该同时仍然能够加载旧的保存文件。

2.1 Shape的ID属性

为了能够保存物体的形状,物体必须记住这些信息才行。最直接的方法是向shape中添加一个shape标识符字段。

理想情况下,此字段是只读的,因为形状实例始终是一种类型,并且不会更改。但是必须以某种方式为它分配一个值。我们可以将私有字段标记为可序列化,并通过每个预制件的检查器为其分配一个值。但是,这不能保证标识符与工厂使用的数组索引匹配。 

我们也有可能在其他地方使用形状预制件,它可能与工厂无关,或者甚至在某个时候将其添加到另一个工厂。 因此,形状标识符取决于工厂,而不取决于预制件。 因此,这是每个实例而不是每个预制件要跟踪的东西。

默认情况下,私有字段不会序列化,因此预制与它无关。一个新实例将简单地获取该字段的默认值,大多数时候是0,因为我们没有给它另一个默认值。为了使标识符可公开访问,我们将向Shape添加一个ShapeId属性。除了第一个字母是大写字母外,我们使用相同的名称。属性是伪装成字段的方法,因此它们需要一个代码块。

属性实际上需要两个单独的代码块。一种获取它表示的值,另一种进行设置。这些通过get和set关键字标识。也可以仅使用其中之一,但是现在,我们两个都需要。

getter部分只是返回私有字段。setter只给私有字段赋值。为此,setter有一个名为value的适当类型的隐式参数。

通过使用属性,可以向看似简单的检索或赋值添加额外的逻辑。在我们的示例中,当工厂实例化形状标识符时,必须为每个实例精确设置一次。在那之后再设置它将是错误的。

我们可以通过验证标识符在赋值时是否仍然具有默认值来检查赋值是否正确。如果是,则赋值有效。如果没有,则记录一个错误。

但是,0其实是一个有效的标识符。所以我们必须使用别的东西作为默认值。这里先使用可能的最小整数即int.MinValue,也就是-2147483648。另外,我们应该确保标识符不会被重置为默认值。

为什么不直接使用只读(readonly)属性呢? 只读字段或属性只能分配默认值,或在构造函数方法中分配。但不巧的是,我们不能在实例化Unity对象时使用构造函数方法。所以只能使用这样的方法。

调整ShapeFactory.get,它在返回实例之前设置实例的标识符。

2.2 鉴别文件的版本

之前我们没有形状标识符,所以我们没有保存它们。如果我们从现在开始保存它们,我们将使用不同的保存文件格式。如果之前教程中的旧版本无法读取这种格式也没关系,但我们应该确保新游戏仍然可以使用旧格式。

我们将使用保存版本号来标识保存文件使用的格式。因为现在刚开始介绍这个概念,所以我们从版本1开始。将其作为常量整数添加到Game。

const是什么意思? 它将一个简单值声明为常量,而不是字段。它不能被改变,也不存在于内存中。相反,它只是代码的一部分,它的显式值在编译过程中被引用和替换。

保存游戏时,请先编写保存版本号。加载时,请先阅读存储的版本。它告诉我们正在处理什么版本。

但是,这只适用于包含了版本保存的文件。上一教程中的旧保存文件并没有此信息。对应的,写入这些文件的第一件事是对象计数。所以按照现有逻辑的话,我们最终会将计数解释为版本。

值得注意的是,存储在旧保存文件中的对象计数可以是任何数,但它始终至少为零。我们可以使用它来区分保存版本和对象计数。这是通过不逐字写入保存版本来实现的。相反,书写时要翻转版本符号。因为我们从1开始,这意味着存储的保存版本总是小于0。

读取版本时,再次翻转其符号以检索原始数字。如果我们正在读取旧的保存文件,这将导致计数符号的翻转,因此它将变为零或负。因此,当我们最终得到一个小于或等于0的版本时,我们知道我们处理的是一个旧文件。在这种情况下,我们已经有了计数,只需要翻转一下符号。否则,我们就按照需要读取计数。

问号是什么意思? 它是三元运算符,条件是?trueResult:falseResult,它是if-else表达式的一种简写形式。 

在这种情况下,代码等效于以下代码:

这使得新代码能够处理旧的保存文件格式。但是旧代码不能处理新的格式。我们对此无能为力,因为旧的代码已经写好了。我们能做的是确保从现在开始游戏将拒绝加载它不知道如何处理的,未来保存的文件格式。如果加载的版本比我们当前保存的版本高,记录一个错误并立即返回。

2.3 保存形状id

一个形状不应该写它自己的标识符,因为它必须被读取以确定实例化的是哪个形状,并且只有在那之后形状才能加载它自己。所以写标识符是Game的责任。因为我们将所有形状存储在一个列表中,所以我们必须在形状保存自己之前写入每个形状的标识符。

注意,这不是保存形状标识符的唯一方法。例如,还可以为每种形状类型使用单独的列表。在这种情况下,每个列表只需要写入每个形状标识符一次。

2.4 加载形状ID

对于列表中的每个形状,首先加载其形状标识符,然后使用该标识符从工厂获得正确的形状。

但是这只对新的save版本1有效。如果我们是从较旧的保存文件中读取数据,那么只需要获取立方体即可。

3 材质多样性

除了改变衍生对象的形状,我们还可以改变它们的组成。目前,所有的形状使用相同的材质,这是Unity的默认材质。我们可以把它变成随机选择的材质。

3.1 三种材质

创建三种新材质。命名第一个为Standard,保持它不变,以匹配Unity的默认材质。将第二种命名为“Shiny”,并将其平滑度提高到0.9。将第三种命名为metal,并将其金属性和平滑度设置为0.9。

(Standard,Shiny和Metal)

当从工厂得到一个形状时候,现在也需要指定什么类型的材质。这需要ShapeFactory知道的材质的种类。给它一个材质数组,就像它的预置数组一样然后给它分配三个材质。确保Standard是第一个元素。第二种是Shiny的材质,第三种是Metal。

(带有材质的工厂)

3.2 设置形状的材质

为了保存形状的材质,我们现在还需要跟踪材质标识符。为该形状添加一个属性。但是,与其显式地编写属性的工作方式,不如省略getter和setter的代码块。以分号结尾。这将生成一个默认属性,其中包含一个隐式隐藏的私有字段。

当设置一个形状的材质时,我们必须给它实际的材质和它的标识符。这意味着我们必须同时使用两个参数,但是对于属性来说这是不可能的。我们不会依赖于属性的setter。若要禁止在Shape类本身之外使用它,请将setter标记为private。

取而代之,我们添加了一个带有必需参数的公共SetMaterial方法。

这个方法可以通过调用GetComponent 方法来获得形状的MeshRenderer组件。注意,这是一个泛型方法,就像List是泛型类一样。设置渲染器的材质和材质标识符属性。确保将参数赋值给属性,区别在于M是否是大写字母。

3.3 获取带有材质的形状

现在我们可以调整ShapeFactory。以便使用材质。给它增加第二个参数来表示应该使用哪些材质。然后使用它来设置形状的材质和材质标识符。

有可能任何调用Get的人都不关心材质,有标准材质就感到很满意了。所以我们可以支持带有单个形状标识符参数的Get变体。我们可以通过使用0为它的materialId参数分配一个默认值来实现这一点。这使得在调用Get时可以省略materialId参数。因此,现有代码在此时编译时就不会出现错误。

我们也可以对shapeId参数做同样的操作,将其默认值设为0。

如何表示哪些地方需要使用默认值? 只需省略materialId参数传递,这样就可以调用像Get(0)这样的方法。你还可以通过调用Get()来省略这两个参数。然而,如果你想省略shapeId而不是materialId,那么你必须明确你提供的是哪个参数。可以通过在参数值前面加上冒号来标记参数,从而实现这一点。例如,Get(materialId: 0)。

GetRandom方法现在应该选择一个随机的形状和一个随机的材质。所以要使用Random.Range 范围选择一个随机材质标识。

(随机材质的形状)

3.4 保存和加载材质id

保存材质标识符和保存形状标识符的工作原理是一样的。把它写在每个形状的形状标识符之后。

加载也一样。我们不会为这个更改而增加保存版本,因为我们仍然在同一个教程中,这代表着一个公共版本。因此,对于存储形状标识符而不是材料标识符的保存文件,加载将会失败。

4 随机颜色

除了材质,我们还可以改变形状的颜色。通过调整每个形状实例材质的颜色属性来完成。

当然可以像之前一样,定义一组有效的颜色并将它们添加到形状工厂,但是在本例中我们将使用不受限制的颜色。这意味着工厂不需要注意形状和颜色。相反,形状的颜色就像它的位置、旋转和缩放一样被设置。

4.1 形状颜色

为Shape添加SetColor方法,使其能够调整其颜色。当然,调整的它所使用的材质的颜色属性。

为了保存和加载形状的颜色,它必须能够追踪到它。我们不需要提供对颜色的公共访问,所以通过SetColor设置一个私有字段就足够了。

颜色的保存和加载是通过覆盖PersistableObject的保存和加载方法来完成的。首先处理基类,然后处理颜色数据。

但这是假设有方法写入和读取颜色,但目前不是这样的。把它们加起来。首先是GameDataWriter的一个新的写方法。

然后还有GameDataReader的ReadColor方法。

需要将颜色通道存储为float吗? 还可以将它们存储为字节,但如果这样做,最好在任何地方始终使用Color32。这确保了保存和加载的数据总是相同的。你没必要为每个形状可以节省12个字节而费心,除非你确实需要最小化保存文件的大小。同样的,你可以跳过alpha通道,因为它对于不透明的材质来说是不需要的,但是一般来说这也不值得担心。

4.2 剩余的向后兼容

虽然这种方法可以存储形状颜色,但它现在假设颜色存储在保存文件中。这不是旧的保存格式的情况。为了仍然支持旧的格式,我们必须跳过加载颜色。在Game中,我们使用读取版本之后来决定做什么。然而,Shape并不知道这个版本。所以我们必须在加载时传递我们正在读取的数据的版本。将版本定义为GameDataReader的属性是有意义的。

因为读取文件的版本在读取时不会改变,所以该属性应该只设置一次。由于GameDataReader不是Unity对象类,我们可以使用只读属性,只给它一个get部分。这些属性可以通过构造函数方法初始化。为此,我们必须添加版本作为构造函数参数。

可以使用Version = version吗? 可以,但我加this表述会更精确。

现在,读写版本号已经成为PersistentStorage .的职责。版本必须作为参数添加到它的保存方法中,保存方法必须在其他方法之前写入版本。Load方法在构造GameDataReader时读取它。也是在这里,我们将执行符号更改技巧来支持读取0版本文件。

这意味着Game不再需要编写版本保存。

取而代之,它必须在调用PersistentStorage.Save时将其作为参数提供。

在它的Load方法中,它现在可以通过reader.Version检索版本。

现在还可以在Shape.Load中检查版本。如果我们至少有一个版本,然后读取颜色。否则,使用白色。

4.3 选择形状颜色

要创建任意颜色的形状,只需在Game.CreateShape中的新实例上调用SetColor。我们可以用随机。ColorHVS方法生成随机颜色。如果没有参数,该方法可以创建任何有效的颜色,这可能会有点混乱。通过将饱和度范围限制为0.5~1和值范围限制为0.25~1,让我们将自己限制为一个彩色调色板。因为我们现在没有使用,所以我们总是将它设为1。

使用ColorHVS的所有8个参数会使它很难理解,因为它不能立即清楚哪个值控制什么。通过显式地命名参数,可以使代码更易于阅读。

(带有随机颜色的形状)

4.4 记住渲染器

在设置它的材质和颜色时,我们现在需要访问Shape的MeshRenderer组件。使用GetComponent;两次性能并不理想,特别是当我们决定在将来多次改变一个形状的颜色时。因此,让我们将引用存储在一个私有字段中,并在一个新的Awake方法中初始化它。

现在我们可以在SetColor和SetMaterial中使用这个字段。

4.5 使用属性块(Property Block)

设置一个材质的颜色的缺点是,它会导致创建一个新的材质,并且每次设置它的颜色时都会发生这种情况。我们可以使用MaterialPropertyBlock来避免这种情况。通过调用MeshRenderer.SetPropertyBlock,创建一个新的属性块,设置一个名为color的颜色属性,然后使用它作为渲染器的属性块。

除了使用字符串来命名颜色属性外,还可以使用标识符。这些标识符是由Unity设置的。它们可以改变,但在每个会话中保持不变。所以我们只需要获得一次color属性的标识符,然后将它存储在一个静态字段中就足够了。通过调用着色器找到标识符。带有名称的PropertyToID方法。

还可以重用整个属性块。当设置渲染器的属性时,复制块的内容。所以我们不必为每个形状创建一个新的块,我们可以为所有形状不断改变相同块的颜色。

我们可以再次使用静态字段来跟踪块,但是不可能通过静态初始化来创建块实例。Unity不允许这样做。相反,我们可以在使用块之前检查它是否存在。如果没有,我们就在那一点创建它。

现在我们不会再得到重复的材质,你可以通过调整其中一个材质来验证,当在播放模式下使用时,形状会根据变化来调整它们的外观,但如果它们使用了重复的材质,就不会发生这种情况。当然,如果你去调整材质的颜色的话,不会有任何效果,因为每个形状都使用自己的颜色属性,它会覆盖材质的原本的颜色。

4.6 GPU实例化

当我们使用属性块时,可以使用GPU实例化在一个绘图调用中组合使用相同材质的形状,即使它们有不同的颜色。然而,这需要一个支持实例颜色的着色器。这就是这样一个着色器,你可以在Unity GPU实例化手册页面上找到它。唯一的区别是我删除了注释并添加了#pragma实例化选项assumeuniformscaling指令。假设统一的缩放使得实例化更高效,因为它只需要更少的数据,并且因为我们所有的形状使用统一的缩放让性能更好。

改变我们的三个材质,使他们使用这个新的着色器而不是标准的。虽然它支持较少的特性,并且有一个不同的检查器接口,但是目前已经足够满足我们的需求了。然后确保所有材质都检查了启用GPU实例化。

(具有实例颜色的标准材质)

(有和没有GPU实例化)

下一篇 介绍 复用对象。

0 人点赞