贝塞尔曲线/面

环境

    windows 10
    Unity 2022.3.52f1c1

贝塞尔曲线

        在游戏开发的过程中,贝塞尔曲线是一个十分常用的知识。它常用来实现游戏中投掷物的抛物线效果。除此之外,游戏引擎中的动画曲线也是用类似的逻辑来实现。

什么是贝塞尔曲线

        贝塞尔曲线(Bézier curve),又称贝兹曲线或贝济埃曲线,是应用于二维图形应用程序的数学曲线。贝塞尔曲线于1962年由法国工程师皮埃尔·贝塞尔(Pierre Bézier)所广泛发表,他运用贝塞尔曲线来为汽车的主体进行设计。贝塞尔曲线最初由Paul de Casteljau于1959年运用de Casteljau演算法开发,以稳定数值的方法求出贝兹曲线。(上述段落取自百度百科。)虽然上文说是在二维图形中的应用,但是现在贝塞尔曲线在三维中也有广泛的应用。

一次(一阶)贝塞尔曲线

        一次贝塞尔曲线是最简单的形式。我们值需要做两点间的线性插值简单的代码如下:

1
2
3
4
public Vector3 OneLevelBezier(Vector3 p0, Vector3 p1, float t)
{
return (1 - t) * p0 + t * p1;
}

函数的输入参数有两个点p0和p1,以及一个参数t,t的取值范围是0到1。这里我们会将p0和p1称为贝塞尔曲线的控制点。上述代码也可以写成:

1
2
3
4
public Vector3 OneLevelBezier(Vector3 p0, Vector3 p1, float t)
{
return Vector3.Lerp(p0, p1, t);
}

多次(多阶)贝塞尔曲线

        多次贝塞尔曲线其实就是多加了几个控制点,然后控制点间按照顺序进行一次贝塞尔曲线计算出新的控制点,接下来在用这新得到的控制点计算出新的控制点,如此反复,直到只能计算出一个点为止。这里我们二次贝塞尔曲线为例子,二次贝塞尔曲线其实就是有三个控制点形成的曲线。我们架设这三个控制点的顺序是p0 -> p1 -> p2。

1
2
3
4
5
6
public Vector3 TwoLevelBezier(Vector3 p0, Vector3 p1, Vector3 p2, float t)
{
Vector3 newP0 = Vector3.Lerp(p0, p1, t);
Vector3 newP1 = Vector3.Lerp(p1, p2, t);
return Vector3.Lerp(newP0, newP1, t);
}

如果你有看过网上的其他文章,你会发现这些文章在介绍二次贝塞尔曲线的时候通常是下面这样的公式:

\[target = (1-t)^2 * p0 + 2t(1-t) * p1 + t^2 * p2\]

实际上这个公式和上面的代码是等价的。下面是我的推导:

\[\begin{aligned} &方程1: newP0 = (1 - t) * p0 + t * p1 \\&方程2:newP1 = (1 - t) * p1 + t * p2 \\&方程3:target = (1 - t) * newP0 + t * newP1 \end{aligned}\]

我们将方程1和方程2代入方程3,得到:

\[\begin{aligned} &target = (1 - t) * ((1 - t) * p0 + t * p1) + t * ((1 - t) * p1 + t * p2) \\ &target = (1 - t)^2 * p0 + t(1 - t) * p1 + t(1 - t) * p1 + t^2 * p2 \\ &target = (1 - t)^2 * p0 + 2t(1 - t) * p1 + t^2 * p2 \end{aligned}\]

这个求解贝塞尔曲线点位的过程很符合递归的思想,我们可以用递归的方式来实现多次贝塞尔曲线。代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public Vector3 MultiLevelBezier(Vector3[] points, float t)
{
if (points.Length < 2)
throw new System.Exception("贝塞尔曲线至少要两个控制点");

if(points.Length == 2)
{
return Vector3.Lerp(points[0], points[1], t);
}
Vector3[] newPoints = new Vector3[points.Length - 1];
for(int i = 1;i < points.Length; ++i)
{
newPoints[i - 1] = Vector3.Lerp(points[i - 1], points[i], t);
}

return MultiLevelBezier(newPoints, t);
}

