贝塞尔曲线、分段贝塞尔曲线和贝塞尔曲面是几何部分的第二讲次的主要内容,贝塞尔曲线是计算机图形学中相当重要的参数曲线。于 1962 年由法国工程师 Pierre Bézier 所发表,贝塞尔曲线完全由其控制点决定其形状,N 个控制点对应着 N-1 阶的贝塞尔曲线,并且可以通过递归的方式来绘制。
常规贝塞尔曲线 (Bézier Curves)
在进入具体原理讲解之前,首先看一下一条实际的贝塞尔曲线长什么样子

其中 $p_0 , p_1 , p_2 , p_3$ 为控制点,蓝色所表示曲线正是非常著名的贝塞尔曲线了,可以从图中观察到,曲线会与初始与终止端点相切,并且经过起点 $p_0$ 与终点 $p_3$ 。那么这样一条曲线究竟是怎么得到的呢?
其实贝塞尔曲线的定义很像参数方程,给定一个参数 $\mathrm {t} \in [0,1]$ 就能确定贝塞尔曲线上的一点,倘若取完所有 t 值,就能得到完整的贝塞尔曲线,了解一下大概之后,接下来我们就开始介绍计算曲线的过程。
首先从简单的 3 个控制点情形出发,示意如何画出曲线。n 个控制点得到的是 n-1 次曲线,如图中 3 个控制点便是 2 次贝塞尔曲线,正如一开始所说,第一步选定一个参数 $\mathrm {t} \in [0,1]$ 在 $b_0 b_1$ 线段之上利用 t 值进行线性插值:

即 ,得到 后在 线段上重复做相同的线性插值得到点 $\mathrm {b}_{1}^{1}$

通过给点参数 t 计算得到点 、 之后将两点连接,相信许多读者都能猜到下一步是什么了,没错就是在 、 线段之上再进行一次线性插值!

如此便成功获得了如图所示的 3 个控制点之下的 2 次贝塞尔曲线上的一点 $\mathrm {b}_{0}^{2}$ 了,那么对所有的 $\mathrm {t} \in [0,1]$ 都重复上述的过程,就可以得到上图所示的蓝色贝塞尔曲线了。通过这样一个简单的 3 个控制点的例子,相信很快就能理解贝塞尔曲线的原理,那么对于 4 个控制点,5 个控制点,乃至任意控制点步骤都是类似的。

其核心所在就是多次的线性插值,并在生成的新的顶点所连接构成的线段之上递归的执行这个过程,直到得到最后一个顶点

那么我们为何称由 n 个控制点的贝塞尔曲线为 n-1 次呢?同样以一开始的 3 个控制点为例,将贝塞尔曲线方程完全展开看看:

其实看到这就已经非常清楚了,最终得到的贝塞尔曲线方程恰好就是一个关于参数 t 的二次方程,如果细心观察的话,其实可以发现控制点的系数是非常有规律的,很像二项系数,因此可以总结规律得到一个任意控制点组成的贝塞尔曲线的方程如下:

对于这样一个特殊系数其实也有一个多项式与之对应,正是伯恩斯坦多项式,其定义如图中下方所示。

好了至此就是贝塞尔曲线原理的所有内容了,相信显现在对于任意的控制点,都能很快的画出对应的曲线,最后我们对贝塞尔曲线的几点性质做一个概括:
1、必定经过起始与终止控制点
2、必定经与起始与终止线段相切
3、具有仿射变换性质,可以通过移动控制点移动整条曲线
4、凸包性质,曲线一定不会超出所有控制点构成的多边形范围
下面可以看看贝塞尔曲线绘制动图,这是二次贝塞尔曲线:

三次贝塞尔曲线:

四次贝塞尔曲线:

分段贝塞尔曲线
回想一下 PS 等具有画图功能的软件中的钢笔工具所运用的便是贝塞尔曲线了,除了这个例子之外许多字体或是矢量图都广泛运用了贝塞尔曲线。但对于高阶贝塞尔曲线它有一个严重的缺陷:

对于上图所示的由 11 个控制点所得到的 10 次贝塞尔曲线,由于控制点众多,很难控制局部的贝塞尔曲线形状,因此为了解决该问题,有人提出了分段贝塞尔曲线,即将一条高次曲线分成多条低次曲线的拼接,其中用的最多的便是用很多的 3 次曲线来拼接,如下图:

分段贝塞尔曲线 Demo Online https://math.hws.edu/eck/cs424/notes2013/canvas/bezier.html
如果想要使得拼接的点看起来较为光滑的话,就要满足一些连续条件如一阶连续 (连接点导数的左右极限相等),二阶连续等等,这些都是高数的基础知识,在此不多做赘述。
对于本文来说,贝塞尔曲线到这里就已经讲解完毕了,但其实除了贝塞尔曲线之外还有 B 样条曲线,NURBS 曲线等等。简单说两句
B 样条曲线相对于贝塞尔曲线可以更好的进行局部控制
NURBS 曲线可以得到一些 B 样条曲线无法精准描述的圆锥曲线,如下图:

相对于贝塞尔曲线来说,这两种曲线更加深入与复杂,读者可以自行收集资料,本文不作过多展开。
贝塞尔曲面 (Bézier Surfaces)

其实在理解了贝塞尔曲线之后,贝塞尔曲面的原理也是十分容易理解的了,无非是一个从 2 维到 3 维的过渡。
如果说对于曲线来说只有一个参数 $\mathrm {t} \in [0,1]$ 那么对于一个面来说,就应该有两个参数,分别设 $\mathrm {u} \in [0,1]$ $\mathrm {v} \in [0,1]$ ,具体过程如下图所示:

首先规定一共 4 x 4 = 16 个控制点,其水平面位置如图中 16 个黑点所示 (并未表示出高度,防止图形太乱),将这 16 个点分成 4 列,图中红色圈中的为一列的具体例子。
第 1 步,在这 4 个控制点之下利用第一个参数 u 运用第一章的计算贝塞尔曲线的方法得到蓝色点,因为有 4 列,所以一共可以得到如图所示的 4 个蓝色点。(灰色曲线分别为每列 4 个点所对应的贝塞尔曲线)
第 2 步,在得到 4 个蓝色顶点之后,在这四个蓝色顶点的基础之下利用第二个参数 v 便可以成功得出贝塞尔曲面上的正确一点
第 3 步,遍历所有的 u,v 值就可以成功得到一个贝塞尔曲面
Java 绘制三次贝塞尔曲线
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
| import java.awt.Color; import java.awt.event.MouseAdapter; import java.awt.event.MouseEvent; import java.awt.event.MouseMotionAdapter; import java.awt.geom.Ellipse2D; import java.awt.geom.Point2D; import java.awt.Dimension; import java.awt.Graphics; import java.awt.Graphics2D; import java.awt.RenderingHints;
import javax.swing.JFrame; import javax.swing.JPanel;
public class TestBezier {
private static Point2D [] keyPointP;
private static Ellipse2D.Double [] keyPointE;
private static int keyPointNum;
private static double t = 0.001;
private static boolean flagShow = true;
static class BezierPanel extends JPanel {
private static final long serialVersionUID = 1L;
private int keyPointID = -1;
BezierPanel () { this.addMouseListener (new MouseAction()); this.addMouseMotionListener (new MouseMotion()); }
@Override protected void paintComponent(Graphics gs) { super.paintComponent (gs); Graphics2D g = (Graphics2D) gs; g.setRenderingHint (RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); g.setColor (Color.BLUE); if (flagShow) { for (int i = 0; i < keyPointNum; i++) { if (i > 0 && i < (keyPointNum - 1)) { g.fill (keyPointE [i]); } else { g.draw (keyPointE [i]); } if (keyPointNum > 1 && i < (keyPointNum - 1)) { g.drawLine ((int) keyPointP [i].getX (), (int) keyPointP [i].getY (), (int) keyPointP [i + 1].getX (), (int) keyPointP [i + 1].getY ()); } if (i == 0) { g.drawString ("A", (int) keyPointE [i].x, (int) keyPointE [i].y); } else if (i == 1) { g.drawString ("B", (int) keyPointE [i].x, (int) keyPointE [i].y); } else if (i == 2) { g.drawString ("C", (int) keyPointE [i].x, (int) keyPointE [i].y); } else if (i == 3) { g.drawString ("D", (int) keyPointE [i].x, (int) keyPointE [i].y); } } } if (keyPointNum == 3) { double x, y; g.setColor (Color.RED); for (double k = t; k <= 1 + t; k += t) { double r = 1 - k; x = Math.pow (r, 2) * keyPointP [0].getX () + 2 * k * r * keyPointP [1].getX () + Math.pow (k, 2) * keyPointP [2].getX (); y = Math.pow (r, 2) * keyPointP [0].getY () + 2 * k * r * keyPointP [1].getY () + Math.pow (k, 2) * keyPointP [2].getY (); g.drawOval ((int) x, (int) y, 1, 1); } } if (keyPointNum == 4) { double x, y; g.setColor (Color.RED); for (double k = t; k <= 1 + t; k += t) { double r = 1 - k; x = Math.pow (r, 3) * keyPointP [0].getX () + 3 * k * Math.pow (r, 2) * keyPointP [1].getX () + 3 * Math.pow (k, 2) * (1 - k) * keyPointP [2].getX () + Math.pow (k, 3) * keyPointP [3].getX (); y = Math.pow (r, 3) * keyPointP [0].getY () + 3 * k * Math.pow (r, 2) * keyPointP [1].getY () + 3 * Math.pow (k, 2) * (1 - k) * keyPointP [2].getY () + Math.pow (k, 3) * keyPointP [3].getY (); g.drawOval ((int) x, (int) y, 1, 1); } } }
class MouseAction extends MouseAdapter { @Override public void mouseClicked(MouseEvent e) { if (e.getButton () == MouseEvent.BUTTON1) { if (keyPointNum < 4) { double x = e.getX (); double y = e.getY (); keyPointP [keyPointNum] = new Point2D.Double (x, y); keyPointE [keyPointNum] = new Ellipse2D.Double (x - 4, y - 4, 8, 8); keyPointNum++; repaint (); } } else if (e.getButton () == MouseEvent.BUTTON3) { flagShow = false; repaint (); } }
@Override public void mousePressed(MouseEvent e) { for (int i = 0; i < keyPointNum; i++) { if (keyPointE [i].contains ((Point2D) e.getPoint ())) { keyPointID = i; break; } else { keyPointID = -1; } } } }
class MouseMotion extends MouseMotionAdapter { @Override public void mouseDragged(MouseEvent e) { if (keyPointID != -1) { double x = e.getX (); double y = e.getY (); keyPointP [keyPointID] = new Point2D.Double (x, y); keyPointE [keyPointID] = new Ellipse2D.Double (x - 4, y - 4, 8, 8); repaint (); } } } }
public TestBezier() { JFrame f = new JFrame(); f.setTitle ("Bezier Test"); f.setDefaultCloseOperation (JFrame.EXIT_ON_CLOSE); f.setSize (800, 600); f.setLocationRelativeTo (null);
keyPointNum = 0; keyPointP = new Point2D[4]; keyPointE = new Ellipse2D.Double [4]; BezierPanel bezierPanel = new BezierPanel(); bezierPanel.setPreferredSize (new Dimension(800, 600)); bezierPanel.setBackground (Color.WHITE);
f.setContentPane (bezierPanel); f.setVisible (true); }
public static void main(String [] agrs) { new TestBezier(); } }
|

Reference
《GAMES101 - 现代计算机图形学入门 - 闫令琪》
《Tiny renderer or how OpenGL works》
《计算机图形学笔记》