某愿朝闻道译/原文地址

  • 支持多个函数方法
  • 使用委托和枚举
  • 使用网格(Grid)显示2D函数
  • 定义3D空间中的表面

本篇教程是组成图形教程的延续. 我们将继续通过代码显示多种复杂的函数图形
本篇教程假定你使用Unity2017.1.0或更高版本.
数学曲面 - 图1
将多个曲线进行组合, 创造出复杂的形状

切换函数曲线

完成上一篇教程后, 我们已经有了一个可以在运行模式下产生动画效果的正弦曲线. 我们还可以让它显示其他形状的函数曲线. 你甚至可以在游戏运行过程中改变代码, 从而变化当前正在显示的函数曲线, 当运行中修改代码并保存, 返回Unity窗口后游戏将暂停, 并存储当前的运行状态, 然后脚本代码再次编译, 最终游戏运行状态被重新加载并恢复运行. 不是所有的游戏内容都可以在重编译(recompiled)后恢复 但是我们的图形可以, 它将切换为新的函数曲线.

虽然在运行期间改变代码做起来很方便, 但是它并不是一种趁手的切换函数曲线的办法. 最好是我们可以简单的调整某个配置数据就能在运行过程中告诉图形进行变化, 跟着教程我们一起做做看吧.

函数方法

为了让我们的图形能够支持显示多种函数曲线, 我们需要用代码来定义所有的函数公式.

不过, 设置图形中每个cube位置的循环代码并不关心现在使用的是什么函数公式. 我们不需要为每个新的函数公式重新写一遍类似的循环代码. 我们只需要提取出于数学函数有关的部分, 并且添加它们到各自的方法中.

首先在Graph类中添加一个新的方法, 它将包含我们的正弦含水度代码. 这个过程类似书写Awake或是Update方法, 只不过这个方法的名字不再是Unity指定的, 而是我们自己定义的, 就叫它SineFunction好了 :

  1. void SineFunction () {}

这个方法代表了我们的数学函数f(x,t)=sin(π*(x+t)). 那么这个方法还需要可以返回给我们一个小数数据, 代表数学函数的函数结果. 所以让我们将代表不返回数据的void关键字, 换成要返回的数据类型名称float :

  1. //void SineFunction () {}
  2. float SineFunction () {}

这个数学函数还需要传入参数, 目前它的参数列表是空的. 我们需要将要传入的参数放置在方面名称后的圆括号中. 我们需要在参数名称前写上它的数据类型. 由于我们的正弦函数使用的参数也是小数, 所以是float类型 :

  1. //float SineFunction () {}
  2. float SineFunction (float x) {}

接着, 同样的方式添加参数t. 方法需要的多个参数之间在圆括号中使用逗号间隔 :

  1. //float SineFunction (float x) {}
  2. float SineFunction (float x, float t) {}

现在我们可以在方法中写下计算函数值的代码了, 用x和t这两个参数来进行计算 :

  1. float SineFunction (float x, float t) {
  2. Mathf.Sin(Mathf.PI * (x + t));
  3. }

最后一步是明确的指出方法要返回给我们的结果是什么. 由于是一个float类型的方法, 所以在函数代码技术时这个方法要返回一个float类型的数据. 我们在return这个关键字后面书写方法的返回值, 也就是我们计算出来的函数结果 :

  1. float SineFunction (float x, float t) {
  2. //Mathf.Sin(Mathf.PI * (x + t));
  3. return Mathf.Sin(Mathf.PI * (x + t));
  4. }

现在可以在Update方法中调用这个新的方法, 并使用position.x和Time.time作为要传给它的方法参数. 方法的返回值可以直接设置给cube的Y坐标, 取代之前的赋值内容 :

  1. void Update () {
  2. for (int i = 0; i < points.Length; i++) {
  3. Transform point = points[i];
  4. Vector3 position = point.localPosition;
  5. //position.y = Mathf.Sin(Mathf.PI * (position.x + Time.time));
  6. //使用SineFUnction 方法来为y坐标赋值
  7. position.y = SineFunction(position.x, Time.time);
  8. point.localPosition = position;
  9. }
  10. }

需要注意, Time.time在循环中每次调用时返回的值都是一样的, 所以我们可以只获取一次它的值, 存储在变量中 :

  1. void Update () {
  2. //新增变量存储Time.time的值
  3. float t = Time.time;
  4. for (int i = 0; i < points.Length; i++) {
  5. Transform point = points[i];
  6. Vector3 position = point.localPosition;
  7. //position.y = SineFunction(position.x, Time.time);
  8. //此处调用SineFunction方法时使用变量t代替Time.time
  9. position.y = SineFunction(position.x, t);
  10. point.localPosition = position;
  11. }
  12. }

第二个数学函数

现在我们已经有了一个函数方法, 让我们再做一个. 这次我们要做一个稍微复杂一些的函数, 使用多个正弦. 首先赋值SineFunction方法, 并将赋值的方法重新命名为MultiSineFunction :

  1. float SineFunction (float x, float t) {
  2. return Mathf.Sin(Mathf.PI * (x + t));
  3. }
  4. //复制并重命名的新方法
  5. float MultiSineFunction (float x, float t) {
  6. return Mathf.Sin(Mathf.PI * (x + t));
  7. }

我们保持赋值来的正弦函数公式, 并额外增加一些内容. 简单起见, 我们首先将正弦函数的结果存储到一个变量y中, 然后用变量y作为函数的返回值 :

  1. float MultiSineFunction (float x, float t) {
  2. //return Mathf.Sin(Mathf.PI * (x + t));
  3. float y = Mathf.Sin(Mathf.PI * (x + t));
  4. return y;
  5. }

让我们的正弦曲线变得更复杂的一种简单办法是, 在函数结果上再添加一个频率加倍的正弦函数, 这是通过让该正弦函数的参数都加倍做到的, 这样该函数的变化速度就加倍了. 同时我们将这个新增函数的结果除2, 这样就可以得到一个与我们现在的正弦函数形状相同但是大小减半的新正弦函数, 将该函数的结果与已有的函数结果相加, 得到新的函数曲线 :

  1. float y = Mathf.Sin(Mathf.PI * (x + t));
  2. //新增一行代码, x和t两个参数都乘了2, 最终计算的正弦值除以2
  3. y += Mathf.Sin(2f * Mathf.PI * (x + t)) / 2f;
  4. return y;

上述代码对应的最终数学函数为数学曲面 - 图2. 因为正弦的取值范围是-1到1, 所以这个函数的取值范围是-1.5到1.5. 无可让我们的函数结果依然在-1到1这个范围内, 我们应该讲函数结果再除以1.5, 也就是说乘以2/3 :

  1. float y = Mathf.Sin(Mathf.PI * (x + t));
  2. y += Mathf.Sin(2f * Mathf.PI * (x + t)) / 2f;
  3. //函数结果乘以2/3
  4. y *= 2f / 3f;
  5. return y;

现在让我们使用MultiSineFunction方法替代Update中的SineFunction方法, 并运行看看效果 :

  1. //position.y = SineFunction(position.x, t);
  2. position.y = MultiSineFunction(position.x, t);

JaggedSpottedJohndory-mobile (1).mp4 (53.1KB)复合正弦曲线

