Unity基础系列(四)——构造分形(递归的实现细节)

点击蓝字关注我们吧!

目录

1 如何构建分形

2 展示内容

3 构造子节点

4 塑造子节点

5 创建多个子节点

6 更多的子节点,更好的代码

7 爆炸性生长

8 添加颜色

9、随机化Mesh

10 使分形不规则

11 旋转分形

12 添加更多的不确定

本文重点:

1、实例化游戏对象

2、了解递归

3、使用协程

4、添加随机性

分形是一个非常有意思的东西,而且大部分时候都很漂亮。在本教程中,我们将编写一个小的C#脚本,让它完成一些类似分形的行为。

这里假设你已经能够了解一些Unity的基本操作,并且能够创建基本的C#脚本了。如果这些还不熟悉的话,可以再复习一下第一章 时钟 相关的内容。

这是一篇比较旧的教程,里面提到的漫反射和镜面材质可能已经不使用与Unity2017了,所以可以忽略这些,但除此之外,这篇教程所展示的内容还是很有意思的。

(创建随机的3D分形)

1 如何构建分形

在开始构建3D分形之前,先要理解分形的概念。

简单的来说就是一个粗糙的几何物体,可以分为若干部分,每个部分都是(或者近似)该物体缩小后的形状。可以将其应用到Unity中的对象hierarchy中来实现这个效果。比如从某个根对象开始,然后向其中添加较小但在其他方面相同的子对象。

手动完成该操作将会非常麻烦,因此创建脚本来完成。

创建一个新项目和一个新场景。在里面放了一个方向光,把相机移到一个合适的角度,也可以随意设置。

继续创建一个用于分形的材质。材质很简单,仅仅使用specular 着色器与默认设置即可,比起漫反射,这个看起来更舒服一些。

创建一个新的空游戏对象并将其放置在原点。这将是分形的母体。然后创建一个名为Fractal的新C#脚本,并将其添加到对象上。

(工程创建)

2 展示内容

脚本有了,那么分形是什么样子的呢?这里通过在 Fractal 组件脚本中添加一个公共的Mesh和材料material

来实现它的可配置性。然后插入一个Start方法,在其中添加一个新的MeshFilter组件和一个新的MeshRenderer组件。同时,直接分配对应的网格和材料给它们。

什么是mesh?

按照传统理解,mesh是图形硬件用来绘制复杂东西的结构。它是一个3D对象,要么从外部导入到Unity中,这是Unity的默认形状之一,要么是由代码生成。mesh需要包含3D空间中的点集合,以及由这些点定义的一组三角形(最基本的2D形状)。由三角形构成网格所代表的任何表面。

大部分时候,你不会意识到你看到的其实是一堆三角形。

什么是材质?

材质用来定义物体的视觉特性。它们可以是非常简单(比如一个恒定的颜色),也可以非常复杂。材质一般要包括一个着色器和任何着色器需要的数据。

着色器基本作用是告诉显卡如何绘制物体的多边形。标准漫射着色器使用单一的颜色和可选的纹理,结合场景中的光源,来确定多边形的外观。这里使用的是稍微复杂的镜面着色器,同时模拟了一个亮点。

Start函数什么时候调用组件创建之后,处于active状态,并且在第一次调用它的Update方法之前(如果它有的话),Start方法会被Unity调用。而且只调用一次。

AddComponent 怎么用?

AddComponent方法可以创建特定类型的新组件,并将其附加到游戏对象,返回对其的引用。这就是为什么我们可以立即访问组件的值。当然也可以使用中间变量。

MeshFilter Filter=gameObject.AddComponent();

filter.mesh=Mesh;

这里展示了一个特殊的语法。因为它是一个通用方法,实际上是可以处理一系列类型的模板。你可以通过在尖括号中传入参数它来告诉它应该使用什么类型。

现在可以把我们定制的材质分配给fractal组件了。还可以通过单击属性旁边的点并从弹出窗口中选择Unity默认的立方体来分配Mesh。弄完之后,进入播放模式时,就会显示一个立方体了。当然,也可以在代码里手动添加组件。

(运行时可以看到组件了)

3 构造子节

该如何为这个分形创作子节点呢?最简单的方法就是在Start函数里创建一个新的Game Object并向其添加一个Fractal组件,试一下。

new 干了什么事情?

new 关键字用于构造对象或结构体的新实例。然后调用一个特殊的构造函数方法,该方法与它所属的类或结构的名字相同。

