Unity基础系列(二)——构建一个视图(可视化数学)

点击蓝字关注我们吧!

目录

1 创建一排立方体

1.1 预制体

1.2 视图组件

1.3 实例化组件

1.4 代码循环

1.5 简化语法

1.6 改变域

1.7 把向量挪出循环

1.8 用X来定义Y

2 创建更多的立方体

2.1 定义分辨率

2.2 变量实例化

2.3 设置父节点

3 给视图上色

3.1 创建自定义Shader

3.2 基于世界坐标来上色

4 给视图配置动画

4.1 保持追踪点

4.2 更新点

4.3 显示正弦波

本篇内容概括:

1、创建一个prefab

2、实例化一排立方体

3、展示一个数学方法

4、创建一个自定义的Shader

5、让视图动起来

在本章教程中,我们将使用游戏对象来构建一个图形,这样我们就可以把数学公式用图像展示出来。然后再把函数和时间关联起来,从而产生一个运动的图像。

本教程假设您已经完成了“游戏对象和脚本”相关教程,对Unity有了基础的了解,并且Unity的版本至少要在2017.1。

(使用一排立方体来展示 正弦 波)

1 创建一排立方体

在编程的时候,充分地理解数学是非常非常有必要的事情。

从最基础的层次理解,数学就是操作一堆表示数字的符号。比如,解一个方程可以理解为重写一组符号,这样它就变成了另一组符号集(一般来说会比原始的简单)。而数学的规则决定了如何对它们进行重写。

比如我们有一个函数 f(x)=x+1 ,我们可以用一个数字来代替x,比如说3。这就会产生f(3)=3+1=4的结果。

我们提供了3作为输入,并在最后以4作为输出。我们可以说函数映射了 3到4,写这个的一个更短的方法是把它们作为一个输入-输出对,比如(3,4)。我们可以创建许多对形式(x,f(X))。例如(5,6)和(8,9)和(1,2)和(6,7)等等。但是,当我们按输入编号排序对时,会更容易理解该函数,例如:(1,2)和(2,3)和(3,4)等。

函数f(X)=x+1f(X)=x+1很容易理解。但

就比较难了。当然可以和之前一样写下一些输入-输出对,但这样的方式不能让我们很好地掌握它所代表的映射究竟是什么。如果要做到轻松的理解,就需要非常非常多的点,紧密排列在一起,但这无疑会导致巨量的数据,不但难以列举还难以分析。

相反,我们可以将对解释为形式[x,f(X)]的二维坐标。这是一个2D向量,上面的数字代表水平坐标,在X轴上,底部的数字代表Y轴上的垂直坐标。换句话说,y=f(X)。然后在一个坐标轴的表面上画出这些点。只要有足够多的点,就会得到一条线。这个结果是一个视图(graph)。

(用视图表示 x在(-2,2)的区间)

查看一个视图可以让我们快速了解一个函数是如何工作的。既然这么方便的话,那么我们就看下如何在Unity里创建吧。

通过File / New Scene启动一个新场景,或者使用新项目的默认场景。

1.1 预制体

视图是通过在适当的坐标处放置点来创建的。

要做到这一点的话,就需要把每个点的变成三维可视化的。我们将简单地使用Unity默认立方体的游戏对象。

在场景中添加一个立方体,并移除其碰撞组件,因为这个示例中不会使用到物理。

我们会使用脚本来创建这个立方体的诸多实例并正确定位它们。要做到这一点的话,就需要把这些立方图做成模板。把立方体从层次结构窗口(hierarchy window)拖到项目窗口(project window)中。

这会创建一个新的Asset,一个具有蓝色立方体图标,我们称为预制体。它是一个预先制作好的游戏对象,存在于项目中,但不在场景中。

(一个立方体的预制件)

预制体(Prefabs )是配置游戏对象的一种方便的方法。如果你更改了预置体资源,那么它在任何场景中的所有实例都会以相同的方式进行变更。例如,更改预制体的Scale会改变仍然在场景中的立方体的Scale。

但是要注意的是,每个实例只会使用自己的位置和旋转。也就是说,预制体不会统一修改场景示例对象的位置和旋转。此外,游戏对象也可以修改相关属性,并覆盖预置值。而如果进行了很大的更改,比如添加或删除了组件,则Prefab和实例之间的关系将被打破,需要重新保存或者应用修改。

