canvas学习:贝塞尔曲线
前言
在学习Canvas绘图的过程中贝塞尔曲线是我遇到的又一个"拦路虎"。受限于自身贫瘠的计算机和数学知识我一开始并没有弄懂它,所幸网络上有许多优秀的介绍文章,它们帮助我掌握了贝塞尔曲线,如今我写下这篇文章简单的介绍一下Canvas当中的贝塞尔曲线。
1.什么是贝塞尔曲线?
贝塞尔曲线(Bezier curve)是一种数学曲线,常用于计算机图形学和计算机辅助设计(CAD)中。贝塞尔曲线由法国工程师皮埃尔·贝塞尔(Pierre Bézier)在1962年提出,用于描述平滑曲线的路径。
贝塞尔曲线通常由控制点(control points)来定义,这些控制点确定了曲线的形状。贝塞尔曲线可以是一维、二维或更高维的。
贝塞尔曲线在计算机图形学中被广泛应用,用于绘制平滑的曲线和曲面,如绘制字体、CAD软件中的曲线绘制等。其简单的数学描述和良好的特性使得贝塞尔曲线成为计算机图形学中重要的工具之一。
2.贝塞尔曲线构造过程解析
下面我们就简单的来解析一下一个二次贝塞尔曲线,请看下面的解析图:
上图中的红色曲线正是一条贝塞尔曲线,而图中的 P0、P1、P2 分别称之为控制点,贝塞尔曲线的产生完全与这三个点位置相关。
这三个控制点就分别构成了两条灰色的线P0P1和P1P2。现在我们从两条线上各选取一个点即Q0、Q1。Q0、Q1需要满足以下的公式:
接着我们再将Q0、Q1相连,就能得到上图中的绿色线Q0Q1,在这条线上我们再取一个点B,点B也要满足上述规律:
令上述等式等于 t,t 肯定是 [0,1] 的,其意义是点在它所处线段的位置。那么随着 t 的增大,Q0、Q1、B 的位置也就随之确定了!最终 B 的轨迹,便构成了贝塞尔曲线。
上面分析的是二次贝塞尔曲线,仔细观察它的构造过程,我们不难发现其存在递归性质。
首先,有三个控制点;三个控制点形成两个线段,每个线段上有一个点在运动,于是得到两个点;两个点形成一个线段,这个线段上有一个点在运动,于是得到一个点;最后一个点的运动轨迹便构成了贝塞尔曲线!
我们发现,实际上是每轮都是 n 个点,形成 n-1 条线段,每个线段上有一个点在运动,那么就只关注这 n-1 个点,循环往复。最终只剩一个点时,它的轨迹便是结果。
那么,似乎最开始的控制点,也不一定是三个?如果是四个、五个,甚至更多呢?
当然是有的,其实我们一开始介绍的有三个控制点的就是二次贝塞尔曲线(要递归两次),而四个、五个控制点的就是三次、四次贝塞尔曲线。
三次贝塞尔曲线
四次贝塞尔曲线
3.canvas中使用贝塞尔曲线
在canvas中有绘制二次贝塞尔曲线路径和三次贝塞尔曲线路径的方法:
1、quadraticCurveTo(cx, cy, x, y)
****以(cx, cy)为控制点,绘制一条从上一点到(x, y)的弧线(二次贝塞尔曲线)。
2、bezierCurveTo(c1x, c1y, c2x, c2y, x, y)
:以(c1x, c1y)和(c2x,c2y)为控制点,绘制一条从上一点到(x, y)的弧线(三次贝塞尔曲线)。
接着我们就可以尝试封装一个方法drawCurvePath
这个方法可以基于二次贝塞尔曲线去绘制一条曲线路径。
当然最简单的方法我们可以这样封装:
/**
* 绘制二次贝赛尔曲线路径
* @param {CanvasRenderingContext2D} ctx
* @param {Array<number>} p0
* @param {Array<number>} p1
* @param {Array<number>} p2
*/
function drawCurvePath( ctx, p0, p1, p2 ) {
ctx.moveTo( p0[ 0 ], p0[ 1 ] );
ctx.quadraticCurveTo(
p1[ 0 ], p1[ 1 ],
p2[ 0 ], p2[ 1 ]
);
}
但是函数这样设计有点小问题。如果我们是在做一个图形库,我们想给使用者提供一个绘制曲线的方法。
对于使用者来说,他只想在给定的起点和终点间间绘制一条曲线,他想要得到的曲线尽量美观,但是又不想关心具体的实现细节,如果还需要给第三个点,使用者会有一定的学习成本(就像我刚接触的时候一样至少需要弄明白什么是贝塞尔曲线)。
我们可以在起点和终点的垂直平分线上选一点作为第三个控制点,可以提供给使用者一个参数来控制曲线的弯曲程度,现在函数就变成了这样:
/**
* 绘制一条曲线路径
* @param {Object} ctx canvas渲染上下文
* @param {Array<number>} start 起点
* @param {Array<number>} end 终点
* @param {number} curveness 曲度(0-1)
*/
function drawCurvePath( ctx, start, end, curveness ) {
// 计算中间控制点
var cp = [
( start[ 0 ] + end[ 0 ] ) / 2 - ( start[ 1 ] - end[ 1 ] ) * curveness,
( start[ 1 ] + end[ 1 ] ) / 2 - ( end[ 0 ] - start[ 0 ] ) * curveness
];
ctx.moveTo( start[ 0 ], start[ 1 ] );
ctx.quadraticCurveTo(
cp[ 0 ], cp[ 1 ],
end[ 0 ], end[ 1 ]
);
}
接着我们就可以尝试使用drawCurvePath
方法绘制一条曲线
ctx.lineWidth = 6
ctx.strokeStyle = 'red'
ctx.beginPath()
drawCurvePath(ctx, [100, 100], [200, 300], 0.4)
ctx.stroke()
4.绘制贝塞尔曲线解析图
在这篇文章中你所看到的贝塞尔曲线动图都是我通过canvas绘制的。之前我在查阅资料的时候,就在许多文章的看到这样的图片,于是我就自己实现了它,下面我简单的介绍一下我是如何绘制的。
(1) 准备工作
首先我们需要先准备两个函数。第一个函数的作用是在已知两个点的坐标以及比率 t 的情况下计算,两点连线上的点坐标。例如:已知Q1Q2计算B点的坐标。
/**
*t-根据t从线段上取点
* @param {Array<number>} from 线段起点
* @param {Array<number>} to 线段重点
* @param {number} t 所求的点到起点的长度与线段总长度之比
*/
function getPointDependT(from, to, t) {
return [(to[0] - from[0]) * t + from[0], (to[1] - from[1]) * t + from[1]]
}
第二个函数的作用是依据贝塞尔曲线的原理,通过已知的控制点算出每一级对应的点。
例如在下面的这个二次贝塞尔曲线的示例中就是要求出如下的点:
- 控制点:P0、P1、P2
- 第一级: Q0、Q1
- 第二级: B
/**
*t-根据控制点计算出贝塞尔曲线每一级的点
* @param {Array} from 控制点坐标
* @param {number} t 所求的点到起点的长度与线段总长度之比
*/
function getBezierPoints(cps, t) {
const res = [cps]
while (cps.length > 1) {
cps = cps.reduce((accumulator, cp, i, arr) => {
if (i !== arr.length - 1) {
accumulator.push(getPointDependT(cp, arr[i + 1], t))
}
return accumulator
}, [])
res.push(cps)
}
return res
}
(2) 绘制一帧图像
在绘制动画之前我们要先绘制一帧的图像,这里我以三次贝塞尔曲线图为例。
我们预先设置好 t 和控制点的值:
let t = 0.5
// 控制点坐标
const P0 = [200, 350],
P1 = [150, 100],
P2 = [550, 100],
P3 = [750, 350]
然后利用准备好的getPointDependT
方法计算出所有的点:
const allPoint = getBezierPoints([P0, P1, P2, P3], t)
之后画出每一级的点和线(其中的drawPoints
、drawLine
等都是我封装的绘图方法,由于比较简单这里就不详细介绍了):
const [cps, first, second, third] = allPoint
//控制点
drawPoints(ctx, cps, { color: '#d0d0d0', pixelSize: 6 })
drawLine(ctx, cps, {
color: '#d0d0d0',
width: 6,
})
//第一级
drawPoints(ctx, first, { color: '#19c764', pixelSize: 6 })
drawLine(ctx, first, {
color: '#19c764',
width: 6,
})
//第二级
drawPoints(ctx, second, { color: 'blue', pixelSize: 6 })
drawLine(ctx, second, {
color: 'blue',
width: 6,
})
// 最后一个点
const B = third[0]
drawPoint(ctx, B, { pixelSize: 6 })
最后,我们要绘制从P0到B的弧线,但是这条弧线是不能随意绘制的,它实际上也是一条三次贝塞尔曲线,控制点为Q0和C0。
因此我们就可以使用ctx.bezierCurveTo()
方法进行绘制:
ctx.lineWidth = 6
ctx.strokeStyle = 'red'
ctx.beginPath()
ctx.moveTo(...cps[0])
ctx.bezierCurveTo(...first[0], ...second[0], ...B)
ctx.stroke()
这样一帧的内容就绘制完成了
(3) 实现三次贝塞尔曲线动画
实现动画就很简单了, canvas中绘制动画的基本步骤如下:
- 清除之前绘制的内容,由于在canvas中,已经绘制的图形是没有办法再修改的,因此想要实现动画就要不断地清除上一帧的内容,可以使用
clearRect()
实现清除。 - 使用
save()
保存canvas状态 - 绘制动画帧
- 使用
restore()
恢复canvas状态 - 循环绘制动画,可以使用计时器或者递归调用
requestAnimationFrame()
根据以上的步骤我们就能够写出完整的动画代码:
let t = 0
// t-三次贝塞尔
function cubicBezier() {
ctx.clearRect(100, 50, 750, 350)
ctx.save()
// 控制点坐标
const P0 = [200, 350],
P1 = [150, 100],
P2 = [550, 100],
P3 = [750, 350]
const allPoint = getBezierPoints([P0, P1, P2, P3], t)
const [cps, first, second, third] = allPoint
//控制点
drawPoints(ctx, cps, { color: '#d0d0d0', pixelSize: 6 })
drawLine(ctx, cps, {
color: '#d0d0d0',
width: 6,
})
//第一级
drawPoints(ctx, first, { color: '#19c764', pixelSize: 6 })
drawLine(ctx, first, {
color: '#19c764',
width: 6,
})
//第二级
drawPoints(ctx, second, { color: 'blue', pixelSize: 6 })
drawLine(ctx, second, {
color: 'blue',
width: 6,
})
// 最后一个点
const B = third[0]
drawPoint(ctx, B, { pixelSize: 6 })
ctx.lineWidth = 6
ctx.strokeStyle = 'red'
ctx.beginPath()
ctx.moveTo(...cps[0])
ctx.bezierCurveTo(...first[0], ...second[0], ...B)
ctx.stroke()
const nameList = ['P0', 'P1', 'P2', 'P3', 'Q0', 'Q1', 'Q2', 'C0', 'C1', 'B']
addTexts(
ctx,
// allPoint.flat().map((p, i) => [nameList[i], [p[0] + 23, p[1] + 15]]),
cps
.map((p, i) => [nameList[i], [p[0] + 23, p[1] + 15]])
.concat([
[`t=`, [450, 350]],
[`${t.toFixed(2)}`, [480, 350]],
]),
{
fontSize: '20px',
}
)
ctx.restore()
t += 0.001
if (t > 1) t = 0
if (t == 0.001) {
setTimeout(requestAnimationFrame, 3000, cubicBezier)
} else {
requestAnimationFrame(cubicBezier)
}
}
cubicBezier()
(4)其它贝塞尔曲线的绘制方法
之前我演示了如何绘制三次贝塞尔曲线的动画,如果需要绘制其它的,其实整体的步骤基本一致,唯一值得注意的就是红色曲线该怎么画?
如果是二次贝塞尔曲线,直接使用ctx.quadraticCurveTo()
方法即可绘制,但是如果是三次以上的贝塞尔曲线就没有现成的canvas方法来绘制了,这就需要我们自己封装一个方法。
这里我就以四次贝塞尔曲线为例,此时我们同样是要绘制从P0到B点的弧线,这个弧线是一条以Q0、C0、D0为控制点的四次贝塞尔曲线。
这里我绘制这条曲线的基本思路就是:通过贝赛尔曲线的方程计算出一系列点,用多段直线来模拟曲线。基于这个思路我就封装了一个绘制贝塞尔曲线路径的方法:
/**
*t- 绘制一条贝塞尔曲线路径
* @param {*} ctx canvas绘图上下文
* @param {Array} cps 控制点(加上起点终点)坐标数组
*/
function bezierPath(ctx, cps) {
const curvePoints = []
for (let t = 0; t <= 1; t += 0.01) {
curvePoints.push(getBezierPoints(cps, t).at(-1).flat())
}
curvePoints.forEach((p) => {
ctx.lineTo(...p)
})
}
使用这个方法就可以绘制出四次贝塞尔动画中的红色弧线了:
ctx.lineWidth = 6
ctx.strokeStyle = 'red'
ctx.beginPath()
ctx.moveTo(...cps[0])
bezierPath(ctx, [cps[0], first[0], second[0], third[0], B])
ctx.stroke()
之后就可以实现四次贝塞尔曲线的动画了,完整代码如下:
let t = 0
// t-四次贝塞尔
function forthBezier() {
ctx.clearRect(100, 550, 750, 350)
ctx.save()
// 控制点坐标
const P0 = [200, 850],
P1 = [150, 600],
P2 = [550, 600],
P3 = [750, 850],
P4 = [850, 650]
const allPoint = getBezierPoints([P0, P1, P2, P3, P4], t)
const [cps, first, second, third, forth] = allPoint
//控制点
drawPoints(ctx, cps, { color: '#d0d0d0', pixelSize: 6 })
drawLine(ctx, cps, {
color: '#d0d0d0',
width: 6,
})
//第一级
drawPoints(ctx, first, { color: '#19c764', pixelSize: 6 })
drawLine(ctx, first, {
color: '#19c764',
width: 6,
})
//第二级
drawPoints(ctx, second, { color: 'blue', pixelSize: 6 })
drawLine(ctx, second, {
color: 'blue',
width: 6,
})
//第三级
drawPoints(ctx, third, { color: '#EA27C2', pixelSize: 6 })
drawLine(ctx, third, {
color: '#EA27C2',
width: 6,
})
// 最后一个点
const B = forth[0]
drawPoint(ctx, B, { pixelSize: 6 })
ctx.lineWidth = 6
ctx.strokeStyle = 'red'
ctx.beginPath()
ctx.moveTo(...cps[0])
bezierPath(ctx, [cps[0], first[0], second[0], third[0], B])
ctx.stroke()
const nameList = [
'P0',
'P1',
'P2',
'P3',
'P4',
'Q0',
'Q1',
'Q2',
'Q3',
'C0',
'C1',
'C2',
'D0',
'D1',
'B',
]
addTexts(
ctx,
// allPoint
// .flat()
// .map((p, i) => [nameList[i], [p[0] + 23, p[1] + 15]])
cps
.map((p, i) => [nameList[i], [p[0] + 23, p[1] + 15]])
.concat([
[`t=`, [450, 850]],
[`${t.toFixed(2)}`, [480, 850]],
]),
{
fontSize: '20px',
}
)
ctx.restore()
t += 0.001
if (t > 1) t = 0
if (t == 0.001) {
setTimeout(requestAnimationFrame, 3000, forthBezier)
} else {
requestAnimationFrame(forthBezier)
}
}
forthBezier()
参考资料
转载自:https://juejin.cn/post/7352075813259198514