某愿朝闻道译/原文地址

  • 实例化游戏物体
  • 运用递归
  • 使用协程
  • 加入随机性

分形(Fractal)神秘而又美丽. 本教程我们将写一个能够生成某些分形的C#脚本.

我假定你已经了解了Unity编辑器的基本元素及操作, 并且知道创建C#脚本的基础知识. 比如你已经完成了教程游戏物体与脚本的话那就可以直接开始本教程.

这篇教程创造时所用的Unity版本较老, 我还没有使用新版本重新制作本教程, 不过它依然值得一试. 你需要注意的就是diffuse(漫反射)和specular(镜面反射)材质在Unity2017版本开始已经不再支持, 所以你可以忽略教程中与它们相关的内容.
构造分形 - 图1
你将生成随机性的3D分形

如何制作分形

我们将要制作3D分形(3D fractal). 我们将会对Unity中的物体层次结构来做这件事. 由一些根节点物体(root object)开始, 为它们添加尺寸小一些单具有很多共性的子物体. 如果手动执行这个过程会非常的繁琐, 所以我们选择使用脚本来为我们完成这件事.

让我们从一个新建项目的新建场景开始. 在开始前我会对场景进行一些初始操作, 我会添加一个平行光源(较新版本的Unity新建3D项目的场景以及会自带一个默认的平行光源]Directional light了)并设置摄像机的位置和姿态以获得一个更好的观察视角. 不过你可以按照你喜欢的方式进行场景的初始设置. 我也会为一会儿要生成的分形创建一个新的材质(material). 它使用specular shader的默认设置, 比材质默认的diffuse shader好看一些.

新建一个空物体并设置其position值为(0, 0, 0). 它将作为分形的基础. 然后创建一个名为Fractal的C#脚本, 并添加到这个新建的空物体上 :

  1. //老规矩, 除了下方保留的内容, 删除Unity自动创建的其他脚本
  2. using UnityEngine;
  3. using System.Collections;
  4. public class Fractal : MonoBehaviour {
  5. }

构造分形 - 图2项目设置
项目初始状态源码

显示点什么

我们的分形看起来会像是什么呢? 首先为Fractal脚本添加公开的Mesh网格和Material材质字段. 我们接着添加一个Start方法并在方法内通过代码对物体添加一个新的MeshFilter组件和一个MeshRenderer组件. 同时, 我们直接将Mesh和Material字段分配给它们 :

  1. using UnityEngine;
  2. using System.Collections;
  3. public class Fractal : MonoBehaviour {
  4. public Mesh mesh;
  5. public Material material;
  6. private void Start () {
  7. gameObject.AddComponent<MeshFilter>().mesh = mesh;
  8. gameObject.AddComponent<MeshRenderer>().material = material;
  9. }
  10. }

什么是Mesh(网格)?

Mesh(网格)是图形硬件绘制复杂图形时要用到的组成结构. 它是3D的, 并且Unity提供了默认的Mesh图形, 你也可以通过代码生成自己的Mesh
一个Mesh至少包含3D空间中的点的集合, 以及一组三角面, 三角面是组成3D的Mesh所需的主要2D形状, 三角面通过点的关系来确定. 三角面的数量和状态决定了Mesh的表面形状. 通常, 观察3D物体时, 你不会意识到意识到你正在观察的一个个三角面.

什么是Material(材质)?

Material常常用来定义物体的视觉属性. 它们可以表现的非常简单, 比如说纯色填充; 也可以非常复杂.
Material由Shader(着色器)组成. Shader是用来告诉图形硬件一个物体的轮廓外形要如何绘制的基本脚本.
标准漫反射Shader(standard diffuse shader)使用单一的颜色并可设置Texture(纹理贴图), 根据场景中的光照情况来决定一个物体的外观. 我在本教程会使用更为复杂一点的用来模拟高光效果的镜面反射Shader(specular shader)

Start方法在什么时候被调用?

Start方法将在场景中的组件被创建并激活后调用, 并且如果你的代码加入了Update方法, Start会在第一次Update方法执行之前执行. Start方法只会执行一次.

AddComponet语句都干了什么?

AddComponent方法将会创建一个指定类型的组件, 并将它附加到游戏物体上, 同时该方法回使用返回值返回这个组件的数据. 这就是为什么我们可以立即去为它设置一个值. 你也可以使用中间变量来存储返回的组件数据, 然后再给它赋值 :
MeshFilter filter = gameObject.AddComponent();
filter.mesh = mesh;
它的语法有些特别, 使用了一对尖括号包裹的数据类型名称, 这是因为它是一个泛型(generic)方法. 它可以被看做是根据所指定的数据类型来决定使用哪一种方法的模板. 而数据类型就是通过尖括号来告诉它的.

现在我们可以将自定义的Fractal材质拖拽给Fractal组件的Material属性, 然后点击Mesh属性栏右侧的圆圈小按钮, 并在弹出的窗口中选择Unity提供的cube. 做完以上步骤, 运行游戏后场景内就会出现一个立方体

当然我们也可以手动添加使用代码添加的组件, 不过在本教程, 我们要使用代码来做这件事
构造分形 - 图3
构造分形 - 图4 构造分形 - 图5
运行游戏后, Fractal物体的Inspector中出现了代码添加的组件

点我下载源码(跟着教程写代码遇到问题的话可以对照源码看看)

制作子物体

我们应该如何为Fractal物体创建子物体? 最简单的办法就是在Start方法中使用代码生成一个新物体并附加Fractal脚本组件. 添加代码后运行试试看, 然后迅速的关闭运行模式 :

  1. private void Start () {
  2. gameObject.AddComponent<MeshFilter>().mesh = mesh;
  3. gameObject.AddComponent<MeshRenderer>().material = material;
  4. new GameObject("Fractal Child").AddComponent<Fractal>();
  5. }

new关键字都干了什么?