这个章节,我们会使用脚本来创建预制体的实例,因此我们不再需要当前场景中的立方体实例。所以可以删除它了。

1.2 视图组件

我们需要一个C#脚本来生成我们的视图,所以创建一个并将其命名为Graph。从一个简单的扩展MonoBehaviour的类开始,这样它就可以成为game objects对象的组件。给它设置一个公共字段来保存预置体文件的引用,以便一会用来创建视图上的“点”,命名为pointPrefab。由于我们需要访问Transform组件来定位这些点,所以要使其成为字段的类型。

将一个空的游戏对象添加到场景中,通过GameObject/Create Empty,并将其放置在原点,命名为Graph。通拖动或通过其Add Component按钮将Graph组件添加到此对象。然后将预置体文件拖到Graph的PointPrefab字段上。现在它保存了对预置Transform组件的引用。

(视图对象和引用的Prefab)

1.3 实例化组件

实例化游戏对象是通过Instantiate方法完成的。

这是Unity Object类型的一个可公开使用的方法,它通过扩展MonoBehaviour间接继承。Instantiate方法会克隆作为参数提供的任何Unity类型的Object。在针对预制体的时候,它会实例一个对象并添加到当前场景中。我们就在Graph组件Awake时,去实例化。

(实例化的Prefab)

点击Play之后,会将在原点生成一个立方体,不过,前提是预置体的原始位置设置的就为零。要将这一点放到其他地方,就需要调整实例后的位置。

实例化方法为我们提供了对它创建的任何内容的引用。因此,我们给它创建一个 Transform 组件的临时变量来持有函数的返回值。

现在我们可以通过给它分配3D向量来调整点的位置。如我们在上一篇教程中中调整时钟指针的Local rotation 一样,但这次我们将通过修改它的localPosition属性而不是position来调整点的位置。

三维向量是用[Vector3]的结构创建的。因为它是一个struct,它的作用就像一个值,就像是一个数字一样,而不是对象。例如,让我们将点的X坐标设为1,将其Y和Z坐标设为零。Vector 3可以用right属性来表示。

现在进入运行模式,我们得到的仍然一个立方体,但是位置已经发生了变化。下面继续实例化第二个点,并将其放在第一个的增量迭代位置,比如将right向量乘以2来实现。重复实例化和定位代码,然后将乘法添加到新代码中。

可以把结构体和数字相乘吗?

通常是不能的,但是可以定义这样的功能。这需要通过创建一个具有特殊语法的方法来完成的,因此就可以像调用乘法一样调用它。在这种情况下,看似简单的乘法实际上是一种方法调用,类似于Vector3.Multiply(Vector3.right,2f)。它能够像使用简单的操作符一样来调用方法,然后更快更容易地编写代码。

但它不是必要的,有的话就会更加便捷,就像能够隐式使用名称空间一样。这种方便的语法被称为句法糖。但话虽如此,大部分时候,方法只有在严格符合操作员的意愿的时候才能被操作人员使用。而对于向量而言,一些数学算式已经定义得很好了,所以没必要扩展。

这两行代码会导致编译错误,因为我们尝试两次定义Point变量。如果我们想使用另一个变量,我们必须给它一个不同的名称。或者,我们重用我们已经拥有的变量。但其实第一种方式并不好,第二种只需去掉变量的定义,将新的点赋值给同一个变量即可。

(两个实例,X坐标分别为1和2)

1.4 代码循环

如果要展示一排的话,就需要更多的点,现在创建10个看看。其实可以再重复相同的代码八次,但这种是非常非常低效率的编程。理想情况下,我们只编写一个点的代码,并指示程序多次执行它,稍有变化即可。

while语句可用于代码块循环。将其应用于方法的前两行,并删除其他行。

就像if语句一样,但后面必须有方括号中的表达式。与if一样,只有当表达式计算为true时,才会执行后面的代码块。之后,程序将循环回while语句。如果此时表达式再次计算为true,则代码块将再次执行。这会重复执行,直到表达式的计算结果为false为止。