进阶——分段贝塞尔曲线

        虽然上述就是贝塞尔曲线的基本逻辑,但是在实际应用的时候,我们大多数见到的实现都不是用这样的方法。上述方法生成的贝塞尔曲线有两个问题:

  1. 性能开销大,我们不难得出上述的时间复杂是\(O(n^2)\),n是控制点的数量。如果我们还要生成较为光滑的曲线,那么我们还要计算多个点。这样\(O(n^2)\)的复杂度就不是我们能够接受的。

  2. 因为这样贝塞尔曲线操作由于组合过于复杂而很不稳定,因此很难移控制。

所以在实际生产过程中,我们会使用分段贝塞尔曲线来进行来操作。例如Unity的AnimationCurve其中的曲线绘制就类似这个逻辑。在实际生产过程中,分段贝塞尔曲线一般是使用三次贝塞尔曲线来实现的。当然理论上,你用多少次贝塞尔曲线都可以实现,但是工业界中普遍都使用三次贝塞尔曲线。我想是因为三次贝塞尔曲线的四个控制点可以很好地控制曲线的形状,而且计算量也比较小。网络中的文章普遍都介绍了一个分段贝塞尔曲线的网址:https://math.hws.edu/eck/cs424/notes2013/canvas/bezier.html。而这个网址并没有提供具体的实现,下面是我用Unity实现的代码,希望对你有所帮助:

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
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
using System.Collections.Generic;
using UnityEngine;

public class BezierTest : MonoBehaviour
{
[Header("点位的集合")]
public Transform pointsTrans;

private List<Transform> controllPoints = new List<Transform>();

[Header("绘制的被控制的点大小,颜色和贝塞尔曲线的颜色")]
public float pointSize = 0.5f;
public Color pointColor = Color.black;
public Color lineColor = Color.green;

[Header("绘制的控制的点大小,颜色和其与被控制点的关系连线颜色")]
public float controllSize = 0.25f;
public Color controllColor = Color.red;
public Color linkColor = Color.white;

[Header("绘制单独的贝塞尔曲线的时候需要有多少个点位连接")]
public int singleBezierPointNum = 10;

[Header("是否要隐藏其与被控制点的关系连线")]
public bool hideLinkLine = true;

private void Start()
{
// 创建我们想要的控制点
for (int i = 1; i < pointsTrans.childCount; ++i)
{
var pre = pointsTrans.GetChild(i - 1);
var cur = pointsTrans.GetChild(i);

for (int j = -1; j <= 1; j += 2)
{
var trans = new GameObject().transform;
controllPoints.Add(trans);
// 控制点以其被控制的点做为父节点会更好控制
if (j < 0)
{
trans.position = pre.position;
trans.SetParent(pre);
}
else
{
trans.position = cur.position;
trans.SetParent(cur);
}
}
}
}

private void OnDrawGizmos()
{
if (!Application.isPlaying)
return;

if(pointsTrans.childCount > 1)
{
for (int i = 0; i < pointsTrans.childCount - 1; ++i)
{
Vector3[] curBezierPoint = new Vector3[4];
curBezierPoint[0] = pointsTrans.GetChild(i).position;
curBezierPoint[1] = controllPoints[i * 2].position;
curBezierPoint[2] = controllPoints[i * 2 + 1].position;
curBezierPoint[3] = pointsTrans.GetChild(i + 1).position;

Gizmos.color = pointColor;
Gizmos.DrawSphere(curBezierPoint[0], pointSize);
Gizmos.color = controllColor;
Gizmos.DrawSphere(curBezierPoint[1], controllSize);
Gizmos.DrawSphere(curBezierPoint[2], controllSize);

Gizmos.color = lineColor;
Vector3 startPos = curBezierPoint[0];
for(int j = 1;j <= singleBezierPointNum; ++j)
{
var nextPos = MultiLevelBezier(curBezierPoint, 1f * j / singleBezierPointNum);
Gizmos.DrawLine(startPos, nextPos);
startPos = nextPos;
}
if(!hideLinkLine)
{
Gizmos.color = linkColor;
Gizmos.DrawLine(curBezierPoint[0], curBezierPoint[1]);
Gizmos.DrawLine(curBezierPoint[2], curBezierPoint[3]);
}
}
Gizmos.color = pointColor;
Gizmos.DrawSphere(pointsTrans.GetChild(pointsTrans.childCount - 1).position, pointSize);
}
}

public Vector3 MultiLevelBezier(Vector3[] points, float t)
{
if (points.Length < 2)
throw new System.Exception("贝塞尔曲线至少要两个控制点");

if (points.Length == 2)
{
return Vector3.Lerp(points[0], points[1], t);
}
Vector3[] newPoints = new Vector3[points.Length - 1];
for (int i = 1; i < points.Length; ++i)
{
newPoints[i - 1] = Vector3.Lerp(points[i - 1], points[i], t);
}

return MultiLevelBezier(newPoints, t);
}
}

        在讨论分段贝塞尔曲线时,人们还会讨论这样的一个问题。如何确定分段之间链接端的连续性?我倒是想不明白这个问题。不过从我看到资料中说明了,以三次贝塞尔曲线为例,当连接的两段贝塞尔曲线的连接点和相邻两点形成的三点共线即可。如下图所示。

