深入理解贝塞尔曲线

46,354 阅读8分钟

贝塞尔曲线(Bezier Curve)在计算机图形领域应用非常广泛,比如我们熟知的 CSS 动画、 Canvas 以及 Photoshop 等都可以看到贝塞尔曲线的身影。

文章目录

一、什么是贝塞尔曲线?

贝塞尔曲线于 1962 年,由法国工程师皮埃尔·贝济埃(Pierre Bézier)所广泛发表,他运用贝塞尔曲线来为汽车的主体进行设计。

贝塞尔曲线主要用于二维图形应用程序中的数学曲线,曲线由起始点,终止点(也称锚点)和控制点组成,通过调整控制点,通过一定方式绘制的贝塞尔曲线形状会发生变化。后面会具体介绍绘制的方法。

在计算机图形学中贝赛尔曲线的运用很广泛,例如Photoshop中的钢笔效果,Flash5的贝塞尔曲线工具,在软件GUI开发中一般也会提供对应的方法来实现贝赛尔曲线,我们熟知的CSS动画过渡时间函数也是通过贝塞尔曲线(三阶贝塞尔曲线)获取的。

二、贝塞尔曲线分为哪些类型?

贝塞尔曲线根据控制点的数量分为:

  • 一阶贝塞尔曲线(2 个控制点)
  • 二阶贝塞尔曲线(3 个控制点)
  • 三阶贝塞尔曲线(4 个控制点)
  • n阶贝塞尔曲线(n+1个控制点)

三、贝塞尔曲线是如何绘制出来的?

下图为一个三阶的贝塞尔曲线,包括四个控制点,分别为P_0,P_1,P_2,P_3

三阶贝塞尔曲线

那我们通过控制点是怎么绘制出贝塞尔曲线的呢?