所以我们必须在When之后添加一个表达式。一定一定要小心确保循环不会进入无限循环。无限循环会导致程序卡住,需要用户手动终止。编译的最安全的表达式就是false。

是否可以在循环里面定义point?

可以。虽然代码会重复,但我们只会定义了一次变量。循环的每一次迭代都会重用它,就像我们之前手动做的那样。

当然其实还可以在循环之前定义point。这也允许你在循环之外使用变量。否则,其作用域仅限于while循环的块。

限制循环可以通过追踪重复代码的次数来完成,使用一个整数变量来跟踪即可。它用来记录循环的迭代次数,我们将其命名为i先。为了能够在while表达式中使用它,必须在循环之前定义它。

每次迭代,i增长1。

代码写到这,会产生一个编译错误,因为在给i赋值之前,正在尝试使用i。所以必须先明确地将零赋值给i,直接在定义的时候赋值即可。

现在i在第一次迭代开始时变成1,在第二次迭代开始时变成2,依此类推。但是while表达式是在每次迭代之前计算的。所以在第一次迭代之前,i是0,在第二次迭代之前是1,依此类推。所以在第十次迭代之后,i是10。此时需要终止循环,因此它的表达式应该被计算为false。

换句话说,只要i不到10,循环就应该继续下去。从数学上讲,用i<10来表示。代码也是这样写。

运行之后,会得到10个立方体。但他们都在同一个位置。若要将它们沿X轴排成一行,需要用right向量乘以i。

注意,目前第一个立方体的X坐标为1,最后一个立方体为10。理想情况下,我们从0开始,将第一个立方体定位在原点。我们可以把所有的点左移动一个单位,用right乘(i-1)代替i。然而,在块的末尾增加i就可以跳过这次减法所带来的一次额外计算。

1.5 简化语法

循环是代码编程里最常见的格式之一,所以用尽可能短的语法会更加简洁。C#提供一些语法糖来帮助我们。首先,让我们考虑增加迭代次数。当执行x=x*y形式的操作时,可以将其缩短为x*=y,这适用于对两个相同类型的操作数进行操作的所有操作符。

更进一步,当将一个数字增加或减少1时,可以将其缩短为++x或--x。

赋值语句的一个属性是它们也可以用作表达式。这意味着您可以编写类似于y=(x+=3)的东西。这将使x增加3,并将其结果分配给y。这表明我们可以在while表达式中增加i,从而缩短代码块。

然而,现在我们在比较之前就增加i,而不是事后,这将导致少一个迭代的执行。在这种情况下,增量和递减运算符也可以放在变量之后,而不是在变量之前。该表达式的结果是更改前的原始值。

尽管while语句适用于所有类型的循环,但还有一种特别适合于遍历范围的替代语法。这是for循环。除了迭代器变量声明和它的比较都包含在圆括号中,用分号隔开之外,它的工作方式类似于while。

这将产生编译错误,因为实际上有三个部分。第三种方法是递增迭代器,使其与比较保持分离。

1.6 改变域

到现在为止,我们的立方体被赋予了X坐标从0到9。但这在处理函数时并不方便。通常,0-1的范围用于X,或者当使用围绕着0的函数时,范围为?1~1。现在重新定位立方体。

把我们的十个立方体沿一条线段,两个单位长,会导致他们重叠。为了防止这种情况,我们将减少他们的缩放。默认情况下,每个立方体在每个维中都有1的大小,因此为了使它们适合,我们必须将它们的比例尺缩小到2/10=1/5。我们可以通过将每个点的local scale 设置为Vector3.one属性除以5来实现这一点。

(变小后的立方体)

要使立方体再次聚在一起,把它们的位置也除以5即可。

这使得他们涵盖了0-2的范围。若要将其转换为?1~1范围,要在Scale矢量之前减去1。

现在,第一个立方体X坐标为?1,而最后一个立方体为0.8个。然而,立方体大小为0.2。由于立方体以其位置为中心,第一个立方体的左侧位于?1.1的位置,而最后一个立方体的右侧为0.9。为了整齐地用我们的立方体填充?1-1范围,我们必须将它们移到右边的半个立方体。这可以通过在除以前将i加0.5来完成。

1.7 把向量挪出循环

