Canvas-系列
Canvas 2D 基础
Canvas 2D 进阶
Canvas 2D 贝塞尔曲线
Canvas 2D 事件
开发了一个 Canvas 2D 渲染引擎

前言

Canvas 提供的贝塞尔函数只能一次性绘制,而无法阶段性绘制,也就无法直接实现动画,并且只提供了二阶和三阶,有时候还需要更多阶。

最重要一点是,原生的贝塞尔曲线不太好做碰撞检测,判断一个点是否在一个封闭的直边多边形内部相对是比较容易的。

贝塞尔曲线于1962年,由法国工程师皮埃尔·贝塞尔(Pierre Bézier)所广泛发表,他运用贝塞尔曲线来为汽车的主体进行设计。贝塞尔曲线最初由保尔·德·卡斯特里奥于1959年运用德卡斯特里奥算法开发,以稳定数值的方法求出贝塞尔曲线。贝塞尔曲线由 n 个控制点对应着 n-1 阶的贝塞尔曲线,并且可以通过递归的方式来绘制。

参考:
深入浅出贝塞尔曲线
贝塞尔曲线 javascript.info
从零开始学图形学:10分钟看懂贝塞尔曲线
用canvas绘制一个曲线动画——深入理解贝塞尔曲线
用Javascript+Canvas画N阶贝塞尔曲线
canvas实现高阶贝塞尔曲线

公式推导

下面是贝塞尔曲线的公式。

基本过程:

  1. 贝塞尔曲线由 n 个控制点绘制,各个点依次连成折线
  2. 第一个点到 n-1 个点都发出一个运动点,每个运动点以相同时间到达下一个相邻点。
  3. 一共有 n-1 个运动点,此时出现递归,可以视为 n-1 个控制点的贝塞尔曲线绘制。
  4. 注意,递归过程形成的所有运动点,都同时到达下一个相邻点。
  5. 最终,递归到一阶贝塞尔曲线,其运动点的轨迹就是要绘制的贝塞尔曲线。

这么说还是有些抽象,下面从一阶开始实际推导下。

一阶

一阶贝塞尔曲线具有两个点,也就是一条直线。t 是单位时间,值为 [0, 1]。

很显然,运动点 Pt 坐标可以这么计算:
Pt = P0 + (P1 - P0)t = (1 - t)P0 + tP1

二阶

二阶贝塞尔曲线具有三个点,已经可以绘制为曲线。

Pa 和 Pb 两个运动点都要以相同时间到达下一个相邻点,于是有:
|P0Pa| / |P0P1| = |P1Pb| / |P1P2| = t

每个运动点在各自线段上都可视为一阶贝塞尔曲线:
Pa = (1 - t)P0 + tP1
Pb = (1 - t)P1 + tP2
Pt = (1 - t)Pa + tPb

将 Pa、Pb 代入 Pt,可以得到:
Pt = (1 - n)^2P0 + 2t(1 - t)P1 + t^2P2

更多阶数也是如此,推导出的 Pt 已经符合公式了。

实现

明白了公式,就可以开始手写贝塞尔曲线了。

新建一个 Bezier 类,传入 canvas 上下文。

1
2
3
4
5
class Bezier {
constructor(ctx) {
this.ctx = ctx;
}
}

计算运动点

也就是计算一阶贝塞尔曲线。

1
2
3
4
5
6
7
8
// 计算两个点之间运动点位置
calcMotionPoint(p1, p2, t) {
// 也就是一阶贝塞尔曲线公式
return {
x: (1 - t) * p1.x + t * p2.x,
y: (1 - t) * p1.y + t * p2.y,
};
}

绘制曲线

