使用 Unity 实现漂亮的数学曲面(上)

这篇博客是上一篇 使用Unity3D展示Sin函数动画 的续篇,在上一篇的基础上,来实现更复杂的效果。在文章最后会有完整的 C# 代码和 Shader 代码,先来看一下最终的效果图

由于篇幅太长,所以将分为上下两部分,现在开始第一部分的内容。


先将上一节 使用Unity3D展示Sin函数动画 的资源准备好,可以按下面的步骤手动建立,也可以直接导入上一节内容的完整 Unity 资源包,点这里下载

  1. 使用Unity的Cube做成一个Prefab
  2. 新建一个 Shader,命名为 ColoredPoint,Shader 的代码为上一篇博客的 Shader
  3. 新建一个材质,命名为 ColoredPoint,并使用第2步中创建的Shader
  4. C# 逻辑代码,也使用上一篇博客中的完整代码,在本文中会有很多修改

接下来,我们开始新的内容。

1 在不同的效果函数之间切换

在上一篇博客中我们实现了Sine函数的展示,现在要加入更多函数的展示,为了方便在运行状态可以随时切换到其他函数,我们需要把每一种类型的展示放在独立的函数中。

1.1 将 Sine 函数的表示放在独立函数中

首先在 Graph 脚本中添加一个新的函数 float SineFunction(float x, float t) {},这个函数将用于展示 $f(x,t) = sin(π(x + t))$。然后我们需要将函数体的内容填写进去,代码如下

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

然后把 Update 函数里的代码改为调用我们新添加的函数

1
2
3
4
5
6
7
8
9
void Update () {
for (int i = 0; i < points.Length; i++) {
Transform point = points[i];
Vector3 position = point.localPosition;
//position.y = Mathf.Sin(Mathf.PI * (position.x + Time.time));
position.y = SineFunction(position.x, Time.time);
point.localPosition = position;
}
}

注意 Time.time 在每一次循环中是一样的,所以我们这把个抽理出来。

1
2
3
4
5
6
7
8
9
void Update () {
float t = Time.time;
for (int i = 0; i < points.Length; i++) {
Transform point = points[i];
Vector3 position = point.localPosition;
position.y = SineFunction(position.x, t);
point.localPosition = position;
}
}

1.2 添加第二个展示函数

现在我们来添加第二个展示函数 MultiSineFunction,稍微复杂那么一点点。

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

上面代码中表示的数学公式是

$$f(x,t) = (sin(π(x + t)) + \frac{sin(2π(x + t))}{2}) \frac{2}{3}$$

和代码一起看,就很容易看懂,首先是一个随着时间变化的 sin,然后结果加上一个2倍的 sin。除以2是因为太快了,我们减少一半。最后一句之所有乘 2/3 是因为前两句代码算出来的 y 的结果最大值最小值是 [-1.5, 1.5],我们要将 y 限制在 [-1, 1] 之间,所以才乘上 2/3,和除以 1.5 结果是一样的。

接下来看Update中的调用函数改为调用 MultiSineFunction,运行后,结果如下

上面的代码中,运行结果可以看到一个小的 Sine 函数跟随一个大的 Sine 函数。

下面我们再修改一下代码,让小的 Sine 函数沿着较大的 Sine 函数路径运行。

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

下面是运行结果

1.3 在编辑中选择函数

来回改代码切换表现函数不太好,所以我们添加一个变量,来控制当前使用哪一个表现函数。

1
2
[Range(0, 1)]
public int function;

添加上面的代码后,就可以在编辑器看到变量

p002301_function-slider

修改 Update 函数,使用 function 变量决定调用哪个表现函数

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

现在就可以在运行模式下,通过改变 function 的值,查看不同的函数展示效果了。

1.4 静态函数

虽然 SineFunctionMultiSineFunction 都是 Graph 的一部分,但其实这两个函数并不依赖于 Graph,所以可以把这两个函数做成静态的,或者放到其他文件中,都是可以的,而 Graph 只是这两个函数。

1
2
3
4
5
6
7
8
9
10
11
12
public static float SineFunction(float x, float t)
{
return Mathf.Sin(Mathf.PI * (x + t));
}

public static float MultiSineFunction(float x, float t)
{
float y = Mathf.Sin(Mathf.PI * (x + t));
y += Mathf.Sin(2f * Mathf.PI * (x + 2f * t)) / 2f;
y *= 2f / 3f;
return y;
}

