贝塞尔曲线

贝塞尔原理

贝塞尔曲线(Bézier curve),又称贝兹曲线,是应用于二维图形应用程序的数学曲线,贝塞尔曲线的运用十分广泛,可以说贝塞尔曲线奠定了计算机绘图的基础(因为它可以将任何复杂的图形用精确的数学语言进行描述),接下来以二阶贝塞尔曲线为例介绍贝塞尔曲线的定义、性质。
二阶曲线由两个数据点(P0 和 P2),一个控制点(P1)来描述曲线状态,如图所示红色曲线就是二阶贝塞尔曲线

推导过程如下

  1. 在P0P1、P1P2上分别取点A、B满足
  • P0A/P0P1 = P1B/P1P2 = t
  1. 在AB上取点C满足
  • AC/AB = t
    C为贝塞尔曲线上一个点,当t从0-1变化时,所有满足条件的点C构成完整的贝塞尔曲线,公式为

    二阶贝塞尔曲线有两个特性:
  1. 以P0P2为底边,二阶贝塞尔曲线最高点的坐标为P1P3的中点,其中P3为P0P2的中点。
  2. 贝塞尔曲线与P0P1、P1P2分别相切于P0、P2点

三阶贝塞尔曲线如下如所示,构造过程和二阶类似

应用一:实现粘性效果

使用贝塞尔曲线可以实现很多复杂的动画效果,这里介绍一种较为简单的小球粘性下拉动画,效果如下

首先我们定义小球可以下拉的最大高度为MAX_HEIGHT,当小球随手势向下拉动的时候通过当前的高度/MAX_HEIGHT计算出当前的进度progress,后面就通过progress来确定绘制所需要的所有点

如图所示在圆上取点e1,e1与圆心连城的直线与垂线夹角为θ。过e1做圆的切线交基准线L于c1点,取基准线上s1点和e1为数据点,c1为控制点做二阶贝塞尔曲线(右侧类似)即可实现粘性效果。其中随着progress从0到1变化过程中

  • θ从0到105度变化
  • s1从最左侧到距离O点120距离移动
    以下是添加辅助点后的效果

应用二:平滑拟合曲线

在一些绘图应用上会提供用户手动绘制的功能,绘制的曲线其实是一个一个点连接而成的,这样就会导致曲线可能并不光滑,出现很多折角,这种情况就可以用贝塞尔曲线进行拟合,拟合方法如下:

假设绘制路径上有三个连续的点a、b、c,则分别取ab、bc中点A、B,以AB为数据点,b为控制点绘制二阶贝塞尔曲线。
使用如上方法就可以实现平滑曲线绘制,部分代码如下

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
/**
* author:wulinpeng
* date:2019/5/27 22:26
* desc: 平滑绘制Path,将lineTo通过二阶贝塞尔转换
*/
class SmoothCurvePath: Path() {

private val points = mutableListOf<PointF>()

override fun moveTo(x: Float, y: Float) {
super.moveTo(x, y)
points.add(PointF(x, y))
}

override fun lineTo(x: Float, y: Float) {
points.add(PointF(x, y))
makeBezier()
}

private fun makeBezier() {
val size = points.size
val prePoint = points[size - 2]
val curPoint = points[size - 1]
quadTo(prePoint.x, prePoint.y, (prePoint.x + curPoint.x) / 2, (prePoint.y + curPoint.y) / 2)
}

override fun reset() {
super.reset()
points.clear()
}
}

效果如下

应用三:小说仿真翻页效果

目前市面上的小说阅读app基本都提供仿真翻页阅读的功能,本节将详细介绍实现的细节,demo效果如下

原理解析

本节着重讲解实现翻页效果的理论知识,下面这张图是整个翻页效果的精髓所在,接下来以从右下角开始翻页为例进行讲解。
图1
图2
图3

如图2所示,黄色区域为当前页面的背面,蓝色区域为下一页的内容,绿色为当前页面,我们第一步要做的就是确定三块区域对应的Path,这样后面才可以在Canvas上绘制对应的内容。
如图3所示,三角形aeh与三角形feh关于eh对称,aeh也就是我们翻页的部分,但是如果就按照这样来绘制显得不够逼真,没有真实翻页的效果,所以我们需要给翻页部分做一个平滑过渡的效果。经过ag中点做直线cj牌型与eh,交与三角形aeh于b、k点,且c、j为边界上的点。以cb为二阶贝塞尔曲线数据点,e为控制点做贝塞尔曲线,由贝塞尔性质可知该曲线与ce、ab相切,可以达到平滑过渡的效果。同理以ja为二阶贝塞尔曲线数据点,h为控制点做贝塞尔曲线平滑多度ih、ak。分别取两条贝塞尔曲线的定点d、i连接,则d、b、a、k、i5个点连接的区域为图2黄色区域,也就是当前页背页的区域。有了黄色区域的范围,就可以很简单的求得蓝色区域和绿色区域的范围了。