new关键字用来创建一个新的实例(instance). 它的后面需要跟随着实例数据类型的构造方法, 构造方法与它要创建的实例的类名相同.

运行游戏后我们会发现, 每个新生成的物体都会继续再生成一个新物体, 如果我们不停止游戏运行, 这个过程将一直持续不会停止. 如果这个过程不停的继续下去, 迟早会将你计算机的内存占满, 最终造成内存溢出或崩溃. 本例中你不用过于担心, 我们的代码较为温和, 对于计算机来说它占用内存的速度极其缓慢.

为了防止发生上述情况, 我们引入了一个概念叫做最大深度. 初始Fractal物体的深度将被设置为0. 它的子物体深度将设置为1. 子物体的子物体的深度设置为2, 以此类推, 直到达到最大深度值.

添加一个public(公开)整数字段maxDepth, 在Inspector中将其设置为4. 同时添加一个private(私有)整数字段depth.
之后我们修改代码逻辑, 让它被最大深度控制能生成多少子物体 :

  1. //新增字段
  2. public int maxDepth;
  3. //新增字段
  4. private int depth;
  5. private void Start () {
  6. gameObject.AddComponent<MeshFilter>().mesh = mesh;
  7. gameObject.AddComponent<MeshRenderer>().material = material;
  8. //生成子物体的代码被新增的if语句包裹
  9. if (depth < maxDepth) {
  10. new GameObject("Fractal Child").AddComponent<Fractal>();
  11. }
  12. }

构造分形 - 图6
Max Depth设置为4

代码修改后运行, 看看发生了什么? 我们发现只生成了一个子物体, 这是为什么?

因为我们没有位depth字段赋值, 它就总是默认值0. 因为0小于4, 所以我们初始的Fractal物体会通过代码创建一个子物体. 虽然新建的子物体的depth也是0, 但是它的maxDepth字段并没有被赋值, 所以也是0, 因此子物体的if语句条件不满足, 就无法继续执行创建物体的代码.

除此之外, 子物体也缺乏Material和Mesh. 我们需要从父物体Fractal复制这两个组件的引用(reference)然后加到子物体上. 根据以上思路, 我们新建一个Initialize方法来进行新建物体的初始化工作, 并在Start方法中调用 :

  1. private void Start () {
  2. gameObject.AddComponent<MeshFilter>().mesh = mesh;
  3. gameObject.AddComponent<MeshRenderer>().material = material;
  4. if (depth < maxDepth) {
  5. //new GameObject("Fractal Child").AddComponent<Fractal>();
  6. new GameObject("Fractal Child").AddComponent<Fractal>().Initialize(this);
  7. }
  8. }
  9. //新建Initialize方法
  10. private void Initialize (Fractal parent) {
  11. mesh = parent.mesh;
  12. material = parent.material;
  13. maxDepth = parent.maxDepth;
  14. depth = parent.depth + 1;
  15. }

this是什么

this关键字在代码中指的是当前被调用的的类或结构的实例对象. 当引用来自同一个类中的东西(如属性, 方法)时, 都隐式的使用了它. 比如说, 我们在类中访问depth字段, 书写的depth等同于this.depth.
通常只需要在你想要在代码中引用实例本身时才需要明确的书写它, 比如我们调用Initialize方法时做的那样. 为什么要使用this作为Initialize的参数? 因为我们调用的是子物体的Initialize方法, 而不是父物体的, 我们需要将父物体传递给子物体来为子物体的字段赋值.
(如果缺少 面向对象 程序基础, 此处不是那么好理解, 如果你感到困惑, 可以你这么理解 : 一个脚本文件可以附加给多个物体, 每个物体附加后的脚本都是一个单独的副本, 这个this代表的就是当前物体的这个脚本副本. 此处”副本”这个概念并不准确, 只是为了帮助你理解而使用.)

Initialize方法在Start方法之前调用?

是的. 首先新的游戏物体被创建, 然后一个新的Fractal组件也随之被创建并附加给新的物体. 在这时, 如果存在Awake方法和OnEnable方法, 接着将调用它们. 再然后AddComponent方法就执行完毕, 这时就会直接执行我们在AddComponent方法后书写的Initialize方法. 而新物体的脚本的Start方法则会在下一帧才被调用

修改代码后, 再次进入运行模式, 会看到创建了四个子物体, 与我们的预期一致了. 但是它们还没有真正的成为创建它的物体的子物体, 在Hierarchy窗口中并没有与其形成父子关系层级. 物体的父子关系是依靠它们Transform组件之间的层级关系来确定的. 所以, 我们需要将新建物体Transform组件的父物体, 设置为Fractal物体的Transform组件 :

  1. private void Initialize (Fractal parent) {
  2. mesh = parent.mesh;
  3. material = parent.material;
  4. maxDepth = parent.maxDepth;
  5. depth = parent.depth + 1;
  6. //新增设置父子关系代码
  7. transform.parent = parent.transform;
  8. }

构造分形 - 图7 构造分形 - 图8
左图 : 父子关系未设置 ||| 右图 : 父子关系已设置

点我下载源码(跟着教程写代码遇到问题的话可以对照源码看看)

塑造子物体形状

现在, 新建的物体已经被正确的设置为了创建它的物体的子物体. 但是我们在场景中看到的依然只是一个cube, 因为它们大小一样位置相同, 重叠在了一起. 我们需要在新建物体的本地空间(local space)中移动它们, 从而可以观察到它们. 并且因为它们应该比父物体的尺寸小一些, 我们需要调整它们的Scale属性.

首先进行缩放. 怎么做呢? 让我们设置一个新的字段, 叫做childScale并在Inspector中设置它的值为0.5. 不要忘记将该字段的值传递给新建物体的脚本. 然后使用它来设置子物体的本地缩放(local scale).