图中蓝色小球是控制小球。

贝塞尔曲面

PS:网络上关于贝塞尔曲面的文章还是太少了,下面的实现和说法也是我对网络上文章的理解。如果有错欢迎告知,我立刻修改。

        贝塞尔曲面是贝塞尔曲线的推广。贝塞尔曲线定下控制点后就只由一个参数来进行变化,而贝塞尔曲面则是由两个参数来变化的。在我所看到文章中经常将这两个参数设为u,v,其中u,v都是在0到1之间。如下是一个俯视下排列的贝塞尔曲面控制点分布。

\[\begin{aligned} &ABCD\\ &FGHI\\ &JKLM\\ &NOPQ \end{aligned}\]

如下图所示,图中我只标明了部分点位,希望大家可以看得懂:

这里我设定AB的方向的参数由u控制,AF方向的参数由v控制。首先我们通过各各沿着AB方向上的点位创建贝塞尔曲线。即我们分别对“ABCD”,“FGHI”,“JKLM”和“NOPQ”进行贝塞尔曲线的创建。此时我们得到了四条曲线,我设定为“AD”,“FI”,“JM”和“NQ”。接下来我们选定一个u值,并在这四条曲线上得到当前u值所对应的顶点,设得到的四个顶点为\(Point_{AD}\)\(Point_{FI}\)\(Point_{JM}\)\(Point_{NQ}\)。然后我们将这新得到的顶点再做一次贝塞尔曲线。而v值就是用来在这个新得到的贝塞尔曲线中取值的。假设,我们可以无限次的取不同的u值,我们就可以得到无限个不同的曲线,而这些曲线就构成了贝塞尔曲面。当然在开发过程中,我们自然做不到无限次的选取。但是我们只要选取到能满足我们需求的值就好了。下图是依照上述逻辑所形成的图像,其中绿色的线对应的是“ABCD”,“FGHI”,“JKLM”和“NOPQ”这些曲线,而蓝色的线则表示由\(Point_{AD}\)\(Point_{FI}\)\(Point_{JM}\)\(Point_{NQ}\)所得到的曲线。

实际上u,v的值完全可以对应在渲染过程中物体的uv,下面是我写的用来展示贝塞尔曲面的代码,希望对你的理解有所帮助:

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
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
using System.Collections.Generic;
using UnityEngine;