通过上图的三阶贝塞尔曲线举例,基本的步骤如下:

  1. 四个控制点通过先后顺序进行连接,形成了三条线段,也就是上图中的P_0P_1,P_1P_2,P_2P_3,然后通过一个参数t,其中 t\in[0,1],该参数的值等于线段上某一个点距离起点的长度除以线段长度。就比如P_0P_1线段上有一个点P_0^{'},此时t的值就是\frac{P_0P_0^{'}}{P_0P_1},其中P_0^{'}位置如下图所示。

bezier-01

  1. 接下来对每一条线段做同样的操作,得到三个控制点P_0^{'},P_1^{'},P_2^{'},如下图所示。

bezier-02

  1. 然后对这三个控制点重复第1步操作,得出两个控制点P_0^{''},P_1^{''},如下图所示。

bezier-03

  1. 最后再使用同样的方法可以得到,最终的一个点P_0^{'''},如下图所示,此时这个点就是贝塞尔曲线上的一个点。

bezier-04

通过控制t的值,由 0 增加至 1,就绘制出了一条由起点P_0至终点P_1的贝塞尔曲线。

你可以通过下面这个动画直观感受一下绘制的过程:

三阶贝塞尔曲线绘制过程

四、如何求贝塞尔曲线上的点坐标?

1、一阶贝塞尔曲线

一阶贝塞尔曲线绘制过程

对于一阶贝塞尔曲线,我们可以通过几何知识,很容易根据t的值得出线段上那个点的坐标:

B_{1}(t) = P_0 + (P_1 - P_0)t

然后可以得出:

B_{1}(t) = (1 - t)P_0 + tP_1,t\in[0,1]

2、二阶贝塞尔曲线

二阶贝塞尔曲线绘制过程

对于二阶贝塞尔曲线,其实你可以理解为:在P_0P_1上利用一阶公式求出点P_0^{'},然后在P_1P_2上利用一阶公式求出点P_1^{'},最后在P_0^{'}P_1^{'}上再利用一阶公式就可以求出最终贝塞尔曲线上的点P_0{''}。具体推导过程如下:

先求出线段上的控制点。

P_0^{'} = (1 - t)P_0 + tP_1
P_1^{'} = (1 - t)P_1 + tP_2

将上面的公式带入至下列公式中:

B_{2}(t) = (1 - t)P_0^{'} + tP_1^{'}
= (1 - t)((1 - t)P_0 + tP_1) + t((1 - t)P_1 + tP_2)
= (1 - t)^2P_0 + 2t(1 - t)P_1 + t^2P_2

得出以下公式:

B_{2}(t) = (1 - t)^2P_0 + 2t(1 - t)P_1 + t^2P_2 , t\in[0, 1]

3、三阶贝塞尔曲线

三阶贝塞尔曲线绘制过程

与二阶贝塞尔曲线类似,可以通过相同的方法得出以下坐标公式:

B_{3}(t) = (1 - t)^3P_0 + 3t(1 - t)^2P_1 + 3t^2(1 - t)P_2 + t^3P_3 , t\in[0, 1]

4、多阶贝塞尔曲线

这里我就直接把n阶贝塞尔曲线公式给出来了,有兴趣的同学可以自行研究一下。

B(t) = \sum_{i=0}^{n}C_n^{i}P_i(1-t)^{n-i}t^i,t\in[0,1]

即:

B(t) = \sum_{i=0}^{n}P_ib_{i,n}(t),t\in[0,1]

公式中C_n^i的值为\frac{n!}{(n - i)!\cdot i!},与统计学有关,有兴趣的同学可以看一看我的这篇文章

其中b_{i,n}(t)的值为:

b_{i,n}(t)=C_n^{i}(1-t)^{n-i}t^i,其中i=0,1,...,n

五、如何实现一个类似CSS中easing属性的三阶贝塞尔曲线构造函数?

如果要实现一个这样的三阶贝塞尔曲线,我们需要不仅需要获取到一些曲线上的点,还需要通过x轴获取y轴坐标。

CSS中的easing贝塞尔曲线有一个特点,那就是起点和终点是固定的,也就是分别是[0, 0],\ [1,1]。所以未知的点就只有两个,也就是需要传入四个值,并且这四个值的范围需要在[0,1]内。

所以我们需要创建一个类CubicBezier,它拥有属性controlPoints

class CubicBezier {
  constructor(x1, y1, x2, y2) {
    this.controlPoints = [x1, y1, x2, y2];
  }
}

通过上述代码初始化以后,我们还需要根据t(取值范围为[0, 1])值获取坐标,以及一个曲线上坐标集合的数组。另外还需要使用三阶贝塞尔公式:

B_{2}(t) = (1 - t)^3P_0 + 3t(1 - t)^2P_1 + 3t^2(1 - t)P_2 + t^3P_3 , t\in[0, 1]

因为P_0点坐标为[0, 0],P_1点坐标为[1, 1]为所以公式进而可以写成:

B_{3, x}(t) = 3t(1 - t)^2x_1 + 3t^2(1 - t)x_2 + t^3 , t\in[0, 1]
B_{3, y}(t) = 3t(1 - t)^2y_1 + 3t^2(1 - t)y_2 + t^3 , t\in[0, 1]
class CubicBezier {
  constructor(x1, y1, x2, y2) {
    this.controlPoints = [x1, y1, x2, y2];
  }

  getCoord(t) {
    // 如果t取值不在0到1之间,则终止操作
    if (t > 1 || t < 0) return;
    const _t = 1 - t;
    const [ x1, y1, x2, y2 ] = this.controlPoints;
    const coefficient1 = 3 * t * Math.pow(_t, 2);
    const coefficient2 = 3 * _t * Math.pow(t, 2);
    const coefficient3 = Math.pow(t, 3);
    const px = coefficient1 * x1 + coefficient2 * x2 + coefficient3;
    const py = coefficient1 * y1 + coefficient2 * y2 + coefficient3;
    // 结果只保留三位有效数字
    return [parseFloat(px.toFixed(3)), parseFloat(py.toFixed(3))];
  }
}

利用上述的Bezier类,我们就可以根据两个控制点构建Bezier实例,通过这个实例我们可以根据t值,获取点上的近似值。

那么如果我们想要根据x轴坐标值,来获取y轴坐标时,我们该怎么做呢?

这里我使用了一个近似处理的办法,具体如下:

  1. 先获取离需要求值点最近的两个点。
  2. 然后通过这两个点可以得到一个直线方程。
  3. 最后通过将x轴坐标传入直线方程中,就可以近似求得y轴坐标值了。

所以我们需要进一步改造Bezier构造函数,需要缓存固定数量坐标数组的属性coords,以及获取coords的方法getCoordsArray,最后还有获取y轴坐标的方法getY,具体的实现方法如下:

class CubicBezier {
  constructor(x1, y1, x2, y2) {
    const precision = 100;
    this.controlPoints = [x1, y1, x2, y2];
    this.coords = this.getCoordsArray(precision);
  }
  
  getCoord(t) {
    // 如果t取值不在0到1之间,则终止操作
    if (t > 1 || t < 0) return;
    const _t = 1 - t;
    const [ x1, y1, x2, y2 ] = this.controlPoints;
    const coefficient1 = 3 * t * Math.pow(_t, 2);
    const coefficient2 = 3 * _t * Math.pow(t, 2);
    const coefficient3 = Math.pow(t, 3);
    const px = coefficient1 * x1 + coefficient2 * x2 + coefficient3;
    const py = coefficient1 * y1 + coefficient2 * y2 + coefficient3;
    // 结果只保留三位有效数字
    return [parseFloat(px.toFixed(3)), parseFloat(py.toFixed(3))];
  }
  
  getCoordsArray(precision) {
    const step = 1 / (precision + 1);
    const result = [];
    for (let t = 0; t <= precision + 1; t++) {
      result.push(this.getCoord(t * step));
    }
    this.coords = result;
    return result;
  }
  
  getY(x) {
    if (x >= 1) return 1;
    if (x <= 0) return 0;
    let startX = 0;
    for (let i = 0; i < this.coords.length; i++) {
      if (this.coords[i][0] >= x) {
        startX = i;
        break;
      }
    }
    const axis1 = this.coords[startX];
    const axis2 = this.coords[startX - 1];
    const k = (axis2[1] - axis1[1]) / (axis2[0] - axis1[0]);
    const b = axis1[1] - k * axis1[0];
    // 结果也只保留三位有效数字
    return parseFloat((k * x + b).toFixed(3));
  }
}

然后通过下述方式就可以使用我们的CubicBezier了:

const cubicBezier = new CubicBezier(0.3, 0.1, 0.3, 1);
cubicBezier.getY(0.1); // 0.072
cubicBezier.getY(0.7); // 0.931

我写了一个应用这个CubicBezier构造函数的库Animate-Scroll,有兴趣的可以去看一下源码。

六、如何用高阶贝塞尔曲线表示低阶贝塞尔曲线?

一个n阶贝塞尔曲线可以通过一个形状完全一致的n+1阶贝塞尔曲线表示。那我们该怎么做,才能获取这个n+1阶贝塞尔曲线呢?

由高阶贝塞尔曲线表示低阶贝塞尔曲线的过程,我们称之为升阶

我们需要用到B(t)=(1-t)B(t)+tB(t)这个等式来做升阶。

  1. 先以二阶升三阶为例,二阶贝塞尔曲线坐标公式为:
B(t) = (1 - t)^2P_0 + 2t(1 - t)P_1 + t^2P_2

将以下等式带入上面这个公式中:

P_0=(1-t)P_0 + tP_0
P_1=(1-t)P_1 + tP_1
P_2=(1-t)P_2 + tP_2

然后得出以下公式:

B(t) = (1-t)^3P_0 + (1-t)^2tP_0 + 2t(1-t)^2P_1
+ 2t^2(1-t)P_1 + t^2(1-t)P_2 + t^3P_2
=(1-t)^3P_0 + 3(1-t)^2t\frac{P_0+2P_1}{3} + 3(1-t)t^2\frac{2P_1+P_2}{3} + t^3P_2

根据以上结果可以得出控制点由之前的P_0,P_1,P_2变成了P_0\frac{P_0+2P_1}{3}\frac{2P_1+P_2}{3}P_2四个控制点了,从而完成了升阶。

  1. 如果对于任意的n值,我们该如何进行升阶呢?(以下为推导过程,没兴趣的同学可以直接跳转至下面👇的公式)

这里需要进行一些推导(这里的推导需要用到C_n^{i}公式,有兴趣的同学可以自己推导一下),因为:

(1-t)b_{i,n}=\frac{n+1-i}{n+1}b_{i,n+1}
tb_{i,n}=\frac{i+1}{n+1}b_{i+1,n+1}

贝塞尔公式可以表示为:

B(t) = (1-t)\sum_{i=0}^{n}b_{i,n}(t)P_i+t\sum_{i=0}^{n}b_{i,n}(t)P_{i}

带入上述两个等式,得:

B(t) = \sum_{i=0}^{n}\frac{n+1-i}{n+1}b_{i,n+1}(t)P_i+\sum_{i=0}^{n}\frac{i+1}{n+1}b_{i+1,n+1}P_i \quad--\ (0)

因为当i=n+1时:

\frac{i}{n+1}P_{i-1}=0

所以该式可以写成:

\sum_{i=0}^{n}\frac{n+1-i}{n+1}P_i = \sum_{i=0}^{n+1}\frac{n+1-i}{n+1}P_i \quad--\ (1)

又因为:

\sum_{i=0}^{n}\frac{i+1}{n+1}P_{i} = \sum_{i=1}^{n+1}\frac{i}{n+1}P_{i-1}

i=0时:

\frac{i}{n+1}P_{i-1} = 0

所以:

\sum_{i=0}^{n}\frac{i+1}{n+1}P_i=\sum_{i=0}^{n+1}\frac{i}{n+1}P_{i-1} \quad--\ (2)

将上述两个等式(1)和(2)代入公式(0)中,最终可以得出下面这个升阶公式:

B(t) = \sum_{i=0}^{n+1}(\frac{i}{n+1}P_{i-1} + \frac{n+1-i}{n+1}P_i)b_{i,n+1}(t)
B(t) = \sum_{i=0}^{n+1}(P_{i}^{'})b_{i,n+1}(t)
式中\ P_{i}^{'} = \frac{i}{n+1}P_{i-1} + \frac{n+1-i}{n+1}P_i,其中i=0,1,...n+1

关于贝塞尔曲线基本的内容就差不多讲完了,如果您发现不正确或者有补充的地方,欢迎在评论里指出😊。

参考文献