设置缩放后, 我们要将子物体移动到什么位置? 我们可以简单的将它们向上移动. 子物体相对于父物体的尺寸是childScale的值, 父物体相对于子物体的尺寸是1, 每个物体的位置点处于它们的中心. 所以如果将子物体向上移动0.5个单位那么子物体的中心就正好可以位于父物体的上表面的平面中, 我们要继续将子物体向上移动半个自己的高度, 子物体的高度尺寸等于childScale, 所以半个子物体的高度也就是0.5*childScale, 这样我们最终就可以将每个子物体的下表面与它父物体的上表面恰好接触 :

  1. //新增字段childScale
  2. public float childScale;
  3. private void Initialize (Fractal parent) {
  4. mesh = parent.mesh;
  5. material = parent.material;
  6. maxDepth = parent.maxDepth;
  7. depth = parent.depth + 1;
  8. //设置子物体的childScale值等于父物体的
  9. childScale = parent.childScale;
  10. transform.parent = parent.transform;
  11. //设置子物体的localScale
  12. transform.localScale = Vector3.one * childScale;
  13. //设置子物体的localPosition向上移动
  14. transform.localPosition = Vector3.up * (0.5f + 0.5f * childScale);
  15. }

运行代码, 查看效果 :
构造分形 - 图9 构造分形 - 图10
设置childScale为0.5时的效果
构造分形 - 图11
childScae在0.3到07之间, 代码执行的不同效果

点我下载源码(跟着教程写代码遇到问题的话可以对照源码看看)

制作多个子物体

目前我们的代码执行后, 生成的形状像是一个塔, 这还不能称之为分形. 我们需要它开枝散叶, 所以要为每个父物体创建不止一个子物体. 额外创建第二个子物体很容易, 我们还需要让它向另外一个方向移动, 所以我们为Initialize方法增加一个新的参数, 用来指定第二个子物体移动的方向, 本例使用向右的方向 :

  1. private void Start () {
  2. gameObject.AddComponent<MeshFilter>().mesh = mesh;
  3. gameObject.AddComponent<MeshRenderer>().material = material;
  4. if (depth < maxDepth) {
  5. //new GameObject("Fractal Child").AddComponent<Fractal>().Initialize(this);
  6. new GameObject("Fractal Child").AddComponent<Fractal>().Initialize(this, Vector3.up);
  7. //新增创建第二个物体的代码
  8. new GameObject("Fractal Child").AddComponent<Fractal>().Initialize(this, Vector3.right);
  9. }
  10. }
  11. //private void Initialize (Fractal parent) {
  12. private void Initialize (Fractal parent, Vector3 direction) {
  13. ...
  14. //其余代码略, 修改设置子物体位置的代码, 使用参数direction来代替之前的Vector3.up
  15. //transform.localPosition = Vector3.up * (0.5f + 0.5f * childScale);
  16. transform.localPosition = direction * (0.5f + 0.5f * childScale);
  17. }

代码中的…是什么?

它表示我省略了一段代码中没有被修改的部分. 这样以便于更清晰的让你观察新增或修改的代码.

构造分形 - 图12
生成两个子物体的运行效果

现在我们生成的物体有点儿分形的意思了! 你是不是不是很能理解物体被创建的顺序呢? 因为它们几乎在瞬间被创建出来, 过程太快让我们无法观察到创建的顺序. 如果可以放慢这个生成过程, 我们就可以清晰地观察到生成顺序. 我们可以使用协程(coroutine)机制生成子物体来做到这一点

你可以理解协程是一个允许你插入暂停语句的方法. 当它被暂停时, 它以外的程序继续执行. 虽然这样描述协程有些过于简单和不严谨, 但是这有助于你理解它, 现在让我们使用它来生成子物体.

首先我们将创建新物体的两行代码移动到一个新创建的方法CreateChildren中. 这个方法需要使用IEnumerator作为它的返回类型, 该类型存在于System.Collections命名空间. 这也就是为什么Unity在默认生成的脚本你代码中会引用该命名空间, 也是为什么我们会在删除默认代码时保留它.

在Start方法中, 我们不会简单的直接调用CreateChildren方法, 而是将该方法作为Unity提供的StartCoroutine方法的参数.

然后我们需要在创建每个新物体之前暂停生成物体的指令, 比如暂停0.5秒, 这需要创建一个0.5秒的WaitForSeconds实例对象, 然后将它使用yield return语句返回, 从而实现暂停效果 :

  1. private void Start () {
  2. gameObject.AddComponent<MeshFilter>().mesh = mesh;
  3. gameObject.AddComponent<MeshRenderer>().material = material;
  4. if (depth < maxDepth) {
  5. //new GameObject("Fractal Child").AddComponent<Fractal>().Initialize(this,Vector3.up);
  6. //new GameObject("Fractal Child").AddComponent<Fractal>().Initialize(this,Vector3.right);
  7. //调用协程方法取代之前生成物体的代码
  8. StartCoroutine(CreateChildren());
  9. }
  10. }
  11. //新增CreateChildren方法
  12. private IEnumerator CreateChildren () {
  13. //下面一行代码会使得协程方法暂停0.5秒, 然后再执行后面创建第一个子物体的代码
  14. yield return new WaitForSeconds(0.5f);
  15. new GameObject("Fractal Child").AddComponent<Fractal>().Initialize(this, Vector3.up);
  16. //下面一行代码会使得协程方法暂停0.5秒, 然后再执行后面创建第二个子物体的代码
  17. yield return new WaitForSeconds(0.5f);
  18. new GameObject("Fractal Child").AddComponent<Fractal>().Initialize(this, Vector3.right);
  19. }

枚举器(Enumerator)是什么?

枚举(Enumeration), 概念指的是一次遍历集合所有元素的过程, 比如说, 循环一个数组中的所有元素. 枚举器(Enumerator), 也叫作迭代器(Iterator), 指提供了枚举功能接口的对象. System.Collections.IEnumerator就提供了这样的接口.
为什么我们需要这样做? 因为协程使用它们.

return是什么?