[RequireComponent(typeof(MeshFilter))]
[RequireComponent(typeof(MeshRenderer))]
public class BezierPlane : MonoBehaviour
{
[Header("显示控制点控制的原型")]
public GameObject pointMode;
[Header("是否要显示控制点间的关系")]
public bool showPointConnect = false;
[Header("从另外一个方向生成")]
public bool otherSider = false;
[Header("一个方向上有多少个控制点")]
public int oneSidePointNum;
[Header("一条贝塞尔曲线,要生成多少的点位")]
public int singleBezierPointNum;
[Header("是否要展示单个贝塞尔曲线的生成过程")]
public bool showProgress = false;
[Header("展示不同贝塞尔曲线的生成过程的间隔")]
public float addInvel = 1;

[Header("是否要展示形成的贝塞尔面")]
public bool showMesh = false;
[Header("是否要更新贝塞尔面的数据\n第一次的时候一定要先更新,否则不展示任何东西")]
public bool updateMesh = false;

private Mesh mesh;
private MeshFilter meshFilter;
private MeshRenderer meshRenderer;

private List<Transform> points = new List<Transform>();

private int count = 0;

private float nextAddTime = 0;

private void Start()
{
mesh = new Mesh();
meshFilter = GetComponent<MeshFilter>();
meshRenderer = GetComponent<MeshRenderer>();
meshRenderer.enabled = false;
meshFilter.mesh = mesh;
for (int i = 0;i < oneSidePointNum; ++i)
{
for (int j = 0; j < oneSidePointNum; ++j)
{
Transform trans = null;
if (pointMode != null)
{
trans = GameObject.Instantiate(pointMode).transform;
trans.gameObject.SetActive(true);
}
else trans = new GameObject().transform;
trans.parent = transform;
if(otherSider)
{
trans.localPosition = new Vector3(j, 0, i);
trans.name = $"{j} + {i}";
}
else
{
trans.localPosition = new Vector3(i, 0, j);
trans.name = $"{i} + {j}";
}
points.Add(trans);
}
}
}

private void OnDrawGizmos()
{
if (!Application.isPlaying)
return;
if(Time.time > nextAddTime)
{
count = (count + 1) % singleBezierPointNum;
nextAddTime = Time.time + addInvel;
}

List<Vector3[]> beizerCurve = new List<Vector3[]>();
Vector3[] startPoss = new Vector3[oneSidePointNum];
Gizmos.color = Color.black;
for (int i = 0; i < oneSidePointNum; ++i)
{
Vector3[] point = new Vector3[oneSidePointNum];
int startInd = i * oneSidePointNum;
beizerCurve.Add(point);
for (int j = 0; j < oneSidePointNum; ++j)
{
point[j] = transform.GetChild(startInd + j).position;
if (showPointConnect)
{
if (j > 0)
Gizmos.DrawLine(point[j - 1], point[j]);
if (i > 0)
Gizmos.DrawLine(beizerCurve[i - 1][j], beizerCurve[i][j]);
}
}
startPoss[i] = point[0];
}

meshRenderer.enabled = showMesh;

if (showMesh)
{
if(updateMesh)
{
UpdateMesh(beizerCurve, startPoss);
updateMesh = false;
}
}
else JustDrawLine(beizerCurve, startPoss);

if(showProgress)
{

Vector3[] point = new Vector3[oneSidePointNum];
Gizmos.color = Color.red;
for (int i = 0; i < oneSidePointNum; ++i)
{
var curPos = MultiLevelBezier(beizerCurve[i], count * 1f / singleBezierPointNum);
point[i] = curPos;
Gizmos.DrawSphere(curPos, 0.05f);
}
DrawOneBezier(point, point[0], Color.yellow);
}
}

private void UpdateMesh(List<Vector3[]> beizerCurve, Vector3[] startPoss)
{
List<Vector3> meshPoints = new List<Vector3>();
List<Vector3> meshNormals = new List<Vector3>();
List<int> meshNormalsCount = new List<int>();
List <Vector2> meshUv = new List<Vector2>();
List<int> meshTriangles = new List<int>();

for (int i = 0; i <= singleBezierPointNum; ++i)
{
Vector3[] point = new Vector3[oneSidePointNum];
for (int j = 0; j < oneSidePointNum; ++j)
{
var curPos = MultiLevelBezier(beizerCurve[j], i * 1f / singleBezierPointNum);
point[j] = curPos;
}
int curStartInd = i * (singleBezierPointNum + 1);
var u = 1f * i / singleBezierPointNum;
for (int j = 0; j <= singleBezierPointNum; ++j)
{
var v = 1f * j / singleBezierPointNum;
var pos = MultiLevelBezier(point, 1f * j / singleBezierPointNum);
meshUv.Add(new Vector2(u, v));
meshPoints.Add(pos - transform.position);
meshNormals.Add(Vector3.zero);
meshNormalsCount.Add(0);
if (i > 0)
{
if (j > 0)
{
int curInd = curStartInd + j;
meshTriangles.Add(curInd);
meshTriangles.Add(curInd - 2 - singleBezierPointNum);
meshTriangles.Add(curInd - 1);

meshTriangles.Add(curInd);
meshTriangles.Add(curInd - singleBezierPointNum - 1);
meshTriangles.Add(curInd - 2 - singleBezierPointNum);
}
}
}
}

for (int i = 0;i < meshTriangles.Count; i+=3)
{
var point1 = meshPoints[meshTriangles[i]];
var point2 = meshPoints[meshTriangles[i + 1]];
var point3 = meshPoints[meshTriangles[i + 2]];
meshNormals[meshTriangles[i]] += Vector3.Cross(point1 - point3, point2 - point1).normalized;
meshNormalsCount[meshTriangles[i]]++;
meshNormals[meshTriangles[i + 1]] += Vector3.Cross(point2 - point1, point3 - point2).normalized;
meshNormalsCount[meshTriangles[i + 1]]++;
meshNormals[meshTriangles[i + 2]] += Vector3.Cross(point3 - point2, point1 - point3).normalized;
meshNormalsCount[meshTriangles[i + 2]]++;
}
int endIndStart = singleBezierPointNum * singleBezierPointNum + singleBezierPointNum;
for (int i = 0;i < meshNormals.Count; ++i)
{
meshNormals[i] /= meshNormalsCount[i];
}

mesh.vertices = meshPoints.ToArray();
mesh.normals = meshNormals.ToArray();
mesh.uv = meshUv.ToArray();
mesh.triangles = meshTriangles.ToArray();
}

private void JustDrawLine(List<Vector3[]> beizerCurve, Vector3[] startPoss)
{

Vector3 startPos = startPoss[0];

DrawOneBezier(startPoss, startPoss[0], Color.blue);

for (int i = 1; i <= singleBezierPointNum; ++i)
{
Gizmos.color = Color.green;
Vector3[] point = new Vector3[oneSidePointNum];
for (int j = 0; j < oneSidePointNum; ++j)
{
var curPos = MultiLevelBezier(beizerCurve[j], i * 1f / singleBezierPointNum);
point[j] = curPos;
Gizmos.DrawLine(startPoss[j], curPos);
startPoss[j] = curPos;
}

DrawOneBezier(point, point[0], Color.blue);
}
}

private void DrawOneBezier(Vector3[] curPoints,Vector3 startPos,Color color)
{
Gizmos.color = color;
for (int i = 1; i <= singleBezierPointNum; ++i)
{
var nextPos = MultiLevelBezier(curPoints, 1f * i / singleBezierPointNum);
Gizmos.DrawLine(startPos, nextPos);
startPos = nextPos;
}
}

public Vector3 MultiLevelBezier(Vector3[] points, float t)
{
if (points.Length < 2)
throw new System.Exception("贝塞尔曲线至少要两个控制点");

if (points.Length == 2)
{
return Vector3.Lerp(points[0], points[1], t);
}
Vector3[] newPoints = new Vector3[points.Length - 1];
for (int i = 1; i < points.Length; ++i)
{
newPoints[i - 1] = Vector3.Lerp(points[i - 1], points[i], t);
}

return MultiLevelBezier(newPoints, t);
}
}

额外的补充

        如果你有找过网络上其他的文章,你会发现他们还会说一个"伯恩斯坦多项式"。我没能理解这个,但是我发现这我的实现毫无关系。所以我也没有去了解太多。实际上关于贝塞尔曲线,它其中相关的知识还有很多。我个人认为如果只是想要了解它,我们并不需要这么深的理解。而且大多数情况下,二阶的贝塞尔曲线就够用了。至于贝塞尔曲面,我几乎没在游戏中看到它的应用。我也只有在PS这样的工具中看到。我个人认为,这仍然是一个需求驱动的东西,可我现在并没有与之相关的需求。所以此篇文章可能看似潦草,但是这就是我现在对贝塞尔曲线的全部了解了,希望这篇文章能帮助到你。

参考文章