改成 static 后,就可以在任何一个文件直接使用 Graph.SineFunction() 这样的形式去调用函数,就像 Mathf.Sin() 函数一样。

1.5 Delegates

使用 if-else 来控制操作哪个函数不是很好,特别是函数更多的情况下,所以我们优化一下代码,使用C#的委托来做这件事情。

首先我们来创建一个新的脚本,名为 GraphFunction,在里面定义委托类型,完整代码如下

p002302_graph-function

1
2
3
using UnityEngine;

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

将新建的脚本里原来自动生成的代码全部删除掉,只保留上面两行就行。委托其实就像是一个函数指针,可以指向某种类型的函数。

接下来我们修改 Update 函数,进行第一次优化,修改后的代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
void Update()
{
float t = Time.time;
GraphFunction f;

if(function == 0)
{
f = SineFunction;
}
else
{
f = MultiSineFunction;
}
for (int i = 0; i < points.Length; i++)
{
Transform point = points[i];
Vector3 position = point.localPosition;
position.y = f(position.x, t);
point.localPosition = position;
}
}

上面的代码把 if-else 拿到了 for 循环的外面,通过 function 变量来设置当前指向哪个函数。看起来并没有解决本质问题,if-else 依然存在。不要着急,接着往下看。

1.6 使用 Delegate 数组

现在我们就要把 if-else 从代码中去掉。而方法是定义一个 Delegate 数组,用于存放所有的展示函数,然后使用 function 变量值作为所引,访问指定的函数。

第一步,先在 Graph 中添加下面的代码,将函数存在 Delegate 数组中。

1
2
3
4
static GraphFunction[] functions =
{
SineFunction, MultiSineFunction
};

因为这个也和 Graph 脚本关系不大,没什么依赖,所以我们也写成 static 静态的形式。

接着,再次修改 Update 函数如下,去掉了 if-else 代码,直接使用 function 作为所引。

1
2
3
4
5
6
7
8
9
10
11
12
13
void Update()
{
float t = Time.time;
GraphFunction f = functions[function];

for (int i = 0; i < points.Length; i++)
{
Transform point = points[i];
Vector3 position = point.localPosition;
position.y = f(position.x, t);
point.localPosition = position;
}
}

但这样还会有问题,那就是很不直观,谁知道 function 变量 0 是代表哪个函数,1 又是代表哪个函数,所以接下来我们就要解决这个问题。

1.7 使用枚举

定义一个枚举,枚举的每一个值,与 Delegate 数组中的函数一一对应,这样就可以很清晰地知道当前选择的是什么函数。

我们创建一个新的脚本文件,名为 GraphFunctionName,然后删除自动生成的代码,将下面的代码复制进去

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

接下来,我们修改 function 变量的类型,不在使用 int 型,改为使用 GraphFunctionName 枚举类型

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

然后在 Update 中就可以使用枚举强制转成 int 作为索引,去访问函数,修改后的 Update 代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
void Update()
{
float t = Time.time;
GraphFunction f = functions[(int)function];

for (int i = 0; i < points.Length; i++)
{
Transform point = points[i];
Vector3 position = point.localPosition;
position.y = f(position.x, t);
point.localPosition = position;
}
}

现在就可以在编辑器中通过选择枚举来查看指定的数学函数展示了

p002303_function-dropdown-list
p002304_function-dropdown-list-expanded

运行一下看效果,到此为止还不错,接下来我们继续(哭笑表情)。在继续之前,先把目前的完整 Graph 代码贴出来

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
using UnityEngine;

public class Graph : MonoBehaviour
{
public Transform pointPrefab;

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

public GraphFunctionName function;

Transform[] points;

static GraphFunction[] functions =
{
SineFunction, MultiSineFunction
};

void Awake()
{
float step = 2f / resolution;
Vector3 scale = Vector3.one * step;
Vector3 position;
position.y = 0f;
position.z = 0f;
points = new Transform[resolution];
for (int i = 0; i < points.Length; i++)
{
Transform point = Instantiate(pointPrefab);
position.x = (i + 0.5f) * step - 1f;
point.localPosition = position;
point.localScale = scale;
point.SetParent(transform, false);
points[i] = point;
}
}

void Update()
{
float t = Time.time;
GraphFunction f = functions[(int)function];

for (int i = 0; i < points.Length; i++)
{
Transform point = points[i];
Vector3 position = point.localPosition;
position.y = f(position.x, t);
point.localPosition = position;
}
}

public static float SineFunction(float x, float t)
{
return Mathf.Sin(Mathf.PI * (x + t));
}

public static float MultiSineFunction(float x, float t)
{
float y = Mathf.Sin(Mathf.PI * (x + t));
y += Mathf.Sin(2f * Mathf.PI * (x + 2f * t)) / 2f;
y *= 2f / 3f;
return y;
}
}

