几何-贝塞尔曲线、分段贝塞尔曲线和曲面

贝塞尔曲线、分段贝塞尔曲线和贝塞尔曲面是几何部分的第二讲次的主要内容,贝塞尔曲线是计算机图形学中相当重要的参数曲线。于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}_0^1=\mathrm{b}_0+\mathrm{t} *\left(\mathrm{~b}_1-\mathrm{b}_0\right)$ 得到

$\mathrm{b}_0^1 $ 后在 $b_1 b_2$ 线段上重复做相同的线性插值得到点 $\mathrm{b}_1^1$

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

如此便成功获得了如图所示的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绘制三次贝塞尔曲线

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 {

    /**
     * 存关键点的x、y的数组
     */
    private static Point2D[] keyPointP;
    /**
     * 存关键点的x、y、width、height的数组
     */
    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) {
            // 重写repaint
            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);// 画圆的方式比下面注释掉的画线效果更好
                    // g.drawLine((int) x, (int) y, (int) x, (int) y);
                }
            }
            // 三次贝塞尔曲线
            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》

《计算机图形学笔记》