- 为形状创建一个”生产工厂”
- 保存和加载形状的代号(identifier)
- 支持多个材质与随机颜色
- 启用GPU实例化提高渲染性能
这是对象管理章节的第二个教程. 本篇教程中将增加使用不同材质和颜色的形状, 并支持加载上个教程游戏版本的保存文件
本教程使用Unity2017.4.1f1制作
图中的形状都可以在游戏关闭后得到保存
形状工厂
本教程的目标是除了立方体之外再创建一些其他形状, 让我们的游戏更加有趣. 我们将在每次创建形状时随机的选择一种形状.
Shape类
我们的游戏需要产生形状(Shape), 所以需要创建一个新的类, 命名为Shape, 这个类用来代表3D几何形状. 它要继承上个教程中创建的PersistableObject类 :
using UnityEngine;
public class Shape : PersistableObject {
}
**
接下来从cube的预制体上移除PersistableObject脚本, 并将Shape脚本添加上去. 它俩不能同时存在, 因为我们对PersistableObject类使用了[DisallowMultipleComponent]特性, 所以该类及其子类的脚本均在同一个物体上只能存在一个
添加了Shpe组件的Cube预制体
上述操作将会导致场景中的Game脚本中对预制体的引用被破坏, 这是因为我们删除了预制体原有的脚本PersistableObject. 所以需要我们重新将预制体向Game的prefab字段拖拽一次进行关联, 由于Game是继承自PersistableObject类, 所以可以被prefab字段接收
将Cube预制体再次拖拽到Game的prefab字段上
多种不同形状
在场景中创建一个Unity自带的Sphere(球体)和Capsule(胶囊体), 并为它们添加Shape脚本, 然后通过它们分别创建一个预制体, 之后在场景中删除它们, 我们只需要使用它们制作的预制体.
新建的Sphere和Capsule
把其中一个形状换成圆柱体怎么样?
我之所以没有使用圆柱体是因为圆柱体没有与其形状匹配的碰撞体, 它使用的其实是胶囊状的碰撞体, 与它的实际外形并不完全吻合. 这在后续的步骤中可能会出现问题
形状工厂
Game脚本现在只能产生一种东西, 因为它只引用了一种预制体. 为了可以产生我们新增加的两个形状, 需要再为Game增加两种预制体的引用. 那么我们就需要再增加两个预制体字段, 不过这样做, 扩展性并不好. 更好的方式是使用一个数组存储多个预制体的引用. 另外还存在一个潜在的问题, 随着我们的功能不断增加, 可能在未来我们会使用其他方式去创建形状. 这可能会导致Game脚本越来越复杂, 特别是它还要负责处理用户输入, 记录场景中的物体, 以及执行保存和加载过程.
为了让让Game脚本不会越来越复杂, 我们将使用一个新的类来处理可以生成什么形状. 这个类就像是一个工厂, 它根据需要生产形状, 而不需要它的客户知道这些形状是如何产生的以及到底有多少种形状. 我们将这个类命名为ShapeFactory :
using UnityEngine;
//生产形状的"工厂"
public class ShapeFactory {
}
工厂唯一的职责就是提供要产生的形状的实例. 它不需要在场景中存在, 也不属于某个特定的场景, 它是独立存在的, 只属于当前的项目. 换句话说, 它更像是项目中的一个资源(Asset). Unity对于这种使用要求的类提供了专门的父类, 叫做ScriptableObjet, 所以接下来, 让ShapeFactory继承ScriptableObject类 :
//public class ShapeFactory {
public class ShapeFactory : ScriptableObject {
}
这样我们就有了一个自定义的资源类型. 要在项目中添加这一类型的资源, 需要在Unity菜单中添加对应的操作按钮. 最简单的方式是向类中添加[CrateAssetMenu]特性 :
[CreateAssetMenu]
public class ShapeFactory : ScriptableObject {
}
保存代码, 回到编辑器界面, 你就可以通过菜单位置Assets › Create › Shape Factory来创建一个工厂资源, 只需要增加一个即可, 将其改名为Shape Factory :
创建出的Shape Factory资源
为了让我们的工厂能够知道都有哪些要生产的形状预制体, 需要为工厂提供一个形状预制体的数组. 我们不希望这个字段是公开的, 因为这是它的内部工作, 不需要暴露给其他类. 但是我们还需要在Inspector中对这个字段进行编辑, 所以要为其添加[SerializdeField]特性 :
public class ShapeFactory : ScriptableObject {
//将非公开字段显示在Inspector中, 方便编辑
[SerializeField]
//用来存储要产生的形状的预制体的引用
Shape[] prefabs;
}
保存代码, 回到编辑器, 在Project窗口中选中Shape Factory资源, 把现有的三种形状的预制体拖拽到形状数组字段, 要注意, 让Cube在第一个, Sphee在第二个, Capsule在第三个(首先打开Prefabs字段的三角下拉按钮, 然后设置Size为3并按回车, 然后下面就会多出三个可以设置预制体的属性栏)
引用了三种预制体的Shape Factory资源
生产形状
为了让工厂可以正常工作, 需要让它可以产生形状的实例.
为ShapeFactory类其新增一个公开的Get方法, 工厂的用户可以通过形状的代号属性来告诉工厂他想要一个什么种类的形状. 我们可以使用整型数字作为形状种类的代号 :
//返回工厂生产的形状实例的方法, 参数shapeId用来代表要生产的形状种类
public Shape Get (int shapeId) {
}
为什么不创建一个枚举类型作为形状的代号?
这当然可以, 你可以自己这样做来替代我使用的整型代号参数. 但是我们在这里并不关心每个形状到底使用的是哪一个代号, 所以整型代号就足以胜任了, 这使得可以只通过改变工厂的数组内容来控制它可以生产什么形状, 而不需要修改任何代码
我们可以直接使用代号作为索引从而在预制体数组中获取适合的预制体引用, 然后创建并返回它的实例. 根据我们数组的顺序, 0号就代表立方体, 1号代表球体, 2号代表胶囊体. 我们在之后无论如何修改ShpaeFactory类, 也不能破坏这个对应规则, 保持功能的兼容性
public Shape Get (int shapeId) {
//创建并返回通过shapeId索引到的预制体的实例
return Instantiate(prefabs[shapeId]);
}
让我们再让工厂可以生成一个随机的的形状, 添加新的方法GetRandom. 通过Random.Range方法来选择形状预制体数组的随机索引 :
//产生随机形状的方法
public Shape GetRandom () {
//将随机的数组索引号作为参数来调用Get方法, 从而获取并返回随机形状预制体的实例
return Get(Random.Range(0, prefabs.Length));
}
不应该使用Random.Range(0,prefab.Length-1)吗?
Unity的Random.Range方法, 在使用整型参数调用时, 其结果中将排除最大值, 也就是随机结果的范围会在最小值和最大值减一之间. 之所以如此设计是因为该方法的典型用途就是用来获取随机的数组索引, 而这也恰恰使我们的代码要做的事情.
注意, 该方法如果使用浮点类型参数调用, 将不会排除最大值.
获得形状
因为现在形状是在Game脚本中创建的, 所以让我们前往Game脚本, 使用Shape类型和shapes字段名替代上个教程中书写的PersistableObject类型与objects字段名. 你可以借助代码编辑器的重构功能简单的完成字段名的修改, 该功能会帮助你在所有使用过该字段的代码出重命名字段, 首先修改字段名称 :
//是用代码编辑器的重构功能, 将Game脚本中的所有objects字段名修改为shapes字段名
List<PersistableObject> shapes;
====================翻译者补充内容开始↓↓↓↓↓====================
“重构”功能修改字段名的步骤 :
1) 首先, 在objects声明的代码中, 将objects修改为shapes
2) 修改后的字段名shapes会被特殊的边框包裹起来, 鼠标悬停在它上面会出现一个小灯泡图标按钮, 如下图 :
3) 直接点击该按钮, 会弹出一个菜单列表, 点击其中的”将’objects’重命名为’shapes’”项, 如下图所示, 则会直接该脚本内所有原来使用objects的地方都替换为shapes
====================翻译者补充内容结束↓↓↓↓↓====================
接下来, 将用到shapes字段类型的地方, 都把PersistableObject改为Shape :
//List<PersistableObject> shapes;
List<Shape> shapes;
void Awake () {
//shapes = new List<PersistableObject>();
shapes = new List<Shape>();
}
然后移除脚本中的prefab字段, 并添加一个shapeFactory字段, 用于存储ShapeFactory资源的引用 :
// public PersistableObject prefab;
//存储工厂类资源的引用
public ShapeFactory shapeFactory;
在CreateObject方法中, 使用shapeFactory.GetRandom方法代替原来的实例化语句, 从而创建一个随机的形状 :
void CreateObject () {
// PersistableObject o = Instantiate(prefab);
//创建一个随机形状的实例
Shape o = shapeFactory.GetRandom();
接着, 为了让代码与功能逻辑匹配, 增加可读性, 我们将变量o改名为instance, 从而明确的表示这个变量代表的是一个形状的实例, 你依然可以使用上面提到过的代码编辑器重构功能来快速完成代码中所有变量名称的替换 :(注: 作者还把方法名CrateObject改成了CreateShape, 他原文忘了写, 记得一起改掉, 方法名的修改一样可以利用重构功能)
//利用重构功能, 将CreateObject方法名改为CrateShape
void CreateShape()
{
//利用重构功能, 将变量名o改为instance
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);
//shapeFactory.Get(0)产生0号形状, 也就是立方体
Shape o = shapeFactory.Get(0);
o.Load(reader);
shapes.Add(o);
}
}
上面的代码中我们依然要将变量名o修改为instance :
Shape instance = shapeFactory.Get(0);
instance.Load(reader);
shapes.Add(instance);
在Inspector中将ShapeFactory资源文件拖拽到新增的shapeFactory字段, 如上图所示
在将形状工厂资源文件ShapeFactory分配给Game脚本后, 现在我们可以在每次按下创建形状按键时随机产生一种形状了
创建随机形状
记住形状种类
虽然现在可以创建三种不同的形状了, 但是形状的种类信息还不会被保存下来. 每次我们加载游戏保存数据, 都只会生成立方体. 我们需要继续为保存功能增加对不同形状数据的支持, 并且还需要兼容旧版本的保存文件.
形状的代号属性
要能够保存形状的种类, 需要得到对应的数据信息. 最直接的方式就是在Shape类中增加一个代表形状代号的字段 :
public class Shape : PersistableObject {
//形状的代号, 告诉程序是哪一种形状
int shapeId;
}
由于生成后的形状类型不会也不应该被改变, 所以这个字段似乎应该设置为只读. 但是我们也必须为不同形状高属性进行不同的赋值. 我们可以将它标记为可序列化的(serializable)私有字段, 并通过Inspector为每个预制体的该字段赋值. 然而, 这不能保障我们分配的代号与形状工厂中的预制体数组索引是正确对应的. 另一方面, 每一个形状预制体, 也可能在其他的形状工厂中使用, 对于另外的工厂, 可能代号的规则与现在的规则并不一样, 也就是说, 形状的代号只有在具体的某个工厂中才有意义, 那么就不应该让每个形状保持一个固定的代号.
根据以上分析, 首先我们不需要将shapeId设置为可序列化字段, 那么它在形状的实例初始化时的默认值就会是0, 因为我们没有为它在声明时赋予其他默认值. 由于该字段不是公开的, 所以我们需要增加一个可以对该字段进行访问的属性(Property)ShapeId. 注意, 这个属性的名字与shapeId一样, 区别是属性名称的首字母是大写的. 属性是一种特殊的, 用来访问字段的方法, 所以它还带有一个用来放置方法代码的大括号 :
//其他的类将使用属性ShapeId, 实现对非公开字段shapeId的访问
public int ShapeId {
}
int shapeId;
属性实际上需要两个代码块. 一个用来返回值, 一个用来接收值. 这两个代码块通过关键字get和set进行区分. 一个属性可以只设置其中一个代码块, 对于我们现在的代码, 需要为ShapeId全部设置 :
public int ShapeId {
//属性的get方法, 在后面的大括号中书写返回属性值的逻辑
get {}
//属性的set方法, 在后面的大括号中书写设置属性值的逻辑
set {}
}
我们让属性的get方法直接返回shapeId字段的值. 让set方法直接将属性接收到的值设置给shapeId. set方法中将使用与属性类型一致的, 叫做value的特殊变量存放接收到的值 :
public int ShapeId {
get {
//返回shapeId的值
return shapeId;
}
set {
//属性接收到的值会放在叫做value的特殊变量中, 我们将其赋值给shapeId字段
shapeId = value;
}
}
通过使用属性, 我们就有可能通过在get和set中添加自定义的逻辑, 从而实现不同的赋值与取值功能. 在我们的例子中, 形状代号字段只需要为每个被工厂生产的实例赋值一次, 在此之后, 如果还要对其进行赋值, 是一种错误的操作.
我们可以检查下shapedId的值与其声明时的默认值做对比, 从而判断它是否已经被进行过额外的赋值操作了. 如果它在声明后还没有被赋值过, 就正常为其赋值; 否则就需要提示一条错误信息 :
public int ShapeId {
get {
return shapeId;
}
set {
//新增if语句, 将之前的赋值语句包围起来
if (shapeId == 0) {
//如果shapeId的值是声明时的默认值0, 则可以对其进行赋值
shapeId = value;
}
else {
//如果shapeId的值已经不是声明时的默认值了, 则不对其赋值, 并在控制台打印一条错误信息
Debug.LogError("ShapeId赋值失败, 因为字段shapeId不可以进行多次赋值.");
}
}
}
然而, 立方体预制体在数组中的索引是0, 所以这就与我们的判断条件冲突了, 所以需要使用其他方式解决这个冲突. 我们可以使用整型的静态属性int.MinValue作为shapeId的默认值, 该属性的返回值是−2147483648. 另外, 我们也应该保障形状的代号不可以被设置为这个值. 注意, 使用这种解决办法有一个限制条件, 那就是不可以使用int.MinValue的值作为一个有效的形状代号 :
public int ShapeId {
get {
return shapeId;
}
set {
//if (shapeId == 0) {
//使用int.MinValue代替0作为判断shapeId默认值的条件, 并且不允许设置int.MinValue这个值
if (shapeId == int.MinValue && value != int.MinValue) {
shapeId = value;
}
else {
Debug.LogError("ShapeId赋值失败, 因为字段shapeId不可以进行多次赋值.");
}
}
}
//int shapeId;
//在声明shapeId时使用int.MinValue为其赋默认值
int shapeId = int.MinValue;
为什么不使用readonly设置属性?
只读(readonly)属性只能使用构造方法设置默认值. 不幸的是, 我们不能在初始化一个Unity对象时使用构造方法. 所以我们的例子不能将ShapeId设置为readonly
(所谓”Unity对象”, 指的就是继承了MonoBehavior的类, 有一篇文章详细的阐述了, 如果你对这种类添加自定义的构造函数会出现什么问题)
调整ShapeFactory.Get方法, 让它在返回产生的形状实例之前设置其形状代号 :
public Shape Get (int shapeId) {
//return Instantiate(prefabs[shapeId]);
Shape instance = Instantiate(prefabs[shapeId]);
//为新创建的形状设置设置代号
instance.ShapeId = shapeId;
return instance;
}
识别文件版本
我们之前的保存功能代码并没有保存形状代号数据. 如果现在要保存它们, 需要使用与之前不一样文件数据格式. 我们将在支持保存形状代号数据的同时, 兼容上个教程中的旧版本保存文件的加载.
需要保存版本号来区分文件的格式. 因为我们从现在开始才引入版本号这个概念, 所以就从1号开始. 将它作为一个整型常量添加到Game脚本中
//整型常量, 代表存档文件的版本号
const int saveVersion = 1;
const关键字是什么意思?
它代表一个被其修饰的值将作为常量, 而不是字段. 常量不可以改变并且不存在于内存中. 它的值将在编译时被替换到任何代码中引用它的地方, 变成代码的组成部分.(用整型常量举例解释这句话, 就是说你使用整型常量的地方, 等价于你直接写了与常量值相等的一个数字)
然后在保存游戏时, 把版本号数据先写入保存文件. 当加载时, 也以读取版本号数据作为开始, 这样就保障我们读取或存储后续的数据之前, 能够知道我们在处理什么版本的数据, 继续修改Game脚本 :
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();
…
}
不过, 上个教程中创建的保存文件中并不包含版本号数据, 它的第一个数据代表的是保存物体数据的数量, 所以上述代码会将其存储的数量数据错误的作为版本号数据.
仔细想一下, 我们存储的数量数据, 最小值不会出现小于0的情况, 所以我们可以在保存版本号时将其存储为负数, 由于我们最近的版本号从1开始, 所以存储的版本号一定小于0, 修改存储版本号的代码如下 :
//writer.Write(saveVersion);
writer.Write(-saveVersion);
另一方面, 当读取版本号时, 由于其在保存时被做了负数处理, 所以在读取时应该对其再做一次负数处理使其恢复原本的值. 如果读取到的版本号数据最终是正数, 表示保存文件中已经包含了版本号数据, 否则表示是没有版本号数据的旧版本保存文件. 我们需要根据这两种情况决定要如何加载数量数据, 修改加载代码如下 :
//int version = reader.ReadInt();
//由于保存时对版本号数据做了负数处理, 所以加载时也需要对版本号数据做一次负数处理
int version = -reader.ReadInt();
//int count = reader.ReadInt();
//使用问号三元操作符赋值, 如果version是负数, 则赋值-version, 否则赋值reader.ReadInt()
int count = version <= 0 ? -version : reader.ReadInt();
代码中的问号是什么意思?
这是三元操作符, 写法是 “条件 ? 条件为真使用的值 : 条件为假使用的值”, 它是简单的if-else语句的替代写法, 上述代码中的三元操作符等价于下列代码 :
**int** version = -reader.ReadInt();<br /> **int** count;<br /> **if** (version <= 0) {<br />count = -version;<br /> }<br /> **else** {<br />count = reader.ReadInt();<br /> }
这样一来, 现在的代码就可以处理不带有版本号数据的旧保存文件了. 但是对于上个教程中的旧代码, 无法处理我们这个教程中新建的保存文件, 我们对此无能为力, 因为在书写上个版本的代码时, 我们并没有考虑如何处理保存文件数据格式的变动. 我们能做就是, 保障从现在开始的代码可以对保存数据的改动做出反应, 让它发现加载数据的版本号高于自己能够处理的版本号时, 对用户进行提示并结束加载过程, 继续修改加载代码如下 :
int version = -reader.ReadInt();
//在加载版本号之后进行if判断
if (version > saveVersion) {
//如果加载到的版本号大于自身的saveVersion, 则显示错误信息, 并终止执行方法
Debug.LogError("Unsupported future save version " + version);
return;
}
保存形状代号
形状不应该为自己设置代号, 因为它的代号是用来决定生产什么形状的, 先有代号, 后有形状. 为形状设置代号的责任要由Game脚本来承担.
修改Game脚本的Save方法, 在每个形状保存自身数据之前, 保存它的形状代号 :
for (int i = 0; i < shapes.Count; i++) {
//在每个形状保存自身数据之前, 保存它的形状代号
writer.Write(shapes[i].ShapeId);
shapes[i].Save(writer);
}
加载形状代号
在加载保存数据时, 我们要根据得到的形状代号来决定产生什么形状 , 修改Game脚本的Load方法 :
for (int i = 0; i < count; i++) {
//Shape instance = shapeFactory.Get(0);
//加载形状代号
int shapeId = reader.ReadInt();
//使用形状代号作为参数调用工厂类的Get方法, 得到代号代表的形状实例
Shape instance = shapeFactory.Get(shapeId);
instance.Load(reader);
shapes.Add(instance);
}
我,们依然要考虑向低版本存档文件兼容, 如果发现加载的是旧版本文件, 我们需要将shapeId变量设置为0, 只加载立方体形状 :
// int shapeId = reader.ReadInt();
// 如果version数据大于0, 表示是新版本, 则使用reader.ReadInt()为shapeId赋值, 否则赋值为0
int shapeId = version > 0 ? reader.ReadInt() : 0;
材质变化
除了可以控制产生什么样子的形状, 我们还可以控制产生形状的材质(material). 此时, 所有的形状都使用的是同一种Unity默认的材质. 让我们将其变为随机选择的一种材质.
三种材质
创建三种新材质, 第一个材质命名为Standard, 除了改名什么都不需要调整, 保持默认设置; 第二个材质命名为Shiny, 设置它的Smoothness属性为0.9; 第三个材质命名为Metallic, 设置它的Metallic 和 Smoothness属性都为0.9
三种材质
我们应该在工厂类生产形状的过程中为其指定要使用哪一种废纸. 这就需要ShapeFactory脚本中存储三种材质资源的引用, 我们使用一个Material类型的数组来存储材质引用, 然后在Project窗口中选择Shpae Factory资源文件, 像分配形状数组一样将三个材质分配给这个材质数组, 注意顺序, 第一个材质是Standard, 第二个使Shiny, 第三个是Metallic :
[SerializeField]
//存储为形状设置用的随机材质引用
Material[] materials;
按照图中顺序将三个材质分配给Shape Factory资源
设置形状材质
为了保存形状的材质数据, 我们还需要为材质也增加代号, 所以在Shape脚本中增加一个属性MaterialId. 不过, 我们不为这个属性的get方法和set方法书写任何代码, 而是直接书写get和set关键字, 并在它们后面分别添加分号作为结束. 这种写法会创建默认属性, 它包含了一个隐含的private字段 :
//形状的材质代号属性
public int MaterialId { get; set; }
为一个形状设置材质代号时, 也需要同时根据材质代号为形状分配对应的材质资源. 这表示我们需要在属性的Set方法中使用两个传入参数, 但是属性并不支持这样做, 所以我们不应该依赖属性的set方法完成这件事. 另外, 我们将set方法设置为私有的, 以防止Shape以外的类使用它 :
//public int MaterialId { get; set; }
public int MaterialId { get; private set; }
为了可以在设置材质代号的同时完成对材质资源的分配, 在Shape脚本中新建一个SetMaterial方法, 并设置所需参数 :
//通过该方法完成对形状材质代号和材质资源的设置
public void SetMaterial (Material material, int materialId) {
}
该方法中将通过GetComponent
public void SetMaterial (Material material, int materialId) {
//获取形状的MeshRenderer组件实例, 并使用参数material的值赋值它的material属性
GetComponent<MeshRenderer>().material = material;
//使用参数值赋值形状的材质代号属性
MaterialId = materialId;
}
获得设置了材质的形状
现在我们继续调整ShapeFactory脚本的Get方法, 加入对材质的处理. 为该方法增加第二个参数代表我们要使用的材质代号, 通过这个参数为形状设置材质和材质代号 :
public Shape Get (int shapeId, int materialId) {
Shape instance = Instantiate(prefabs[shapeId]);
instance.ShapeId = shapeId;
//调用Shape的SetMaterial方法完成对形状材质代号和材质资源的设置
instance.SetMaterial(materials[materialId], materialId);
return instance;
}
我们可以创建Get方法的一个变化版本, 使得在产生形状时, 不特别指定使用哪种材质, 直接默认使用Standard材质. 这种变化版本的方法可以通过为materialId参数设置一个默认值0来做到. 这就使得我们可以在调用Get方法时不需要传入第二个参数, 编译器也不会报错 :
//public Shape Get (int shapeId, int materialId) {
//为Get方法的第二个参数设置一个默认值, 从而可以在不传入第二个参数的情况下调用Get方法
public Shape Get (int shapeId, int materialId = 0) {
我们也可以为shapeId参数也设置一个默认值0 :
//public Shape Get (int shapeId, int materialId = 0) {
//为Get方法的第一和第二个参数均设置一个默认值, 从而可以在只传入一个参数甚至完全不传参数的情况下调用Get方法
public Shape Get (int shapeId = 0, int materialId = 0) {
只传入一个参数来调用Get方法时怎么知道代表的哪个参数?
如果你使用Get(0)这种写法, 表示你省略的是第二个参数materialId. 如果使用Get()写法调用则表示两个参数全部省略.
但是, 如果你希望省略第一个参数shapeId, 你就需要明确的指明当前的一个参数代表的是第二个参数materialId, 写法是在参数值前面写上方法声明时的参数名并使用冒号分隔 : Get(materialId : 0)
GetRandom方法现在应该既设置随机形状, 也设置随机材质. 所以我们需要它里面的Get方法传入随机材质代号参数 :
public Shape GetRandom () {
//return Get(Random.Range(0, prefabs.Length));
//为GetRandom中的Get方法调用传入随机材质代号参数
return Get(Random.Range(0, prefabs.Length), Random.Range(0, materials.Length));
}
使用随机材质生成的随机形状
保存和加载材质代号
修改Game脚本的Save方法, 添加保存形状的材质代号的语句 :
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);
//在调用工厂类的Get方法时, 将加载到的材质代号数据作为第二个参数传递进去
Shape instance = shapeFactory.Get(shapeId, materialId);
instance.Load(reader);
shapes.Add(instance);
}
}
随机颜色
我们还可以变化形状的颜色. 只要调整每个形状实例所使用的材质的颜色数据就可以做到这一点.
我们可以将待选的颜色数据添加到Shape Factory中, 但是我们也可以使用无限制的颜色选择. 工厂类将不需要去特别处理可以设置哪些颜色, 颜色数据将像位置, 旋转, 缩放等数据一样, 直接被保存到文件中.
形状颜色
在Shape脚本中添加SetColor方法, 该方法将完成调整材质颜色的任务 :
//该方法将完成调整材质颜色的任务
public void SetColor (Color color) {
//使用方法的color参数设置当前形状使用的材质的颜色
GetComponent<MeshRenderer>().material.color = color;
}
为了可以保存和加载形状的颜色数据, 我们需要一个字段来代表颜色数据, 我们不需要将其设置为公开字段, 而是使用SetColor方法来完成对其的访问 :
//代表形状的颜色数据的字段
Color color;
public void SetColor (Color color) {
//使用方法的color参数设置color字段的值
this.color = color;
GetComponent<MeshRenderer>().material.color = color;
}
然后我们需要通过在Shape脚本中重写PersistableObject类的Save和Load方法完成对形状颜色数据的保存或加载. 首先使用base关键字调用PersistableObject类的对应方法, 然后再增加处理颜色的代码 :
//重写PersistableObject类的Save方法
public override void Save (GameDataWriter writer) {
//base.方法名()表示, 调用其父类对应名称的方法, 在这里父类就是PersistableObject类
base.Save(writer);
//向文件写入形状的颜色数据
writer.Write(color);
}
//重写PersistableObject类的Load方法
public override void Load (GameDataReader reader) {
//base.方法名()表示, 调用其父类对应名称的方法, 在这里父类就是PersistableObject类
base.Load(reader);
//从文件中加载形状的颜色数据
SetColor(reader.ReadColor());
}
========================译者感悟开始========================
不知道看到这里的看官, 有没有跟我一样的一个小疑问: “为什么要重写这俩方法? 去PersistableObject类中对应方法的保存和加载Transform数据代码的下面加上保存和加载颜色数据的代码不就好了么?”
这里一定是仁者见仁, 智者见智, 我写一下我个人的想法, 聊作参考 :
首先, 以面向对象设计的角度来看这种做法.
对于PersistableObject来说, 它只处理Transform数据, 这是所有Unity场景内物体都一定会带有的组件数据, 而材质颜色则不是, 有的游戏物体可能根本不需要材质, 只不过我们目前在做的”形状”恰好需要.
从可扩展性来说, PersistableObject类应该只处理所有游戏物体均适用的逻辑和数据, 这样就算以后不保存形状了, 保存摄像机, 保存光源, 都可以继承该类减少重复工作.
但是如果你让该类增加了类似处理材质颜色这种, 并不适用所有游戏物体的数据, 那么以后你要保存其他不带有材质的物体数据时, 就需要自己重新去写处理这类物体基本数据的保存和加载逻辑了.
========================译者感悟结束========================
不过, 我们之前写的Write方法, 并不存在接受Color类型参数的变体, 而加载方法中也不存在ReadColor这个方法, 所以上述保存和加载颜色数据的代码会报错, 接下来就解决一下这两处错误.
首先在GameDataWriter脚本中添加一个接受Color类型参数的新Write方法 :
//可以接收Color类型参数的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;
}
可以使用浮点数来分别保存颜色的各个色值吗?
你可以选择这样做, 如果你真的这样做了, 我建议你使用Color32结构类型去存储颜色色值数据, 这可以帮你更好的保障保存和记载时的数据一致性.
(译者注 : 如果你是萌新, 不用在意此处, 什么也不用改, 继续在跟着做即可)
保持向后兼容性
上面的代码让我们可以保存和加载形状的颜色数据了, 不过要记得, 对于旧版本的保存文件, 不存在颜色数据. 所以为了正确加载旧版本的保存文件, 需要在发现版本号不大于0时跳过对颜色数据的加载. 在Game脚本中, 我们读取版本数据并作为不同加载的判断条件, 但是在Shape类中, 并不了解版本号数据是多少. 所以需要在数据加载时让它们以某种方式”交流”读取到的数据. 所以有必要在GameDataReader脚本中定义一个代表版本数据的属性 :
因为加载完版本号数据后, 它不应该在被任何方式改变, 所以这个版本号数据应该只可以被设置一次. 由于GameDataReader并不是Unity对象类(就是说它没有继承MonoBehavior), 所以我们可以在构造方法设置该属性的值, 那么就可以只提供get方法来将它变为只读的. 另外, 原有的构造方法应该增加一个代表版本号的参数 :
//该属性只有get方法, 没有set方法, 也就是它只能读取, 不能设置, 即它是只读(read-only)属性
public int Version { get; }
//public GameDataReader (BinaryReader reader) {
//原有的构造方法增加一个代表版本号的参数version
public GameDataReader (BinaryReader reader, int version) {
this.reader = reader;
//在构造方法中可以为只读属性赋值
this.Version = version;
}
接下来, 需要通过PersistentStorage脚本完成保存或加载版本号数据的任务. 将版本号作为参数添加给它的Save方法, 并在将其最先写入文件. 然后在它的Load方法中调用GameDataReader的构造方法时, 新增读取到的版本号数据作为第二个参数, 记得, 读取到的版本号数据同样需要进行负数处理, 以便判断旧版本文件 :
//public void Save (PersistableObject o) {
//Save方法新增代表版本号的第二个参数
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));
//调用GameDataReader的构造函数时, 读取文件中的第一个整数数据, 作为版本号参数通过第二个参数传递进去
o.Load(new GameDataReader(reader, -reader.ReadInt32()));
}
}
既然我们已经通过上述代码保存和加载了版本号数据, 那么在Game脚本中就不必进行对应的处理了, 首先修改Game脚本的Save方法 :
public override void Save (GameDataWriter writer) {
//在Game脚本中删除向文件写入版本号的代码, 该任务现在由PersistentStorage.Save方法完成
//writer.Write(-saveVersion);
writer.Write(shapes.Count);
…
}
同时, 还需要在Game脚本调用PersistentStorage.Save时, 将版本号数据作为第二个参数传递 :
void Update () {
…
else if (Input.GetKeyDown(saveKey)) {
//storage.Save(this);
//将版本号数据作为第二个参数传递个storage.Save方法
storage.Save(this, saveVersion);
}
…
}
接下来, Game脚本的Load 方法中可以通过reader.Version获得保存文件的版本号数据 :
public override void Load (GameDataReader reader) {
//int version = -reader.ReadInt();
//不再自己去文件中读版本号数据, 而是使用reader.Version属性值代替
int version = reader.Version;
…
}
另外, 我们还需要在Shape.Load方法中检查版本号, 如果大于0, 则表示是带有颜色数据的新版本, 需要读取颜色数据. 否则, 不读取颜色数据, 使用写死的白色代替 :
public override void Load (GameDataReader reader) {
base.Load(reader);
//SetColor(reader.ReadColor());
//如果版本号大于0, 则表示是带有颜色数据的新版本, 需要读取颜色数据. 否则, 不读取颜色数据, 使用写死的白色代替
SetColor(reader.Version > 0 ? reader.ReadColor() : Color.white);
}
选择一种形状颜色
现在, 需要在生成每个形状时, 为其设置各种各样的个性颜色. 这需要在Game.CreateShpae方法中调用形状的SetColor方法. 我们可以使用Random.ColorHVS方法获得随机的颜色值, 如果该方法不使用参数, 表示在所有Unity可设置的颜色中随机选择一种, 如果这样做, 生成很多形状时的会显得颜色太杂乱了. 我们可以增加参数来对可选择的颜色范围进行限制, 将饱和度范围限制在0.5到1之间, 将色值范围限制在0.25到1之间. 因为我们并不准备设置透明效果, 所以将取得颜色的alpha值始终设置为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);
}
Random.ColorHSV传入了八个参数, 这非常难于理解它到底在如何控制颜色范围, 可读性较差. 你可以让它更易于阅读理解, 只需要明确的在每个参数前面将方法的参数名称也一并写出来, 另外, 也可以在参数之间增加换行 :
//instance.SetColor(Random.ColorHSV(0f, 1f, 0.5f, 1f, 0.25f, 1f, 1f, 1f));
//使用明确的参数名称以及美观的换行, 来增加Random.ColorHSV方法调用意义的可读性
instance.SetColor(Random.ColorHSV(
hueMin: 0f, hueMax: 1f,
saturationMin: 0.5f, saturationMax: 1f,
valueMin: 0.25f, valueMax: 1f,
alphaMin: 1f, alphaMax: 1f
));
生成的形状都拥有了不同的颜色
记住渲染器
Shape脚本中, 我们使用了两次GetComponent
//该字段用于存储形状的MeshRenderer组件实例的引用
MeshRenderer meshRenderer;
void Awake () {
//在Awake方法中获取到MeshRenderer组件实例的引用, 存储到meshRenderer字段中
meshRenderer = GetComponent<MeshRenderer>();
}
然后我们就可以在SetColor方法和SetMaterial方法中使用该字段 :
public void SetColor (Color color) {
this.color = color;
//GetComponent<MeshRenderer>().material.color = color;
//使用meshRenderer字段代替GetComponent<MeshRenderer>()
meshRenderer.material.color = color;
}
public void SetMaterial (Material material, int materialId) {
//GetComponent<MeshRenderer>().material = material;
//使用meshRenderer字段代替GetComponent<MeshRenderer>()
meshRenderer.material = material;
MaterialId = materialId;
}
使用属性块
为形状的材质设置颜色, 导致在游戏运行时创建了一种专属于于当前形状的新材质. 这会在每个形状进行颜色设置时发生. 我们可以通过使用MaterialPropertyBlock方法避免这个问题. 创建一个新的属性块(property block), 设置一个叫做_Color的颜色属性, 然后使用它作为渲染器的属性块 :
public void SetColor (Color color) {
this.color = color;
//meshRenderer.material.color = color;
//新建一个渲染器的属性块变量, 用于指定一个渲染器属性块及其值, 通过它设置材质颜色有更好的性能
var propertyBlock = new MaterialPropertyBlock();
//为该属性块设置对应的属性名称"_Color"与属性值color
propertyBlock.SetColor("_Color", color);
//使用SetPropertyBlock方法, 传上述属性块作为参数, 完成对材质颜色的设置
meshRenderer.SetPropertyBlock(propertyBlock);
}
我们也可以使用一个代号来代表渲染器的颜色属性. 这个代号由Unity设置, 该代号在同一次游戏运行中对于所有形状都可以使用, 不需要根据每个形状变化改变, 也就是对于每个形状, 该代号都表示”_Color”这个属性名称. 所以我们可以将这个代号存储在一个静态字段中, 我们可以通过Shader.PropertyToID方法得到Unity设置的属性代号 :
//新增一个静态字段colorPropertyId, 用来存储为名为"_Color"的渲染器属性块
//Shader.PropertyToID方法会自动帮我们得到一个代表"_Color"的代号, 通过这个方面可以看出来, 其实就是在操作Shader的_Color属性
static int colorPropertyId = Shader.PropertyToID("_Color");
public void SetColor (Color color) {
this.color = color;
var propertyBlock = new MaterialPropertyBlock();
//propertyBlock.SetColor("_Color", color);
//使用colorPropertyId作为"_Color"属性块名称的代号
propertyBlock.SetColor(colorPropertyId, color);
meshRenderer.SetPropertyBlock(propertyBlock);
}
另外我们还可以将属性块变量propertyBlock提炼为所有形状的材质共享的属性块字段, 因为对于所有的形状都只需要设置”_Color”这个属性块, 所以不需要每次都创建一个新的属性块变量 :
我们使用一个静态字段来代替原来的属性块变量, 但是由于Shape脚本继承了MonoBehavior类, 所以不可以在声明时调用字段的构造方法, 这就需要我们在SetColor中调用它的构造方法来完成初始化, 我们要在初始化之前判断它是否已经被初始化过了 :
//代表材质属性块的字段
static MaterialPropertyBlock sharedPropertyBlock;
public void SetColor (Color color) {
this.color = color;
//var propertyBlock = new MaterialPropertyBlock();
//删除变量propertyBlock, 使用静态字段sharedPropertyBlock代替它
if (sharedPropertyBlock == null) {
//由于MonoBehavior不可以在声明字段时调用字段的构造方法, 所以需要在一个具体的方法内调用
//如果sharedPropertyBlock为null, 表示还没有初始化, 则调用其构造方法进行初始化
sharedPropertyBlock = new MaterialPropertyBlock();
}
//propertyBlock.SetColor(colorPropertyId, color);
//使用静态字段sharedPropertyBlock代替变量propertyBlock
sharedPropertyBlock.SetColor(colorPropertyId, color);
//meshRenderer.SetPropertyBlock(propertyBlock);
//使用静态字段sharedPropertyBlock代替变量propertyBlock
meshRenderer.SetPropertyBlock(sharedPropertyBlock);
}
通过上述代码改动, 我们就可以在运行时设置一个形状的材质颜色, 而又不会导致创建额外的材质数据. 你可以在运行后, 在Project窗口中调整材质资源文件除了Color以外的属性观察所有使用这个材质的形状的外观变化, 你会发现所有使用同一种材质的形状都会根据材质资源的属性调整而出现变化, 如果我们使用之前的直接设置MeshRenderer.material.color的方式, 就不会有这个效果.
另外, 之所以要调整除了Color以外的属性观察变化, 是因为我们在代码中为形状指定了渲染器的颜色属性块, 这会覆盖掉材质本身的Color属性设置, 也就是上述代码会导致无法在每个形状的Inspector中去调整它使用的材质的Color属性.
===========翻译者补充内容开始↓↓↓========
- 上面提到的所谓”属性块Property block”, 对应的其实就是材质的Shader的属性, 这里需要你有基础的Shader知识才能理解, 不理解不影响学习教程, 如果你想学习Shader基本知识, 可以查看Unity Shader 超详细基础教程
- 关于使用MeshRenderer.material.color设置材质颜色, 和使用属性块设置材质颜色做法的区别, 可以查看这篇文章
===========翻译者补充内容结束↑↑↑========
GPU实例化
由于我们使用了属性块, 所以现在能够在同一次draw call(绘制请求)中通过GPU实例化组合那些使用了相同材质的形状, 即便它们之间的颜色是不同的. 不过这需要用到支持实例化颜色的Shader(着色器), 我们将使用Unity GPU Instancing manual page页面中提供的Shader示例代码, 唯一的改动就是添加了”#pragma instancing_options assumeuniformscaling”指令. 当我们的形状都是按照比例关系均匀缩放的时候可以使用该指令, 该指令会让实例化过程更有效率, 因为它需要的数据更少.
===========翻译者补充内容开始↓↓↓========
为萌新补充下所需的自定义Shader创建步骤:
1) 在Project窗口的Asset文件夹内任意路径下, 点击右键菜单 > Create > Shader >Standar Surface Shader, 这样就会创建一个新的Shader资源文件
2) 将其命名为”InstancedColors”
3) 双击在代码编辑器打开, 删除全部默认代码, 并将下方代码复制进去
===========翻译者补充内容结束↑↑↑========
(以下代码, 看不懂没关系, 复制粘贴完事儿了, 你要明白的是作者创建这个Shader是为了减少渲染时候的draw call, 提高性能表现)
//指定为材质资源设置Shader时, 可以找到该Shader的选项菜单位置
Shader "Custom/InstancedColors" {
Properties{
//这些东西就对应了材质的属性, 左边双引号中的字符串就是暴露在材质资源Inspector中的属性名称,
//没错, 你可能想到了, 所谓材质material, 本质上就像是Shader的实例, Shader定义了材质, 材质需要关联一个Shader
_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"
}
保存上述代码, 回到Unity编辑器, 分别通过三个材质资源的Inspector中的Shader下拉选项, 将它们的Shader设置上面新建的Shader. 对比材质的默认Shader, 我们会发现可用的属性减少了, 不过这对于我们的教程足够了. 设置后, 要勾选每一个材质的Enable GPU Instancing选项 :
点击红圈下拉框, 然后在列表中点击Custom, 接着选择InstancedColors, 完成材质Shader的更改
另外设置完成后, 记得勾选Enable GPU Instancing选项
你可以通过游戏窗口的Stats面板中的统计数据观察默认Shader与InstancedColors之间运行时绘制性能的差异 :
左图 : 使用新的Shader, 材质勾选了Enable GPU Instancing
右图 : 默认Shader, 而且材质没有启用Enable GPU Instancing
(重点观察Batches值, 代表的是draw call的次数, 次数越少, 性能越好; 另外不需要太在意FPS的巨大差异, 如果不是编辑器环境, 不会有这么高的FPS, 但是这也体现出了两种做法前后的性能差异)