虽然所有的立方体都有相同的缩放了,但我们在循环的每一次迭代中都计算了它,这并没有必要。相反,我们可以在循环之前计算一次,将其存储在Vector 3变量中,并在循环中使用。

我们也可以为循环之前的位置定义一个变量。当我们沿着X轴创建一条线时,我们只需要调整环内位置的X坐标而不再需要乘以向量Vector3.right。

是否可以单独改变向量的分量?

Vector3 结构体有三个浮点字段,x、y和z。这些字段是公共的,因此我们可以修改它们。因为结构的行为是简单的值类型,所以它们应当是不可变的。一旦定义好,就不应该改变。如果要使用不同的值,需要将新的结构赋给字段或变量,就像我们处理数字一样。如果我们说x=3,然后x=5,我们给x分配了一个不同的数字。我们没有将数字3本身修改为5。但是,Unity的向量类型是可变的。这既是为了方便,也是为了性能,因为单个向量组件通常是独立操作的。要了解如何处理可变向量,可以考虑使用三个单独的浮点值代替Vector3。这样既可以独立地访问它们,也可以将它们作为一个组进行复制和分配。

这会导致编译错误,编译器提示使用未赋值变量。这是因为我们还没有设置它的Y坐标和Z坐标就把位置分配给某个对象。因此,在循环之前显式地将它们设置为零。

1.8 用X来定义Y

我们的想法是,把立方体的位置定义为(x,f(x),0),这样我们就可以用这些点来展示一个函数了。此时,如果Y坐标始终为零,它表示简单的函数f(X)=0。为了显示不同的函数,我们必须确定循环中的Y坐标,而不是之前的做法直接等于X,之前的函数可以表示为f(X)=x。

(Y等于X)

一个稍微不那么明显的函数是

,它定义了一个抛物线,最小值为零。

(Y 等于X 的平方)

2 创建更多的立方体

虽然现在我们已经有了一个基于函数来排布的点,但是因为我们只有10个立方体,排出来的图形比较丑,间距也大。如果我们使用更多更小的立方体,效果就会好很多。

2.1 定义分辨率

解决固定立方体的数量,就是让它变为可配置。要实现这个目的,就给Graph增加一个字段用来定义立方体数量。

(自定义数量)

通过改变这个自定义值来调整视图的分辨率,值可以通过修改inspector面板的值来完成。然而,并非所有整数都是有效或者有意义的。至少,它们都必须是正的。我们可以指示inspector 为我们的值提供一个可选的范围。通过在字段定义之前在方括号中写入 Range 来实现。

Range 是由Unity定义的attribute类型。attribute是一种可以将元数据附加到代码结构的方法,在本例中是字段。Unity的inspector会检查字段是否附加了范围属性。如果附加了,它将使用一个滑块而不是数字的默认输入字段。

然而,要滑动,就需要提供允许的范围。因此范围有两个参数,最小值和最大值。这里我们用10和100。此外,属性通常写在字段上面,而不是前面。

(分辨率滑块)

这是否意味着这个值只能以10-100为限?

不是的。它所做的只是在Unity面板上使用滑块可以得到的范围。除此之外,它不会以任何其他方式影响分辨率。所以你可以自己写代码来修改它,让它变为任何其他的值。在本教程中,我们假设分辨率仅通过检查器面板进行调整,而不是代码或者其他地方。

2.2 变量实例化

要实际使用分辨率,我们必须更改实例化的立方体数量。不需要在Awake里循环固定的次数,而是用我们设置的分辨率的值。因此,如果分辨率设置为50,我们将在运行后后创建50个立方体。

分辨率变化了,必须要同时调整立方体的规模和位置,以便它们仍然保存在?1~1域中。每一次迭代所要做的步长的大小现在是2 /resolution,而不是总是1/5。把这个值存储在一个变量中,然后用它来计算立方体及其X坐标的比例尺。

(使用50的分辨率)

2.3 设置父节点

分辨率设置为50之后,大量实例化的立方体出现在场景中,而场景的视图视窗里也显示了这么多。

(很多根节点对象)

这些立方体目前都是根对象,但它们其实可以作为图形对象的子对象。通过调用立方体的Transform组件的SetParent方法,就可以在实例化立方体之后建立这种节点关系。