retrun关键字用来表示一个方法完成了, 并且返回了什么. 你使用return语句返回的数据类型必须与方法声明的数据类型一致. 如果方法声明的是void, 那么方法不需要返回任何值.
对于void类型的方法以及构造方法来说, 不必须在方法末尾书写return语句, 但是除此之外的方法都需要书写.
一个方法中可以书写多个return语句. 这种情况下每一个return语句都代表着方法可能的结束为止. 通常应该配合if语句来设置在什么情况下执行哪一个return语句.

yield是什么?

如果希望枚举过程可以完成, 需要程序跟踪你的枚举过程. 那么你可能需要一些类似的与return firstItem; return secondItem;之类的代码来告诉程序, 你遍历到了集合的第几个元素之类, 直到全部遍历完毕.
yield语句就是来明确的告诉程序这件事的.
所以当你使用yield语句时, 一个迭代器的实例对象就在后台自动创建了, 用于处理对应的枚举过程. 这就是为什么CreateChildren方法需要使用IEnumerator作为返回类型
另外, 你也可以返回其他的迭代器, 当你熟练掌握它们的使用方法后, 可以进行更为复杂和多样的逻辑处理.

协程是如何工作的?

当你在Unity中创建一个协程时, 其实是创建了一个迭代器. 当你把它传递给StartCoroutine方法, 它将在每一帧存储和检测下一个枚举内容(原文next item), 直到整个枚举过程完成.
yield语句会提供枚举内容.
你可以通过yield语句返回指定内容, 比如我们用到的WaitForSecond, 以便于当其他代码继续执行时可以对协程进行特殊的控制, 不过协程本质上还是一个迭代器.
(此处原文较为晦涩难懂, 而且协程的概念如果你想深度的了解清楚, 还是需要单独寻找资料补充相关知识, 如果看不懂也不用太纠结, 继续跟着教程写代码看结果, 慢慢消化)

现在我们可以观察物体生成的过程了! 运行查看后, 你能发现这样做可能有什么问题吗? 让我们增加生成第三个子物体的代码, 将它向左边移动 :

  1. private IEnumerator CreateChildren () {
  2. yield return new WaitForSeconds(0.5f);
  3. new GameObject("Fractal Child").AddComponent<Fractal>().Initialize(this, Vector3.up);
  4. yield return new WaitForSeconds(0.5f);
  5. new GameObject("Fractal Child").AddComponent<Fractal>().Initialize(this, Vector3.right);
  6. //以下为生成第三个子物体用的新增代码
  7. yield return new WaitForSeconds(0.5f);
  8. new GameObject("Fractal Child").AddComponent<Fractal>().Initialize(this, Vector3.left);
  9. }

构造分形 - 图13 构造分形 - 图14
每个父物体三个子物体, 左边是实际效果, 右边是作为参照的透视(Overdraw)效果

我怎么查看透视效果?

Scene顶部工具栏左上角中有一个下拉列表, 其中有一个选项就是透视(Overdraw)
(如果不想看透视效果了, 记得还是在这个下拉选项中, 选择顶部的Shaded选项, 就恢复默认效果了)
image.png

现在生成物体过程的问题是, 子物体与父物体有着相同的朝向. 这意味着在右侧子物体在生成它自己左侧的子物体时, 它的子物体会穿透到它的父物体内部. 为了解决这个问题, 我们需要对子物体进行旋转, 使得它们自身的本地Y轴指向远离父物体的方向.

我们将为Initialize方法添加一个代表子物体旋转状态的新参数. 它是一个四元数, 用来设置新物体的本地旋转. 位于父物体上方的子物体不需要旋转, 位于右侧的子物体需要顺时针旋转90度, 位于左侧的子物体需要逆时针旋转90度 :

  1. private IEnumerator CreateChildren () {
  2. yield return new WaitForSeconds(0.5f);
  3. //new GameObject("Fractal Child").AddComponent<Fractal>().Initialize(this, Vector3.up);
  4. new GameObject("Fractal Child").AddComponent<Fractal>().Initialize(this, Vector3.up, Quaternion.identity);
  5. yield return new WaitForSeconds(0.5f);
  6. //new GameObject("Fractal Child").AddComponent<Fractal>().Initialize(this, Vector3.right);
  7. new GameObject("Fractal Child").AddComponent<Fractal>().Initialize(this, Vector3.right, Quaternion.Euler(0f, 0f, -90f));
  8. yield return new WaitForSeconds(0.5f);
  9. //new GameObject("Fractal Child").AddComponent<Fractal>().Initialize(this, Vector3.left);
  10. new GameObject("Fractal Child").AddComponent<Fractal>().Initialize(this, Vector3.left, Quaternion.Euler(0f, 0f, 90f));
  11. }
  12. private void Initialize (Fractal parent,Vector3 direction,Quaternion orientation) {
  13. //在Initialize方法末尾加入以下代码用来设置新物体的旋转角度
  14. transform.localRotation = orientation;
  15. }

构造分形 - 图16 构造分形 - 图17
操作子物体旋转后的运行效果

通过控制子物体的旋转, 我们避免了它们很快就穿透到分形的内部. 但是你可能已经注意到, 一些最小的子物体穿透到了根节点cube中. 这是因为缩放系数是0.5, 分形将会在四次生成子物体的过程后自相交. 你可以减少childScale的值来解决这个问题, 或是使用spheres球体来代替cube
构造分形 - 图18 构造分形 - 图19
值依然是0.5, 但是将物体换成了球体

点我下载源码(跟着教程写代码遇到问题的话可以对照源码看看)

生成更多子物体时的更好方法