2 增加一个维度

在此之前,我们只是用一个维度作为输入,影响另一个输出,也就是使用 x 值作为输入,影响 y 值。现在我们要增加一个维度,z 轴的值,也就是使用 x 和 z 作为输入,输出还是 y 值。也就是我们从,现在要过度到

首先修改 Shader,使用 z 值控制颜色的蓝色通道。只需要修改一句,将 Albedo 赋值改为下面的代码

1
o.Albedo.rgb = IN.worldPos.xyz * 0.5 + 0.5;

2.1 修改函数,添加 z 轴参数

首先将 GraphFunction 的 Delegate 添加上 z 参数

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

然后将当前的两个展示函数,SinFunctionMultiSineFunction 添加 z 参数

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

public static float MultiSineFunction(float x, float z, float t)
{
float y = Mathf.Sin(Mathf.PI * (x + t));
y += Mathf.Sin(2f * Mathf.PI * (x + 2f * t)) / 2f;
y *= 2f / 3f;
return y;
}

最后在 Update 函数中调用的地方将 z 值传进去。

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

到目前为止,我们只是先把 z 参数加上,还没有在函数中使用,接下来我们继续。

2.2 创建网格点

在此之前,我们创建的只是点,只有 x 轴和 y 轴的相互作用。现在,我们要来创建面,也就是向 z 轴扩展。

首先要修改点的数量,在 Awake 中我们 new 了一个 Transform 数组,用于存放每一个点的 Transform,之前数量是由 resolution 决定的,现在需要改为下面的代码

1
points = new Transform[resolution * resolution];

接下来还要修改创建点的代码,还是 Awake 函数中,完整的 Awake 函数代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
void Awake()
{
float step = 2f / resolution;
Vector3 scale = Vector3.one * step;
Vector3 position;
position.y = 0f;
position.z = 0f;
points = new Transform[resolution * resolution];

int i = 0;
for (int z = 0; z < resolution; ++z)
{
position.z = (z + 0.5f) * step - 1f;
for (int x = 0; x < resolution; ++x)
{
Transform point = Instantiate(pointPrefab);
position.x = (x + 0.5f) * step - 1f;
point.localPosition = position;
point.localScale = scale;
point.SetParent(transform, false);
points[i] = point;
i += 1;
}
}
}

稍微解释一下,其实最后的结果就是原来只有一条曲线的基础上,我们复制了整条曲线,然后往 z 轴方向偏移,这样就形成了一个

这里有一个要注意的地方就是 resolution 的值,如果设置的太大,可能会导致卡,如果设置为 100,按上面的代码,resolution * resolution,最终是创建了 10000 个 GameObject。

运行后效果如下

p002305_grid-light-rotated

2.3 将 z 轴值加入运算

因为 Mathf.PI 在很多地方都会用到,所以我们把这个变量值抽出来,修改代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const float pi = Mathf.PI;

public static float SineFunction(float x, float z, float t)
{
return Mathf.Sin(pi * (x + t));
}

public static float MultiSineFunction(float x, float z, float t)
{
float y = Mathf.Sin(pi * (x + t));
y += Mathf.Sin(2f * pi * (x + 2f * t)) / 2f;
y *= 2f / 3f;
return y;
}

现在,来添加第三个函数,Sine2DFunction,在这个函数中让 z 值也参与运算。

要展现的公式是: $f(x,z,t) = sin(π(x + z + t))$

记得要改三个地方

1
2
3
4
5
// 添加新函数
public static float Sine2DFunction(float x, float z, float t)
{
return Mathf.Sin(pi * (x + z + t));
}
1
2
3
4
5
// 将新函数加入到函数委托数组中
static GraphFunction[] functions =
{
SineFunction, MultiSineFunction, Sine2DFunction,
};
1
2
3
4
5
6
7
// 将函数名添加到枚举中
public enum GraphFunctionName
{
Sine,
MultiSine,
Sine2D,
}