这里需要向它提供它的新的父级的 Transform 组件。通过Graph继承的Transform属性,可以直接访问Graph对象的Transform组件。

(Graph的子节点)

当一个新的父对象被设置时,Unity将尝试将对象保持在它原来的世界位置、旋转和缩放。而我们现在的情况并不需要。可以直接通过向SetParent提供第二个参数false来决定。

3 给视图上色

Unity提供的默认的白色不好看,当然可以换成另外一个纯色,但这也没什么意思。其实我们可以通过点的位置来改变它的颜色。调整立方体颜色的一个简单方法是设置其材质的颜色属性,可以在循环里设置即可。但由于每个立方体都会得到不同的颜色,这意味着最终会变成每一个物体有一个单独的材质球。虽然这么做能实现,但效率太低。如果我们有一种材质球能够根据自己的位置设置不同的颜色就可以了。但其实Unity并没有这样的材质球,所以只能我们自己做了。

3.1 创建自定义Shader

GPU运行着色器程序来渲染3D对象。Unity的材质球资源决定使用哪个着色器,并允许配置相关的属性。这里需要创建自定义着色器来获得我们想要的功能了。通过Asset/Create/Shader/StandardSurfaceShader来创建一个Shader,并将其命名为ColoredPoint。

(自定义Shader文件)

我们现在有一个着色器资源,可以像打开脚本一样打开它。我们的着色器文件包含了一些定义表面着色器的代码,它使用的语法与C#语法不同。下面是文件的内容,为了简洁起见,删除了所有注释行。

表面着色器怎么工作?

Unity提供了一个框架,可以快速生成用于执行默认照明计算的着色器,你可以通过调整某些值来影响这些计算,这样的着色器被称为表面着色器。如果你想了解更多关于着色器的知识,你可以浏览 渲染 教程系列。

我们的新着色器具有自定义的颜色,纹理,以及表面的光泽和金属的特性。因为我们将基于一个点的位置,我们不需要自定义的颜色或纹理。下面的代码已经删除了所有不必要的位,使反照率变成黑色,并且使用的alpha值为1。

什么是Albedo和alpha?

物质的漫反射率的颜色称为albedo。albedo是拉丁文表示白色。它描述了多少红色,绿色和蓝色通道是扩散反射。其余的都被吸收了。阿尔法是用来衡量不透明度的。在α =0的时候,表面是完全透明的,而在alpha 1是完全不透明的。

现在,着色器还无法编译,因为表面着色器不能使用空的输入结构。这里需要我们自定义数据格式,来支持着色怎么绘制颜色。在这个例子里,我们需要拿到一个点的坐标。这可以通过在输入中添加Float3 worldPos来访问位置。

这是否意味着,如果我们移动Graph的位置会影响它们的颜色?

是的。使用这种方法的话,只有当视图位于原点的时候,着色才是正确的。

还要注意,这个位置是每个顶点决定的。在我们的例子中,这是单个立方体的每个角。颜色将被插入到立方体的表面上。立方体越大,颜色转换就越明显。

现在我们有了一个满足功能着色器,为它创建一个材质,名为Colored Point。通过下来列表选中Custom / Colored Point 来更换为我们自定义的Shader。

(Colored Point 材质球)

直接将材料拖到预置资源上来为它更换材质球。

3.2 基于世界坐标来上色

启动游戏,我们的图形现在将实例化出黑色立方体。为了给它们渲染颜色,必须修改o.albedo。将X坐标赋值给红色分量。

(基于X坐标渲染颜色的视图)

正X坐标的立方体现在变得越来越红。而负X坐标保持为黑色,因为颜色不能是负的。要得到从?1到1的红色转换,我们必须将X坐标减半,然后添加0.5。

(正确的转换)

用Y坐标来表示绿色成分,就像X一样。在着色器中,我们可以在一行中使用IN.worldPos.xy并分配给o.Albedo.rg。

(使用X和Y坐标上色)

红色加绿色变成黄色,所以我们的图表目前从浅绿色变成黄色。如果Y坐标从?1开始,我们也会得到深绿色的颜色。要想看效果的话,请更改Graph.Awake中的代码,以便它显示函数。