现在问题是,每一个新的分形实例都会产生另一个分形实例。每一帧都会发生,无穷无尽,导致死循环。如果不手动关闭,运行一段时间,当它把内存耗尽了之后,你的电脑就会死机了。

但大部分时候,无法停止的递归算法几乎会立即消耗完机器的资源,并导致堆栈溢出异常或崩溃。但在这个示例中,相对来说没那么快,因为它的递归的比较慢。

为了防止这种情况发生,需要引入一个最大深度的概念。最开始的分形实例的深度为零。每个它的后代节点都会有一个深度值。比如它的孙节点会有一个2的深度值,以此类推,直到达到最大的深度。

在inspector 窗口中添加一个公共maxDepth整数变量并将其设置为4。再添加一个私有深度整数。然后,只有当我们在最大深度以下时,才创建一个新的子级。

(最大深度)

现在进入播放模式时会如何呢?

只有一个子节点被创造出来了。这是为什么呢?因为我们从来没有给 depth 值,它总是零。因为零小于4,我们的根分形对象创建了一个子对象。孩子的深度值也是零。又因为,也没有设置子节点的maxDepth,所以它也是零。因此,该子节点并没有创造另一个。

除此之外,子节点也没有分配材质和Mesh。这些引用可以直接从它的父级复制。现在添加一个处理所有必要初始化的新方法。

this是什么意思?

this此关键字引用正在调用其方法的当前对象或结构。在引用同一个类的内容时,它一直被隐式地使用。例如,每当我们访问深度时,我们也可以通过this.depth来完成。通常只在需要传递对对象本身的引用时才需要使用此方法,就像对Initialization所做的那样。那又是为什么要这样做呢?因为需要调用的是新的子对象的Initialization方法,而不是父对象的初始化方法。

Initialize 调用是否在 Start 之前?

是的。首先创建新的游戏对象。然后创建并添加一个新的分形组件。此时,如果存在其Awake和OnEnable方法,则将调用它们。然后AddComponent方法完成。在此之后,直接调用Initialization。Start的调用要到下一帧才会执行了。

进入游戏模式,如预期的逻辑,这一次会创建四个子孙代。但它们现在还不是真正的孩子,因为它们都出现在层次根节点中。游戏对象之间的父子关系是由它们的转换层次来定义的。因此,一个孩子需要使它的transform组件的parent等于它的分形父transform 。

(两种不同的层次结构)

4 塑造子节点

到目前为止,子节点已经被叠加在父节点上了,这意味着仍然只看到一个立方体。现在需要把他们移动到他们的本地空间中,让它们也能被看到。

每个子节点都应该比它们的父母小,所以我们也必须缩小它们的Scale值。

第一个要解决的是缩放。那么应该缩放多少呢?用一个名为child Scale的新变量来配置它,并在inspector中给它赋值0.5。别忘了把这个值也从父节点传给子节点。然后用它来设置子节点的local scale。

接下来,该把这些孩子节点搬到哪里去呢?那就直接向上移动吧,这样它们就能接触到它们的父节点。假设父节点在所有方向上的大小的单位是1,对于现在正在使用的立方体来说正好合适。向上移动一半,使父节点和子节点正好接触在一起。因此,我们还需要移动一个额外的距离,距离相当于子节点的一半大小。

(子节点缩放值为0.5,从0.3至0.7)

5 创建多个子节点

现在我们做出来的东西有点像一座塔,还不是真正的分形,要完成分形还需要将它分支化。每个父节点创建多个子节点比较容易。但它们必须朝着不同的方向发展。因此,需要向Initialization方法中添加一个方向参数,并使用它将第二个子节点定位到右边而不是上面。

…是什么意思?

这意味着我省略了一段没有改变的代码。应该清除或更改代码的位置,或者它的确切位置并不重要。

(每个父节点拥有2个子节点)

这看起来已经有点感觉了!那么光从结果来看你能知道它是按照什么顺序来建造的吗?因为它们都是在几帧之内创建的,速度太快,无法看到它的创建的过程。如果能放慢这个过程应该会很有意思,因为这样就能看到它的发生的过程。要如何去完成放慢的过程呢?答案是可以通过协同线创建子节点来实现。

协程可以看做是可以插入暂停语句的方法。当方法调用暂停时,程序的其余部分继续进行。虽然这个类比不太恰当,太过于简单化,但我们现在只需要利用这个特点就可以了。