目前位置的代码有一些拙劣. 让我们将子物体的移动方向数据和旋转角度数据概括为两个静态数组. 之后我们可以使用一个循环来设置物体的方向, 从而减少CreateChildren方法的代码量, 同时我们也可以精简Initialize方法的参数, 只需要传递静态数组的索引就可以通过静态数组获取到每个子物体的移动方向和旋转角度 :

  1. //代表子物体移动方向的静态数组, 元素分别对应第一第二第三个物体
  2. private static Vector3[] childDirections = {
  3. Vector3.up,
  4. Vector3.right,
  5. Vector3.left
  6. };
  7. //代表子物体旋转角度的静态数组, 元素分别对应第一第二第三个物体
  8. private static Quaternion[] childOrientations = {
  9. Quaternion.identity,
  10. Quaternion.Euler(0f, 0f, -90f),
  11. Quaternion.Euler(0f, 0f, 90f)
  12. };
  13. private IEnumerator CreateChildren () {
  14. //yield return new WaitForSeconds(0.5f);
  15. //new GameObject("Fractal Child").AddComponent<Fractal>().Initialize(this, Vector3.up,Quaternion.identity);
  16. //yield return new WaitForSeconds(0.5f);
  17. //new GameObject("Fractal Child").AddComponent<Fractal>().Initialize(this, Vector3.right, Quaternion.Euler(0,0,-90));
  18. //yield return new WaitForSeconds(0.5f);
  19. //new GameObject("Fractal Child").AddComponent<Fractal>().Initialize(this, Vector3.left, Quaternion.Euler(0, 0, 90));
  20. //使用for循环代替之前写了三遍的物体生成代码
  21. for (int i = 0; i < childDirections.Length; i++) {
  22. yield return new WaitForSeconds(0.5f);
  23. new GameObject("Fractal Child").AddComponent<Fractal>().Initialize(this, i);
  24. }
  25. }
  26. //private void Initialize(Fractal parent, Vector3 direction,Quaternion quaternion) {
  27. //修改Initialize方法的参数, 使用代表数组索引的整数参数代替之前的方向和角度参数
  28. private void Initialize (Fractal parent, int childIndex) {
  29. //…
  30. //transform.localPosition = direction * (0.5f + 0.5f * childScale);
  31. //使用数组元素来决定移动方向
  32. transform.localPosition = childDirections[childIndex] * (0.5f + 0.5f * childScale);
  33. //transform.localRotation = quaternion;
  34. //使用数组元素来决定旋转角度
  35. transform.localRotation = childOrientations[childIndex];
  36. }

数组是如何工作的?

数组中存储固定数量的数值, 称之为数组元素. 当声明一个变量或字段时, 在其类型名称后面加上方括号, 表示你要建立一个该类型的数组. 所以 int myVariable;代表一个整数, 而int[] myVariable;则代表一个整数数组.
你可以通过 “数组名称[序号]” 的方式获取一个数组元素的值, 注意数组的序号从0开始. 比如 myVaribale[0], 表示的获取myVariable数组的第一个元素值, 以此类推.
int[] myVariable = new int[10]; 这样的写法会创建一个长度为10的整数数组. 你也可以使用int[] myVariable = {1,2,3}; 这样的写法直接完成对数组元素的赋值加长度定义, 此例将定义长度为3的数组, 元素值分别为1,2,3

for循环是如何工作的?

for循环可以重复执行被它的大括号包围的代码, 这个过程也可以叫迭代.
我们上文的代码中使用了一个叫做i的整数作为迭代器, 我们在for后面的圆括号中书写的第一部分声明了整数迭代器i, 第二部分设置了循环结束的条件, 第三部分对迭代器进行了增加操作.
你可以使用while循环代替for循环, 不过while循环需要你在循环代码中控制迭代器的值
for(int i = 0; i < 10; i++) {
//具体代码
}
等同于
int i = 0; while(i < 10) {
//具体代码
i++;
}
另外, i++是i = i+1的另一种写法变体

现在我们将通过对以上两个静态数组增加新数据的方式来新增两个子物体的生成. 一个向前移动, 一个向后移动, 旋转角度分别是顺时针90和逆时针90 :

  1. private static Vector3[] childDirections = {
  2. Vector3.up,
  3. Vector3.right,
  4. Vector3.left,
  5. //新增两个生成物体的方向
  6. Vector3.forward,
  7. Vector3.back
  8. };
  9. private static Quaternion[] childOrientations = {
  10. Quaternion.identity,
  11. Quaternion.Euler(0f, 0f, -90f),
  12. Quaternion.Euler(0f, 0f, 90f),
  13. //新增两个生成物体的旋转角度
  14. Quaternion.Euler(90f, 0f, 0f),
  15. Quaternion.Euler(-90f, 0f, 0f)
  16. };

构造分形 - 图20
每个父物体生成五个子物体, 全分形(full fractal)

我们现在有了一个全分形结构, 但是不对啊, 对于最下面根节点物体Fractal, 它还有一个面没有生成子物体不是吗? 我们虽然可以增加生成第六个子物体, 让它和所有父物体的下面都可以同样生成分形, 但是你要知道, 这样做之后, 会有大量的子物体分形被浪费, 因为每个物体与其父物体接触那一侧方向上生成的子物体, 全部都穿透到了分形内部.

点我下载源码(跟着教程写代码遇到问题的话可以对照源码看看)

爆炸式增长

我们到底创建了多少个cube? 因为我们为每个父物体都生成五个子物体, 所以生成的总物体数量取决于我们的最大深度. 最大深度为0时只会产生一个cube, 也就是根节点Fractal物体; 最大深度为1, 则会产生五个子物体再加一个根节点物体, 一共六个cube. 经过简单思考, 我们可以将分形生成物体的数量写作函数f(0)=1, f(n)=5*f(n-1)+1.

上面的函数, n从递增, 可以产生的物体数量依次是1,6,31,156,781,3906,19531,97656等等. Game窗口的分析面板(Statistics)中, 生成物体的数量会累加到Draw Calls数据中进行显示(如果你的Unity版本较新, 这个数据名称可能已经改成了Batches, 如下图); 如果你开启了动态合批(Dynamic Batching), 那么生成物体的数量将不会全部累加到Draw Call数据, 而是相当大的一步累加到Saved by Batching数据.
image.png


