某愿朝闻道译/原文地址

  • 创建一个预制体(Prefab)
  • 生成一条由cube(立方体)组成的线
  • 显示数学函数
  • 创建一个自定义shader
  • 让图像动起来

本教程将使用游戏物体组成一幅图像, 来显示一个数学函数曲线. 我们也可以让函数曲线随时间变化, 从而使我们的组成的图像动起来
本教程假定你已经完成教程游戏与物体并拥有了对应的知识, 并使用Unity2017.1.0或更高版本.
组成图形 - 图1
使用cube显示正弦曲线

创建一条cube组成的线

对于数学概念的理解程度对编程工作必不可少, 最基础的数学就是对数字符号的操作. 解决数学方程式可以看做是重写一组符号, 使它们变成另一组更短的符号, 数学规则在这里就规定了可以如何进行重写.

比如说, 函数f(x)=x+1. 我们可以将x值使用3来代替. 那么函数就变成了f(3)=3+1, 结果是4. 我们使用3作为函数的输入参数并得到了函数的结果, 4. 我们可以说通过这个函数将3与4进行了映射. 这就是一个输入输出组合, 可以被简写为(3,4). 我们可以创建很多这样格式的组合. 比如(5,6), (8,9), (1,2), (6,7).

函数f(x)=x+1非常容易理解, 函数 f(x)=(x−1)4+5x3−8x2+3xf(x)=(x-1)4+5x3-8x2+3x就难以理解多了. 我们同样可以为这个函数写出几种输入输出组合, 但是似乎这也不能让我们很好的理解这个函数代表的映射关系. 我们需要更多的, 连续的输入输出组合, 这样罗列下去也许会永无止境. 此时, 我们需要换一个方法, 使用二维坐标系来表现函数映射的规律. 在这个坐标系中, x轴代表函数参数, y轴代表函数结果, 可以写作y=f(x), 每一组函数输入输出都对应坐标系内的一点, 我们将这些点连接起来, 就得到了函数曲线的图形
组成图形 - 图2
x在-2到2区间时的函数曲线图形

根据上面的图形可以让我们直观快速的观察到函数的规律情况, 绘制函数曲线是一个非常好用的数学工具, 所以我们也试试在Unity中做一下. 首先在已打开的项目中通过菜单File / New Scene新建一个场景, 你也可以新建一个项目并使用它的默认场景.

预制体

通过将点放置在适当的位置就可以创造图形, 我们要使用3D物体作为构成图形的点, 本例我们使用Unity自带的cube物体.

首先新建一个cube, 设置它的Position为(0,0,0). 移除它的Box Collider组件,本例用不到.

cube是构成图形的最好选择吗?

你也可以使用粒子系统或是其他什么你熟悉的物体代替cube, 不过cube是最简单方便的

我们将使用脚本来创建非常多的cube并设置它们的位置. 为了做到这一点, 我们需要将cube转换为一个模板, 通过模板来生成更多的cube.

将cube从Hierarchy窗口拖拽到Project窗口. 这样就会创建一个新的资源文件, 它显示为一个蓝色立方体图标, 这就是Unity中被称之为预制体(Prefab)的资源. 顾名思义, 它代表的是预先制备好的游戏物体. 此时我们看到Hierarchy窗口中原来的cube也变成了蓝色立方体图标, 这代表它由某个预制体产生的副本, 我们可以管它叫做预制体的实例(Instance).
组成图形 - 图3
cube预制体

预制体是一种配置游戏物体的好办法. 如果你改变了预制体资源文件, 所有场景中的预制体实例也都会进行对应的变化. 比如说, 修改预制体的Scale将会导致所有场景中的该预制体实例的缩放也被修改. 然而每个实例在场景中的位置和旋转是不受预制体变化的影响的. 另一方面, 场景中的实例也可以重写修改预制体赋予它的属性值. 你对实例物体进行了比较大的更改, 比如说移除了某个组件, 那么这一部分预制体对这一部分属性的影响将会被破坏.

本例中要使用脚本来创建预制体的实例, 所以场景中的cube并不需要, 删除它.

图形脚本组件

我们需要C#脚本来生成图形. 创建一个名为Graph的C#脚本. 我们的脚本依然基于MonoBehaviour类, 这样就可以把脚本作为一个组件添加到物体上. 添加一个公开字段来代表生成图形的点需要用到的预制体, 命名为pointPrefab. 因为我们需要访问Transform组件来调整图形的点的位置, 所以将它作为字段的数据类型 :

  1. using UnityEngine;
  2. public class Graph : MonoBehaviour {
  3. public Transform pointPrefab;
  4. }

通过菜单GameObject / Create Empty在场景中新建一个空物体, 将它的Position设置为(0,0,0), 然后重命名为Graph. 接着直接拖拽或是点击物体Inspector的Add Component按钮, 来将你的Graph脚本添加给这个空物体. 记着, 将预制体资源从Project窗口拖拽到物体Inspector中脚本组件的Point Prefab属性的空栏中, 这样, pointPrefab字段就拿到了预制体的数据.
组成图形 - 图4
Graph物体通过脚本得到了预制体数据

实例化预制体

所谓实例化预制体, 指的是使用预制体的数据在场景中创建物体.

我们需要用到实例化方法Instantiate. 这是Object类的一个公开方法, 我们脚本中的Graph类通过MonoBehavior类间接的继承(inherited)了该类.

Instantiate方法将作为参数传递给它的Unity物体进行克隆. 本例中这个参数就是我们的预制体, 该方法将会在当前场景添加一个与预制体数据一样的物体, 我们先在Graph脚本的初次运行启动时添加这个方法 :

  1. public class Graph : MonoBehaviour {
  2. public Transform pointPrefab;
  3. void Awake () {
  4. Instantiate(pointPrefab);
  5. }
  6. }