将创建两个子节点的代码行移动到一个名为CreateChildren的新方法中。此方法需要将IEnumerator作为返回类型,该类型存在于System.Collection命名空间中。这就是为什么Unity在他们默认的脚本模板中包含它,以及为什么本示例在一开始也包括它的原因。

改变了方法类型之后,调用的方式也要调整,这里不能再用直接调用的方式了,取而代之,要使用Unity的StartCoroutine方法。

然后在创建每个子节点之前添加一个暂停指令。如代码所示,每半秒钟内创建一个新的WaitForSecond对象,然后将其返回给Unity。

enumerator是什么?

枚举是一次遍历某个集合的概念,就像循环遍历数组中的所有元素一样。enumerator(枚举器)或iterator(迭代器)是为此功能提供接口的对象。System.Collections.IEnumerator描述了这样的接口。

为什么我们需要用这个呢?因为协程需要用。这也是Unity在默认脚本模板中包含System.Collection的原因,也是本示例将它包括在内的原因。

return 做了什么?

return关键字可以表示一个方法中断或者已经完成,把响应的结果返回给调用者。返回的内容必须与方法的类型匹配。如果它是一个空方法,那么也只需要返回空。

对于一个函数定义为空,可以省略return关键。

同样的,一个方法中可能有多个return语句。在这种情况下,有多个可能的返回点。通常使用if语句来确定使用了哪些return。

yield有什么用?

yield语句被迭代器用来控制协程的生命周期。要使枚举,就需要跟踪它的进度。这涉及到一些基本相同的样模板码。你真正想要的是只编写类似于 return firstItem; return secondItem这样的代码,直到函数执行结束。yield语句允许你准确地做到这一点。

因此,无论何时使用yield,都会在幕后创建枚举器对象,以处理繁琐的部分。这就是为什么我们的CreateChildren方法将IEnumerator作为其返回类型的原因。

顺便说一下,你还可以生成另一个迭代器。在这个示例里,另一个迭代器会被完全的处理,所以你其实可以用创造性的方式将它们缝合在一起。

协程怎么工作?

当你在Unity中创建协程时,真正做的其是创建一个迭代器。当你将它传递给StartCooutine方法时,它将被存储,并被要求每帧都要它的下一个Item,直到它完成为止。

yield语句会产生Item。而这中间的部分就是你可以发挥的地方了。

当你自己的代码继续运行时,你也可以产生一些特殊的协程,比如WaitForSecond,这样就可以更好地控制代码逻辑,但是总的来说都是一个迭代器而已。

现在可以看着它生长了!你能看出来这样做有什么问题吗?可能现在还不明显,现在为每个父节点添加第三个子节点,这一次放在左边。

(每个父节点3个子节点,正常和overdraw视角)

如果查看overdraw效果?

场景视图的工具栏有一个下拉列表,默认设置为RGB。它的另一个选择是 Overdraw 。

其实问题是子节点和他们的父节点有着相同的参考点。这意味着,其父母本身就是右子节点的左子节点。可能有点绕,就是说,父节点和子节点在某些方向上重合了。

为了解决这个问题,需要对子节点进行旋转,这样他们的向上方向就会远离他们的父节点。

我通过向Initialization添加一个方向参数来解决这个问题。它将是一个四元数,用于设置新子节点的local rotation。向上的子节点不需要旋转,右边的子节点需要顺时针旋转90度,左边的子节点需要向相反的方向旋转。

(旋转后的效果)

现在子节点已经被旋转了,但它们生成出来的却不是分形了。一些最小的子节点最终仍然会消失在根立方体里面。这是因为如果Scale因子为0.5,这个分形将在四个步骤中产生了自相交。你可以通过减少缩放来解决这个问题,也可以使用球体代替立方体。

(子节点缩放为0.5的球体并没有产生自相交)

6 更多的子节点,更好的代码

现在的代码已经有些笨重了。可以通过将方向和方位数据移动到静态数组来优化。然后,再将CreateChildren简化为一个短循环,并使用子索引作为Initialization的参数。

数组如何工作?

数组是长度固定的对象,包含一个线性变量序列。在声明变量时,将方括号放在其类型后面表示需要该类型的数组。所以int myVariable;让你获得一个整数,而int[]myVariable;让你获得一个整数数组。