(Game窗口右上角, 点击这个Stats按钮, 就可以打开Static, 我的是2019版, Draw Calls这个数据已经被Batches取代, 我设置的最大深度是5, 你会发现Batches显示的是3908, 比生成的物体数量多了2, 这是因为场景中默认就有2的绘制数量, 都被加到一起了)

===========翻译者补充内容开始↓↓↓========
此处原作者省略了一个步骤, 那就是去开启动态合批设置, 然后再观察对比分析面板, 我补充一下 :
image.png
动态合批的在菜单位置 Edit / Projects Settings / Player / Other Settings中进行设置, 如上图. 该设置默认不开启, 当你运行程序后. 你会看到Draw Call数据会加上你生成的物体数量.
但是如果你开启了动态合批, 再运行程序, 就会发现, Draw Call数据增加的没有那么多了, 而另一个数据Saved by batching累加了剩余的物体数量. 置于这是为什么教程后面会继续提及, 我进行赘述了, 继续看就行.
记得在这里去设置界面开启动态合批, 后面马上要用到.
===========翻译者补充内容结束↑↑↑========

Unity适合处理的最大深度大概在4到5之间. 如果设置更大的深度, 你的运行帧率可能会下降的很严重.

除了数量, 物体生成的间隔时间也是一个问题. 现在我们在创建一个新物体之前暂停0.5秒. 过程显得较为生硬. 我们可以通过随机延迟来让增长过程更自然, 看起来很有趣.

所以让我们替换固定的延迟时间, 使用0.1到0.5秒的随机时间. 我也把最大深度设置为了5, 这样看到更明显的效果 :

  1. private IEnumerator CreateChildren () {
  2. for (int i = 0; i < childDirections.Length; i++) {
  3. //yield return new WaitForSeconds(0.5f);
  4. yield return new WaitForSeconds(Random.Range(0.1f, 0.5f));
  5. new GameObject("Fractal Child").AddComponent<Fractal>().Initialize(this, i);
  6. }
  7. }

Random.Range方法是如何工作的?

Random是一个可以创建随机值的类. 它的Range方法可以用来在特定范围生随机数.

Range方法有两个参数, 分别代表了要进行随机的数字范围.
如果参数使用浮点型, 就会返回浮点型的随机结果, 如果参数使用整型, 就会返回整数随机结果

点我下载源码(跟着教程写代码遇到问题的话可以对照源码看看)

加入颜色

分形现在有点呆板. 我们可以给它添加一些颜色变化让它生动起来. 我们设置节点为白色, 最后生成的子物体为黄色, 其他物体的颜色根据这两个颜色插值计算而来. 静态方法Color.Lerp用来处理这个情况非常适合, 我们将根据生成物体的深度来进行颜色的计算, 插值的比例在0%到100%之间, 每个cube的插值比例就等于它的深度除以最大深度. 因为我们不希望比例计算的结果是整数, 所以我们需要将它强制转换为float型 :

  1. private void Start () {
  2. gameObject.AddComponent<MeshFilter>().mesh = mesh;
  3. gameObject.AddComponent<MeshRenderer>().material = material;
  4. //添加下面的代码进行颜色的插值计算
  5. GetComponent<MeshRenderer>().material.color = Color.Lerp(Color.white, Color.yellow, (float)depth / maxDepth);
  6. if (depth < maxDepth) {
  7. StartCoroutine(CreateChildren());
  8. }
  9. }

Lerp方法做了什么?

Lerp的含义代表的就是线性插值. 它的典型用法就是Lerp(a,b,t), 返回值通过a+(b-a)*t来计算, t的取值范围在0到1之间. 它有多种不同类型参数的方法版本, 比如小数, 向量, 颜色等

构造分形 - 图23
染色后的分形(注意, 高版本中的Unity, 图中Draw Calls这个数据已经被Batches取代)

现在产生的分形就有趣一些了! 但是我们发现了另一个问题, 就算是我们开了动态合批, 它也并没有工作, 所有的物体生成数量依然全部累加到了Draw Call数据上, 这是为什么呢?

什么是动态合批?

动态合批(Dynamic batching)是”Unity绘制调用机制(draw call batching performed by Unity)”的一种形式.
简单的说, 它将多个使用相同材质的网格(Mesh)组合成一个更大的网格整体. 这样做可以减少CPU和GPU之间的通信数量, 从而优化性能. 你可以通过菜单Edit / Projects Settings / Player/Other Settings 来开启或关闭动态合批
该机制只是为了小一些的网格而准备的. 比如说, 你会发现它适用于Unity内置的cube网格, 但是不适用于内置的sphere网格

这个问题的原因是因为, 当我们设置了每个子物体材质的时候, 实际上Unity就悄悄的创建了它们材质的一个副本. 因为如果不这么做, 而是直接更改材质资源本身, 那么所有使用这个材质的物体的颜色都会都一样, 而不会出现差异了. 而动态合批只能针对使用相同材质的物体生效.

让我们只为每个深度创建一个材质副本, 来代替为每个cube创建一个材质副本. 首先添加一个新的数组字段保存材质数据. 然后在Start方法中检查数组是否存在, 如果不存在的话, 调用一个新增的方法InitializeMaterials. 在这个方法中我们将对每个深度的cube创建一个材质, 并且设置它的颜色 :

  1. //新增存储材质副本的数组
  2. private Material[] materials;
  3. //新增InitializeMaterials方法
  4. private void InitializeMaterials () {
  5. materials = new Material[maxDepth + 1];
  6. for (int i = 0; i <= maxDepth; i++) {
  7. materials[i] = new Material(material);
  8. materials[i].color = Color.Lerp(Color.white, Color.yellow, (float)i / maxDepth);
  9. }
  10. }
  11. private void Start () {
  12. //Start方法中判断材质数组是否存在
  13. if (materials == null) {
  14. InitializeMaterials();
  15. }
  16. gameObject.AddComponent<MeshFilter>().mesh = mesh;
  17. //gameObject.AddComponent<MeshRenderer>().material = material;
  18. //GetComponent<MeshRenderer>().material.color = Color.Lerp(Color.white,Color.yellow,(float)depth/maxDepth);
  19. //使用材质数组的第depth个元素作为新建物体的材质, 取代之前的材质设置及颜色设置代码
  20. gameObject.AddComponent<MeshRenderer>().material = materials[depth];
  21. if (depth < maxDepth) {
  22. StartCoroutine(CreateChildren());
  23. }
  24. }