组成图形 - 图5
运行游戏, 会看到场景中生成的预制体实例

代码写完并保存后, 运行游戏, 将会在场景中生成一个cube物体, 其Position与预制体的Position属性值一致, 本例中是(0, 0, 0)位置. 为了在图形不同位置生成点, 我们需要调整实例的位置. Instantiate方法可以让我们在代码中获得实例物体的数据. 由于我们传给方法的预制体参数是Transform类型, 所以我们得到的数据也是这个类型, 我们将得到的示例数据保存在变量中 :

  1. void Awake () {
  2. Transform point = Instantiate(pointPrefab);
  3. }

现在我们可以通过point变量来调整实例的位置. 像是我们在上个教程设置物体的localRotation属性一样, 我们要设置物体的localPosition属性, 而不是设置position属性.

物体的位置值是一个3D向量(3D vector), 3D向量的数据类型是Vector3, 这是一个结构, 所以它的用法与数字等基本;类型相似.

我们试一下设置物体的X坐标为1, Y和Z为0, Vector3类型有一个专门的叫做right的属性代表这个特定的向量 :

  1. Transform point = Instantiate(pointPrefab);
  2. point.localPosition = Vector3.right;

属性名称不是要大写首字母吗?

虽然C#推荐的代码规范建议大写属性的首字母, 但是Unity常常不遵守这个规范

我们保存代码, 并再次运行, 观察生成的cube物体的Position属性, 已经按照代码移动了位置. 接下来继续生成第二个点, 并让它的位置更靠右一点. 这可以通过将Vector3.right乘2来做到. 复制一份生成物体的代码, 并且让新生成物体的位置设置代码乘2 :

  1. void Awake () {
  2. Transform point = Instantiate(pointPrefab);
  3. point.localPosition = Vector3.right;
  4. Transform point = Instantiate(pointPrefab);
  5. point.localPosition = Vector3.right * 2f;
  6. }

结构类型和数字类型可以直接相乘?

默认情况下不可以.
但是如果结构类型自定义了数学操作方法的话就可以, 这种情况下, 如果结构要进行对应的数学计算, 就会调用自定义的计算方法. 在本例中, 看上去相乘的操作实际调用了结构的一个方法, 在概念上类似Vector3.Multiply(Vector3.right, 2f).
而直接使用数学符号调用对应方法的方式可以使得代码书写的更快也更简洁易懂. 这不是必须如此, 但是如此更好, 就像是使用命名空间的好处. 这种方便的语法被称之为糖衣语法(或语法糖, syntactic sugar)
要注意的是, 只有当方法功能与数学操作符代表的操作含义十分相近时才应该使用操作符调用. 对于Vector3来说, 类似乘法这种计算就非常适合使用这种方式

我们刚刚写完的代码会编译报错不能运行, 因为我们队point这个变量定义(define)了两次. 如果我们想使用另一个变量, 我们应该使用一个不同的名字. 我们也可以选择复用一个已有的变量.

本例中我们在设置好第一个物体的位置后已经不需要point继续保存它的位置数据了, 可以存放第二个物体的数据 :

  1. Transform point = Instantiate(pointPrefab);
  2. point.localPosition = Vector3.right;
  3. // 注释报错代码
  4. // Transform point = Instantiate(pointPrefab);
  5. point = Instantiate(pointPrefab);
  6. point.localPosition = Vector3.right * 2f;

组成图形 - 图6
两个x坐标为1和2的cube挨在一起生成

代码循环

我们接着来创建更多的点, 一共十个.
我们可以继续复制八遍生成代码, 但是这样变成的效率很低. 理想情况下, 我们只需要书写一个点的生成代码, 然后让程序把这条代码执行多次.

while语句可以用来重复执行一段代码. 在我们的代码中添加它, 将我们的生成代码包在它的大括号中, 然后注释或删除生成第二个物体的代码 :

  1. void Awake () {
  2. while {
  3. Transform point = Instantiate(pointPrefab);
  4. point.localPosition = Vector3.right;
  5. }
  6. // point = Instantiate(pointPrefab);
  7. // point.localPosition = Vector3.right * 2f;
  8. }

像if语句一样, while语句也需要一个被圆括号包裹的逻辑板表达式, 只有逻辑表达式的结果为真时while才会继续循环执行一次大括号中的代码段, 没循环一次, 程序都会再次检查while的逻辑表达式是否为真, 以决定是否需要再次执行代码. 这个循环过程会直到逻辑表达式结果为假时停止.

所以我们需要为while添加它的逻辑表达式. 我们必须谨慎, 防止出现逻辑表达式始终为真, 代码无限循环的情况. 无限循环的代码会让我们的程序卡住无法继续运行, 此时需要我们手动的强制结束程序的运行.

最安全的逻辑表达式方式就是只写一个false作为表达式 :

  1. while (false) {
  2. Transform point = Instantiate(pointPrefab);
  3. point.localPosition = Vector3.right;
  4. }

可以在循环代码中定义point变量吗?

可以.
尽管代码在重复执行, 我们也只会定义变量一次, 它在每一次循环执行时都会被重用, 就像我们之前手动操作的一样.
你也可以在循环区域外定义point变量, 这样也就使得你可以在循环之外使用它, 否则它只能在循环范围内使用.

我们可以使用一个数字来限制循环的次数, 我们使用名称i来代表这个数字. 为了可以在while的逻辑表达式使用它, 需要对它进行声明定义 :

  1. void Awake () {
  2. int i;
  3. while (false) {
  4. Transform point = Instantiate(pointPrefab);
  5. point.localPosition = Vector3.right;
  6. }
  7. }