设计上,采用增量更新,传入曲线点集合,只画最后两个点的连线。

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
// 绘制贝塞尔曲线
drawCurve(curvePoints, isAnimation, p2d) {
// 任务很简单,把所有的点连起来就行了,这里采取增量绘制
const len = curvePoints.length;
if (isAnimation) {
const x = curvePoints[len - 1].x;
const y = curvePoints[len - 1].y;
// 对于动画,使用Path2D对象,保存上次绘制的路径
p2d.lineTo(x, y);
this.ctx.stroke(p2d);
// 绘制圆圈
this.ctx.save();
this.ctx.beginPath();
this.ctx.strokeStyle = "blue";
this.ctx.lineWidth = 1;
this.ctx.arc(x, y, 5, 0, 2 * Math.PI);
this.ctx.stroke();
this.ctx.restore();
} else {
// 对于静态绘制,直接增量绘制
if (len < 3) return;
this.ctx.beginPath();
this.ctx.moveTo(curvePoints[len - 3].x, curvePoints[len - 3].y);
this.ctx.lineTo(curvePoints[len - 2].x, curvePoints[len - 2].y);
this.ctx.lineTo(curvePoints[len - 1].x, curvePoints[len - 1].y);
this.ctx.stroke();
}
}

递归降阶

这里递归将 n 阶贝塞尔曲线降为 1 阶,每次递归都调用 calcMotionPoint() 计算当前运动点(下一阶的控制点)位置,最后 1 阶的运动点就是曲线上的点,将其加入曲线点集合数组,并调用 drawCurve() 增量绘制。

绘制出控制点之间的连线,更加直观。

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
// 计算和连线各个控制点和运动点
drawLines(points, t, curvePoints, isAnimation, p2d, color) {
// 点小于2时,说明是最终运动点,即贝塞尔曲线上的点
if (points.length < 2) {
curvePoints.push({ ...points[0] });
this.drawCurve(curvePoints, isAnimation, p2d);
return;
}
if (isAnimation) {
// 绘制控制点连线
this.ctx.save();
for (let i = 0; i < points.length; i++) {
// 绘制控制点连线
this.ctx.beginPath();
this.ctx.strokeStyle = color ?? "red";
this.ctx.lineWidth = 2;
i > 0 && this.ctx.moveTo(points[i - 1].x, points[i - 1].y);
this.ctx.lineTo(points[i].x, points[i].y);
this.ctx.stroke();
// 端点绘制圆圈
this.ctx.beginPath();
this.ctx.strokeStyle = "black";
this.ctx.lineWidth = 1;
this.ctx.arc(points[i].x, points[i].y, 5, 0, 2 * Math.PI);
this.ctx.stroke();
}
this.ctx.restore();
}
// 下一阶新的控制点数组
const newPoints = [];
// 遍历所有控制点,算出下一阶的控制点
for (let i = 0; i < points.length - 1; i++) {
newPoints.push(this.calcMotionPoint(points[i], points[i + 1], t));
}
// 递归调用,计算从n阶到1阶的所有控制点
this.drawLines(newPoints, t, curvePoints, isAnimation, p2d, null);
}

静态绘制

1
2
3
4
5
6
7
8
9
10
11
12
draw(controlPoints, t = 1) {
if (!controlPoints.length) return;
const curvePoints = [];
let i = 0;
while (i <= t) {
this.drawLines(controlPoints, i, curvePoints, false, null, "blue");
i += 0.01;
}
// 补偿绘制最后一段
this.drawLines(controlPoints, t, curvePoints, false, null, "blue");
return curvePoints;
}

动画绘制

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 绘制动画
drawAnimation(controlPoints, t = 1) {
if (!controlPoints.length) return;
return new Promise((resolve) => {
const curvePoints = [];
let i = 0;
const p2d = new Path2D();
const draw = () => {
ctx.clearRect(0, 0, canvas.width, canvas.height);
if (i > t) {
// 补偿绘制最后一段
this.drawLines(controlPoints, t, curvePoints, true, p2d, "blue");
resolve(curvePoints);
return;
}
this.drawLines(controlPoints, i, curvePoints, true, p2d, "blue");
i += 0.01;
requestAnimationFrame(draw);
};
draw();
});
}