null是什么?

不是简单数值类型(simple value)的数据类型的默认值是null. null该值不引用任何东西. 如果尝试使用一个null的值, 将会报错. 你可以通过类似if判断的测试语句来避免发生这种情况. 你也可以在不再需要使用一个值时, 手动的设置某个值它为null.
注意, 将值设置为null, 不会自动的使之前的存储值所代表的数据消失. 只有没有任何其他地方引用那个数据时, 才会将其进行垃圾回收器(garbage collector)处理
同时也要注意, 这个私有private字段才可能是null, 而public的字段不会是null, 因为Unity的序列化系统(serialization system)会为公开字段创建默认数据, 比如如果materials如果是public的, 就会被默认设置为一个元素数量为0的数组, 而不是null

接着还需要将父物体的materials数组传递给它的子物体, 此时已经不需要将父物体的material字段传递给子物体了

如果我们不这么做, 那么每一个子物体都将会再次创建一个属于自己的材质数组, 那么就依然是每个物体自己一个单独的材质, 也就依然不能对生成的物体进行动态合批 :

  1. private void Initialize (Fractal parent, int childIndex) {
  2. mesh = parent.mesh;
  3. //material = parent.material;
  4. //只需要传递材质数组而不需要传递材质了, 因为子物体的材质将通过材质数组来设定
  5. materials = parent.materials;
  6. }

为什么不将materials数组设置为static?

这是因为该数组的长度取决于分形的最大深度, 我们可以同时生成多个最大深度不同的分形, 所以它们也就需要不同长度的materials数组, 所以不能设置该数组为静态.

构造分形 - 图24
运行修改后的代码, 分形被染色, 动态合批也生效了

动态合批又生效了, 然而颜色依然跟之前一样. 我们可以让最后一层深度生成的物体使用完全不一样的颜色, 这能帮你更好的观察之前可能不太被你注意的分形细节.

我们直接将材质数组中代表最后一层深度材质的颜色设置为洋红色(magenta). 同时调整Lerp方法的第三个参数, 以便我们观察到更明显的颜色过渡 :

  1. private void InitializeMaterials () {
  2. materials = new Material[maxDepth + 1];
  3. for (int i = 0; i <= maxDepth; i++) {
  4. //新增变量t存储插值计算用的百分比参数, 此处减一是因为最后一层深度材质的颜色单独定义, 所以这里就少计算一层
  5. float t = i / (maxDepth - 1f);
  6. //将参数2次方计算, 以便让每个材质之间的颜色差距增大
  7. t *= t;
  8. materials[i] = new Material(material);
  9. //materials[i].color = Color.Lerp(Color.white, Color.yellow, t);
  10. materials[i].color = Color.Lerp(Color.white, Color.yellow, t);
  11. }
  12. //设置最后一层深度的材质为洋红色
  13. materials[maxDepth].color = Color.magenta;
  14. }

构造分形 - 图25
现在最后一层生成的物体都变成了洋红色

接着让我们添加另一种颜色渐变, 比如说, 生成的分形会从白色渐变到青色, 并且最后一层深度的物体会掺杂一些红色. 我们将使用一个二维数组来做到这一点, 当我们需要一个材质的时候随机在二维数组中选择一个使用. 这样修改之后, 我们的分形会在每次运行后都能有一些颜色变化 :

二维数组是如何工作的?

你可以通过在声明数组的方括号中加入一个逗号, 来为数组增加第二个维度. 这样, 就需要在访问数组元素时, 提供两个数组元素索引. 也可以使用同样的方法将数组扩展为更高的维度

  1. //private Material[] materials;
  2. //数组声明处的方括号内加一个逗号, 变成二维数组
  3. private Material[,] materials;
  4. private void InitializeMaterials () {
  5. //materials = new Material[maxDepth + 1];
  6. //新建数组时的new语句, 就需要提供两个维度分别长多少, 这里第二个维度设置了2
  7. materials = new Material[maxDepth + 1, 2];
  8. for (int i = 0; i <= maxDepth; i++) {
  9. float t = i / (maxDepth - 1f);
  10. t *= t;
  11. //materials[i] = new Material(material);
  12. materials[i, 0] = new Material(material);
  13. //materials[i].color = Color.Lerp(Color.white, Color.yellow, t);
  14. materials[i, 0].color = Color.Lerp(Color.white, Color.yellow, t);
  15. //新增两行代码为第二个维度的元素赋值, 并且第二个维度的渐变色时从白色到青色(cyan)
  16. materials[i, 1] = new Material(material);
  17. materials[i, 1].color = Color.Lerp(Color.white, Color.cyan, t);
  18. }
  19. //materials[maxDepth].color = Color.magenta;
  20. materials[maxDepth, 0].color = Color.magenta;
  21. //新增代码设置第二个维度的最后一层深度颜色为红色
  22. materials[maxDepth, 1].color = Color.red;
  23. }
  24. private void Start () {
  25. if (materials == null) {
  26. InitializeMaterials();
  27. }
  28. gameObject.AddComponent<MeshFilter>().mesh = mesh;
  29. //gameObject.AddComponent<MeshRenderer>().material = materials[depth];
  30. //使用Random.Range(0,2)随机的选择第一个维度或第二个维度的数组元素
  31. gameObject.AddComponent<MeshRenderer>().material = materials[depth, Random.Range(0, 2)];
  32. if (depth < maxDepth) {
  33. StartCoroutine(CreateChildren());
  34. }
  35. }