每次循环, 将i的数值加1 :

  1. int i;
  2. while (false) {
  3. i = i + 1;
  4. Transform point = Instantiate(pointPrefab);
  5. point.localPosition = Vector3.right;
  6. }

以上代码会报错, 因为我们在i还没有进行任何赋值之前就要使用它.
(实际测试, while(false)这种写法时, 编译器不会去编译循环区域的代码, 因为不会被执行到, 所以并不会报错. 如果while的逻辑表达式换成并非固定的false值, 编译器才会报错)
我们需要在i被定义的时先分配给它一个初始值0, 来修复这个问题 :

  1. int i = 0;

现在i会在第一次循环后变成1, 第二次后变为2, 以此类推. 但是while目前的表达式是false, 所以实际运行一次也不会执行循环代码. 我们需要让逻辑表达式在i变成10, 也就是循环十次后, 变为假, 在那之前都是真, 也就是说我们需要i小于10之前一直循环while的代码, 所以这个表达式可以写为 i<10 :

  1. int i = 0;
  2. while (i < 10) {
  3. i = i + 1;
  4. Transform point = Instantiate(pointPrefab);
  5. point.localPosition = Vector3.right;
  6. }

保存代码, 运行游戏, 会生成十个cube. 但是它们的位置完全一样, 我们可以根据i的值来设置每个cube都一个比一个更靠右 :

  1. point.localPosition = Vector3.right * i;

组成图形 - 图7
十个cube排成行

需要注意目前第一个cube的x坐标是1, 最后一个cube的x坐标是10. 但是让第一个cube从x坐标0开始更为理想, 我们可以更改代码让所有cube的位置都向左偏一个单位.
虽然可以使用(i-1)代替i, 来与Vector3.right相乘做到这一点, 但是我们也可以通过将增加i的代码放在循环末尾来做到这一点 :

  1. while (i < 10) {
  2. // i = i + 1;
  3. Transform point = Instantiate(pointPrefab);
  4. point.localPosition = Vector3.right * i;
  5. i = i + 1;
  6. }

简洁的语法

因为需要使用循环语句的情况非常普遍, 那么如果有什么简单写法能节省我们输入代码的世界就更好了. 事实上一些糖衣语法可以帮助我们做到这一点.

如果一个表达式是x = x y这种形式, 那么它可以简写为x = y. 这种写法适用于所有数学运算符, 所以我们可以修改为i赋值的代码 :

  1. // i = i + 1;
  2. i += 1;

以上代码还可以进一步简化. 如果只是对一个操作数加一或减一, 就可以写成++x或是—x的形式 :

  1. // i += 1;
  2. ++i;

赋值语句也同样可以用在逻辑表达式中. 我们可以在while的逻辑表达式中去增加i的值, 从而进一步简化我们的代码 :

  1. while (++i < 10) {
  2. Transform point = Instantiate(pointPrefab);
  3. point.localPosition = Vector3.right * i;
  4. //++i;
  5. }

但是这样写也有存在一个问题, 那就是i会在逻辑表达式判断之前增加自身的值, 而不是先计算逻辑表达式然后再增加自身的值, 这带来的问题就是我们的代码会少执行一次, 因为i等于0那次没有与10进行比较, 而是i增加了1后才与10进行比较.

对于这种特殊情况, ++或—操作符也可以写在变量的右边, 这代表会先对变量取值去计算逻辑表达式, 然后才会对其进行增加操作 :

  1. // while (++i < 10) {
  2. // ++写在i的右边之后, 就会先判断i是否小于10, 得到判断结果后, 才对i执行加一的操作
  3. while (i++ < 10) {
  4. Transform point = Instantiate(pointPrefab);
  5. point.localPosition = Vector3.right * i;
  6. }

尽管while循环语句适用所有种类的循环逻辑, C#依然提供了其他循环语句. 比如有一种非常适合循环特定次数的循环语句, 那就是for循环语句. 它像While一样执行循环, 区别在于它可以将声明循环变量与逻辑表达式都写在一个圆括号中, 使用分号间隔 :

  1. // 注意, 下面这句之前用来声明i变量的语句也需要注释或删除
  2. // int i = 0;
  3. // while (i++ < 10) {
  4. for (int i = 0; i++ < 10) {
  5. Transform point = Instantiate(pointPrefab);
  6. point.localPosition = Vector3.right * i;
  7. }

上述代码会产生一个编译错误, 因为for循环语句的圆括号中需要三个部分. 第三部分用来增加循环量的值, 而不是在逻辑表达式中去增加它 :

  1. // for (int i = 0; i++ < 10) {
  2. for (int i = 0; i < 10; i++) {
  3. Transform point = Instantiate(pointPrefab);
  4. point.localPosition = Vector3.right * i;
  5. }

为什么在for循环使用i++而不是++i?

这里没有特别的原因, 这是一种经典写法, 随着你看到的代码越来越多, 你会发现for(int i = 0; i < 某个上限; i++)会出现在大量的程序和脚本中

更改X坐标范围

现在, 我们的cube的X坐标被依次设置为0到9.
研究数学函数时通常对X的取值是0到1, 或者-1到1.

所以我们也对cube的大小和位置进行重新调整, 最终使得cube的坐标取值范围变成-1到1之间.
(此处修改范围的目的有两个, 一个是在后面的步骤设置颜色时候方便进行颜色赋值, 一个是更改函数曲线时, 不会因为曲线的变化而导致图形的大小变化过大影响观察, 如果这一步让你感到困惑, 不用多想, 继续往后做就明白了)