完整代码

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
class Bezier {
constructor(ctx) {
this.ctx = ctx;
}

// 静态绘制
draw(controlPoints, t = 1) {
if (!controlPoints.length) return;
const curvePoints = [];
let i = 0;
while (i <= t) {
this.drawLines(controlPoints, i, curvePoints, false, null, "blue");
i += 0.01;
}
// 补偿绘制最后一段
this.drawLines(controlPoints, t, curvePoints, false, null, "blue");
return curvePoints;
}

// 绘制动画
drawAnimation(controlPoints, t = 1) {
if (!controlPoints.length) return Promise.resolve([]);
return new Promise((resolve) => {
const curvePoints = [];
let i = 0;
const p2d = new Path2D();
const draw = () => {
ctx.clearRect(0, 0, canvas.width, canvas.height);
if (i > t) {
// 补偿绘制最后一段
this.drawLines(controlPoints, t, curvePoints, true, p2d, "blue");
resolve(curvePoints);
return;
}
ctx.fillText(`t = (${i.toFixed(2)})`, 10, 20);
this.drawLines(controlPoints, i, curvePoints, true, p2d, "blue");
i += 0.01;
requestAnimationFrame(draw);
};
draw();
});
}

// 计算和连线各个控制点和运动点
drawLines(points, t, curvePoints, isAnimation, p2d, color) {
// 点小于2时,说明是最终运动点,即贝塞尔曲线上的点
if (points.length < 2) {
curvePoints.push({ ...points[0] });
this.drawCurve(curvePoints, isAnimation, p2d);
return;
}
if (isAnimation) {
// 绘制控制点连线
this.ctx.save();
for (let i = 0; i < points.length; i++) {
// 绘制控制点连线
this.ctx.beginPath();
this.ctx.strokeStyle = color ?? "red";
this.ctx.lineWidth = 2;
i > 0 && this.ctx.moveTo(points[i - 1].x, points[i - 1].y);
this.ctx.lineTo(points[i].x, points[i].y);
this.ctx.stroke();
// 端点绘制圆圈
this.ctx.beginPath();
this.ctx.strokeStyle = "black";
this.ctx.lineWidth = 1;
this.ctx.arc(points[i].x, points[i].y, 5, 0, 2 * Math.PI);
this.ctx.stroke();
}
this.ctx.restore();
}
// 下一阶新的控制点数组
const newPoints = [];
// 遍历所有控制点,算出下一阶的控制点
for (let i = 0; i < points.length - 1; i++) {
newPoints.push(this.calcMotionPoint(points[i], points[i + 1], t));
}
// 递归调用,计算从n阶到1阶的所有控制点
this.drawLines(newPoints, t, curvePoints, isAnimation, p2d, null);
}

// 绘制贝塞尔曲线
drawCurve(curvePoints, isAnimation, p2d) {
// 任务很简单,把所有的点连起来就行了,这里采取增量绘制
const len = curvePoints.length;
if (isAnimation) {
const x = curvePoints[len - 1].x;
const y = curvePoints[len - 1].y;
// 对于动画,使用Path2D对象,保存上次绘制的路径
p2d.lineTo(x, y);
this.ctx.stroke(p2d);
// 绘制圆圈
this.ctx.save();
this.ctx.beginPath();
this.ctx.strokeStyle = "blue";
this.ctx.lineWidth = 1;
this.ctx.arc(x, y, 5, 0, 2 * Math.PI);
this.ctx.stroke();
this.ctx.restore();
} else {
// 对于静态绘制,直接增量绘制
if (len < 3) return;
this.ctx.beginPath();
this.ctx.moveTo(curvePoints[len - 3].x, curvePoints[len - 3].y);
this.ctx.lineTo(curvePoints[len - 2].x, curvePoints[len - 2].y);
this.ctx.lineTo(curvePoints[len - 1].x, curvePoints[len - 1].y);
this.ctx.stroke();
}
}

// 计算两个点之间运动点位置
calcMotionPoint(p1, p2, t) {
// 也就是一阶贝塞尔曲线公式
return {
x: (1 - t) * p1.x + t * p2.x,
y: (1 - t) * p1.y + t * p2.y,
};
}
}

使用

在线使用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const points = [
{ x: 120, y: 120 },
{ x: 100, y: 200 },
{ x: 300, y: 200 },
{ x: 400, y: 200 },
{ x: 200, y: 20 },
{ x: 40, y: 20 },
{ x: 30, y: 200 },
];
const bezier = new Bezier(ctx);
ctx.lineWidth = 4;
bezier.drawAnimation(points).then((res) => {
console.log(res);
});
// console.log(bezier.draw(points));