构造分形 - 图26
随机颜色效果
参考源码

随机Mesh网格

除了颜色之外, 我们也可以随机的选择使用哪一种网格. 我们可以将用一个网格数组替代现在单一的网格, 然后在Start方法中随机选择使用哪个网格 :

  1. //public Mesh mesh;
  2. public Mesh[] meshes;
  3. private void Start () {
  4. if (materials == null) {
  5. InitializeMaterials();
  6. }
  7. //gameObject.AddComponent<MeshFilter>().mesh = mesh;
  8. //使用网格数组为物体的网格赋值
  9. gameObject.AddComponent<MeshFilter>().mesh = meshes[Random.Range(0, meshes.Length)];
  10. gameObject.AddComponent<MeshRenderer>().material = materials[depth, Random.Range(0, 2)];
  11. if (depth < maxDepth) {
  12. StartCoroutine(CreateChildren());
  13. }
  14. }
  15. private void Initialize (Fractal parent, int childIndex) {
  16. //mesh = parent.mesh;
  17. //将父物体的网格数组传递给子物体, 代替之前的网格传递
  18. meshes = parent.meshes;
  19. }

如果我们在Inspector中只为meshes数组设置一个cube网格, 那么生成的分形与没有使用网格数组时候并不会有差别. 但如果我们在Inspector中为网格数组再加入一个球体网格, 那么每个分形的物体就有50%的几率生成球体.

按照你的喜好设置这个数组, 我的做法是在数组中添加两个球体网格, 这样球体物体生成的概率就是立方体的两倍. 你也可以添加其他网格, 不过Capsule胶囊体和cylinder圆柱体不太适合, 因为它们是长条形的.

构造分形 - 图27 构造分形 - 图28
在立方体和球体直接随机选择一个网格

参考源码

制作不规则分形

我们的分形现在看起来不错, 但是还可以通过切断它的一些分支来使它看起来更有生机. 我们可以通过一个新的字段spawnProbability做到这一点. 我们使用它随机的决定某个子物体应该被生成还是被跳过. 设置它为0代表没有子物体会被生成, 设置为1则表示所有子物体都会被生成. 我们设置的比1稍微低一些, 这样我们的分形会看起来大有不同

Random.value将返回一个0到1之间的小数. 我们让它与spawnProbability比较来决定是否生成子物体 :

  1. //新增字段spawnProbability
  2. public float spawnProbability;
  3. private IEnumerator CreateChildren () {
  4. for (int i = 0; i < childDirections.Length; i++) {
  5. //新增if语句, 通过spawnProbability来判断是否要生成子物体
  6. if (Random.value < spawnProbability) {
  7. yield return new WaitForSeconds(Random.Range(0.1f, 0.5f));
  8. new GameObject("Fractal Child").AddComponent<Fractal>().Initialize(this, i);
  9. }
  10. }
  11. }
  12. private void Initialize (Fractal parent, int childIndex) {
  13. //将父物体的spawnProbability传递给子物体
  14. spawnProbability = parent.spawnProbability;
  15. }

构造分形 - 图29 构造分形 - 图30
70%概率生成子物体的效果
参考源码

旋转分形

目前为止我们的分形都像是一个乖宝宝一样规规矩矩不动手动脚. 如果我们让它动起来看起来会更有趣. 在Update方法中让分形绕着Y轴以每秒30度的速度旋转 :

  1. private void Update () {
  2. transform.Rotate(0f, 30f * Time.deltaTime, 0f);
  3. }

通过上述代码可以让分形的每一个部分都开始转动, 并且速度完全一致, 我们可以为它们设置随机的速度, 并设置速度的上限.

注意我们需要在Start方法中初始化旋转速度, 而不是在Initialize方法中, 这是因为根节点也应该进行旋转 :

  1. //最大旋转速度
  2. public float maxRotationSpeed;
  3. //存储随机后获得的旋转速度值
  4. private float rotationSpeed;
  5. private void Start () {
  6. //随机赋值旋转速度
  7. rotationSpeed = Random.Range(-maxRotationSpeed, maxRotationSpeed);
  8. }
  9. private void Initialize (Fractal parent, int childIndex) {
  10. //将旋转速度上限传递给子物体
  11. maxRotationSpeed = parent.maxRotationSpeed;
  12. }
  13. private void Update () {
  14. //transform.Rotate(0f, 30f * Time.deltaTime, 0f);
  15. //随机计算的速度值替代30
  16. transform.Rotate(0f, rotationSpeed * Time.deltaTime, 0f);
  17. }

构造分形 - 图31
不要忘了配置旋转速度上限的值
参考源码

燥起来

我们还能进行什么调整让分形更加酷炫吗? 当然有! 其中一种做法是将分形的每个元素都略微旋转而使得分形变得不那么规则和整齐, 也就是让它看起来拧巴一点 :

  1. //设置旋转的最大角度
  2. public float maxTwist;
  3. private void Start () {
  4. //使用maxTwist让物体绕着Y轴旋转随机角度
  5. transform.Rotate(Random.Range(-maxTwist, maxTwist), 0f, 0f);
  6. }
  7. private void Initialize (Fractal parent, int childIndex) {
  8. //把maxTwist传递给子物体
  9. maxTwist = parent.maxTwist;
  10. }

构造分形 - 图32 构造分形 - 图33
拧起来了, 别忘了去Inspector设置maxTwist的值

还能做什么呢? 让每个子物体的缩放尺寸都不同? 还是跳过某个深度不进行物体生成? 你现在完全可以自己设计和实践你的想法了, 你已经掌握了如何控制分形的方法, 为你鼓掌! 下一个教程我们将学习 每秒帧数(FPS)

参考源码
教程PDF