如果我们直接将cube的坐标范围从0-9调整为0-1, 它们之间就会彼此重叠, 所以我们同时也要对cube的Scake属性进行调整. 每个cube的Scale值默认都设置的(1,1,1), 我们将X坐标要从0到9变成-1到1, 也就是距离范围变为原来的1/5, 所以我们对Scale进行同样比例的变化, 我可以将每个cube的localScale直接设置为Vector3.one除以5的结果, Vector3.One等同于(1, 1, 1)这样的向量 :

  1. for (int i = 0; i < 10; i++) {
  2. Transform point = Instantiate(pointPrefab);
  3. point.localPosition = Vector3.right * i;
  4. // Vector3.one/5 会得到类似 (1/5, 1/5, 1/5) 这样的结果, 从而设置Scale的三个值
  5. point.localScale = Vector3.one / 5f;
  6. }

组成图形 - 图8
保存脚本修改, 运行游戏, 可以看到修改后的效果, cube变小了

下一步我们需要调整cube的localPosition属性, 让它们彼此靠近, 符合现在的尺寸 :

  1. point.localPosition = Vector3.right * i / 5f;

此时cube的X坐标范围是0到2之间, 为了将其变成-1到1的范围, 设置cube的位置需要额外减去1 :

  1. //一定注意 一定注意 一定注意 数字要带着f 不带f会被当做整数处理, 结果会没有小数位
  2. point.localPosition = Vector3.right * (i / 5f - 1f);

通过上面的代码, 第一个cube会被生成在X=-1的坐标, 最后一个cube会被生成在X=0.8的坐标. 因为cube的中心点与它的坐标点重合, 所以第一个cube的左侧处于-1.1这样的位置, 右侧处于-0.9这样的位置(第一个cube大小0.2, 中心位置X=-1, 那么它左右就都向外凸出了0.1的长度, 所以右边是-0.9, 左边就是-1.1),

为了让我们的cube能精确的代表-1到1这样的函数变量取值范围, 我们需要再次调整它们的位置, 将它们全部再向右移动半个cube的距离, 这可以通过对i的值增加0.5之后再进行位置计算而做到 :

  1. point.localPosition = Vector3.right * ((i + 0.5f) / 5f - 1f);

精简循环代码

所有的cube大小都一样, 我们却在每一次循环中都去计算了一次. 我们不需要这样做, 相反, 我们只需要在循环开始之前, 只计算一次, 将计算结果存储在一个Vector3类型的变量中, 然后只需要每次循环中直接使用该变量来调整cube的尺寸 :

  1. void Awake () {
  2. //新增的Vector3变量
  3. Vector3 scale = Vector3.one / 5f;
  4. for (int i = 0; i < 10; i++) {
  5. Transform point = Instantiate(pointPrefab);
  6. point.localPosition = Vector3.right * ((i + 0.5f) / 5f - 1f);
  7. //point.localScale = Vector3.one / 5f
  8. point.localScale = scale;
  9. }
  10. }

我们还可以在循环开始前, 定义一个Vector3类型的变量代表位置值. 我们只需要在循环中去调整该Vector3变量的X值, 而不再需要乘以Vector3.right :

  1. Vector3 scale = Vector3.one / 5f;
  2. //新增的位置变量
  3. Vector3 position;
  4. for (int i = 0; i < 10; i++) {
  5. Transform point = Instantiate(pointPrefab);
  6. //在循环中为新增的变量赋值
  7. position.x = (i + 0.5f) / 5f - 1f;
  8. //point.localPosition = Vector3.right * ((i + 0.5f) / 5f - 1f);
  9. point.localPosition = position;
  10. point.localScale = scale;
  11. }

我们能单独的调整Vector3变量的X值?

Vector3结构类型有三个float类型的字段, 分别是x, y和z. 这些字段是公开的, 所以我们可以直接对它们赋值
虽然结构(Struct)本身也是一种数值类型, 数值类型应该只能被重新赋值而不能部分的改变, 但是在Unity中的Vector3类型是可以只改变它某个字段值的, 这样设置是因为大部分情况都只需要改变Vector3中的部分字段

上述代码会导致一个编译错误, 提示我们使用了未赋值的变量(unassigned variable). 因为我们还没有设置position变量的y值和z值. 我们需要在循环开始之前设置y和z为0 :

  1. Vector3 position;
  2. position.y = 0f;
  3. position.z = 0f;
  4. for (int i = 0; i < 10; i++) {
  5. //循环代码略…
  6. }

用X坐标来决定Y坐标

现在, cube的Y坐标全部都是0, 那么我们做出的曲线等同于f(x)=0这样的函数曲线.

为了可以表示不同的函数曲线, 我们需要让Y坐标也在循环中被更改, 而不是在循环之前赋值. 让我们试着生成f(x) = x 这样的函数曲线 :

  1. Vector3 position;
  2. //position.y = 0f;
  3. position.z = 0f;
  4. for (int i = 0; i < 10; i++) {
  5. Transform point = Instantiate(pointPrefab);
  6. position.x = (i + 0.5f) / 5f - 1f;
  7. //设置y与x相等
  8. position.y = position.x;
  9. point.localPosition = position;
  10. point.localScale = scale;
  11. }

image.png
代码更改后运行, 新生成的形状, y坐标等于x坐标

另一个明显不同的函数曲线就是f(x)=x^2. 它的曲线是一条顶点在原点的抛物线 :

  1. //position.y = position.x;
  2. position.y = position.x * position.x;

组成图形 - 图10
Y坐标等于X的平方

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

生成更多Cube

尽管我们已经可以生成函数曲线图形了, 但是还有点丑. 因为我们只有十个cube, 曲线显得短短的, 看起来也不连续. 如果我们生成更多更小的cube, 就可以改善曲线的显示效果.

添加cube生成数量字段

我们要使用一个数字字段的值来代表要生成的cube数量, 而不再固定生成10个cube.