访问数组中的一个条目的方法是将数组索引(而不是位置)放在变量后面的方括号中。MyVariable[0]获取数组中的第一个条目,myVariable[1]获取第二个条目,依此类推。

实际上,创建一个数组并将其赋值给变量是使用myVariable=newint[10]完成的;在本例中,该数组创建了一个包含10个条目空间的新数组。或者,您可以通过在花括号中列出它的初始值来隐式地创建一个,比如myVariable={1,2,3};。

for循环怎么工作?

for循环是编写遍历某些循环的一种紧凑方式。在本例中,我们使用一个名为i的整数作为迭代器。第一部分声明迭代器整数,第二部分检查循环的条件,第三部分增加迭代器。您可以使用while循环来获得完全相同的结果,但是迭代器代码不方便分组。

对于(int i=0;i<10;i++){doStuff(I);}

与int i=0;

while(i<10){doStuff(I);i++}效果相同。

顺便说一句,i++是i+=1的缩写,它是i=i+1的缩写。

现在,让我们通过简单地将数据添加到数组中,再引入两个子元素。一个向前,另一个向后。

(完整的分形,每个父节点拥有5个子节点)

现在有了完整的分形结构。但是根立方体的底部为什么没有呢?可以这样想,分形是从某种东西中生长出来的,比如一种植物。虽然我没有,但如果你想的话,可以添加一个特殊的第六个子节点向下,但只是添加到根节点就好。添加到所有子节点的话又会变成第6个子分形了。

7 爆炸性生长

刚才的示例,我们实际创建了多少个立方体?因为我们总是为每个父节点创建五个子节点,当完全成长的时候,立方体的总数将取决于最大的深度。最大深度为零只产生一个立方体,即初始的根节点。最大深度为一个,产生五个额外的孩子,总共有六个立方体。由于它是分形的,这个图案重复,我们可以把它写成函数f(0)=1,f(N)=5×f(n-1)+1。

上述函数产生序列1、6、31、156、781、3906、19531、97656等。你将看到这些数字显示为Unity游戏视图中统计数据中的DrawCall的数量。如果启用了动态批处理,则它将是DrawCall 和 Saved by batching 的总和。

Unity处理四五层的深度还绰绰有余。再高的话,你的帧率将急速下降。

除了数量,持续时间也是一个问题。现在,我们在创建一个新的子节点之前暂停了半秒钟。这会产生几秒钟的同步增长。我们可以通过随机延迟来更均匀地分配增长。这也导致了一个更不可预测和有机的模式,让观察更有意思。

把固定的延迟替换为0.1到0.5之间的随机范围。我还增加了最大深度到5,使效果更加明显。

随机范围是如何工作的?

Random是一个实用工具类,它包含一些接口来创建随机值。它的 Range 方法可用于在一定范围内生成随机值。Range方法有两个版本。可以使用两个浮点数来调用它,在这种情况下,它会在最小值和最大值之间返回一个浮点数,这两者都包括在内。或者,可以用两个整数调用Range,在这种情况下,它返回一个整数,介于最小、排除最大值之间的某个值。这个版本的典型用例是随机选择一个索引,比如某某数组[Random.Range(0,omeArray.Length)]。

8 添加颜色

这个分形没有什么生气。通过添加一些颜色变化来搞点气氛。通过从根部的白色插入到最小的子节点的黄色来实现吧。Color.Lerp 接口是一种方便的方式。内插器从0到1,我们通过将当前深度除以最大深度来实现。因为这里不能用整数除法,所以我们首先将深度转换为浮点数。

Lerp是干什么的?

LERP是线性插值的简称。它的典型特征是Lerp(a,b,t),它计算a+(b-a)*t,t在0-1范围内。有不同类型存在多个版本,包括浮点数、向量和颜色。

(上色了,但是没有动态批处理)

这看起来有内味了!但另一件事也发生了。动态批处理过去是起作用的,但现在不行了。我们该如何解决这个问题呢?

什么是动态批处理?

动态批处理是由Unity执行的一种drawcall批处理形式。简而言之,它将共享相同材料的网格组合成更大的网格。这样做减少了CPU和GPU之间的通信量。

你可以通过 Edit/Projects Settings/Player/, 在 Other Settings 启用或禁用它。

它只适用于小网格。比如,你会发现它适用于Unity默认的立方体,但不适用于默认的球面。