(Y轴从-1~1)

4 给视图配置动画

显示静态的视图已经完成了,但是运动视图会更有意思的。因此,让添加对动画功能的支持吧。通过将时间作为附加函数参数来实现,它使用形式为f(x,t)的函数,而不只是f(X),其中t是时间。

4.1 保持追踪点

要实现视图动画,需要随着时间来调整点的位置。当然可以通过删除所有点并创建每个更新的新点来实现,但这种方法的效率太低了。最好是能够继续使用相同的点,在每个Update调整他们的位置。简单点的实现,可以使用一个字段来保持对点的引用。

在Graph里增加Transform的数组来表示所有的点。

这个字段允许我们引用一个点,但是我们需要访问所有的点。可以通过在字段类型后面放置空方括号将字段转换为数组。

Point字段现在可以引用数组,其元素是Transform类型。数组是对象,而不是简单的值。我们必须显式地创建这样一个对象并使我们的字段引用它。这是通过New和数组类型来完成的,所以在我们的例子中,new Transform[]。在循环之前,在Awake时创建数组,并将其分配给点。

创建数组时,必须指定其大小。这定义了数组支持多少个元素,这些元素在创建之后不能更改。此长度在构造数组时写入方括号中。在这个例子中,它的长度等于分辨率。

现在,可以在数组中填充对点的引用了。访问数组元素的方法是将其索引写入数组字段或变量后面的方括号中。对于第一个元素,数组索引从零开始,就像循环的迭代计数器一样。因此,可以使用它来访问适当的数组元素。

现在需要遍历这所有的点。因为数组的长度与分辨率相同,所以我们也可以使用它来约束我们的循环。每个数组都有一个Length属性,可以使用它来进行循环限定。

4.2 更新点

要实际绘制图形,我们需要在组件的Update方法中设置点的Y坐标。因此,我们不能只在Awake时计算它们了。但我们仍然需要显示的把某些值设置为0。

添加一个Update方法,它有一个for循环,就像Awake方法一样,但是它的循环体中还没有任何代码。

每次迭代,首先获得对当前数组元素的引用。然后找到那个点的位置。

现在,我们可以设置位置的Y坐标,就像我们之前所做的那样。只是把它从Awake挪到Update里来。

因为向量不是对象,所以只能调整一个局部变量。为了把它应用到点上,就必须重新设置它的坐标。

我们不能直接分配Point.localPosition.y吗?

如果localPosition是一个字段,那么这是可以的。可以直接设置点的位置的Y坐标。但是,localPosition是一个属性。它把一个向量传递给我们,或者接受我们传递的。所以最终调整一个的是一个局部向量值,它根本不影响要修改的点位置。因为我们没有首先显式地将它存储在变量中,所以操作是没有意义的,并且还会产生编译器错误。

4.3 显示正弦波

从现在起,在运行模式下,我们的视图的点被每一个帧都会被重新定位。至于为何看不出来,是因为它们总是处于相同的位置。所以,必须将时间纳入到功能中,以使其发生变化。然而,简单地添加时间只会将让这些点迅速上升,并消失在视线之外。为了防止这种情况发生,我们必须使用一个函数,该函数可以更改,但需要保持在固定的范围内。正弦函数是一个理想的表现,所以我们使用f(X)=sin(X)。我们可以用Unity的Mathf类的Sin函数来计算它。

(X的正弦表现)

正弦波在?1和1之间振荡,它重复每2个π(π)单位,约为6.28。由于现在的视图的X坐标在?1和1之间,所以目前看到的重复模式还不到三分之一。为了完整地看到它,用π标识X,所以最终可以得到f(X)=sin(πx)。可以使用Mathf.PI常数作为π的近似值来计算。

(πx的正弦)

若要让此函数动起来,可以在计算正弦函数之前将当前游戏时间添加到X上。如果我们也通过π缩放时间,这个函数将每两秒重复一次。所以使用f(x,t)=sin(π(x+t)),其中t是经过的游戏时间。这将推动正弦波随着时间的推移,向负X方向移动。

(动画展示)

Unity基础系列(一)——创建一个时钟(GameObjects与Scripts)

下一章内容为:数学表面。

授权声明