你会发现现在一条小一些的正弦曲线跟随着大一些的正弦曲线. 我们甚至可以让这个小的曲线沿着大曲线滑动, 比如说我们可以将小曲线的时间系数加倍. 这会使得曲线不再简单的随着时间摆动, 它还会改变它的形状. 不过由于正弦曲线的重复特性, 每隔两秒形状就会出现一次相同的形状 :

  1. float MultiSineFunction (float x, float t) {
  2. float y = Mathf.Sin(Mathf.PI * (x + t));
  3. y += Mathf.Sin(2f * Mathf.PI * (x + 2f * t)) / 2f;
  4. y *= 2f / 3f;
  5. return y;
  6. }

EdibleBigHartebeest-mobile.mp4 (70.13KB)变形复合正弦曲线

运行期间切换函数曲线

接下来我们要做的是添加一些代码, 以便控制图形使用哪个方法来显示函数曲线. 我们可以使用滑动条来控制, 就像是为在脚本中添加的resolution字段那样. 因为我们有两个函数可供选择, 所以我们使用一个公开的整数字段, 设置其可选范围为0到. 将它命名为function, 一看就能联想到它要用来干什么 :

  1. public class Graph : MonoBehaviour
  2. {
  3. [Range(0, 1)]
  4. public int function;

数学曲面 - 图5
保存代码, 前往Inspector会发现我们新增的function字段滑动条

接着我们在Update中使用if-else语句来控制将显示哪个函数曲线. 如果滑动条设置为0, 我们调用SineFuntion方法, 否则我们调用MultiSIneFunction方法 :

  1. void Update () {
  2. float t = Time.time;
  3. for (int i = 0; i < points.Length; i++) {
  4. Transform point = points[i];
  5. Vector3 position = point.localPosition;
  6. //position.y = MultiSineFunction(position.x, t);
  7. //新增的if-else语句部分
  8. if (function == 0) {
  9. position.y = SineFunction(position.x, t);
  10. }
  11. else {
  12. position.y = MultiSineFunction(position.x, t);
  13. }
  14. point.localPosition = position;
  15. }
  16. }

保存代码, 运行游戏, 运行期间在Inspector中拖动function滑动条改变它的值, 会发现图形会随着function值的改变而变换函数曲线

静态方法

尽管SineFunction和MultiSineFunction方法都属于Graph类, 但是它们实际上是自包含的(self-contained).

它们的功能只依赖于传给它们的参数值, 它们之外的其他代码无关. 虽然它们也依赖Mathf结构提供数学计算方法, 但是我们可以将Mathf结构只看做是实现数学计算的通用工具. 除此之外, 它们不需要Graph类中的任何其他方法或字段. 这表明我们就算将它们放到另一个类(class)或结构体(struct)中, 它们依然可以正常工作. 因此我们可以创建另一个类, 专门用于放置所有的数学函数方法. 然而, 因为只有Graph类需要使用这些方法, 我们又没用必要单独创建一个新的类.

默认情况下, 方法和字段都与某个类或结构体的实例(instance)相关联. 但是事实并非总是如此, 我们可以直接通过类本身来访问方法或字段, 而不是必须通过类的实例. 这需要在方法或字段声明语句前方加上static关键字来做到, 现在就为我们的两个数学函数方法加上该关键字 :

  1. static float SineFunction (float x, float t) {
  2. return Mathf.Sin(Mathf.PI * (x + t));
  3. }
  4. static float MultiSineFunction (float x, float t) {
  5. float y = Mathf.Sin(Mathf.PI * (x + t));
  6. y += Mathf.Sin(2f * Mathf.PI * (x + 2f * t)) / 2f;
  7. y *= 2f / 3f;
  8. return y;
  9. }

这两个方法依然是Graph类的一部分, 但是他妈现在可以直接通过Graph类来访问, 而不是必须通过它的实例. 并且由于我们将它们设置为public的方法, 我们可以在项目的任何地方通过类似Graph.SineFuncion(0f,0f)的代码调用它们, 就像Mathf.Sin(0f)这种方式一样. 在Graph类中调用这两个方法, 不需要额外的书写类名, 所以我们目前的代码可以正常工作.

为什么将我们的方法变成静态(static)?

目前还看不出这样做的目的, 但是我们在后面的步骤很快就要用到静态方法. 目前static只是告诉我们, 这两个方法是自包含的.
另外, 因为静态方法并不与类的实例关联, 程序编译并运行后将不会去跟踪你是通过哪个实例调用的方法. 这意味着静态方法调用起来速度更快, 不过这种速度差别大多数情况下不值一提.

委托

一个简单的if-else语句块用来切换我们使用哪种函数方法, 但是这种方法在我们想继续加如更多其他函数方法时会显得有些不够简洁与灵活. 如果我们能使用一个变量来存储我们想要调用的方法就会方便的多. C#中这是可以做到的, 需要用到委托(delegate)类型. 委托是一种特殊的数据类型, 它被指定可以引用(reference)哪种方法. 没有一种标准的委托类型供我们的数学函数方法使用, 但是我们可以自己定义这种委托类型. 首先我们需要新建一个C#脚本文件, 并且将其命名为GraphFunction :
数学曲面 - 图6
GraphFunction脚本资源

我们必须要新建一个脚本吗?

对于本教程来说, 确实可以在Graph脚本中去定义我们需要的委托类型. 不过将每种类型放在自己专属的脚本中可以将其明确的与其他脚本区分开, 易于理解和查看
在规模庞大的Unity项目中, 只有仅在当前脚本上下文中使用, 而不在外部使用的类型才会被嵌套书写在同一个类型文件中.

首先删掉新建脚本中的全部默认代码, 不过我们需要用到UnityEngine命名空间. 接着我们定义一个叫做GraphFunction的委托类型, 而且与类和结构体的声明方式不一样的是, 声明语句后面直接使用分号结束 :

  1. using UnityEngine;
  2. public delegate GraphFunction;

委托类型需要定义它可以被当做什么形态的方法来使用. 这个形态指的就是方法的签名(signature), 签名代表一个方法的返回类型及参数列表说明. 在本例中, 方法的返回类型是float, 方法的参数是两个float参数. 将这个签名规则应用给我们的委托类型GraphFunction. 描述方法签名时, 参数的名字无关紧要, 但是参数的类型及顺序必须要正确 :

  1. public delegate float GraphFunction (float x, float t);

现在我们可以在Graph脚本的Update方法中声明一个GraphFunction类型的变量, 将其放在循环代码之前. 添加该变量后, 如果它存储了一个方法就可以通过它来调用方法. 这允许我们摆脱循环代码中的if-else语句块 :

  1. void Update () {
  2. float t = Time.time;
  3. //新增GraphFunction委托类型的变量
  4. GraphFunction f;
  5. for (int i = 0; i < points.Length; i++) {
  6. Transform point = points[i];
  7. Vector3 position = point.localPosition;
  8. // if (function == 0) {
  9. // position.y = SineFunction(position.x, t);
  10. // }
  11. // else {
  12. // position.y = MultiSineFunction(position.x, t);
  13. // }
  14. //新增一行代码, 将GraphFunction类型的变量f作为一个方法来调用
  15. position.y = f(position.x, t);
  16. point.localPosition = position;
  17. }
  18. }

接着, 我们在循环代码之前添加一个if-eles语句块, 用来控制为我们的委托类型变量f分配哪个方法的引用(reference) :

  1. GraphFunction f;
  2. //声明GraphFunction委托变量后, 书写下方的if-else代码
  3. if (function == 0) {
  4. //将SineFunction分配给f, 调用f就等于调用SineFuntion
  5. f = SineFunction;
  6. }
  7. else {
  8. //将MultiSineFunction分配给f, 调用f就等于调用MultiSineFunction
  9. f = MultiSineFunction;
  10. }

委托队列

尽管我们将if-else语句移动到了循环之外, 我们想要添加更多函数方法时依然需要进行多个if-else语句来判断需要给f分配哪个方法. 我们想要完全消除if-else语句, 可以使用一个索引数组来代替它. 目前已经有了GraphFunction类型, 于是我们可以在Graph类中增加一个GraphFunction类型的方法数组字段 :

  1. public class Graph : MonoBehaviour
  2. {
  3. GraphFunction[] functions;

我们知道这个数组中都需要包含哪些数组元素, 所以我们可以在声明它的同时对其初始化. 这是通过包裹在一堆大括号中的数组元素队列来完成的. 最简单的队列是空队列 :

  1. //GraphFunction[] functions;
  2. GraphFunction[] functions = {};

这意味着我们立即就取得了数组的实例, 但是它里面没有元素, 是空的数组. 我们修改一下代码, 让数组初始化后包含我们的两个数学函数方法, 第一个是SineFunction, 随后是MultiSineFunction :

  1. //GraphFunction[] functions = {};
  2. GraphFunction[] functions = {
  3. //如果这两个方法不是static静态的, 那么这里就不能直接使用它们两个作为数组元素
  4. //这就是设置它们为static的原因之一
  5. SineFunction, MultiSineFunction
  6. };

又因为这个数组总是相同的, 所以没有必要为每个Graph类的实例单独创建它. 所以我们只需要为Graph类定义它一次, 那么就可以将它像我们的两个函数方法一样变声明为静态的 :

  1. //GraphFunction[] functions = {
  2. static GraphFunction[] functions = {

接下来, 在Update方法中通过function字段作为数组的索引来使用这个数组. 此时, 我们也终于可以摆脱if-else语句块了 :

  1. //GraphFunction f; 声明f时使用委托数组为其赋值, 索引就是function字段, 达到与if-else一样的效果
  2. GraphFunction f = functions[function];
  3. // if (function == 0) {
  4. // f = SineFunction;
  5. // }
  6. // else{
  7. // f = MultiSineFunction;
  8. // }

枚举

整数滑动条可以满足功能需要, 但是使用0代表正弦函数, 1代表复合正弦函数, 并不易于理解代码, 看起来并不直观. 如果我们可以使用一个下拉菜单选择对应的方法名称, 那含义就清晰的多了. 我们可以使用一个枚举(enumeration)来实现这种功能.

枚举可以通过定义一个enum类型来创建. 让我们再新建一个C#脚本资源来包含这个枚举类型, 命名为GraphFuncionName
数学曲面 - 图7
GraphFunctionName脚本资源

打开新的脚本文件, 删除所有默认代码. 定义枚举的语法类似定义类的语法, 只不过将class关键字换成enum关键字 :

  1. public enum GraphFunctionName {}

在枚举名称后方的大括号中, 需要包含被逗号分隔额度枚举标签列表. 我们使用Sine代表我们的正弦函数方法, 使用MultiSine代表复合正弦函数方法 :

  1. public enum GraphFunctionName {
  2. Sine,
  3. MultiSine
  4. }

接着, 修改Graph类中的funcition字段类型, 由int改成GraphFunctionName枚举类型, 并且Rang语句也不需要了 :

  1. // [Range(0, 1)]
  2. // public int function;
  3. public GraphFunctionName function;

枚举类型可以被看做一种语法糖. 默认情况下, 每个枚举标签代表一个整数. 第一个标签代表0, 第二个代表1, 以此类推. 所以我们可以使用枚举字段来作为数组的索引. 然而, 目前为止的代码编译器会报错, 提示我们枚举不能隐式(implicitly)的转换为整数. 所以需要在Unpdate中想作为整数使用枚举时, 明确的对其进行类型转换 :

  1. //GraphFunction f = functions[function];
  2. //function字段前加(int), 显式的将枚举转换为整数
  3. GraphFunction f = functions[(int)function];

我们限制可以通过更改枚举字段的值来切换函数缺陷了, 在Inspector中枚举字段会以下拉选择的形式显式. 这样我们进函数切换操作时, 能够清晰的知道我们在选择哪个函数方法, 因为我们已经让GraphFunctionName枚举的标签与Graph中functions数组的内容正确的对应了起来.
数学曲面 - 图8

数学曲面 - 图9
Inspector中的枚举字段

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

增加一个维度

至此我们已经可以制作传统的曲线图形. 它们将一个维度的值与另一个维度的值按照函数公式进行映射.
不过如果你将时间也考虑在内, 它实际上将两个维度的数值映射到了另一个维度. 也就是我们已经将多个维度的输入另一个维度. 就像我们已经在函数公式中加入了时间维度, 我们还可以在函数中再增加一个空间维度.

现在, 我们的函数方法已经使用X轴作作为输入参数. Y轴作为输出结果. 那么就还剩下一个Z轴可以作为第二个空间维度的输入. 为了让Z轴的输入变得可视化, 我们药更新ColoredPoint这个Shader的代码, 用Z坐标设置蓝色通道(blue color channel)色值. 这可以通过在计算反射色时用rgb和xyz替换代码中的rg和xy来做到 :

  1. //o.Albedo.rg = IN.worldPos.xy * 0.5 + 0.5;
  2. o.Albedo.rgb = IN.worldPos.xyz * 0.5 + 0.5;

调整函数

为了让Z坐标成为函数的输入, 需要在GraphFunction委托的x参数后增加一个z参数, 类型也是float :

  1. //public delegate float GraphFunction (float x, float t);
  2. public delegate float GraphFunction (float x, float z, float t);

同时我们也需要对两个正弦函数方法增加同样类型的z参数, 即便是目前函数方法内还没有代码与它有关 :

  1. //static float SineFunction (float x, float t) {
  2. static float SineFunction (float x, float z, float t) {
  3. return Mathf.Sin(Mathf.PI * (x + t));
  4. }
  5. //static float MultiSineFunction (float x, float t) {
  6. static float MultiSineFunction (float x, float z, float t) {
  7. float y = Mathf.Sin(Mathf.PI * (x + t));
  8. y += Mathf.Sin(2f * Mathf.PI * (x + 2f * t)) / 2f;
  9. y *= 2f / 3f;
  10. return y;
  11. }

接着, 我们要修改Update方法中调用函数方法设置Y坐标的代码, 为被调用的方法提供cube的z坐标作为新增的参数 :

  1. //position.y = f(position.x, t);
  2. position.y = f(position.x, position.z, t);

构成网格图形

为了能体现出Z轴的维度, 我们需要将图形从曲线变成网格(grid). 可以通过创建多条曲线来组成网格, 每条曲线都沿着Z轴发生偏移. 我们将像约束了X轴坐标范围一样约束Z轴的坐标范围, 所以我们需要创建跟现在的cube数量一样多的曲线. 这意味着我们必须将现有的cube数量变为2次方. 调整Awake方法中points数组的初始化语句以便让它的容量增大到能够包含所有的cube :

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

此时, 改变数组长度后, 将导致我们在X轴方向上创建更多的cube, 导致曲线延长. 为了将Z轴也影响曲线图形的生成, 我们需要调整循环语句中的代码.
数学曲面 - 图10
一条加长了的曲线

首先, 我们需要能够明确的在每次循环中知道当前cube的x坐标. 可以在for循环中向使用循环变量i一样去定义和增加这个x变量. 循环定义中的声明部分和增加部分可以使用逗号分隔符来添加更多循环变量, 那么我们就使用这个方法来修改Awake方法中的循环语句代码 :

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

每次我们循环完一行X坐标, 都要讲x变量重置为0. 一行X坐标循环完毕的标志是x变量等于resolution的值. 所以我们可以使用一个if语句放在循环顶部来检查这个条件. 并且我们还需要使用x变量来代替i去计算cube的X坐标 :

  1. for (int i = 0, x = 0; i < points.Length; i++, x++) {
  2. //新增的if判断语句, 如果x与resolution相等, x重置为0
  3. if (x == resolution) {
  4. x = 0;
  5. }
  6. Transform point = Instantiate(pointPrefab);
  7. //position.x = (i + 0.5f) * step - 1f;
  8. //使用x代替i进行cube的横坐标计算
  9. position.x = (x + 0.5f) * step - 1f;
  10. point.localPosition = position;
  11. point.localScale = scale;
  12. point.SetParent(transform, false);
  13. points[i] = point;
  14. }

下一步, 要将每行曲线都沿着Z轴进行位置偏移. 这依然可以通过在for循环语句中增加一个z循环变量来实现. 这个循环变量不需要每一次循环都增加值, 而是要在一行X坐标循环完时增加, 像前面加入x循环变量一样加入z循环变量 :

  1. //for (int i = 0, x = 0; i < points.Length; i++, x++) {
  2. //注意, for后面圆括号中不需要增加z++这句代码
  3. for (int i = 0, x = 0, z = 0; i < points.Length; i++, x++) {
  4. if (x == resolution) {
  5. x = 0;
  6. //每次x需要重置为0时也代表需要在z轴偏移以生成一条平行的曲线
  7. z += 1;
  8. }
  9. Transform point = Instantiate(pointPrefab);
  10. position.x = (x + 0.5f) * step - 1f;
  11. //用z变量计算和设置cube的z轴坐标
  12. position.z = (z + 0.5f) * step - 1f;
  13. point.localPosition = position;
  14. point.localScale = scale;
  15. point.SetParent(transform, false);
  16. points[i] = point;
  17. }

至此, 我们就将原来的一条曲线, 扩充成了一个网格平平面. 因为我们的函数值依然只依赖于X轴, 所以每个点看起来都像是被沿着z轴拉伸成了一条直线.
数学曲面 - 图11
正弦网格

因为现在有了很多cube构成的点, 它们都处于一块比较小的空间, 会发现好像这些点会使用在彼此直接投射出阴影. 场景中的平行光源(directional light)默认被设置在Y轴上进行了负30度旋转, 这样会导致图形上出现很多可见的阴影. 为了更好地观察图形的颜色, 你可以将光源的Y旋转设置为正30度旋转, 以便减少阴影的影响, 或是你可以直接将光源的Shadow Type属性设置为No Shadows来关闭阴影效果.
数学曲面 - 图12
Y旋转30度的光影效果

为什么我运行时出现了掉帧或卡顿?

对比之前制作的函数曲线来说, 现在生成的网格拥有更多的cube代表的点. resolution字段是50时, 网格有2500个点. 如果resolution设置为100, 那么网格就有10000个点. 对于你的电脑来说这个计算量是可以承受的, 但是如果你在运行时同时显示Scene窗口和Game窗口去观察图形, 可能会发现出现了卡顿, 这是因为此时计算机要同时处理这两个窗口中的图形, 计算量被增加了不少, 尤其是你在场景中选择Graph物体时, 此时Unity还需要为图形中的每个cube绘制表示被选中状态的轮廓线

双重循环

虽然现在生成一个网格图形的代码可以正常工作, 不过使用if语句来处理相关逻辑并不够合理. 这种在两个维度进行循环的代码, 可以通过为每个维度都使用一层循环代码来增强可读性, 易于理解代码功能. 要做到这一点, 首先移除已有的for循环和if语句, 将它们替换为在z维度上的循环. 在循环内部, 创建另一个循环来处理x维度. 图形中的每个点都在第二个循环中创建. 最终的代码功能依然是循环计算个点的x坐标, 计算一条曲线的后, 增加在z轴上的偏移, 然后重新计算新一条曲线点的x坐标.
此时, 循环变量i已经不再用来控制循环的结束条件, 但是它依然需要用来索引points数组. 我们要在外层循环定义i, 但是在内层循环增加i, 这样i就会不断地随着循环而加大, 用于在points数组中依次获取每个元素 :

  1. // for (int i = 0, x = 0, z = 0; i < points.Length; i++, x++) {
  2. // if (x == resolution) {
  3. // x = 0;
  4. // z += 1;
  5. // }
  6. //新增的第一层z循环
  7. for (int i = 0, z = 0; z < resolution; z++) {
  8. //新增的第二层x循环
  9. for (int x = 0; x < resolution; x++, i++) {
  10. Transform point = Instantiate(pointPrefab);
  11. position.x = (x + 0.5f) * step - 1f;
  12. position.z = (z + 0.5f) * step - 1f;
  13. point.localPosition = position;
  14. point.localScale = scale;
  15. point.SetParent(transform,false);
  16. points[i] = point;
  17. //要使用内存循环的大括号将之前的代码包裹起来, 不要忘了这个结束的大括号
  18. }
  19. }

此时我们还要注意, z坐标应该随着每次外层循环而改变, 这意味着我们不应该在内层循环去设置它. 我们可以把它放在内层循环之前 :

  1. for (int i = 0, z = 0; z < resolution; z++) {
  2. //在内层循环之前增加设置z坐标的代码, 代替下方在内存循环中删除的代码
  3. position.z = (z + 0.5f) * step - 1f;
  4. for (int x = 0; x < resolution; x++, i++) {
  5. Transform point = Instantiate(pointPrefab);
  6. position.x = (x + 0.5f) * step - 1f;
  7. //删除内层循环设置z坐标的代码
  8. // position.z = (z + 0.5f) * step - 1f;
  9. point.localPosition = position;
  10. point.localScale = scale;
  11. point.SetParent(transform, false);
  12. points[i] = point;
  13. }
  14. }

选择哪个维度作为外层循环有区别吗?

我使用z作为外层循环, x作为内层循环. 因为这样代码逻辑与加入新的循环语句之前相似. 也就是都是在先沿着x轴创建一行图形的点, 然后再沿着z轴偏移一些位置, 生成新的沿着x轴的图像的点.
你可以选择用其他循环方式来实现同样的效果, 也就是x循环在外层, z循环在内层. 这样的话, 网格需要先沿着z轴创建一行图形的点, 然后在x轴上进行偏移后继续生成新点.
这两种循环方式的区别只是图形中点创建的顺序不同, 其他都是一样的.

利用Z维度改变函数结果

现在我们已经生成了一个由点构成的2D网格, 接下来要利用好新增的Z维度.
在那之前, 优化一下我们的代码. 定义一个常量π, 所以这样我们就不需要总是使用Mathf.PI去获取π的值了. 我们已经写过很多类似的代码了, 现在这对我们很容易做到 :

  1. //在Graph中定义一个常量pi, 存储Mathf.PI的值
  2. const float pi = Mathf.PI;
  3. static float SineFunction (float x, float z, float t) {
  4. //在SineFunction方法中使用pi代替Mathf.PI
  5. return Mathf.Sin(pi * (x + t));
  6. }
  7. static float MultiSineFunction (float x, float z, float t) {
  8. //在MultiSineFunction方法中使用pi代替Mathf.PI
  9. float y = Mathf.Sin(pi * (x + t));
  10. y += Mathf.Sin(2f * pi * (x + 2f * t)) / 2f;
  11. y *= 2f / 3f;
  12. return y;
  13. }

接下来, 已有的两个函数方法不需要进行更多修改, 取而代之的是创建一个使用x和z作为参数的新函数. 新写一个叫做Sine2DFunction的方法. 它将代表函数数学曲面 - 图13, 这是让正弦曲线随着x和z进行变化的最简单办法 :

  1. static float Sine2DFunction (float x, float z, float t) {
  2. return Mathf.Sin(pi * (x + z + t));
  3. }

将这个方法名称添加到functions委托数组中, 写在SineFunction后面 :

  1. static GraphFunction[] functions = {
  2. //SineFunction, MultiSineFunction
  3. //注意, 新的函数名称插在之前的两个值中间了, 不是写在最后或最前
  4. SineFunction, Sine2DFunction, MultiSineFunction
  5. };

同时也需要在枚举GraphFunctionName中添加对应它的枚举标签, 命名为Sine2D :

  1. public enum GraphFunctionName {
  2. //Sine, MultiSine
  3. //注意, 新的标签插在之前的两个值中间了, 枚举的顺序应该与functions数组的顺序对应
  4. Sine, Sine2D, MultiSine
  5. }

CourageousIncompatibleAplomadofalcon-mobile.mp4 (437.83KB)不断变化的正弦网格形状

当在运行过程中切换为Sine2DFunction函数方法时, 你会看到在XZ平面中沿着平面对角线方向变化的正弦波动(在场景窗口中调整不同观察角度会看的更明显), 而不是像之前一样沿着X轴方向变化, 这是因为我们在函数中使用x+z代替了x去计算函数结果

还有一个更有趣的做法, 那就是将两个维度中独立的正弦波动合并为一个. 将它们合并在一起后, 还需要将函数结果减半, 以便函数结果依然被限制在-1到1之间. 顺着这个思路, 我们就想出了应该使用这样的函数 : 数学曲面 - 图15. 为了让代码更容易理解, 我们使用三行代码来赋值一个新增的变量y从而完成该函数的计算过程 :

  1. static float Sine2DFunction (float x, float z, float t) {
  2. //return Mathf.Sin(pi * (x + z + t));
  3. //声明变量y, 使其等于x轴上的正弦函数
  4. float y = Mathf.Sin(pi * (x + t));
  5. //将z轴上的正弦函数与y相加, 合并x和z上的正弦函数
  6. y += Mathf.Sin(pi * (z + t));
  7. //合并后的函数结果乘以0.5, 使得最后的结果处于-1到1之间
  8. y *= 0.5f;
  9. return y;
  10. }

DeliriousDefiniteBunting-mobile.mp4 (287.72KB)两个维度的正弦函数共同作用的效果

为什么对函数结果减半的计算使用*=0.5f而不是/=2f?

这两种方法在数学计算的逻辑上是一样的, 但是在程序逻辑中使用乘法计算回避除法计算要更快. 假设你的循环中有大量的数学计算, 这是一种简单的优化程序效率的手段. 对于本教程来说选择乘法并不是必须的, 但是这是一种好的编程习惯. 如果你想使用除法, 可以自己更改对应的代码, 依然可以实现相同的图形效果.

让我们接着再创建一个在两个维度中进行变化的复合正弦函数. 我们将使用一个主要的曲线与两个次要的曲线进行结合, 三条曲线分别来自三个维度, 我们要使用的函数是 : 数学曲面 - 图17, 公式中M代表的是主要的正弦波动, Sx代表基于X轴的正弦曲线, Sz代表的是基于Z轴的针线曲线.

我们让数学曲面 - 图18, M所代表的正弦曲线将会在对角线方向上进行波动. 接着让数学曲面 - 图19, 也就是它代表一个X轴方向上的普通正弦波动; 让数学曲面 - 图20, 它代表的是Z轴方向上的频率加倍的正弦波动.

我们对函数做一些调整, 让M的波动大一些, 使用4倍于Sx的振幅. 让Sz的振幅缩小为原来的一半. 因此需要将函数公式调整为数学曲面 - 图21, 并且在代码中我们需要将最终的函数结果除以5.5, 使得结果依然被限制在-1到1之间的范围内, 让我们按照上面的设计, 为该函数创建一个名为MultiSine2DFunction的方法 :

  1. static float MultiSine2DFunction (float x, float z, float t) {
  2. float y = 4f * Mathf.Sin(pi * (x + z + t * 0.5f));
  3. y += Mathf.Sin(pi * (x + t));
  4. y += Mathf.Sin(2f * pi * (z + 2f * t)) * 0.5f;
  5. y *= 1f / 5.5f;
  6. return y;
  7. }

在functions数组中加入该方法名 :

  1. static GraphFunction[] functions = {
  2. //在数组的末尾添加了MultiSine2DFunction
  3. SineFunction, Sine2DFunction, MultiSineFunction, MultiSine2DFunction
  4. };

在GraphFunctionName枚举中加入一个新的标签MultiSine2D代表该方法 :

  1. public enum GraphFunctionName {
  2. //在枚举的末尾加入MultiSine2D
  3. Sine, Sine2D, MultiSine, MultiSine2D
  4. }

WigglyAstonishingAltiplanochinchillamouse-mobile.mp4 (857.2KB)二维中的复合正弦函数, 三个波动组合在一起的效果

产生涟漪

我们还要再制作一个二维函数, 我们要使用这个函数, 让图形中产生涟漪一样的动画效果. 我们要让涟漪向所有维度分散传播, 所以这大概是一个圆形的图案. 想要做到这一点, 我们需要创建一个基于原点距离而变化的正弦波动. 这个距离可以使用勾股定理(原文为 毕达哥拉斯定理)来计算, 也就是公式 数学曲面 - 图23, 其中c代表直角三角形的斜边, a和b代表直角三角形的两条直角边.

对于XZ平面中的一个点来说, 上述c所代表的斜边长度就对应该点到原点的距离, 而另外两条直角边长度就对应它的x坐标和z坐标. 因此, 可以得出计算每一个点到原点的距离是数学曲面 - 图24
数学曲面 - 图25
勾股定理计算点到原点的距离

新增一个名为Ripple的函数方法, 使用它来计算这个距离, 在这个方法中我们会用到Mathf.Sqrt来计算平方根, 然后我们先直接输出这个距离结果 :

  1. static float Ripple (float x, float z, float t) {
  2. float d = Mathf.Sqrt(x * x + z * z);
  3. float y = d;
  4. return y;
  5. }

将这个方法添加到functions数组 :

  1. static GraphFunction[] functions = {
  2. //在数组末尾增加Ripple, 此处为了方便显示将其换行输入了, 代码中换不换行均可
  3. SineFunction, Sine2DFunction, MultiSineFunction, MultiSine2DFunction,
  4. Ripple
  5. };

在GraphFunctionName枚举中添加代表该方法的枚举标签 :

  1. public enum GraphFunctionName {
  2. //在末尾增加枚举标签Ripple
  3. Sine, Sine2D, MultiSine, MultiSine2D,Ripple
  4. }

数学曲面 - 图26
运行程序后会切换到Ripple函数图形, 会看到上图所示形状

我们获得的是一个以原点为顶点的类似椎体形状的网格, 并在网格的四个边角具有最大的高度, 因为这些部分的点距离原点最远. 准确的说, 四个边角处距离原点的距离是数学曲面 - 图27, 约等于1.4124

为了创建出涟漪图形, 我们需要使用函数数学曲面 - 图28, 此处的D代表点到原点距离, 根据这个函数, 修改我们的代码 :

  1. float d = Mathf.Sqrt(x * x + z * z);
  2. //float y = d;
  3. float y = Mathf.Sin(pi * d);
  4. return y;

数学曲面 - 图29
使用正弦函数变化后的图形

但是只有一次波动并不足够, 让我们将波动频率增加4倍 :

  1. //float y = Mathf.Sin(pi * d);
  2. float y = Mathf.Sin(4f * pi * d);

数学曲面 - 图30
4倍频率的正弦波动

现在图形更像是一个涟漪了, 不过现在它波动的有点过于剧烈, 所以我们需要降低它波动的幅度. 我们可以根据每个点的距离决定它要削减多少幅度, 而不是所有点都降低统一的幅度. 比如, 我们可以使用数学曲面 - 图31作为振幅. 这会导致涟漪的幅度由中心向四周减弱, 这样会更接近现实情况中水滴的涟漪效果. 然而, 简单的按照距离来变化幅度, 会导致原点位置的点出现除零错误, 并且距离原点距离小于0.1的点也会因该公式的计算而获得更大的振幅.

我们可以为上述公式的分母增加1来避免出现这些问题, 也就是使用公式数学曲面 - 图32代替它 :

  1. float y = Mathf.Sin(4f * pi * d);
  2. //最终的函数结果按照上文公式进行处理
  3. y /= 1f + 10f * d;
  4. return y;

数学曲面 - 图33
根据距离缩放振幅后的图形

最终, 我们向方法的正弦函数中加入时间参数的影响从而产生图形动画. 因为涟漪应该向外波动运动, 所以我们减去时间参数t而不是加上它 :

  1. //float y = Mathf.Sin(pi * (4f * d));
  2. //注意公式中的括号关系 不要搞错
  3. float y = Mathf.Sin(pi * (4f * d - t));

KindlyPersonalHarrierhawk-mobile.mp4 (152.04KB)涟漪动画效果

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

立体图形

通过使用X和Z去计算Y, 我们制作了可以描述各种不同表面的函数, 但是它们的函数值总是与XZ平面的点相对应. 没有两点可以具有相同的X和Z坐标同时又具有不同的Y坐标. 这意味着图形表面的曲率(curvature)是有限制的. 图形不能出现大于直角的表面弯曲. 为了打破这个限制, 就需要函数方法不止返回Y坐标, 还要返回X和Z坐标.

三维函数

如果函数可以返回三维位置值而不是只有一个维度的位置值, 我们就可以用这个函数创造任意形状的表面. 比如函数数学曲面 - 图35, 它描述的是XZ平面, 而数学曲面 - 图36函数描述的则是XY平面.

像上述的这种函数, 其输入参数与最终函数计算后的X和Z坐标并不对应, 所以我们也不再将参数命名为x和z, 替代方案是, 将它们命名为u和v. 所以我们就可以将函数写成类似这样的形式 : 数学曲面 - 图37

调整我们的GraphFunction委托让它支持这种新的方法, 需要修改的部分是将它的返回类型从float改成Vector3, 以及修改每一个参数的名称 :

  1. //public delegate float GraphFunction(float x, float z, float t);
  2. public delegate Vector3 GraphFunction (float u, float v, float t);

我们最初的正弦函数方法SineFunction现在需要需要使用函数数学曲面 - 图38. 但是因为我们没有调整x和z所以不需要修改方法的参数名. 同时还要让该方法可以返回一个向量值, 这个向量直接使用传入的x和z坐标, 并使用计算出来的y坐标 :

  1. //static float SineFunction (float x, float z, float t) {
  2. //修改函数返回类型为Vector3
  3. static Vector3 SineFunction (float x, float z, float t) {
  4. // return Mathf.Sin(pi * (x + t));
  5. // 定义Vector3变量P, 用来计算方法返回值
  6. Vector3 p;
  7. p.x = x;
  8. p.y = Mathf.Sin(pi * (x + t));
  9. p.z = z;
  10. return p;
  11. }

同时对Sine2DFunction做出类似修改 :

  1. //static float Sine2DFunction (float x, float z, float t) {
  2. static Vector3 Sine2DFunction (float x, float z, float t) {
  3. // float y = Mathf.Sin(pi * (x + t));
  4. // y += Mathf.Sin(pi * (z + t));
  5. // y *= 0.5f;
  6. // return y;
  7. Vector3 p;
  8. p.x = x;
  9. p.y = Mathf.Sin(pi * (x + t));
  10. p.y += Mathf.Sin(pi * (z + t));
  11. p.y *= 0.5f;
  12. p.z = z;
  13. return p;
  14. }

同理, 调整其他三个函数方法 :

  1. //static float MultiSineFunction (float x, float z, float t) {
  2. //修改返回值类型, 增加变量p, 使用x,z参数赋值p的x和z坐标, 使用原函数返回值赋值p的y坐标
  3. static Vector3 MultiSineFunction (float x, float z, float t) {
  4. Vector3 p;
  5. p.x = x;
  6. p.y = Mathf.Sin(pi * (x + t));
  7. p.y += Mathf.Sin(2f * pi * (x + 2f * t)) / 2f;
  8. p.y *= 2f / 3f;
  9. p.z = z;
  10. return p;
  11. }
  12. //static float MultiSine2DFunction (float x, float z, float t) {
  13. //修改返回值类型, 增加变量p, 使用x,z参数赋值p的x和z坐标, 使用原函数返回值赋值p的y坐标
  14. static Vector3 MultiSine2DFunction (float x, float z, float t) {
  15. Vector3 p;
  16. p.x = x;
  17. p.y = 4f * Mathf.Sin(pi * (x + z + t / 2f));
  18. p.y += Mathf.Sin(pi * (x + t));
  19. p.y += Mathf.Sin(2f * pi * (z + 2f * t)) * 0.5f;
  20. p.y *= 1f / 5.5f;
  21. p.z = z;
  22. return p;
  23. }
  24. //static float Ripple (float x, float z, float t) {
  25. //修改返回值类型, 增加变量p, 使用x,z参数赋值p的x和z坐标, 使用原函数返回值赋值p的y坐标
  26. static Vector3 Ripple (float x, float z, float t) {
  27. Vector3 p;
  28. float d = Mathf.Sqrt(x * x + z * z);
  29. p.x = x;
  30. p.y = Mathf.Sin(pi * (4f * d - t));
  31. p.y /= 1f + 10f * d;
  32. p.z = z;
  33. return p;
  34. }

函数方法修改后, 将返回三维位置坐标而不是像修改前返回单一的y坐标, 因此Update方法中我们就不应该继续使用固定的x和z坐标去设置图形中点的坐标. 取而代之的是, 我们应该按照点的u和v参数来计算图形中点的位置, 我们将使用两层循环来取得每个点的u和v参数. 同时由于函数方法现在返回的是一个Vector3类型值, 就可以直接将其赋值给点的position属性, 不需要使用中间变量进行传递 :
(如果对于u和v的含义有困惑, 你可以把它俩理解成序号, 代表的是点, 在平面上, 位于哪行哪列, 图形移动后, 点的x和z坐标会变化, 但是行号和列号不会变化)

  1. void Update () {
  2. float t = Time.time;
  3. GraphFunction f = functions[(int)function];
  4. // for (int i = 0; i < points.Length; i++) {
  5. // Transform point = points[i];
  6. // Vector3 position = point.localPosition;
  7. // position.y = f(position.x, position.z, t);
  8. // point.localPosition = position;
  9. // }
  10. float step = 2f / resolution;
  11. for (int i = 0, z = 0; z < resolution; z++) {
  12. float v = (z + 0.5f) * step - 1f;
  13. for (int x = 0; x < resolution; x++, i++) {
  14. float u = (x + 0.5f) * step - 1f;
  15. points[i].localPosition = f(u, v, t);
  16. }
  17. }
  18. }

因为新的方式不再依赖原点坐标, 我们也就不再需要在Awake方法中初始化它们了, 可以简化Awake方法的代码, 使用一层循环初始化所有的点并且不需要改变它们的初始坐标 :

  1. void Awake () {
  2. float step = 2f / resolution;
  3. Vector3 scale = Vector3.one * step;
  4. // Vector3 position;
  5. // position.y = 0f;
  6. // position.z = 0f;
  7. points = new Transform[resolution * resolution];
  8. // for (int i = 0, z = 0; z < resolution; z++) {
  9. // position.z = (z + 0.5f) * step - 1f;
  10. // for (int x = 0; x < resolution; x++, i++) {
  11. // Transform point = Instantiate(pointPrefab);
  12. // position.x = (x + 0.5f) * step - 1f;
  13. // point.localPosition = position;
  14. // point.localScale = scale;
  15. // point.SetParent(transform, false);
  16. // points[i] = point;
  17. // }
  18. // }
  19. for (int i = 0; i < points.Length; i++) {
  20. Transform point = Instantiate(pointPrefab);
  21. point.localScale = scale;
  22. point.SetParent(transform, false);
  23. points[i] = point;
  24. }
  25. }

生成圆柱

为了演示证明这种函数图形的曲率不再受到限制, 我们可以写一个新的函数方法, 用它生成一个圆柱体. 新增名为Cylinder的函数方法, 一开始我们让它总是返回原点位置坐标.

同时将这个方法名称加入functions数组, 并在GraphFunctionName枚举中新增一个代表该方法的标签名称, 由于我们已经重复过很多次这两处的操作, 这里就不再赘述相关代码, 只描述新方法的代码 :

  1. static Vector3 Cylinder (float u, float v, float t) {
  2. Vector3 p;
  3. p.x = 0f;
  4. p.y = 0f;
  5. p.z = 0f;
  6. return p;
  7. }

圆柱体可以看成一个被拉伸了的圆形, 所以我们首先生成一个圆形. 在上一篇教程中我们提到过, 圆形上所有的点可以通过数学曲面 - 图39的形式进行位置的定义, 随着θ的角度从0变化到2π, 就能得到圆上所有点的坐标. 我们可以使用参数u来代替θ, u取值范围在-1到1之间. 为了在XZ平面生成这个圆, 我们需要使用函数数学曲面 - 图40, 修改Cylinder方法代码如下 :

  1. static Vector3 Cylinder (float u, float v, float t) {
  2. Vector3 p;
  3. p.x = Mathf.Sin(pi * u);
  4. p.y = 0f;
  5. p.z = Mathf.Cos(pi * u);
  6. return p;
  7. }

数学曲面 - 图41
现在Cylinder函数运行后会生成一个圆

由于计算过程中没有用到v, 所以目前所有v参数相同的点都处于同一位置. 所以实际上我们的图像被简化成了一条曲线. 让我们将y坐标设置为u, 看看这条线是如何环绕成一个圆形的 :

  1. p.x = Mathf.Sin(pi * u);
  2. //设置y坐标等于参数u
  3. p.y = u;
  4. p.z = Mathf.Cos(pi * u);

数学曲面 - 图42
沿着圆形而变化的y坐标

我们可以看到, 这条线的起点是数学曲面 - 图43并顺时针围绕原点进行弯曲, 与函数的输入一致. 为了生存一个真正的圆柱体, 我们需要将y坐标设置为参数v, 这样就可以沿着y轴方向将很多圆形堆叠在一起而构成圆柱体 :

  1. p.x = Mathf.Sin(pi * u);
  2. //将y坐标的值由u改成v
  3. p.y = v;
  4. p.z = Mathf.Cos(pi * u);

数学曲面 - 图44
生成的圆柱体

这样我们就将多个圆形组合成了一个基本的圆柱体, 我们还可以做出更多花样.

圆形的半径可以通过缩放正弦和余弦的幅度来调整. 对应这个规则的函数就是数学曲面 - 图45其中R代表圆的半径. 让我们根据这个函数修改Cylinder方法的代码 :

  1. //新增代表半径的变量r
  2. float r = 1f;
  3. //x坐标的赋值在之前基础上乘以变量r
  4. p.x = r * Mathf.Sin(pi * u);
  5. p.y = v;
  6. //z坐标的赋值在之前基础上乘以变量r
  7. p.z = r * Mathf.Cos(pi * u);

如果我们使用不同的振幅会发生什么?

当你将正弦和余弦的振幅变得不同时, 你会得到一个椭圆

圆的半径也可以设置为其他值, 甚至不需要始终不变. 比如, 我们可以根据u来变化半径r. 这样我们就可以得到另一种正弦波动的图形, 比如我们可以设置数学曲面 - 图46 :

  1. //float r = 1f;
  2. float r = 1f + Mathf.Sin(6f * pi * u) * 0.2f;

数学曲面 - 图47
抽风的圆柱体, resolution设置的是100

以上代码会让生成的圆柱体产生晃动状的变形. 圆形变成了像是六角星一样的形状. 图形表面沿着圆形上上下下的波动了六次.

我们也可以让半径与v相关, 比如数学曲面 - 图48. 这种情况下, 每个圆不会发生变形, 但是沿着圆柱的高度方向, 每个圆的半径会起起伏伏的变化 :

  1. //注意, 代码改动了两处, 一是参数u改成参数v, 二是6f*pi变成了2f*pi
  2. float r = 1f + Mathf.Sin(2f * pi * v) * 0.2f;

数学曲面 - 图49
使用参数v代替参数u后的图形

甚至我们还可以使用u和v共同作用, 创造沿着对角线方向的波动, 这将扭曲圆柱体. 同时也把时间参数t加到函数计算过程中, 产生图形动画. 最后, 为了圆形的最大半径不超过1, 将增加的1f改成0.8f :

  1. //float r = 1f + Mathf.Sin(2f * pi * v) * 0.2f;
  2. float r = 0.8f + Mathf.Sin(pi * (6f * u + 2f * v + t)) * 0.2f;

ArtisticShimmeringArchaeopteryx-mobile.mp4 (448.66KB)扭曲的圆柱体

生成球面

让我们再生成一个球面, 新建名为Sphere的方法, 我们可以圆柱体两侧的圆半径逐渐减少到0从而获得一个球面, 使用半径计算公式数学曲面 - 图51 :
(不要忘了将这个方法名称加入functions数组, 并在GraphFunctionName枚举增加代表该方法的标签名称)

  1. static Vector3 Sphere (float u, float v, float t) {
  2. Vector3 p;
  3. float r = Mathf.Cos(pi * 0.5f * v);
  4. p.x = r * Mathf.Sin(pi * u);
  5. p.y = v;
  6. p.z = r * Mathf.Cos(pi * u);
  7. return p;
  8. }

数学曲面 - 图52
上面代码运行效果, 几乎是个球了

这个球还并不完美, 圆柱体半径的波动变化还不是一个圆形, 这是因为因为圆需要由正弦和余弦共同构成, 我们却只使用了余弦. 所以我们要修改y坐标的赋值公式, 将y坐标赋值为数学曲面 - 图53 :

  1. p.y = Mathf.Sin(pi * 0.5f * v);

数学曲面 - 图54
一个球出现了

终于我们得到了一个球形, 而这也就是所谓的UV球面(UV-sphere). 此时球面上点的分布并不均匀, 因为这个球是通过不同半径的圆叠加而成, 在球面的两端点处的圆半径为0.

为了能够控制球面的半径, 我们需要调整函数公式, 我们将使用函数数学曲面 - 图55, 其中数学曲面 - 图56, R代表半径.

通过改变R的值可以让球面的半径发生动态变化. 让我们分别使用u和v的正弦曲线设置半径数学曲面 - 图57 :

  1. ////float r = Mathf.Cos(pi * 0.5f * v);
  2. float r = 0.8f + Mathf.Sin(pi * (6f * u + t)) * 0.1f;
  3. r += Mathf.Sin(pi * (4f * v + t)) * 0.1f;
  4. float s = r * Mathf.Cos(pi * 0.5f * v);
  5. //p.x = r * Mathf.Sin(pi * u);
  6. p.x = s * Mathf.Sin(pi * u);
  7. //p.y = Mathf.Sin(pi * v *0.5f);
  8. p.y = r * Mathf.Sin(pi * 0.5f * v);
  9. //p.z = r * Mathf.Cos(pi * u);
  10. p.z = s * Mathf.Cos(pi * u);

WaryUnrulyBrocketdeer-mobile.mp4 (312.23KB)脱离了低级趣味的球

生成环面

教程的最后我们来生成一下环面. 复制Sphere方法, 重命名位Torus(不要忘了将这个方法名称加入functions数组, 并在GraphFunctionName枚举增加代表该方法的标签名称), 然后删除设置半径r的代码 :

  1. static Vector3 Torus (float u, float v, float t) {
  2. Vector3 p;
  3. float s = Mathf.Cos(pi * 0.5f * v);
  4. p.x = s * Mathf.Sin(pi * u);
  5. p.y = Mathf.Sin(pi * 0.5f * v);
  6. p.z = s * Mathf.Cos(pi * u);
  7. return p;
  8. }

我们将在XZ平面的方向上, 把球面向外拉开来获得环面. 我们可以向增加一个常量来做到这一点, 比如增加0.5 :

  1. //末尾处增加0.5f
  2. float s = Mathf.Cos(pi * 0.5f * v) + 0.5f;

数学曲面 - 图59
球被拉开了个口子

这只能说是半成品环面. 要的到完整的环面, 我们需要将代码中的数学曲面 - 图60都替换为πv :

  1. //float s = Mathf.Cos(pi * 0.5f * v) + 0.5f;
  2. float s = Mathf.Cos(pi * v) + 0.5f;
  3. p.x = s * Mathf.Sin(pi * u);
  4. //p.y = Mathf.Sin(pi * 0.5f * v);
  5. p.y = Mathf.Sin(pi * v);
  6. p.z = s * Mathf.Cos(pi * u);

数学曲面 - 图61
一个挺紧巴的环面出现了

因为我们只把球拉开了0.5个单位远. 所以这个环面看起来很紧巴. 这是一种自交形状(self-intersecting shape), 目前生成的这个形状也被叫做纺锤环面(spindle torus).

我们可以将球拉开1个单位远, 这样就会得到一个没有发生自交的环面, 不过这个环面中间依然没有空洞, 这样的环面也被称之为角环面. 我们也感觉到了, 球形被拉开多远直接影响环面的样子. 准确额说, 这个拉开的距离, 定义了环面的整体半径, 我们将其称之为R1. 那么我们的函数就可以写成数学曲面 - 图62, 其中数学曲面 - 图63:

  1. //新增变量r1代表环形的整体半径
  2. float r1 = 1f;
  3. //float s = Mathf.Cos(pi * v) + 0.5f;
  4. float s = Mathf.Cos(pi * v) + r1;

数学曲面 - 图64
角环面

R1大于1时, 将会在环面环绕的中心区域出现洞, 此时这种环面就叫做环状环面. 此时, 环绕着环面中心的圆形管道的半径记作R2, 我们同样可以修改生成环形的R2的值, 只需要使用函数数学曲面 - 图65, 其中数学曲面 - 图66.
让我们在R1为1的情况下将R2设置为0.5 :

  1. float r1 = 1f;
  2. //新增变量r2
  3. float r2 = 0.5f;
  4. //float s = Mathf.Cos(pi * v) + r1;
  5. float s = r2 * Mathf.Cos(pi * v) + r1;
  6. p.x = s * Mathf.Sin(pi * u);
  7. //p.y = Mathf.Sin(pi * v);
  8. p.y = r2 * Mathf.Sin(pi * v);
  9. p.z = s * Mathf.Cos(pi * u);

数学曲面 - 图67
环状环面

现在, 我们有了两个半径可以设置从而改变环形. 一个不难但是很有趣的做法是用u输入的波动影响R1, 用v输入的波动影响R2, 让这两个半径同时动态变化, 同时也确保了环形的尺寸在-1到1之间 :

  1. //float r1 = 1f;
  2. float r1 = 0.65f + Mathf.Sin(pi * (6f * u + t)) * 0.1f;
  3. //float r2 = 0.5f;
  4. float r2 = 0.2f + Mathf.Sin(pi * (4f * v + t)) * 0.05f;


ImprobableGleefulDungbeetle-mobile.mp4 (175.32KB)动态的环形…无法形容的有趣

至此, 你已经有了一些使用各种函数描述3D表面的宝贵经验, 包括如何用Unity将它们可视化. 你可以用你自己设计的函数进行更多的试验, 以便于更好的理解这其中的原理. 这些看起来复杂的图形其实都是通过组合简单的正弦曲线是新的. 完成本教程后, 你可以前往下一个教程 : 构造分形

教程源码
教程PDF