导致这个结果的问题是,因为调整子节点的材质颜色,Unity默默地创造了一个复制的材质。这其实是必要的,不然一切使用该材质的都将以相同的颜色结束绘制。然而,批处理只有在相同的材质被用于多个物体时才有效。不相等的不检查也不合并–因为要检查的话就太耗性能了,而且结果也不一定就满足合批条件–所以它必须是同一种材质。

那在每个深度都创建一个材质的副本,而不是每个立方体。添加一个新的数组字段来保存材质。然后Start时检查是否存在数组,如果没有,则调用一个新的InitializeMaterials方法。在这种方法中,我们将显式复制我们的材料和改变每一深度的颜色。

null是什么?

非简单值的变量的默认值为NULL。这意味着变量没有引用任何内容。试图从变量中调用或访问任何为NULL的内容都会导致错误。你需要判断这个值,以确保不会发生这种情况。

你也可以自己将这样的变量设置为NULL,以便处理你不再需要它所引用的任何内容。注意,当将对对象的引用设置为NULL时,对象并不会自动被销毁。只有当所有地方都不引用他们的时候,他们才会成为垃圾收集器收集。

还请注意,此方法适用于私有组件字段,但不适用于公共组件字段。这是因为Unity的序列化系统会为它创建一个空数组,而本例中它不会是空数组。

现在,不要将材料引用从父节点传递到子节点,而是只传递材料数组的引用。如果不这么做的话,每个子节点将被迫创造自己的材料数组,我们就不能解决问题了。

为什么不把 materials 设置为静态?

之所以不把materials数组设置为静态,是因为它取决于最大深度,这可能不同于分形和分形之间。同一时间你可以有多个分形但他们可以有不同的最大深度。

(上色了 并且有了动态批处理)

批次合并又回来了,但是已经和之前的不一样了。但颜色还是没那么丰富。一个很好的调整是给最深的层次一个完全不同的颜色。这可以揭示分形的模式,可能你这样也没注意到吧。

简单地改变最后的颜色到洋红之后。此外,调整内插器,使我们仍然看到完全过渡到黄色。当我们在做它的时候,它的平方会带来一个稍微好一些的转变。

(有洋红色的提示了)

再添加第二个颜色级数,例如从白色到青色的红色提示。我们将使用一个单一的二维数组来容纳它们,然后在需要材质时随机选择一个。这样,当我们进入游戏模式时,我们的分形看起来就会有所不同。如果愿意,可以随意添加第三步。

(随机颜色)

9、随机化Mesh

除了颜色,我们还可以随机选择使用哪个Mesh。用数组替换公共网格变量,并从其中随机选择一个。

如果要在检查器中的新数组属性中只放置一个立方体,那么结果将和以前一样。但是如果加上一个球体,你就会突然得到50%的几率,形成一个立方体,或者每个分形元素中的一个球体。

随意填充此数组。我把球体放了两次,所以它被使用的可能性是立方体的两倍。你也可以添加其他Mesh,胶囊和圆柱体不太好,因为它们是拉长的。

(随机选择立方体和球体)

10 使分形不规则

现在的分形完成的很好,很完整,但是可以通过切断它的一些分支来使它更加有独特。通过引入一个新的公共spawnProbability变量来实现。传递这个值,然后用它随机地决定我们是产生一个子节点还是跳过。0的概率意味着根本没有孩子会生长,而1的概率意味着所有的孩子都会产卵。即使数值略低于一个,也会大大改变我们分形的形状。

静态Random.value属性在0到1之间产生一个随机值。将它与 spawnProbability 相比较可以告诉我们是否应该创建一个新的子节点。

(70%概率产生的分形效果)

11 旋转分形

一我们的分形一直是个好孩子,一动不动。但是如果有一点动作是不是会更有趣。添加一个非常简单的Update方法,它以每秒30度的速度围绕当前的Y轴旋转。

有了这个简单的方法,所有的分形部分现在都在快乐地旋转。都是以同样的速度。那么再次随机化!并使最大速度也可配置。

注意,我们必须在start(而不是Initialization)中初始化我们的旋转速度,因为根元素也应该旋转。

(配置速度)

12 添加更多的不确定

我们还能做更多的调整,以微妙的方式打破分形吗?当然!有很多!其中之一是通过增加一个微妙的旋转来破坏分形元素的排列。我们称之为扭曲。

(看起来不错的扭曲)

另一种选择是把子节点的比例弄得乱七八糟。或者有时跳过深度。摆造型?那就自己来尝试下吧!


版权声明:本文为haog87原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。