运行效果如下,现在我们已经在3维的世界了,hhhhh~

2.4 更有趣的效果

接下来我们看两个更有趣的效果,第一个是合并两个 Sine 函数

$$f(x,z,t) = \frac{sin(π(x + t)) + sin(π(z + t))}{2}$$

修改 Sine2DFunction 函数的代码如下

1
2
3
4
5
6
7
public static float Sine2DFunction(float x, float z, float t)
{
float y = Mathf.Sin(pi * (x + t));
y += Mathf.Sin(pi * (z + t));
y *= 0.5f; // 把y轴的结果值限制在[-1,1]之间
return y;
}

运行效果如下

接下来是第二个效果,我们创建一个新的效果函数 MultiSine2DFunction

这个函数要表现的效果,是由一个主波动,和两个次级波动组成。两个次级波动,一个基于 x 值,另一个基于 z 值。也就是 $f(x,z,t)=M+S_x+S_z$。

为了让主波动慢一点,所以我们使用的公式是 $M=sin(π(x+z+\frac{t}{2}))$。而 $S_x$ 是一个正常的波动,所以公式为 $S_x = sin(π(x + t))$。$S_z$ 波动的需要更快一些 $S_z = sin(2π(z + 2t))$。

为了使主波动更大,我们会让 $M$ 乘 4,而 $S_z$ 的速度是 $S_x$ 的两倍,所以我们需要将它最后的结果除2,使其振幅变为原来的1/2。最后使用的公式是 $f(x,z,t) = 4M + S_x + \frac{S_z}{2}$。为了将最后的 y 值限制在 [-1, 1]之间,还需要最终结果除以 5.5。

综合起来,最后的代码如下

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

不要忘记将函数添加到委托数组和枚举中

1
2
3
4
5
6
7
static GraphFunction[] functions =
{
SineFunction,
MultiSineFunction,
Sine2DFunction,
MultiSine2DFunction,
};
1
2
3
4
5
6
7
public enum GraphFunctionName
{
Sine,
MultiSine,
Sine2D,
MultiSine2D,
}

最后,来看运行效果

目前的完整代码如下

1
2
3
4
// GraphFunction.cs
using UnityEngine;

public delegate float GraphFunction(float x, float z, float t);
1
2
3
4
5
6
7
8
// GraphFunctionName.cs
public enum GraphFunctionName
{
Sine,
MultiSine,
Sine2D,
MultiSine2D,
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
// Graph.cs
using UnityEngine;

public class Graph : MonoBehaviour
{
public Transform pointPrefab;

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

public GraphFunctionName function;

Transform[] points;

static GraphFunction[] functions =
{
SineFunction,
MultiSineFunction,
Sine2DFunction,
MultiSine2DFunction,
};

void Awake()
{
float step = 2f / resolution;
Vector3 scale = Vector3.one * step;
Vector3 position;
position.y = 0f;
position.z = 0f;
points = new Transform[resolution * resolution];

int i = 0;
for (int z = 0; z < resolution; ++z)
{
position.z = (z + 0.5f) * step - 1f;
for (int x = 0; x < resolution; ++x)
{
Transform point = Instantiate(pointPrefab);
position.x = (x + 0.5f) * step - 1f;
point.localPosition = position;
point.localScale = scale;
point.SetParent(transform, false);
points[i] = point;
i += 1;
}
}
}

void Update()
{
float t = Time.time;
GraphFunction f = functions[(int)function];

for (int i = 0; i < points.Length; i++)
{
Transform point = points[i];
Vector3 position = point.localPosition;
position.y = f(position.x, position.z, t);
point.localPosition = position;
}
}

const float pi = Mathf.PI;

public static float SineFunction(float x, float z, float t)
{
return Mathf.Sin(pi * (x + t));
}

public static float MultiSineFunction(float x, float z, float t)
{
float y = Mathf.Sin(pi * (x + t));
y += Mathf.Sin(2f * pi * (x + 2f * t)) / 2f;
y *= 2f / 3f;
return y;
}

public static float Sine2DFunction(float x, float z, float t)
{
float y = Mathf.Sin(pi * (x + t));
y += Mathf.Sin(pi * (z + t));
y *= 0.5f;
return y;
}

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

到此,使用 Unity 实现漂亮的数学曲面 的第一部分内容就结束了,下一部分将从这里开始接着书写更复杂有趣的效果,敬请期待~

原文翻译自 mathematical-surfaces

分享到