在Graph脚本代码中添加一个公开的整数字段resolution, 设置它的默认值是10 :

  1. public class Graph : MonoBehaviour
  2. {
  3. public Transform pointPrefab;
  4. //新增字段
  5. public int resolution = 10;

组成图形 - 图11
场景中选中Graph物体后, 在Inspector中可以看到新添加的Resolution字段

现在我看就可以通过Inspector来直接调整这个字段的值. 不过如果我们设置了负数是没有意义的, 所以我们可以在声明字段的时候书写[Range]关键字对其取值范围作出限制 :

  1. [Range] public int resolution = 10;

Range是Unity定义的一种特性(attribute). Uhity会检查一个字段是否带有Range特性, 如果有, 它会使用一个滑动条代替默认的数字输入框, 不过这就需要我们明确定义取值范围, 所以还需要为Range特性添加两个数字定义该范围, 我们在这里把取值范围设置为10到100, 另外, 通常习惯将Range特性写在要作用的目标上面一行, 而不是正前方 :

  1. [Range(10, 100)]
  2. public int resolution = 10;

组成图形 - 图12
此时Inspector中的可以使用滑动条设置字段值

这是否可以保证resolution的值被限制在10到100之间?

不, 该特性的唯一作用就是让Inspector中可以使用滑动条在这个范围内取值.
它不会限制任何其他方式对resolution字段的赋值. 在本例中, 我们将只通过Inspector调整resolution的值

改变cube的生成数量

要让resolution字段产生作用, 我们需要根据它的值来控制生成多少个cube. 用它的值来控制循环语句执行的次数 :

  1. //for (int i =0;i < 10; i++) {
  2. for (int i = 0; i < resolution; i++) {
  3. }

接着我们还需要调整每个cube的Scale和Position, 使得它们依然处于曲线横坐标-1到1之间的范围.

生成10cube时, 每个cube的尺寸和位置值变化的系数都是2/10, 那么生成resolution个cube时, 该系数就是2/resolution, 我们使用这个系数来代替原来代码中的2/10也就是1/5, 我们使用一个新的变量来存储该系数的值 :

  1. void Awake () {
  2. //新增变量step, 存储系数值
  3. float step = 2f / resolution;
  4. //Vector3 scale = Vector3.one / 5f; 使用系数代替原来的1/5
  5. Vector3 scale = Vector3.one * step;
  6. Vector3 position;
  7. position.z = 0f;
  8. for (int i = 0; i < resolution; i++) {
  9. Transform point = Instantiate(pointPrefab);
  10. //position.x = (i + 0.5f) / 5f - 1f;使用系数代替原来的1/5
  11. position.x = (i + 0.5f) * step - 1f;
  12. position.y = position.x * position.x;
  13. point.localPosition = position;
  14. point.localScale = scale;
  15. }
  16. }

组成图形 - 图13
保存代码, 前往Graph物体的Inspector, 设置resolution值为50, 运行效果如图

设置父物体

在生成50个cube后, 会在Hierarchy窗口出现一大串cube物体的名称, 如下图
组成图形 - 图14

这些cube不是任何其他物体的子物体, 我们可以通过代码设置它们的父物体, 本例中, 我们可以设置它们的父物体时Graph物体, 既然是该物体的脚本生成的它们, 那么这么做就显得也比较合理.

我们可以在生成cube之后, 通过Transform组件类的SetParent方法来设置它的父物体. 我们需要向该方法提供Graph物体的Transform组件, 该组件可以在Graph类中通过transform属性直接获得改脚本所属的物体的Transform组件, 也就是Graph物体的Transform组件 :

  1. for (int i = 0; i < resolution; i++) {
  2. Transform point = Instantiate(pointPrefab);
  3. position.x = (i + 0.5f) * step - 1f;
  4. position.y = position.x * position.x;
  5. point.localPosition = position;
  6. point.localScale = scale;
  7. //每生成一个cube都设置它的父物体
  8. point.SetParent(transform);
  9. }

组成图形 - 图15
保存代码, 运行, 此时新生成的cube都成为了Graph物体的子物体, 点开Graph名称前面的小三角按钮即可看到

使用SetParent方法设置父物体时, Unity会尝试保持物体在世界坐标系中的位置, 旋转和缩放都不变. 在我们的例子中, 因为Graph物体的坐标是(0,0,0), 其旋转和缩放也都是默认值, 子物体的世界坐标状态是否变化都看不出影响, 所以我们可以给SetParent方法传递第二个参数false, 表示子物体不需要保持在世界坐标系中的状态 :

  1. //point.SetParent(transform);
  2. point.SetParent(transform, false);

(这一步看似多余, 其实是为了后面步骤设置图形颜色做的铺垫, 如果你困惑为什么作者要加这一步, 没有关系, 继续跟着敲代码完事儿了)

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

为图形染色

白色的图形看起来不是很酷. 我们可以给图形直接设置为其他单一颜色, 但是这也显得有点无趣. 让我们试试根据每个cube的位置来决定它的颜色.

调整颜色最直接方式是设置cube材质(material)的颜色属性. 我们可以在循环代码中完成这个工作, 使用这个方式来为每个cube设置不同的颜色, 就需要为每个cube准备不同的材质(如果是一个材质, 设置多少次都只会以最后一次设置的颜色为准), 这显得非常麻烦和低效. 最理想的方式是我们只使用一个材质, 依然可以让使用该材质的多个cube设置为不同的材质颜色. 遗憾的是, Unity并没有提供这样的材质给我们, 所以需要我们自己动手丰衣足食.

创建自定义Shader

电脑的GPU通过Shader(着色器)程序来渲染(render)3D物体. Unity的材质资源决定了使用哪个Shader, 并可以设置Shader的属性数据. 我们需要创建一个自定义的Shader, 并通过配置Shader的一些属性来实现我们需要的效果. 首先通过菜单Assets / Create / Shader / Standard Surface Shader创建一个Shader, 并将其命名为ColoredPoint
组成图形 - 图16
自定义Shader资源

我们现在拥有了一个Shader资源, 像打开脚本资源一样双击打开它. 我们的Shader文件包含了默认的shader代码, 这些代码并不是使用C#语法书写的. 下面是其中已有的代码内容, 为了显示的简洁一些, 我删掉了所有注释内容 :
(如果你发现你的代码跟这里的差距很大, 检查下文章开头Unity版本要求, 我用的2019版本, 有一点点不易, 我直接赋值了教程中的代码覆盖了我的, 可以正常完成教程)

  1. Shader "Custom/ColoredPoint" {
  2. Properties {
  3. _Color ("Color", Color) = (1,1,1,1)
  4. _MainTex ("Albedo (RGB)", 2D) = "white" {}
  5. _Glossiness ("Smoothness", Range(0,1)) = 0.5
  6. _Metallic ("Metallic", Range(0,1)) = 0.0
  7. }
  8. SubShader {
  9. Tags { "RenderType"="Opaque" }
  10. LOD 200
  11. CGPROGRAM
  12. #pragma surface surf Standard fullforwardshadows
  13. #pragma target 3.0
  14. sampler2D _MainTex;
  15. struct Input {
  16. float2 uv_MainTex;
  17. };
  18. half _Glossiness;
  19. half _Metallic;
  20. fixed4 _Color;
  21. UNITY_INSTANCING_CBUFFER_START(Props)
  22. UNITY_INSTANCING_CBUFFER_END
  23. void surf (Input IN, inout SurfaceOutputStandard o) {
  24. fixed4 c = tex2D (_MainTex, IN.uv_MainTex) * _Color;
  25. o.Albedo = c.rgb;
  26. o.Metallic = _Metallic;
  27. o.Smoothness = _Glossiness;
  28. o.Alpha = c.a;
  29. }
  30. ENDCG
  31. }
  32. FallBack "Diffuse"
  33. }

新建的Shader是如何工作的?

Unity提供了一种快速生成Shader的框架, 新建的Shader可以进行默认光照计算, 你可以通过改变这些Shader的某些属性来实现其他效果. 本例新建的这种Shader被称之为表面Shader(surface shader). 如果你希望了解关于Shader的更多知识, 可以前往渲染入门渲染进阶系列教程

我们的新建Shader包含四个属性, 决定了材质的颜色, 纹理贴图, 以及表面的光泽和金属质感. 因为我们需要颜色根据cube的位置变化, 所以不需要设置固定颜色的属性, 也不需要纹理贴图属性, 下面的代码中注释了不需要的代码, 使得Shadre的材质表现为不透明的黑色(原文说”使Albedo为黑色, Alpha为1”, 为了方便萌新看懂, 我改了一下措辞) :

  1. Shader "Custom/ColoredPoint" {
  2. Properties {
  3. //_Color ("Color", Color) = (1,1,1,1)
  4. //_MainTex ("Albedo (RGB)", 2D) = "white" {}
  5. _Glossiness ("Smoothness", Range(0,1)) = 0.5
  6. _Metallic ("Metallic", Range(0,1)) = 0.0
  7. }
  8. SubShader {
  9. Tags { "RenderType"="Opaque" }
  10. LOD 200
  11. CGPROGRAM
  12. #pragma surface surf Standard fullforwardshadows
  13. #pragma target 3.0
  14. //sampler2D _MainTex;
  15. struct Input {
  16. //float2 uv_MainTex;
  17. };
  18. half _Glossiness;
  19. half _Metallic;
  20. //fixed4 _Color;
  21. UNITY_INSTANCING_CBUFFER_START(Props)
  22. UNITY_INSTANCING_CBUFFER_END
  23. void surf (Input IN, inout SurfaceOutputStandard o) {
  24. //fixed4 c = tex2D (_MainTex, IN.uv_MainTex) * _Color;
  25. //o.Albedo = c.rgb;
  26. o.Metallic = _Metallic;
  27. o.Smoothness = _Glossiness;
  28. //o.Alpha = c.a;
  29. o.Alpha = 1;
  30. }
  31. ENDCG
  32. }
  33. FallBack "Diffuse"
  34. }

什么是”albedo”, 什么是”alpha”?

一个材质通过漫反射(diffuse reflectivity)而表现出的颜色被称之为albedo(反射色). Albedo是一个拉丁文单词, 含义是”白度(whiteness)”. 它描述了材质可以漫反射多少红绿蓝三种颜色通道(color channel)的色彩.
Alpha用来衡量不透明度(opacity). Alpha值为0表示完全透明, 1表示完全不透明.(所以你也可以叫Alpha透明度)

此时, 回到Unity, Shader代码也会进行编译, 并且上述代码会发生编译错误, 因为代码中的”input”结构体是空的. 我们需要在这个结构体中自定义我们需要的颜色. 本例中, 我们需要在这里使用cube的位置数据. 我们可以在input结构体中通过float3 worldPos;这句代码来获得坐标信息 :

  1. struct Input {
  2. float3 worldPos;
  3. };

这是否意味着移动Graph物体将影响cube的颜色?

是的. 因此, 你需要保持你的Graph物体坐标为(0, 0, 0)才能跟我接着做的步骤产生一样的效果.
同时你也需要注意, Shader中的位置数据指的是物体顶点(vertex)的位置. 在本例中, 每个cube的边角就是自身的顶点. 颜色将会沿着cube的表面进行插值计算产生变化. cube越大, 颜色的过渡效果越明显

现在我们的Shader可以编译通过了, 让我们创建一个新的material材质资源文件, 起名为Colored Point. 然后在其Inspector中点击Shader下拉选项, 在弹出的菜单列表中选择Custom / Colored Point. 从何让该材质使用我们刚刚新建的Shader, 如下图
组成图形 - 图17

之后, 将该材质文件拖拽到cube预制体上, 从而让cube预制体使用该材质, 那么生成的cube也将使用该材质

基于位置设置颜色

完成上述步骤, 运行游戏后生成的cube是纯黑色的. 我们需要在Shader中新增一句代码, 来修改o.Albedo的值来为cube染色. 我们需要将o.Albedo的红色色值r设置为位置数据的X坐标:

  1. o.Albedo.r = IN.worldPos.x;//找到下面那行代码, 然后在它上方插入这句代码
  2. o.Metallic = _Metallic;

组成图形 - 图18
保存代码修改, 运行游戏, 生成的cube发生了基于x坐标的颜色变化

o.Albedo的红绿蓝颜色不需要全部进行设置吗?

我们不需要设置它的绿色和蓝色色值, 因为他们在surf函数的代码被调用之前已经被设置为了0

目前x坐标是正数的cube颜色都变成了不同程度的红色. 那些x坐标是负数的cube依然是黑色, 因为颜色色值不能是负数, 最小就是0, 红绿蓝三个色值都是0就代表黑色. 为了让红色可以随着-1到1的坐标区间变化, 我们需要在代码中将x坐标减半再加上0.5, 从而使最终的色值设置从-1到1变成0到2 :

  1. //o.Albedo.r = IN.worldPos.x;
  2. o.Albedo.r = IN.worldPos.x * 0.5 + 0.5;

组成图形 - 图19
保存代码运行游戏, 现在全部cube都被设置了不同程度的红色

我们还可以使用y值来设置绿色色值, 方式与设置红色色值类似. 在Shader代码中, 我们可以用一行代码完成这两个色值修改操作, 这就要用到o.Albedo.rg和IN.worldPos.xy :

  1. //o.Albedo.r = IN.worldPos.x*0.5 + 0.5;
  2. o.Albedo.rg = IN.worldPos.xy*0.5 + 0.5;

组成图形 - 图20
代码修改后运行, x坐标和y坐标现在都会影响颜色

红色与绿色叠加就得到了黄色, 所以我们的图形现在的过渡方式是由绿色过渡到黄色. 如果让Y从-1开始, 我们就可以得到偏黑的绿色. 通过修改Graoh脚本的Awake方法可以做到这一点, 让我们将图形改变成f(x)=x^3的函数曲线 :

  1. position.y = position.x * position.x * position.x;

组成图形 - 图21
函数曲线更改后的图形效果

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

让图形动起来

显示一个静态的图形已经不错了, 但是如果可以让图形动起来那简直酷毙. 让我们做一下!

要实现这个效果, 需要将时间作为函数曲线的额外参数, 差不多类似f(x,t)这种函数, x坐标和时间t都会影响函数的结果.

跟踪每一个cube的位置

为了让图形动起来, 我们需要随着时间调整组成图形的每个cube的位置. 我们可以通过删除所有的点然后再重新生成新的点来更新图形的样子, 但是这样做并不是实现这个效果的较好办法. 更好的方式是能够始终记录每个cube的位置信息, 并随着时间的变化去调整它们的位置.

首先, 我们要在Graph脚本中添加一个Transform类型的新字段, 叫points

  1. public class Graph : MonoBehaviour
  2. {
  3. Transform points;

这个字段允许我们存储一个cube的位置, 但是我们需要获得所有cube的位置. 那么我们就需要用到叫做数组(Array)的数据结构. 我们可以通过在这个字段的类型名称后面加上一对方括号, 来将它转化为一个Transform类型的数组 :

  1. //Transform points;
  2. Transform[] points;

此时points字段代表的是一个数组, 可以存储多个Transform类型的值. 数组是对象类型(object), 而不是像数字那种的基本类型. 我们需要明确的创建一个对象, 并将其赋予points. 创建对象的过程需要用到new关键字, 在new后面书写数组类型, 对于我们, 这句代码可以写成new Transform[]. 在Awake方法中创建这个数组, 写在循环代码前面, 并将其分配给points字段 :

  1. private void Awake() {
  2. points = new Transform[];

当创建一个数组时, 你应该同时指定它的长度, 也就是它可以存储多少个单独的数据, 一旦定义了数组的长度, 之后就不能再改变这个长度. 我们在创建数组的代码中, 把这个长度值写在数组类型的方括号中. 我们的例子中, 数组的长度通过resolution字段来指定 :

  1. //points = new Transform[];
  2. points = new Transform[resolution];

现在我们可以向数组中填充cube的位置数据. 之后可以通过在数组字段名称后方使用包裹着数字的方括号来获取不同的数组元素(数组元素指的就是数组中存储的一个个数据). 数组的元素序号从0开始代表第一个, 就好像是我们的循环, 计数用的i也是从0开始的. 因此我们可以在循环代码中为第i号数组元素赋值 :

  1. for (int i = 0; i < resolution; i++) {
  2. //省略中间代码…
  3. //在循环代码的结束大括号上面一行, 添加下方的数组元素赋值语句
  4. points[i] = point;
  5. }

这样我们就通过循环代码为数据组的每一个元素都赋了值. 因为数组的长度与循环的次数一致, 都是resolution字段控制的, 所以我们可以将循环语句的判断条件中的resolution换成数据长度值, 我们通过数组的Length属性来获取数组长度, 所以代码写法如下 :

  1. //for (int i = 0; i < resolution; i++) {
  2. for (int i = 0; i < points.Length; i++) {

更新cube位置

为了最终让图形动起来, 我们需要在Update方法中设置每一个cube的Y坐标, 那么也就不在需要在Awake方法中来计算cube的Y坐标, 不过仍然需要在使用position前为其y设置初始值, 否则会报错. 所以我们还需要在删除了循环中的y坐标赋值语句后, 在循环代码外设置y为0 :

  1. //在z的赋值代码上一行新添加y的赋值语句
  2. position.y = 0f;
  3. position.z = 0f;
  4. points = new Transform[resolution];
  5. for (int i = 0; i < points.Length; i++) {
  6. Transform point = Instantiate(pointPrefab);
  7. position.x = (i + 0.5f) * step - 1f;
  8. //注释或删除原循环代码中的y赋值语句
  9. //position.y = position.x * position.x * position.x;
  10. point.localPosition = position;
  11. point.localScale = scale;
  12. point.SetParent(transform, false);
  13. points[i] = point;
  14. }

然后, 想Graph脚本中添加一个Update方法, 并在其中使用像Awake方法中一样的for循环语句, 只不过还不需要写循环代码 :

  1. void Update () {
  2. for (int i = 0; i < points.Length; i++) {}
  3. }

Update方法中的for循环在每次循环时都会可以获得一个对应循环次数的数据元素, 这就是我们需要的cube的位置数据 :

  1. for (int i = 0; i < points.Length; i++) {
  2. Transform point = points[i];
  3. Vector3 position = point.localPosition;
  4. }

此时我们可以像之前一样控制y坐标值 :

  1. for (int i = 0; i < points.Length; i++) {
  2. Transform point = points[i];
  3. Vector3 position = point.localPosition;
  4. //设置y坐标的值
  5. position.y = position.x * position.x * position.x;
  6. }

然后我们还需要将这个计算过的坐标值重新赋值给point, 也就是cube的Transform, 更新cube的位置数据 :

  1. for (int i = 0; i < points.Length; i++) {
  2. Transform point = points[i];
  3. Vector3 position = point.localPosition;
  4. position.y = position.x * position.x * position.x;
  5. //position变量的值改变后, 重新赋值给point.localPosition
  6. point.localPosition = position;
  7. }

我们不可以直接为point.localPosition.y赋值吗?

如果localPosition是一个字段, 那么可以这样做. 然而localPosition是一个属性. 它将一个向量值传递给我们, 或是从我们这里接受一个向量值.
因此, 当我们调整了一个它返回给我们的向量的y值时, 我们并没有真正的修改到它自身的y值, 因为这个y是它的y值的一个副本. 如果我们直接去设置point.localPosition.y的值, 会出现编译错误

显示正弦曲线

从现在开始, 每次运行游戏, cube都会在每一帧去更新自身坐标. 我们看不出来这个过程, 是因为他们的位置每次设置的都是不变的值. 我们需要在位置设置的过程中加入时间的影响, 从而随着时间改变图形的样子. 然而, 直接添加时间可能会导致函数曲线变化过于剧烈, 甚至变得太大影响观察. 为了防止发生这种情况, 我们需要既变化函数曲线, 又可以将其限制在一个特定的范围内. 正弦函数是实现这种效果所需的理想方法, 也就是使用f(x)=sin(x)这个函数曲线.

我们可以使用Unity的Mathf结构体的Sin函数来计算正弦值 :

  1. //position.y = position.x * position.x * position.x;
  2. //position.y设置为position.x的正弦值
  3. position.y = Mathf.Sin(position.x);

组成图形 - 图22
上述代码的运行效果

Mathf是什么?

它是一个包含了各种用来计算数字和向量的数学函数的结构体. 它的f后缀表示它使用浮点数进行计算

这条正弦曲线的Y坐标处于-1和1之间. 它在X轴上每隔2π距离就重复一次, 2π约等于6.28. 因为我们的图形x轴坐标范围是-1到1, 我们观察不到曲线的重复. 我们可以在函数曲线中加入π值的影响, 来缩放x坐标, 新的函数曲线是f(x)=sin(πx). 我们可以使用Mathf.PI来获取π的近似值 :

  1. //position.y = Mathf.Sin(position.x);
  2. position.y = Mathf.Sin(Mathf.PI * position.x);

组成图形 - 图23
f(x) = πx 的函数曲线

什么是正弦曲线? 什么是π?

正弦是一种三角函数, 通过一个角度进行计算. 假设有一个半径为1的圆. 圆上的每个点都有一个角度θ以及一个2维位置与其关联. 定义它们坐标的一种方法就是使用组成图形 - 图24. 这代表了从圆的顶部顺时针环绕的点.
你也可以使用余弦来代替sin(θ+π/2), 从而变成组成图形 - 图25
组成图形 - 图26

正弦和余弦曲线
θ角通过弧度来描述, 弧度对应于圆周长的比例. 一半圆周的点, 即180角, 这个弧度对应的圆周长度就是π, 约等于3.14. 因此整个圆周长是2π. 也就是说, π是圆的周长与其直径的比值
(渐渐忘记教程标题…开始复习三角函数…单位圆半径为1, 角度θ的边与圆的交点位置坐标就是[x,y] = [sin(θ) 半径,cos(θ) 半径], 又因为半径是1, 于是交点坐标就是[sin(θ),cos(θ)])

为了让曲线动起来, 将游戏运行时间添加到函数中去影响X, 新的函数曲线是f(x,t) = sin(π(x+t)), t表示运行开始到现在经过的时间. 这会让我们的正弦曲线随着时间而演变, 向着X轴负方向波动 :

  1. //position.y = Mathf.Sin(Mathf.PI * position.x);
  2. position.y = Mathf.Sin(Mathf.PI * (position.x + Time.time));

FlamboyantCheapHoki-mobile.mp4 (68.2KB)图形动起来了

下一个教程是 数学曲面.

完整例子源码
教程PDF