对象多样性
捏造形状
原文地址:https://catlikecoding.com/unity/tutorials/object-management/object-variety/
为形状创建一个工厂。
保存和加载形状标识符。
支持多种材质和随机颜色。
支持GPU实例化。
这是关于Object Management系列的第二篇教程。在本部分中,我们将添加多种不同材质和颜色的形状支持,同时保持向后兼容我们的游戏的前一个版本。
本教程使用Unity 2017.4.1f1制作。
1. 形状工厂
本教程的目标是让我们的游戏更有趣,允许创建其他形状,而不仅仅是白色的立方体。就像位置,旋转和缩放一样,我们将在玩家每次生成一个新立方体时随机创建形状。
1.1 Shape类
我们将明确我们的游戏会刷出什么样的东西。它生成形状,而不是一般的可持久化对象。因此,创建一个新的Shape类,它表示3D几何形状。它只是扩展了PersistableObject,没有添加任何新东西,至少目前是这样。
using UnityEngine;
public class Shape : PersistableObject {}
从Cube预制件中移除PersistableObject组件,并给它一个Shape组件。它不能同时拥有两个,因为我们给了PersistableObject DisallowMultipleComponent属性,这也适用于Shape。
这打破了Game对象对预制构件的引用。但因为Shape也是一个PersistableObject,我们可以重新给它赋值。
1.2 多个不同的形状
创建一个默认的球体和胶囊对象,给每个Shape组件,并把它们变成预制件。这些是我们的游戏将支持的其他形状。
圆柱体呢? 你也可以添加一个圆柱体对象,但我省略了它,因为圆柱体没有自己的碰撞器类型。相反,他们使用的是胶囊对撞机,这并不适合。现在这还不是问题,但以后可能会。
1.3 工厂资产
目前,Game只能生成一个东西,因为它只有一个预制件的引用。为了支持所有三个形状,它需要三个预制引用。这将需要三个字段,但不灵活。更好的方法是使用数组。但也许我们以后会想出一种不同的方法来创建形状。这可能会让Game变得相当复杂,因为它还要负责用户输入、追踪物体、触发保存和加载。
为了保持Game的简单性,我们将把什么形状被支持的责任放在它自己的类中。这个类将像一个工厂,按需创建形状,而它的客户端不需要知道这些形状是如何制作的,甚至不需要知道有多少不同的选项。我们将这个类命名为ShapeFactory。
using UnityEngine;
public class ShapeFactory {}
工厂的唯一责任是交付形状实例。它不需要位置、旋转或缩放,也不需要Update方法来改变状态。所以它不需要是一个组件,它需要附加到游戏对象上。相反,它可以独立存在,不是作为特定场景的一部分,而是作为项目的一部分。换句话说,它是一种资产。令它扩展ScriptableObject而不是MonoBehaviour,让这是可能的。
public class ShapeFactory : ScriptableObject {}
现在我们有了一个自定义资产类型。为了将这样的资产添加到我们的项目中,我们必须在Unity的菜单中添加一个条目。最简单的方法是将CreateAssetMenu属性添加到类中。
[CreateAssetMenu]
public class ShapeFactory : ScriptableObject {}
你现在可以通过Assets › Create › Shape Factory创建我们的工厂。我们只需要一个。
为了让我们的工厂知道形状预制件,给它一个Shape[] prefabs数组字段。我们不希望这个字段是公共的,因为它的内部工作方式不应该暴露给其他类。所以要保密。为了让数组显示在检查器中并被Unity保存,添加SerializeField属性。
public class ShapeFactory : ScriptableObject {
[SerializeField]
Shape[] prefabs;
}
字段出现在检查器中之后,将所有三个形状预制件拖到它上面,这样对它们的引用就会添加到数组中。确保立方体是第一个元素。使用球体作为第二个元素,使用胶囊作为第三个元素。
1.4 获得形状
要使工厂发挥任何作用,必须有一种方法来获得它的形状实例。给它一个公共的Get方法。客户端可以通过形状标识符参数指明它想要的形状类型。为此,我们将使用一个整数。
public Shape Get (int shapeId) {}
为什么不使用枚举? 这当然是可能的,所以你可以这样做。但是我们并不关心代码中确切的形状类型,所以整数就可以了。这使得完全通过更改工厂的数组内容来控制支持的形状成为可能,而不需要更改任何代码。
我们可以直接使用标识符作为索引来找到适当的形状预制件,实例化并返回它。这意味着0代表立方体,1代表球体,2代表胶囊。即使我们以后改变了工厂的工作方式,我们也必须确保这个标识保持相同,保持向后兼容。
public Shape Get (int shapeId) {
return Instantiate(prefabs[shapeId]);
}
除了请求一个特定的形状,我们还可以通过GetRandom方法从工厂中获得一个随机的形状实例。我们可以用Random.Range方法随机选择一个索引。
public Shape GetRandom () {
return Get(Random.Range(0, prefabs.Length));
}
不应该用Random.Range(0, prefab.Length - 1)代替吗? Unity的带整型参数Random.Range的方法不包含最大值。输出范围从最小值到最大值- 1。这样做是因为典型的用例期望获得一个随机数组索引,这正是我们在这里所做的。
请注意,带有float参数的Random.Range则使用包含所有参数的最大值。
因为我们现在正在Game中创建形状,让我们明确地将其列表重命名为shapes。所以,凡是写objects的地方,都要用shapes来代替。最简单的方法是使用代码编辑器的重构功能来更改字段的名称,它将负责在任何使用它的地方重命名字段。我只显示字段声明的更改,而不是它被访问的所有地方。
List<PersistableObject> shapes;
还要将列表的项类型更改为Shape。
List<Shape> shapes;
void Awake () {
shapes = new List<Shape>();
}
接下来,删除预制字段,并添加一个shapeFactory字段来保存对形状工厂的引用。
// public PersistableObject prefab;
public ShapeFactory shapeFactory;
在CreateObject中,我们现在将通过调用shapeFactory.GetRandom创建任意形状,而不是实例化一个显式预置。
void CreateObject () {
// PersistableObject o = Instantiate(prefab);
Shape o = shapeFactory.GetRandom();
…
}
让我们也重命名实例的变量,这样我们处理的是一个形状实例,而不是一个我们仍然需要实例化的预制引用。同样,您可以使用重构来快速且一致地重命名变量。
void CreateShape () {
Shape instance = shapeFactory.GetRandom();
Transform t = instance.transform;
t.localPosition = Random.insideUnitSphere * 5f;
t.localRotation = Random.rotation;
t.localScale = Vector3.one * Random.Range(0.1f, 1f);
shapes.Add(instance);
}
在加载时,我们现在还必须使用形状工厂。在这种情况下,我们不需要随机的形状。我们以前只处理过立方体,所以应该获取立方体,这是通过调用shapeFactory.Get(0)来完成的。
public override void Load (GameDataReader reader) {
int count = reader.ReadInt();
for (int i = 0; i < count; i++) {
// PersistableObject o = Instantiate(prefab);
Shape o = shapeFactory.Get(0);
o.Load(reader);
shapes.Add(o);
}
}
让我们在这里显式地说明我们处理的是一个实例。
Shape instance = shapeFactory.Get(0);
instance.Load(reader);
shapes.Add(instance);
在给Game一个我们的工厂的引用,它现在每次玩家生成一个新的物体,将创建随机的形状,而不是总是得到立方体。
2. 记录形状
虽然现在可以创建三个不同的形状,但还没有保存此信息。所以每次我们加载一个已保存的游戏时,我们最终只剩下立方体。这对于之前保存的游戏是正确的,但对于添加了多种形状支持后保存的游戏就不正确了。我们还必须添加对保存不同形状的支持,理想情况下仍然能够加载旧的保存文件。
2.1 形状标识符属性
为了能够保存一个对象的形状,对象必须记住这个信息。最直接的方法是向shape添加形状标识符字段。
public class Shape : PersistableObject {
int shapeId;
}
理想情况下,该字段是只读的,因为形状实例始终是一种类型,并且不会更改。但它必须被赋值。我们可以将私有字段标记为可序列化,并通过每个预制件的检查器给它赋值。但是,这不能保证标识符与工厂使用的数组索引匹配。也有可能我们在其他地方使用一个形状预制件,这与工厂没有任何关系,甚至可能在某些时候添加到另一个工厂。所以形状标识符取决于工厂,而不是预制件。因此,它是每个实例都要跟踪的,而不是每个预制件。
默认情况下,私有字段不会被序列化,所以预制件与之无关。新实例将获得该字段的默认值,在本例中为0,因为我们没有给它另一个默认值。为了使标识符公开可访问,我们将为Shape添加一个ShapeId属性。我们使用相同的名字,只是第一个字母是大写的。属性是假装为字段的方法,因此它们需要一个代码块。
public int ShapeId {}
int shapeId;
属性实际上需要两个独立的代码块。一个用来获取它所表示的值,另一个用来设置它。这些是通过get和set关键字识别的。可以只使用其中一个,但在本例中两个都需要。
public int ShapeId {
get {}
set {}
}
getter部分只返回私有字段。setter简单地分配给私有字段。为此,setter有一个名为value的隐式参数。
public int ShapeId {
get {
return shapeId;
}
set {
shapeId = value;
}
}
通过使用属性,可以为看似简单的检索或赋值添加额外的逻辑。在我们的例子中,当形状标识符被工厂实例化时,每个实例只能设置一次。在那之后再设置一次就大错特错了。
我们可以通过验证标识符在赋值时仍然具有默认值来检查赋值是否正确。如果是这样,分配是有效的。如果没有,我们将记录一个错误。
public int ShapeId {
get {
return shapeId;
}
set {
if (shapeId == 0) {
shapeId = value;
}
else {
Debug.LogError("Not allowed to change shapeId.");
}
}
}
但是,0是一个有效的标识符。所以我们必须用其他的值作为默认值。让我们用最小可能的整数代替,int.MinValue,即−2147483648。此外,我们应该确保标识符不能被重置为默认值。
public int ShapeId {
…
set {
if (shapeId == int.MinValue && value != int.MinValue) {
shapeId = value;
}
…
}
}
int shapeId = int.MinValue;
为什么不直接使用readonly属性呢? readonly字段或属性只能分配默认值,或在构造函数方法中分配。不幸的是,当实例化Unity对象时,我们不能使用构造函数方法。所以我们必须使用这样的方法。
调整ShapeFactor.Get,它在返回实例之前设置实例的标识符。
public Shape Get (int shapeId) {
// return Instantiate(prefabs[shapeId]);
Shape instance = Instantiate(prefabs[shapeId]);
instance.ShapeId = shapeId;
return instance;
}
2.2 识别文件版本
我们之前没有形状标识符,所以我们没有保存它们。如果我们从现在开始保存它们,我们将使用不同的保存文件格式。如果游戏的旧版本(在之前的教程中)不能读取这个格式,这是没问题的,但我们应该确保新游戏仍然可以使用旧格式。
我们将使用一个保存版本号来标识保存文件所使用的格式。当我们现在引入这个概念时,我们从版本1开始。将此作为一个常量整数添加到游戏中。
const int saveVersion = 1;
const是什么意思? 它将一个简单的值声明为常量,而不是字段。它不能被更改,也不存在于内存中。相反,它只是代码的一部分,它的显式值在编译期间被引用和替换的地方使用。
保存游戏时,首先要写入保存版本号。加载时,首先读取存储的版本。这告诉我们要处理的是什么版本。
public override void Save (GameDataWriter writer) {
writer.Write(saveVersion);
writer.Write(shapes.Count);
…
}
public override void Load (GameDataReader reader) {
int version = reader.ReadInt();
int count = reader.ReadInt();
…
}
但是,这只适用于包含保存版本的文件。以前教程中的旧保存文件没有这些信息。相反,写入这些文件的第一件事是对象计数。所以我们最终会把计数理解为版本。
存储在旧保存文件中的对象计数可以是任何值,但它将始终至少为零。我们可以用它来区分保存版本和对象计数。这是通过不逐字写入保存版本来实现的。相反,在写的时候要翻转版本的符号。当我们从1开始时,这意味着存储的保存版本总是小于零。
writer.Write(-saveVersion);
读取版本时,再次翻转其符号以检索原始数字。如果我们读取旧的保存文件,这最终会翻转计数的符号,所以它不是0就是负的。因此,当我们得到一个小于或等于0的版本时,我们知道我们正在处理一个旧文件。在这种情况下,我们已经有了计数,只是符号翻转了。否则,我们还得看计数。
int version = -reader.ReadInt();
int count = version <= 0 ? -version : reader.ReadInt();
这使得新代码可以处理旧的保存文件格式。但是旧代码不能处理新格式。我们对此无能为力,因为旧的代码已经写好了。我们能做的是确保从现在开始,游戏将拒绝加载它不知道如何处理的未来保存文件格式。如果加载的版本高于当前保存的版本,则记录一个错误并立即返回。
int version = -reader.ReadInt();
if (version > saveVersion) {
Debug.LogError("Unsupported future save version " + version);
return;
}
2.3 保存形状标识符
形状不应该写入自己的标识符,因为必须读取标识符以确定要实例化哪个形状,只有在读取标识符之后,形状才能加载自身。所以写标识符是Game的责任。因为我们将所有形状存储在一个列表中,所以我们必须在形状保存自己之前写入每个形状的标识符。
public override void Save (GameDataWriter writer) {
writer.Write(-saveVersion);
writer.Write(shapes.Count);
for (int i = 0; i < shapes.Count; i++) {
writer.Write(shapes[i].ShapeId);
shapes[i].Save(writer);
}
}
注意,这不是保存形状标识符的唯一方法。例如,也可以为每个形状类型使用单独的列表。在这种情况下,只需要为每个列表写入一次形状标识符。
2.4 加载形状标识符
对于列表中的每个形状,首先加载其形状标识符,然后使用该标识符从工厂获得正确的形状。
public override void Load (GameDataReader reader) {
…
for (int i = 0; i < count; i++) {
int shapeId = reader.ReadInt();
Shape instance = shapeFactory.Get(shapeId);
instance.Load(reader);
shapes.Add(instance);
}
}
但是这只对新保存版本1有效。如果要从旧的保存文件中读取数据,则只需获取多维数据集。
int shapeId = version > 0 ? reader.ReadInt() : 0;
3.材质变量
除了改变生成对象的形状,我们还可以改变它们的组成。目前,所有形状都使用相同的材质,这是Unity的默认材质。让我们把它变成一个随机选择的材质。
3.1 三种材质
创建三个新材质。第一个命名Standard,保持它不变,以便它匹配Unity的默认材质。命名第二个为Shiny,并将其Smoothness提高到0.9。将第三个命名为Metallic,并将其Metallic和Smoothness设置为0.9。
当从工厂获得一个形状时,现在应该可以指定它必须由哪种材质制成。这需要ShapeFactory知道允许的材质。所以给它一个材质数组—就像它的预制件数组一样—并分配三个材质给它。确保标准材质是第一要素。第二个是有光泽的材质,第三个是金属的。
[SerializeField]
Material[] materials;
3.2 设置形状的材质
为了保存形状的材质,我们现在还必须跟踪材质标识符。为此添加一个属性到Shape。但是,不要显式地编写属性的工作方式,而要省略getter和setter的代码块。以分号结尾。这将生成一个默认属性,其中包含一个隐式隐藏私有字段。
public int MaterialId { get; set; }
当设置一个形状的材质时,我们必须给它实际的材质以及它的标识符。这意味着我们必须同时使用两个参数,但对于属性来说这是不可能的。所以我们不依赖属性的setter。若要禁止在Shape类本身之外使用setter,请将其标记为private。
public int MaterialId { get; private set; }
相反,我们添加了一个带有所需参数的公共SetMaterial方法。
public void SetMaterial (Material material, int materialId) {}
这个方法可以通过调用GetComponent<MeshRenderer>方法来获得形状的MeshRenderer组件。注意,这是一个泛型方法,就像List是一个泛型类一样。设置渲染器的材质以及材质标识符属性。确保将参数赋给属性,区别在于M是否为大写字母。
public void SetMaterial (Material material, int materialId) {
GetComponent<MeshRenderer>().material = material;
MaterialId = materialId;
}
3.3 用材质获得形状
现在我们可以调整ShapeFactory.Get处理材质。给它第二个参数来表明应该使用哪种材质。然后使用它来设置形状的材质和材质标识符。
public Shape Get (int shapeId, int materialId) {
Shape instance = Instantiate(prefabs[shapeId]);
instance.ShapeId = shapeId;
instance.SetMaterial(materials[materialId], materialId);
return instance;
}
调用Get的人可能并不关心材质,只满足于标准材质。我们可以支持带有单个形状标识符参数的Get变体。我们可以通过给它的materialId参数分配一个默认值(使用0)来实现这一点。这使得在调用Get时可以省略materialId参数。因此,现有代码在此时编译时不会出现错误。
public Shape Get (int shapeId, int materialId = 0) {
…
}
我们也可以对shapeId参数做同样的操作,给它一个默认值0。
public Shape Get (int shapeId = 0, int materialId = 0) {
…
}
如何指示需要哪些默认值? 要忽略materialId,只需忽略它,这样就可以调用类似Get(0)的方法。也可以通过调用Get()省略这两个参数。但是,如果希望省略shapeId而不省略materialId,则必须明确提供哪些参数。你可以通过给你的参数打上标签,在参数值前写参数名,后面跟一个冒号。例如:Get(materialId: 0)。
GetRandom方法现在应该同时选择一个随机形状和随机材质。所以让它使用Random.Range随机选择材质标识符。
public Shape GetRandom () {
return Get(
Random.Range(0, prefabs.Length),
Random.Range(0, materials.Length)
);
}
3.4 保存和加载材质标识符
保存材质标识符的工作原理与保存形状标识符相同。将它写在每个形状的形状标识符之后。
public override void Save (GameDataWriter writer) {
…
for (int i = 0; i < shapes.Count; i++) {
writer.Write(shapes[i].ShapeId);
writer.Write(shapes[i].MaterialId);
shapes[i].Save(writer);
}
}
加载工作也一样。我们不会为这个改变而增加保存版本,因为我们仍然在相同的教程中,这象征着一个单一的公开发行。因此,对于存储形状标识符但不存储材质标识符的保存文件,加载将失败。
public override void Load (GameDataReader reader) {
…
for (int i = 0; i < count; i++) {
int shapeId = version > 0 ? reader.ReadInt() : 0;
int materialId = version > 0 ? reader.ReadInt() : 0;
Shape instance = shapeFactory.Get(shapeId, materialId);
instance.Load(reader);
shapes.Add(instance);
}
}
4. 随机化颜色
除了整体材料,我们还可以改变形状的颜色。我们通过调整每个形状实例的材质的颜色属性来做到这一点。
我们可以定义一组有效的颜色并将它们添加到形状工厂,但在本例中我们将使用不受限制的颜色。这意味着工厂不需要意识到形状和颜色。相反,形状的颜色的设置就像它的位置、旋转和缩放一样。
4.1 形状颜色
为Shape添加一个SetColor方法来调整它的颜色。它必须调整它所使用的任何材料的颜色属性。
public void SetColor (Color color) {
GetComponent<MeshRenderer>().material.color = color;
}
为了保存和加载形状的颜色,它必须记录它。我们不需要提供对颜色的公共访问,因此通过SetColor设置私有字段就足够了。
Color color;
public void SetColor (Color color) {
this.color = color;
GetComponent<MeshRenderer>().material.color = color;
}
保存和加载颜色是通过重写PersistableObject的Save和Load方法来完成的。首先处理基础,然后是颜色数据。
public override void Save (GameDataWriter writer) {
base.Save(writer);
writer.Write(color);
}
public override void Load (GameDataReader reader) {
base.Load(reader);
SetColor(reader.ReadColor());
}
但是这里假设有读写颜色的方法,目前还不是这样。我们加上它们。首先是GameDataWriter的一个新的Write方法。
public void Write (Color value) {
writer.Write(value.r);
writer.Write(value.g);
writer.Write(value.b);
writer.Write(value.a);
}
还有一个用于GameDataReader的ReadColor方法。
public Color ReadColor () {
Color value;
value.r = reader.ReadSingle();
value.g = reader.ReadSingle();
value.b = reader.ReadSingle();
value.a = reader.ReadSingle();
return value;
}
我们需要将颜色通道存储为float吗? 你也可以决定将它们存储为字节,但如果你这样做,最好始终在任何地方使用Color32。这确保保存和加载的数据总是相同的。你不需要为每个形状节省12个字节而烦恼,除非你真的需要最小化你的保存文件大小。同样地,你可以决定跳过alpha通道,因为不透明材质不需要它,但它通常也不值得担心。
4.2 剩余向后兼容的
虽然这种方法可以存储形状颜色,但它现在假设颜色存储在保存文件中。旧的保存格式不是这样的。为了仍然支持旧的格式,我们必须跳过加载颜色。在Game中,我们使用读取版本号来决定要做什么。然而,Shape并不知道这个版本。因此,当Shape加载时,我们必须以某种方式将读取的数据版本传递给Shape。将版本定义为GameDataReader的属性是有意义的。
由于读取文件时不会更改读取文件的版本,因此只应设置该属性一次。因为GameDataReader不是一个Unity对象类,我们可以使用一个只读属性,只给它一个get部分。这些属性可以通过构造函数方法进行初始化。为此,我们必须添加版本作为构造函数参数。
public int Version { get; }
BinaryReader reader;
public GameDataReader (BinaryReader reader, int version) {
this.reader = reader;
this.Version = version;
}
现在,写入和读取版本号已经成为PersistentStorage的职责。版本必须作为参数添加到它的Save方法中,它必须在写入其他内容之前写入版本。Load方法在构造GameDataReader时读取它。在这里,我们还将执行符号更改技巧,以支持读取版本0文件。
public void Save (PersistableObject o, int version) {
using (
var writer = new BinaryWriter(File.Open(savePath, FileMode.Create))
) {
writer.Write(-version);
o.Save(new GameDataWriter(writer));
}
}
public void Load (PersistableObject o) {
using (
var reader = new BinaryReader(File.Open(savePath, FileMode.Open))
) {
o.Load(new GameDataReader(reader, -reader.ReadInt32()));
}
}
这意味着Game不再需要编写保存版本。
public override void Save (GameDataWriter writer) {
// writer.Write(-saveVersion);
writer.Write(shapes.Count);
…
}
相反,它必须在调用PersistentStorage.Save时将其作为参数提供。
void Update () {
…
else if (Input.GetKeyDown(saveKey)) {
storage.Save(this, saveVersion);
}
…
}
在它的Load方法中,它现在可以通过reader.Version检索版本。
public override void Load (GameDataReader reader) {
int version = reader.Version;
…
}
现在我们还可以在Shape.Load中查看版本。如果我们至少有版本1,那么读取颜色。否则,使用白色。
public override void Load (GameDataReader reader) {
base.Load(reader);
SetColor(reader.Version > 0 ? reader.ReadColor() : Color.white);
}
4.3 选择形状颜色
要创建任意颜色的形状,只需在Game.CreatesShape的新实例上调用SetColor。我们可以用Random.ColorHVS方法生成随机颜色。如果没有参数,该方法可以创建任何有效的颜色,这可能会有点混乱。让我们将饱和度范围限制为0.5-1,并将值范围限制为0.25-1。因为我们现在不用,所以我们总是把它设为1。
void CreateShape () {
Shape instance = shapeFactory.GetRandom();
Transform t = instance.transform;
t.localPosition = Random.insideUnitSphere * 5f;
t.localRotation = Random.rotation;
t.localScale = Vector3.one * Random.Range(0.1f, 1f);
instance.SetColor(Random.ColorHSV(0f, 1f, 0.5f, 1f, 0.25f, 1f, 1f, 1f));
shapes.Add(instance);
}
使用ColorHVS的所有8个参数使它很难理解,因为它不能立即清楚哪个值控制什么。通过显式地命名参数,可以使代码更易于阅读。
instance.SetColor(Random.ColorHSV(
hueMin: 0f, hueMax: 1f,
saturationMin: 0.5f, saturationMax: 1f,
valueMin: 0.25f, valueMax: 1f,
alphaMin: 1f, alphaMax: 1f
));
4.4 记录渲染器
我们现在需要访问形状的MeshRenderer组件,当设置它的材质和设置它的颜色。使用GetComponent两次是不理想的,特别是如果我们决定在未来多次改变形状的颜色。因此,让我们将引用存储在一个私有字段中,并在Shape的一个新的Awake方法中初始化它。
MeshRenderer meshRenderer;
void Awake () {
meshRenderer = GetComponent<MeshRenderer>();
}
现在我们可以在SetColor和SetMaterial中使用这个字段。
public void SetColor (Color color) {
this.color = color;
// GetComponent<MeshRenderer>().material.color = color;
meshRenderer.material.color = color;
}
public void SetMaterial (Material material, int materialId) {
// GetComponent<MeshRenderer>().material = material;
meshRenderer.material = material;
MaterialId = materialId;
}
4.5 使用属性块
设置材料颜色的一个缺点是,这导致了新材料的创建。每次设置颜色时都会发生这种情况。我们可以通过使用MaterialPropertyBlock来避免这种情况。创建一个新的属性块,设置一个名为_Color的颜色属性,然后通过调用MeshRenderer.SetPropertyBlock将其用作渲染器的属性块。
public void SetColor (Color color) {
this.color = color;
// meshRenderer.material.color = color;
var propertyBlock = new MaterialPropertyBlock();
propertyBlock.SetColor("_Color", color);
meshRenderer.SetPropertyBlock(propertyBlock);
}
除了使用字符串来命名color属性,还可以使用标识符。这些标识符是由Unity设置的。它们可以改变,但在每个会话中保持不变。因此,我们只需获得color属性的标识符一次,并将其存储在一个静态字段中就足够了。通过调用Shader.PropertyToID的带名称的方法来找到标识符。
static int colorPropertyId = Shader.PropertyToID("_Color");
…
public void SetColor (Color color) {
this.color = color;
var propertyBlock = new MaterialPropertyBlock();
propertyBlock.SetColor(colorPropertyId, color);
meshRenderer.SetPropertyBlock(propertyBlock);
}
也可以重用整个属性块。当设置渲染器的属性时,块的内容将被复制。所以我们不需要为每个形状创建一个新的块,我们可以为所有形状不断改变同一个块的颜色。
我们可以再次使用静态字段来记录块,但不可能通过静态初始化来创建块实例。Unity不允许这样做。相反,我们可以在使用该块之前检查它是否存在。如果没有,我们就在此时创建它。
static MaterialPropertyBlock sharedPropertyBlock;
…
public void SetColor (Color color) {
this.color = color;
// var propertyBlock = new MaterialPropertyBlock();
if (sharedPropertyBlock == null) {
sharedPropertyBlock = new MaterialPropertyBlock();
}
sharedPropertyBlock.SetColor(colorPropertyId, color);
meshRenderer.SetPropertyBlock(sharedPropertyBlock);
}
现在我们不再得到重复的材料,你可以通过调整一个材料当形状使用它在播放模式的时候来验证。形状将根据变化调整其外观,如果他们使用重复的材料这就不会发生。当然,当你调整材质的颜色时,这是行不通的,因为每个形状都使用自己的颜色属性,它覆盖了材质的颜色。
4.6 GPU实例化
当我们使用属性块时,我们可以使用GPU实例化在一个绘制调用中结合使用相同材质的形状,即使它们有不同的颜色。然而,这需要一个支持实例颜色的着色器。这就是这样一个着色器,你可以在Unity GPU实例化手册页面上找到。唯一的区别是我删除了注释,并添加了#pragma instancing_options assumeuniformscaling指令。假设均匀缩放使实例化更有效,因为它需要更少的数据,并且因为我们所有的形状都使用均匀缩放使得它能工作。
Shader "Custom/InstancedColors" {
Properties {
_Color ("Color", Color) = (1,1,1,1)
_MainTex ("Albedo (RGB)", 2D) = "white" {}
_Glossiness ("Smoothness", Range(0,1)) = 0.5
_Metallic ("Metallic", Range(0,1)) = 0.0
}
SubShader {
Tags { "RenderType"="Opaque" }
LOD 200
CGPROGRAM
#pragma surface surf Standard fullforwardshadows
#pragma instancing_options assumeuniformscaling
#pragma target 3.0
sampler2D _MainTex;
struct Input {
float2 uv_MainTex;
};
half _Glossiness;
half _Metallic;
UNITY_INSTANCING_BUFFER_START(Props)
UNITY_DEFINE_INSTANCED_PROP(fixed4, _Color)
UNITY_INSTANCING_BUFFER_END(Props)
void surf (Input IN, inout SurfaceOutputStandard o) {
fixed4 c = tex2D (_MainTex, IN.uv_MainTex) *
UNITY_ACCESS_INSTANCED_PROP(Props, _Color);
o.Albedo = c.rgb;
o.Metallic = _Metallic;
o.Smoothness = _Glossiness;
o.Alpha = c.a;
}
ENDCG
}
FallBack "Diffuse"
}
改变我们的三个材质,使他们使用这个新的着色器而不是标准的一个。它支持更少的特性,并且有一个不同的检查器界面,但是它已经足够满足我们的需求了。然后确保所有材质都检查了启用GPU实例化。
你可以通过Game窗口的Stat覆盖来验证差异。
下一个教程是重用对象。