代码实现

实现整体的效果非常复杂,本节重点讲解几个比较关键的点,首先将图3中的点命名

  • a -> mTouch
  • f -> mCorner
  • b/c/d/e -> mBzEnd1/mBzStart1/mBzVertex1/mBzControl1
  • h/i/j/k -> mBzControl2/mBzVertex2/mBzStart2/mBzEnd2
    1.mTouch点的确定
    由于在实际触摸中会出现控制点超出屏幕的情况,所以这时候不能将触摸点当作mTouch的坐标,而是应该进行转换。转换原理是将超出屏幕的控制点缩小到屏幕的临界点(刚好不超出屏幕),计算该缩放的比例,将触摸点按该比例向mCorner点缩小。
    2.获得黄色区域Path
    由于d->b / i->k的曲线为贝塞尔曲线的一部分,我们无法直接通过Path来构造,只能通别的方式来获得到。第一步我们先获取黄色和蓝色区域的并集Path1,这一块没有什么难度,其中quadTo是连接二阶贝塞尔曲线
1
2
3
4
5
6
7
8
9
val path1 = Path().apply {
moveTo(mBzStart1.x, mBzStart1.y)
quadTo(mBzControl1.x, mBzControl1.y, mBzEnd1.x, mBzEnd1.y)
lineTo(mTouch.x, mTouch.y)
lineTo(mBzEnd2.x, mBzEnd2.y)
quadTo(mBzControl2.x, mBzControl2.y, mBzStart2.x, mBzStart2.y)
lineTo(mCornerX, mCornerY)
close()
}

第二步将dbaki通过线段连接得到Path2

1
2
3
4
5
6
7
8
val path2 = Path().apply {
moveTo(mBzVertex1.x, mBzVertex1.y)
lineTo(mBzVertex2.x, mBzVertex2.y)
lineTo(mBzEnd2.x, mBzEnd2.y)
lineTo(mTouch.x, mTouch.y)
lineTo(mBzEnd1.x, mBzEnd1.y)
close()
}

由于Path2使用的是线段相连,所以比我们想要获得的黄色区域要多出一部分,而恰好Path2只有这部分不在Path1中,所以我们可以通过取Path1和Path2的并集求得黄色区域的范围

1
2
3
4
5
6
7
8
val matrix = getSymmetricalMatrix(mCalcData.mBzControl1, mCalcData.mBzControl2)
canvas.save()
canvas.clipPath(mPath1)
canvas.clipPath(mPath2, Region.Op.INTERSECT)
// 当前页面对称变换后的矩形会和背页区域有一定的空隙,故背页不绘制正常bg,只绘制文字,在此之前填充背景色
canvas.drawColor(mBackPageColor)
canvas.drawBitmap(curBitmapWithNormalBg, matrix, null)
canvas.restore()

这里在绘制背页的时候需要注意两个点

  1. 绘制文字的时候需要做关于eh的对称变化,这样才有背页的效果,我们需要做的是构造一个矩阵来实现,若点A1(X1,Y1)关于直线y=kx+b成轴对称,则对应点A2(X2,Y2)的坐标为

那么就可以求出对应的矩阵

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
/**
* 关于两个control连线的对称矩阵
*/
private fun getSymmetricalMatrix(point1: PointF, point2: PointF): Matrix {
val k = (point1.y - point2.y) / (point1.x - point2.x)
val values = FloatArray(9)
values[0] = -1 * (k * k - 1) / (k * k + 1)
values[1] = 2 * k / (k * k + 1)
values[3] = values[1]
values[4] = -values[0]
values[8] = 1f

val b = point1.y - point1.x * k
values[2] = -2 * k * b / (k * k + 1)
values[5] = 2 * b / (k * k + 1)

val matrix = Matrix()
matrix.setValues(values)
return matrix
}
  1. 由于背页使用了贝塞尔曲线进行平滑扩展,所以绘制的区域是比实际区域要大,在绘制背页的时候不能带背景图片,只能用纯色填充,不然会出现空白区域