第3章:路径与贝塞尔曲线
3.1 章节概述
在前两章中,我们学习了如何绑制矩形、圆形、直线等基础图形。但你可能已经注意到,这些都是"规则"的几何形状。在实际应用中,我们经常需要绘制更复杂、更自由的形状——比如平滑的曲线、自定义的图标、流畅的动画轨迹等。
这一章将带你深入理解 Canvas 最核心的绘图机制——路径系统(Path System),以及创建平滑曲线的利器——贝塞尔曲线(Bézier Curve)。
本章内容包括:
- 路径的本质:理解 Canvas 绘图的核心机制
- Path2D 对象:可复用的路径容器
- 二次贝塞尔曲线:一个控制点的曲线
- 三次贝塞尔曲线:两个控制点的曲线
- 贝塞尔曲线的数学原理:理解曲线是如何生成的
- 实战应用:绘制平滑曲线、波浪、签名等
学完本章后,你将能够绘制任意复杂的形状,并理解计算机图形学中曲线表示的基本原理。
3.2 深入理解路径系统
3.2.1 什么是路径?
在 Canvas 中,路径(Path) 是绑制复杂形状的基础。你可以把路径想象成一支隐形的笔在画布上移动留下的轨迹。
让我们用一个生活中的类比来理解:
想象你在沙滩上用树枝画画:
- 你拿起树枝(
beginPath())—— 开始一条新路径 - 你把树枝移动到某个位置,但没有按下去(
moveTo(x, y))—— 移动到起点 - 你把树枝按在沙滩上,拖动画线(
lineTo(x, y))—— 绘制线段 - 你继续拖动,画出曲线(
quadraticCurveTo(...)等)—— 绘制曲线 - 你回到起点,形成封闭图形(
closePath())—— 闭合路径 - 最后,你在画好的图形里填上颜色或用墨水描边(
fill()或stroke())—— 渲染路径
关键理解:路径本身是不可见的!它只是一系列点和线的数学描述。只有调用 fill() 或 stroke() 时,路径才会被"渲染"出来。
3.2.2 路径的生命周期
让我们详细了解路径从创建到渲染的完整过程:
┌──────────────────────────────────────────────────────────────┐
│ 路径的生命周期 │
├──────────────────────────────────────────────────────────────┤
│ │
│ ① beginPath() 开始新路径,清除之前的路径数据 │
│ ↓ │
│ ② 构建路径 moveTo、lineTo、arc、curveTo 等 │
│ ↓ │
│ ③ closePath() 可选,自动连接回起点 │
│ ↓ │
│ ④ fill() / stroke() 渲染路径,使其可见 │
│ │
└──────────────────────────────────────────────────────────────┘代码示例:
const ctx = canvas.getContext('2d');
// ① 开始新路径
ctx.beginPath();
// ② 构建路径
ctx.moveTo(100, 100); // 移动到起点
ctx.lineTo(200, 100); // 画线到第二个点
ctx.lineTo(150, 200); // 画线到第三个点
// ③ 闭合路径(自动连接回起点)
ctx.closePath();
// ④ 渲染路径
ctx.fillStyle = '#4D7CFF';
ctx.fill(); // 填充三角形
ctx.strokeStyle = '#2952CC';
ctx.lineWidth = 3;
ctx.stroke(); // 描边三角形3.2.3 beginPath() 的重要性
beginPath() 是路径系统中最重要的方法之一。它做了两件事:
- 清除当前路径:丢弃之前的所有路径数据
- 开始新路径:准备接收新的路径命令
如果忘记调用 beginPath() 会怎样?
// 错误示例:忘记 beginPath()
// 画第一个圆
ctx.arc(100, 100, 50, 0, Math.PI * 2);
ctx.stroke();
// 画第二个圆(没有 beginPath)
ctx.arc(250, 100, 50, 0, Math.PI * 2);
ctx.stroke();
// 结果:两个圆之间会有一条连线!
// 因为第二个 arc 继续在同一条路径上添加内容图示说明:
忘记 beginPath() 的结果:
╭───────╮ ╭───────╮
╱ ╲───────╱ ╲
│ │连接线│ │
╲ ╱───────╲ ╱
╰───────╯ ╰───────╯
两个圆被一条意外的线连接起来正确做法:
// 正确示例:每个图形使用独立的路径
// 画第一个圆
ctx.beginPath(); // 开始新路径
ctx.arc(100, 100, 50, 0, Math.PI * 2);
ctx.stroke();
// 画第二个圆
ctx.beginPath(); // 再次开始新路径
ctx.arc(250, 100, 50, 0, Math.PI * 2);
ctx.stroke();
// 结果:两个独立的圆,没有连线最佳实践:养成每次绘制新图形前都调用 beginPath() 的习惯。
3.2.4 closePath() 的作用
closePath() 会自动绘制一条从当前点回到路径起点的直线,从而形成一个闭合图形。
// 不使用 closePath
ctx.beginPath();
ctx.moveTo(100, 50);
ctx.lineTo(200, 50);
ctx.lineTo(150, 150);
// 没有 closePath
ctx.stroke();
// 结果:开口的 V 形
// 使用 closePath
ctx.beginPath();
ctx.moveTo(300, 50);
ctx.lineTo(400, 50);
ctx.lineTo(350, 150);
ctx.closePath(); // 自动连接回起点
ctx.stroke();
// 结果:完整的三角形closePath 与 lineTo 的区别:
你可能会问:我能不能用 lineTo 手动画回起点,效果不是一样吗?
// 方法1:使用 closePath
ctx.beginPath();
ctx.moveTo(100, 50);
ctx.lineTo(200, 50);
ctx.lineTo(150, 150);
ctx.closePath();
ctx.stroke();
// 方法2:手动 lineTo 回起点
ctx.beginPath();
ctx.moveTo(300, 50);
ctx.lineTo(400, 50);
ctx.lineTo(350, 150);
ctx.lineTo(300, 50); // 手动连回起点
ctx.stroke();看起来效果一样,但有一个关键区别:lineJoin 的处理。
closePath() 创建的闭合点会正确应用 lineJoin 样式,而手动 lineTo 回起点只是简单地画一条线,在起点处线段会"叠加"而不是"连接"。
使用 closePath: 手动 lineTo:
╱╲ ← 圆角连接 ╱╲ ← 线段叠加
╱ ╲ ╱ ╲
╱ ╲ ╱ ╲
╱──────╲ ╱──────╲
当 lineJoin = 'round' 时差异明显3.2.5 子路径(Sub-path)
一个路径可以包含多个子路径。每次调用 moveTo() 都会开始一个新的子路径:
ctx.beginPath();
// 子路径 1:一个三角形
ctx.moveTo(100, 50);
ctx.lineTo(150, 150);
ctx.lineTo(50, 150);
ctx.closePath();
// 子路径 2:另一个三角形(通过 moveTo 开始新子路径)
ctx.moveTo(250, 50);
ctx.lineTo(300, 150);
ctx.lineTo(200, 150);
ctx.closePath();
// 一次 fill 同时填充两个子路径
ctx.fillStyle = '#4D7CFF';
ctx.fill();子路径的概念图:
一个路径(beginPath 到 fill/stroke)可以包含多个子路径
┌─────────────────────────────────────────┐
│ 一个路径 │
│ ┌──────────────┐ ┌──────────────┐ │
│ │ 子路径 1 │ │ 子路径 2 │ │
│ │ (moveTo... │ │ (moveTo... │ │
│ │ closePath) │ │ closePath) │ │
│ └──────────────┘ └──────────────┘ │
└─────────────────────────────────────────┘这个特性非常有用,比如绘制带孔的图形(利用填充规则)或一次性绘制多个相同样式的图形。
3.3 Path2D 对象
3.3.1 为什么需要 Path2D?
在前面的例子中,我们每次绘制图形都需要重新构建路径。如果同一个图形需要多次绘制,这就很低效了:
// 低效方式:每次都重新构建路径
function drawStar(ctx, x, y, size) {
ctx.beginPath();
// ... 20+ 行代码构建星形路径
ctx.fill();
}
// 需要绘制 100 个星形
for (let i = 0; i < 100; i++) {
drawStar(ctx, Math.random() * 800, Math.random() * 600, 20);
// 每次都重复执行路径构建代码
}Path2D 对象允许我们预先构建路径,然后多次复用:
// 高效方式:使用 Path2D
const starPath = new Path2D();
// ... 构建星形路径(只执行一次)
// 绘制 100 个星形,复用同一个路径
for (let i = 0; i < 100; i++) {
ctx.save();
ctx.translate(Math.random() * 800, Math.random() * 600);
ctx.fill(starPath); // 直接使用预构建的路径
ctx.restore();
}3.3.2 创建 Path2D
Path2D 是一个构造函数,有几种创建方式:
方式1:空的 Path2D,手动添加路径
const path = new Path2D();
// 使用与 ctx 相同的方法构建路径
path.moveTo(100, 100);
path.lineTo(200, 100);
path.lineTo(150, 200);
path.closePath();
// 使用路径
ctx.fill(path);
ctx.stroke(path);方式2:从 SVG 路径字符串创建
// SVG 路径语法:M=moveTo, L=lineTo, Z=closePath
const triangle = new Path2D('M 100 100 L 200 100 L 150 200 Z');
ctx.fill(triangle);方式3:从另一个 Path2D 复制
const original = new Path2D();
original.rect(0, 0, 100, 100);
const copy = new Path2D(original); // 复制路径
// 可以继续在复制的路径上添加内容
copy.arc(50, 50, 30, 0, Math.PI * 2);3.3.3 Path2D 支持的方法
Path2D 支持几乎所有用于构建路径的方法:
const path = new Path2D();
// 基础路径方法
path.moveTo(x, y);
path.lineTo(x, y);
path.closePath();
// 矩形
path.rect(x, y, width, height);
// 圆弧
path.arc(x, y, radius, startAngle, endAngle, counterclockwise);
path.arcTo(x1, y1, x2, y2, radius);
// 贝塞尔曲线
path.quadraticCurveTo(cpx, cpy, x, y);
path.bezierCurveTo(cp1x, cp1y, cp2x, cp2y, x, y);
// 椭圆(部分浏览器支持)
path.ellipse(x, y, radiusX, radiusY, rotation, startAngle, endAngle);
// 合并另一个路径
path.addPath(anotherPath, transform);3.3.4 SVG 路径语法
Path2D 支持 SVG 路径字符串,这是一个非常强大的特性。SVG 路径语法使用字母命令来描述路径:
| 命令 | 参数 | 说明 |
|---|---|---|
| M/m | x y | MoveTo - 移动到点 |
| L/l | x y | LineTo - 画直线到点 |
| H/h | x | 水平线 |
| V/v | y | 垂直线 |
| Z/z | 无 | 闭合路径 |
| Q/q | cx cy x y | 二次贝塞尔曲线 |
| C/c | c1x c1y c2x c2y x y | 三次贝塞尔曲线 |
| A/a | rx ry rotation large-arc sweep x y | 椭圆弧 |
大写命令使用绝对坐标,小写命令使用相对坐标。
示例:
// 绘制一个心形
const heart = new Path2D(`
M 150 50
C 150 40, 130 20, 100 20
C 50 20, 50 70, 50 70
C 50 110, 90 140, 150 180
C 210 140, 250 110, 250 70
C 250 70, 250 20, 200 20
C 170 20, 150 40, 150 50
Z
`);
ctx.fillStyle = '#FF6B6B';
ctx.fill(heart);从设计软件导出 SVG 路径:
你可以在 Figma、Illustrator 等设计软件中绘制图形,导出为 SVG,然后提取其中的 d 属性:
<!-- SVG 文件中的路径 -->
<path d="M 10 80 Q 95 10 180 80" fill="none" stroke="black"/>// 在 Canvas 中使用
const myPath = new Path2D('M 10 80 Q 95 10 180 80');
ctx.stroke(myPath);3.3.5 addPath() - 组合路径
addPath() 方法可以将一个 Path2D 添加到另一个中,还可以同时应用变换:
// 创建一个基础形状
const shape = new Path2D();
shape.rect(0, 0, 50, 50);
// 创建组合路径
const combined = new Path2D();
// 添加原始位置的形状
combined.addPath(shape);
// 添加平移后的形状
const matrix = new DOMMatrix();
matrix.translateSelf(100, 0);
combined.addPath(shape, matrix);
// 添加旋转后的形状
const matrix2 = new DOMMatrix();
matrix2.translateSelf(200, 25); // 先平移
matrix2.rotateSelf(45); // 再旋转
matrix2.translateSelf(-25, -25); // 调整中心点
combined.addPath(shape, matrix2);
// 一次性绘制所有形状
ctx.fill(combined);3.3.6 Path2D 实战:创建可复用的图标库
// 创建一个图标库
const icons = {
// 对勾图标
check: new Path2D('M 9 16.17 L 4.83 12 l -1.42 1.41 L 9 19 L 21 7 l -1.41 -1.41 Z'),
// 叉号图标
close: new Path2D(`
M 19 6.41 L 17.59 5 L 12 10.59 L 6.41 5 L 5 6.41 L 10.59 12
L 5 17.59 L 6.41 19 L 12 13.41 L 17.59 19 L 19 17.59 L 13.41 12 Z
`),
// 星形图标
star: new Path2D(`
M 12 2 L 15.09 8.26 L 22 9.27 L 17 14.14 L 18.18 21.02
L 12 17.77 L 5.82 21.02 L 7 14.14 L 2 9.27 L 8.91 8.26 Z
`),
};
/**
* 绘制图标
* @param {string} name - 图标名称
* @param {number} x - x 坐标
* @param {number} y - y 坐标
* @param {number} size - 图标大小
* @param {string} color - 颜色
*/
function drawIcon(ctx, name, x, y, size, color = '#333') {
const icon = icons[name];
if (!icon) return;
ctx.save();
ctx.translate(x, y);
ctx.scale(size / 24, size / 24); // 图标基准大小是 24x24
ctx.fillStyle = color;
ctx.fill(icon);
ctx.restore();
}
// 使用
drawIcon(ctx, 'check', 50, 50, 48, '#4CAF50');
drawIcon(ctx, 'close', 120, 50, 48, '#F44336');
drawIcon(ctx, 'star', 190, 50, 48, '#FFC107');3.4 贝塞尔曲线概述
3.4.1 什么是贝塞尔曲线?
贝塞尔曲线(Bézier Curve) 是计算机图形学中最重要的曲线表示方法之一。它以法国工程师皮埃尔·贝塞尔(Pierre Bézier)的名字命名,他在 1960 年代为雷诺汽车公司开发了这种曲线用于汽车车身设计。
贝塞尔曲线的核心思想:用一组控制点来定义一条平滑的曲线。
- 起点和终点:曲线经过的两个端点
- 控制点:不在曲线上,但"拉动"曲线向自己弯曲
为什么贝塞尔曲线如此重要?
- 直观控制:通过移动控制点可以直观地调整曲线形状
- 数学简洁:可以用简单的参数方程精确描述
- 计算高效:易于细分和渲染
- 应用广泛:从字体设计到动画曲线,无处不在
在 Canvas 中,我们主要使用两种贝塞尔曲线:
- 二次贝塞尔曲线:1 个控制点
- 三次贝塞尔曲线:2 个控制点
3.4.2 贝塞尔曲线的"控制"原理
在深入数学之前,让我们先建立直觉。
想象一根有弹性的线:
- 你固定线的两端(起点和终点)
- 然后用手指按住线的某个位置,把它拉向一侧
- 线就会形成一条平滑的曲线
- 你手指的位置就是"控制点"
直线状态:
起点 ●─────────────────────● 终点
加入控制点后:
控制点
○
╱
╱
起点 ●─────────╱ ● 终点
╲ ╱
╲──────────╱
曲线被"拉向"控制点控制点越远,曲线弯曲越明显:
控制点近: 控制点远:
○ ○
╱ ╱
●───╱───● ●───╱────●
╲
╲──────╱
╲ ╱
╲╱
曲线更弯曲3.5 二次贝塞尔曲线
3.5.1 quadraticCurveTo 方法
Canvas 提供 quadraticCurveTo(cpx, cpy, x, y) 方法绑制二次贝塞尔曲线:
ctx.quadraticCurveTo(cpx, cpy, x, y);参数说明:
| 参数 | 含义 |
|---|---|
| cpx | 控制点的 X 坐标 |
| cpy | 控制点的 Y 坐标 |
| x | 终点的 X 坐标 |
| y | 终点的 Y 坐标 |
注意:起点是当前路径位置(由前一个命令或 moveTo 确定)。
基础示例:
ctx.beginPath();
// 设置起点
ctx.moveTo(50, 200);
// 绘制二次贝塞尔曲线
// 控制点:(150, 50)
// 终点:(250, 200)
ctx.quadraticCurveTo(150, 50, 250, 200);
ctx.strokeStyle = '#4D7CFF';
ctx.lineWidth = 3;
ctx.stroke();
// 可视化控制点(用于理解)
ctx.fillStyle = '#FF6B6B';
ctx.beginPath();
ctx.arc(50, 200, 5, 0, Math.PI * 2); // 起点
ctx.arc(150, 50, 5, 0, Math.PI * 2); // 控制点
ctx.arc(250, 200, 5, 0, Math.PI * 2); // 终点
ctx.fill();3.5.2 图解二次贝塞尔曲线
二次贝塞尔曲线的三个关键点:
控制点 (cpx, cpy)
○
╱ ╲
╱ ╲ ← 控制线(想象的)
╱ ╲
╱ ╲
起点 ●───╯ ╲───● 终点
(当前点) (x, y)
╰────────────╯
曲线
曲线特点:
1. 曲线从起点开始,以起点→控制点方向出发
2. 曲线在终点结束,以控制点→终点方向到达
3. 曲线被控制点"拉"向自己,但不经过控制点3.5.3 二次贝塞尔曲线的数学原理
如果你对数学感兴趣,让我们深入了解曲线是如何生成的。
参数方程:
二次贝塞尔曲线可以用参数 t(从 0 到 1)表示:
P(t) = (1-t)² × P₀ + 2(1-t)t × P₁ + t² × P₂
其中:
- P₀ = 起点
- P₁ = 控制点
- P₂ = 终点
- t ∈ [0, 1]当 t = 0 时,P(0) = P₀(起点) 当 t = 1 时,P(1) = P₂(终点) 当 t = 0.5 时,曲线在中间某处
JavaScript 实现:
/**
* 计算二次贝塞尔曲线上的点
* @param {number} t - 参数,范围 [0, 1]
* @param {Object} p0 - 起点 {x, y}
* @param {Object} p1 - 控制点 {x, y}
* @param {Object} p2 - 终点 {x, y}
* @returns {Object} 曲线上的点 {x, y}
*/
function quadraticBezierPoint(t, p0, p1, p2) {
const mt = 1 - t; // (1 - t)
const mt2 = mt * mt; // (1 - t)²
const t2 = t * t; // t²
return {
x: mt2 * p0.x + 2 * mt * t * p1.x + t2 * p2.x,
y: mt2 * p0.y + 2 * mt * t * p1.y + t2 * p2.y
};
}
// 手动绘制贝塞尔曲线(用于理解原理)
function drawQuadraticBezierManual(ctx, p0, p1, p2, segments = 50) {
ctx.beginPath();
ctx.moveTo(p0.x, p0.y);
for (let i = 1; i <= segments; i++) {
const t = i / segments;
const point = quadraticBezierPoint(t, p0, p1, p2);
ctx.lineTo(point.x, point.y);
}
ctx.stroke();
}
// 使用
const p0 = { x: 50, y: 200 };
const p1 = { x: 150, y: 50 };
const p2 = { x: 250, y: 200 };
drawQuadraticBezierManual(ctx, p0, p1, p2);3.5.4 De Casteljau 算法(可视化理解)
还有一种更直观的方式来理解贝塞尔曲线——De Casteljau 算法。
算法步骤:
- 连接 P₀-P₁ 和 P₁-P₂
- 在每条线上取同样比例 t 的点(记为 Q₀ 和 Q₁)
- 连接 Q₀-Q₁
- 在 Q₀-Q₁ 上取比例 t 的点,这就是曲线上的点
t = 0.5 时的构造过程:
P₁ (控制点)
○
╱╲
╱ ╲
Q₀────Q₁ ← 两条控制线的中点
╱ ● ╲
╱ B ╲ ← Q₀-Q₁ 的中点,就是曲线上的点
╱ ╲
P₀ P₂
(起点) (终点)交互式可视化代码:
/**
* 可视化 De Casteljau 算法
*/
function visualizeDeCasteljau(ctx, p0, p1, p2, t) {
// 计算中间点
const q0 = {
x: (1 - t) * p0.x + t * p1.x,
y: (1 - t) * p0.y + t * p1.y
};
const q1 = {
x: (1 - t) * p1.x + t * p2.x,
y: (1 - t) * p1.y + t * p2.y
};
const b = {
x: (1 - t) * q0.x + t * q1.x,
y: (1 - t) * q0.y + t * q1.y
};
// 绘制控制线
ctx.strokeStyle = '#ccc';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(p0.x, p0.y);
ctx.lineTo(p1.x, p1.y);
ctx.lineTo(p2.x, p2.y);
ctx.stroke();
// 绘制中间线
ctx.strokeStyle = '#FFB6C1';
ctx.beginPath();
ctx.moveTo(q0.x, q0.y);
ctx.lineTo(q1.x, q1.y);
ctx.stroke();
// 绘制曲线(到当前 t 值)
ctx.strokeStyle = '#4D7CFF';
ctx.lineWidth = 3;
ctx.beginPath();
ctx.moveTo(p0.x, p0.y);
ctx.quadraticCurveTo(p1.x, p1.y, p2.x, p2.y);
ctx.stroke();
// 绘制控制点
[p0, p1, p2].forEach((p, i) => {
ctx.fillStyle = i === 1 ? '#FF6B6B' : '#333';
ctx.beginPath();
ctx.arc(p.x, p.y, 6, 0, Math.PI * 2);
ctx.fill();
});
// 绘制中间点
[q0, q1].forEach(p => {
ctx.fillStyle = '#FFB6C1';
ctx.beginPath();
ctx.arc(p.x, p.y, 4, 0, Math.PI * 2);
ctx.fill();
});
// 绘制曲线上的点
ctx.fillStyle = '#4D7CFF';
ctx.beginPath();
ctx.arc(b.x, b.y, 8, 0, Math.PI * 2);
ctx.fill();
}
// 动画演示
let t = 0;
function animate() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
const p0 = { x: 50, y: 300 };
const p1 = { x: 200, y: 50 };
const p2 = { x: 350, y: 300 };
visualizeDeCasteljau(ctx, p0, p1, p2, t);
t += 0.01;
if (t > 1) t = 0;
requestAnimationFrame(animate);
}
animate();3.5.5 二次贝塞尔曲线的应用
应用1:绘制平滑的圆角
function drawRoundedCorner(ctx, x1, y1, x2, y2, x3, y3, radius) {
// 计算两条边的方向向量
const v1 = { x: x1 - x2, y: y1 - y2 };
const v2 = { x: x3 - x2, y: y3 - y2 };
// 归一化
const len1 = Math.sqrt(v1.x * v1.x + v1.y * v1.y);
const len2 = Math.sqrt(v2.x * v2.x + v2.y * v2.y);
v1.x /= len1; v1.y /= len1;
v2.x /= len2; v2.y /= len2;
// 计算切点
const t1 = { x: x2 + v1.x * radius, y: y2 + v1.y * radius };
const t2 = { x: x2 + v2.x * radius, y: y2 + v2.y * radius };
// 绘制
ctx.lineTo(t1.x, t1.y);
ctx.quadraticCurveTo(x2, y2, t2.x, t2.y);
}应用2:绘制对话气泡
function drawSpeechBubble(ctx, x, y, width, height, radius, tailX, tailY) {
ctx.beginPath();
// 左上角
ctx.moveTo(x + radius, y);
// 上边 + 右上角
ctx.lineTo(x + width - radius, y);
ctx.quadraticCurveTo(x + width, y, x + width, y + radius);
// 右边 + 右下角
ctx.lineTo(x + width, y + height - radius);
ctx.quadraticCurveTo(x + width, y + height, x + width - radius, y + height);
// 下边(包含尾巴)
ctx.lineTo(tailX + 10, y + height);
ctx.lineTo(tailX, tailY); // 尾巴尖端
ctx.lineTo(tailX - 10, y + height);
ctx.lineTo(x + radius, y + height);
// 左下角 + 左边 + 左上角
ctx.quadraticCurveTo(x, y + height, x, y + height - radius);
ctx.lineTo(x, y + radius);
ctx.quadraticCurveTo(x, y, x + radius, y);
ctx.closePath();
}
// 使用
ctx.fillStyle = '#FFFFFF';
ctx.strokeStyle = '#333333';
ctx.lineWidth = 2;
drawSpeechBubble(ctx, 50, 50, 200, 100, 15, 120, 180);
ctx.fill();
ctx.stroke();3.6 三次贝塞尔曲线
3.6.1 bezierCurveTo 方法
三次贝塞尔曲线使用两个控制点,可以创建更复杂的曲线形状:
ctx.bezierCurveTo(cp1x, cp1y, cp2x, cp2y, x, y);参数说明:
| 参数 | 含义 |
|---|---|
| cp1x, cp1y | 第一个控制点坐标 |
| cp2x, cp2y | 第二个控制点坐标 |
| x, y | 终点坐标 |
起点仍然是当前路径位置。
基础示例:
ctx.beginPath();
ctx.moveTo(50, 200);
ctx.bezierCurveTo(
100, 50, // 控制点 1
200, 50, // 控制点 2
250, 200 // 终点
);
ctx.strokeStyle = '#4D7CFF';
ctx.lineWidth = 3;
ctx.stroke();3.6.2 图解三次贝塞尔曲线
三次贝塞尔曲线的四个关键点:
控制点1 控制点2
(cp1x,cp1y) (cp2x,cp2y)
○────────────○
╱ ╲
╱ ╲
╱ ╲
╱ ╲
起点 ●───╯ ╲───● 终点
(当前点) (x, y)
╰────────────────────────╯
曲线
曲线特点:
1. 曲线从起点出发,方向指向控制点1
2. 曲线到达终点时,方向从控制点2指来
3. 两个控制点分别影响曲线的两端
4. 可以创建 S 形曲线3.6.3 二次 vs 三次贝塞尔曲线
| 特性 | 二次贝塞尔 | 三次贝塞尔 |
|---|---|---|
| 控制点数量 | 1 个 | 2 个 |
| 曲线复杂度 | 简单曲线 | 可以是 S 形 |
| 应用场景 | 简单弧线、圆角 | 复杂曲线、字体轮廓 |
| 计算复杂度 | 较低 | 较高 |
二次贝塞尔:只能弯向一个方向
○
╱
●──────────╱──────────●
三次贝塞尔:可以创建 S 形
○
╱
●────╯
╲────────●
○3.6.4 三次贝塞尔曲线的数学原理
参数方程:
P(t) = (1-t)³ × P₀ + 3(1-t)²t × P₁ + 3(1-t)t² × P₂ + t³ × P₃
其中:
- P₀ = 起点
- P₁ = 控制点1
- P₂ = 控制点2
- P₃ = 终点
- t ∈ [0, 1]JavaScript 实现:
/**
* 计算三次贝塞尔曲线上的点
*/
function cubicBezierPoint(t, p0, p1, p2, p3) {
const mt = 1 - t;
const mt2 = mt * mt;
const mt3 = mt2 * mt;
const t2 = t * t;
const t3 = t2 * t;
return {
x: mt3 * p0.x + 3 * mt2 * t * p1.x + 3 * mt * t2 * p2.x + t3 * p3.x,
y: mt3 * p0.y + 3 * mt2 * t * p1.y + 3 * mt * t2 * p2.y + t3 * p3.y
};
}3.6.5 控制点的影响分析
控制点1 主要影响曲线的前半部分:
// 控制点1 在不同位置的效果
const scenarios = [
{ cp1: { x: 100, y: 50 }, label: '控制点1 在上方' },
{ cp1: { x: 100, y: 200 }, label: '控制点1 在中间' },
{ cp1: { x: 100, y: 350 }, label: '控制点1 在下方' },
];
scenarios.forEach((scenario, index) => {
ctx.beginPath();
ctx.moveTo(50, 200);
ctx.bezierCurveTo(
scenario.cp1.x, scenario.cp1.y, // 控制点1 变化
200, 50, // 控制点2 固定
250, 200 // 终点固定
);
ctx.strokeStyle = `hsl(${index * 120}, 70%, 50%)`;
ctx.stroke();
});控制点之间的距离影响曲线的"张力":
控制点靠近:曲线较平缓 控制点远离:曲线较陡峭
○○ ○
╱ ╲ ╱
●─╯ ╲─● ●──╯
╲
○──●3.6.6 三次贝塞尔曲线的应用
应用1:绘制平滑的 S 形曲线
function drawSCurve(ctx, x1, y1, x2, y2) {
const midX = (x1 + x2) / 2;
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.bezierCurveTo(
midX, y1, // 控制点1:水平方向
midX, y2, // 控制点2:水平方向
x2, y2 // 终点
);
ctx.stroke();
}
ctx.strokeStyle = '#4D7CFF';
ctx.lineWidth = 3;
drawSCurve(ctx, 50, 100, 250, 200);应用2:绘制心形
function drawHeart(ctx, cx, cy, size) {
ctx.beginPath();
// 从底部尖端开始
ctx.moveTo(cx, cy + size * 0.6);
// 左半边
ctx.bezierCurveTo(
cx - size * 0.8, cy + size * 0.2, // 控制点1
cx - size * 0.8, cy - size * 0.5, // 控制点2
cx, cy - size * 0.2 // 终点(顶部凹陷)
);
// 右半边
ctx.bezierCurveTo(
cx + size * 0.8, cy - size * 0.5, // 控制点1
cx + size * 0.8, cy + size * 0.2, // 控制点2
cx, cy + size * 0.6 // 终点(回到底部)
);
ctx.closePath();
}
drawHeart(ctx, 150, 150, 100);
ctx.fillStyle = '#FF6B6B';
ctx.fill();应用3:绘制波浪线
function drawWave(ctx, startX, startY, width, amplitude, frequency) {
const segmentWidth = width / frequency;
ctx.beginPath();
ctx.moveTo(startX, startY);
for (let i = 0; i < frequency; i++) {
const x1 = startX + i * segmentWidth;
const x2 = startX + (i + 1) * segmentWidth;
const direction = i % 2 === 0 ? -1 : 1;
ctx.bezierCurveTo(
x1 + segmentWidth * 0.33, startY + amplitude * direction,
x1 + segmentWidth * 0.66, startY + amplitude * direction,
x2, startY
);
}
ctx.stroke();
}
ctx.strokeStyle = '#4ECDC4';
ctx.lineWidth = 3;
drawWave(ctx, 50, 200, 500, 40, 4);3.7 曲线的实战应用
3.7.1 绘制平滑的折线(样条曲线)
当我们有一系列点,想要用平滑曲线连接时,可以使用"样条曲线"技术:
/**
* 通过一系列点绘制平滑曲线
* 使用 Catmull-Rom 样条转换为贝塞尔曲线
*/
function drawSmoothCurve(ctx, points, tension = 0.5) {
if (points.length < 2) return;
ctx.beginPath();
ctx.moveTo(points[0].x, points[0].y);
if (points.length === 2) {
ctx.lineTo(points[1].x, points[1].y);
ctx.stroke();
return;
}
// 为每对相邻点计算控制点
for (let i = 0; i < points.length - 1; i++) {
const p0 = points[i === 0 ? i : i - 1];
const p1 = points[i];
const p2 = points[i + 1];
const p3 = points[i + 2] || p2;
// 计算控制点
const cp1x = p1.x + (p2.x - p0.x) * tension / 6;
const cp1y = p1.y + (p2.y - p0.y) * tension / 6;
const cp2x = p2.x - (p3.x - p1.x) * tension / 6;
const cp2y = p2.y - (p3.y - p1.y) * tension / 6;
ctx.bezierCurveTo(cp1x, cp1y, cp2x, cp2y, p2.x, p2.y);
}
ctx.stroke();
}
// 使用示例
const points = [
{ x: 50, y: 200 },
{ x: 100, y: 100 },
{ x: 200, y: 150 },
{ x: 300, y: 50 },
{ x: 400, y: 150 },
{ x: 450, y: 200 },
];
ctx.strokeStyle = '#4D7CFF';
ctx.lineWidth = 2;
drawSmoothCurve(ctx, points, 0.5);
// 显示原始点
points.forEach(p => {
ctx.beginPath();
ctx.arc(p.x, p.y, 4, 0, Math.PI * 2);
ctx.fillStyle = '#FF6B6B';
ctx.fill();
});3.7.2 手写签名绘制
class SignaturePad {
constructor(canvas) {
this.canvas = canvas;
this.ctx = canvas.getContext('2d');
this.points = [];
this.isDrawing = false;
this.setupEvents();
}
setupEvents() {
this.canvas.addEventListener('mousedown', this.startDrawing.bind(this));
this.canvas.addEventListener('mousemove', this.draw.bind(this));
this.canvas.addEventListener('mouseup', this.stopDrawing.bind(this));
this.canvas.addEventListener('mouseleave', this.stopDrawing.bind(this));
}
getPoint(e) {
const rect = this.canvas.getBoundingClientRect();
return {
x: e.clientX - rect.left,
y: e.clientY - rect.top,
time: Date.now()
};
}
startDrawing(e) {
this.isDrawing = true;
this.points = [this.getPoint(e)];
}
draw(e) {
if (!this.isDrawing) return;
const point = this.getPoint(e);
this.points.push(point);
// 至少需要 3 个点才能绘制曲线
if (this.points.length < 3) return;
// 使用最近的几个点绘制平滑曲线
const len = this.points.length;
const p1 = this.points[len - 3];
const p2 = this.points[len - 2];
const p3 = this.points[len - 1];
// 计算控制点(使用中点)
const mid1 = { x: (p1.x + p2.x) / 2, y: (p1.y + p2.y) / 2 };
const mid2 = { x: (p2.x + p3.x) / 2, y: (p2.y + p3.y) / 2 };
// 计算线宽(基于速度)
const speed = this.getSpeed(p2, p3);
const lineWidth = Math.max(1, 5 - speed * 0.02);
// 绘制
this.ctx.beginPath();
this.ctx.moveTo(mid1.x, mid1.y);
this.ctx.quadraticCurveTo(p2.x, p2.y, mid2.x, mid2.y);
this.ctx.strokeStyle = '#333';
this.ctx.lineWidth = lineWidth;
this.ctx.lineCap = 'round';
this.ctx.lineJoin = 'round';
this.ctx.stroke();
}
getSpeed(p1, p2) {
const dx = p2.x - p1.x;
const dy = p2.y - p1.y;
const dt = p2.time - p1.time || 1;
return Math.sqrt(dx * dx + dy * dy) / dt;
}
stopDrawing() {
this.isDrawing = false;
}
clear() {
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
}
}
// 使用
const signaturePad = new SignaturePad(document.getElementById('signature'));3.7.3 动画路径
使贝塞尔曲线成为动画路径:
class PathAnimation {
constructor(ctx, path) {
this.ctx = ctx;
this.path = path; // 路径上的点
this.t = 0;
this.speed = 0.005;
}
// 计算三次贝塞尔曲线上的点
getPointOnCurve(t, p0, p1, p2, p3) {
const mt = 1 - t;
const mt2 = mt * mt;
const mt3 = mt2 * mt;
const t2 = t * t;
const t3 = t2 * t;
return {
x: mt3 * p0.x + 3 * mt2 * t * p1.x + 3 * mt * t2 * p2.x + t3 * p3.x,
y: mt3 * p0.y + 3 * mt2 * t * p1.y + 3 * mt * t2 * p2.y + t3 * p3.y
};
}
update() {
this.t += this.speed;
if (this.t > 1) this.t = 0;
}
draw() {
const { ctx, path, t } = this;
// 绘制路径
ctx.beginPath();
ctx.moveTo(path.start.x, path.start.y);
ctx.bezierCurveTo(
path.cp1.x, path.cp1.y,
path.cp2.x, path.cp2.y,
path.end.x, path.end.y
);
ctx.strokeStyle = '#ccc';
ctx.lineWidth = 2;
ctx.stroke();
// 计算动画对象的位置
const pos = this.getPointOnCurve(
t,
path.start,
path.cp1,
path.cp2,
path.end
);
// 绘制动画对象
ctx.beginPath();
ctx.arc(pos.x, pos.y, 10, 0, Math.PI * 2);
ctx.fillStyle = '#4D7CFF';
ctx.fill();
}
}
// 使用
const path = {
start: { x: 50, y: 200 },
cp1: { x: 100, y: 50 },
cp2: { x: 300, y: 50 },
end: { x: 350, y: 200 }
};
const animation = new PathAnimation(ctx, path);
function animate() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
animation.update();
animation.draw();
requestAnimationFrame(animate);
}
animate();3.7.4 贝塞尔曲线编辑器
创建一个交互式的贝塞尔曲线编辑器:
class BezierEditor {
constructor(canvas) {
this.canvas = canvas;
this.ctx = canvas.getContext('2d');
// 定义四个控制点
this.points = {
p0: { x: 50, y: 300, label: '起点' },
p1: { x: 100, y: 100, label: '控制点1' },
p2: { x: 300, y: 100, label: '控制点2' },
p3: { x: 350, y: 300, label: '终点' }
};
this.dragging = null;
this.pointRadius = 10;
this.setupEvents();
this.draw();
}
setupEvents() {
this.canvas.addEventListener('mousedown', this.onMouseDown.bind(this));
this.canvas.addEventListener('mousemove', this.onMouseMove.bind(this));
this.canvas.addEventListener('mouseup', this.onMouseUp.bind(this));
}
getMousePos(e) {
const rect = this.canvas.getBoundingClientRect();
return {
x: e.clientX - rect.left,
y: e.clientY - rect.top
};
}
findPointAt(pos) {
for (const [key, point] of Object.entries(this.points)) {
const dx = pos.x - point.x;
const dy = pos.y - point.y;
if (dx * dx + dy * dy < this.pointRadius * this.pointRadius * 4) {
return key;
}
}
return null;
}
onMouseDown(e) {
const pos = this.getMousePos(e);
this.dragging = this.findPointAt(pos);
}
onMouseMove(e) {
if (!this.dragging) return;
const pos = this.getMousePos(e);
this.points[this.dragging].x = pos.x;
this.points[this.dragging].y = pos.y;
this.draw();
}
onMouseUp() {
this.dragging = null;
}
draw() {
const { ctx, points } = this;
const { p0, p1, p2, p3 } = points;
// 清空
ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
// 绘制控制线
ctx.beginPath();
ctx.moveTo(p0.x, p0.y);
ctx.lineTo(p1.x, p1.y);
ctx.moveTo(p2.x, p2.y);
ctx.lineTo(p3.x, p3.y);
ctx.strokeStyle = '#ddd';
ctx.lineWidth = 1;
ctx.setLineDash([5, 5]);
ctx.stroke();
ctx.setLineDash([]);
// 绘制贝塞尔曲线
ctx.beginPath();
ctx.moveTo(p0.x, p0.y);
ctx.bezierCurveTo(p1.x, p1.y, p2.x, p2.y, p3.x, p3.y);
ctx.strokeStyle = '#4D7CFF';
ctx.lineWidth = 3;
ctx.stroke();
// 绘制控制点
Object.entries(points).forEach(([key, point]) => {
const isEndpoint = key === 'p0' || key === 'p3';
ctx.beginPath();
ctx.arc(point.x, point.y, this.pointRadius, 0, Math.PI * 2);
ctx.fillStyle = isEndpoint ? '#333' : '#FF6B6B';
ctx.fill();
ctx.strokeStyle = '#fff';
ctx.lineWidth = 2;
ctx.stroke();
// 标签
ctx.fillStyle = '#666';
ctx.font = '12px sans-serif';
ctx.textAlign = 'center';
ctx.fillText(point.label, point.x, point.y - 20);
});
}
}
// 使用
const editor = new BezierEditor(document.getElementById('editor'));3.8 曲线进阶技巧
3.8.1 曲线长度估算
贝塞尔曲线的精确长度没有简单的公式,通常需要数值积分。这里提供一个近似方法:
/**
* 估算三次贝塞尔曲线的长度
* @param {Object} p0, p1, p2, p3 - 四个控制点
* @param {number} segments - 分段数,越大越精确
*/
function estimateBezierLength(p0, p1, p2, p3, segments = 100) {
let length = 0;
let prevPoint = p0;
for (let i = 1; i <= segments; i++) {
const t = i / segments;
const point = cubicBezierPoint(t, p0, p1, p2, p3);
const dx = point.x - prevPoint.x;
const dy = point.y - prevPoint.y;
length += Math.sqrt(dx * dx + dy * dy);
prevPoint = point;
}
return length;
}3.8.2 曲线上等距取点
在动画或虚线绘制中,我们经常需要在曲线上等距离取点:
/**
* 在贝塞尔曲线上等距取点
*/
function getEquidistantPoints(p0, p1, p2, p3, numPoints) {
// 1. 首先计算曲线长度
const totalLength = estimateBezierLength(p0, p1, p2, p3, 200);
const segmentLength = totalLength / (numPoints - 1);
const points = [{ ...p0, t: 0 }];
let currentLength = 0;
let prevPoint = p0;
let lastT = 0;
// 2. 沿曲线移动,每隔 segmentLength 记录一个点
for (let i = 1; i < numPoints; i++) {
const targetLength = segmentLength * i;
// 二分查找对应的 t 值
let low = lastT;
let high = 1;
while (high - low > 0.0001) {
const mid = (low + high) / 2;
const length = measureLengthToT(p0, p1, p2, p3, mid);
if (length < targetLength) {
low = mid;
} else {
high = mid;
}
}
const t = (low + high) / 2;
points.push({
...cubicBezierPoint(t, p0, p1, p2, p3),
t
});
lastT = t;
}
return points;
}
function measureLengthToT(p0, p1, p2, p3, targetT) {
let length = 0;
let prevPoint = p0;
const segments = Math.ceil(targetT * 100);
for (let i = 1; i <= segments; i++) {
const t = (i / segments) * targetT;
const point = cubicBezierPoint(t, p0, p1, p2, p3);
const dx = point.x - prevPoint.x;
const dy = point.y - prevPoint.y;
length += Math.sqrt(dx * dx + dy * dy);
prevPoint = point;
}
return length;
}3.8.3 曲线细分(Split)
有时需要将一条贝塞尔曲线分割成两段:
/**
* 在 t 点分割三次贝塞尔曲线
* 返回两条新曲线的控制点
*/
function splitBezier(p0, p1, p2, p3, t) {
// De Casteljau 算法
const p01 = lerp(p0, p1, t);
const p12 = lerp(p1, p2, t);
const p23 = lerp(p2, p3, t);
const p012 = lerp(p01, p12, t);
const p123 = lerp(p12, p23, t);
const p0123 = lerp(p012, p123, t); // 曲线上的点
return {
// 第一段曲线
first: {
p0: p0,
p1: p01,
p2: p012,
p3: p0123
},
// 第二段曲线
second: {
p0: p0123,
p1: p123,
p2: p23,
p3: p3
}
};
}
function lerp(p1, p2, t) {
return {
x: p1.x + (p2.x - p1.x) * t,
y: p1.y + (p2.y - p1.y) * t
};
}3.9 本章小结
恭喜你完成了路径与贝塞尔曲线的学习!这是 Canvas 中最核心、最强大的绘图能力。
核心知识回顾
| 概念 | 要点 |
|---|---|
| 路径系统 | Canvas 绑制的核心,先构建路径再渲染 |
| beginPath() | 开始新路径,必须在每个新图形前调用 |
| closePath() | 自动闭合路径,正确处理 lineJoin |
| Path2D | 可复用的路径对象,支持 SVG 语法 |
| 二次贝塞尔 | 1 个控制点,适合简单曲线 |
| 三次贝塞尔 | 2 个控制点,可创建 S 形曲线 |
贝塞尔曲线对比
| 特性 | 二次贝塞尔 | 三次贝塞尔 |
|---|---|---|
| 方法 | quadraticCurveTo(cpx, cpy, x, y) | bezierCurveTo(cp1x, cp1y, cp2x, cp2y, x, y) |
| 控制点 | 1 个 | 2 个 |
| 复杂度 | 简单曲线 | 复杂曲线、S 形 |
| 应用 | 圆角、简单弧 | 字体、复杂图形 |
数学原理
二次贝塞尔:P(t) = (1-t)²P₀ + 2(1-t)tP₁ + t²P₂
三次贝塞尔:P(t) = (1-t)³P₀ + 3(1-t)²tP₁ + 3(1-t)t²P₂ + t³P₃
其中 t ∈ [0, 1]3.10 练习题
基础练习
绘制一个云朵:使用多个圆弧和贝塞尔曲线组合成云朵形状
绘制一片树叶:使用两条对称的三次贝塞尔曲线
使用 Path2D 创建:一个简单的图标库(主页、设置、用户)
进阶练习
实现贝塞尔曲线编辑器:
- 可以拖动控制点
- 实时显示曲线
- 显示控制线
实现平滑折线:
- 给定一组点
- 使用贝塞尔曲线平滑连接
挑战练习
实现签名板组件:
- 支持触摸和鼠标
- 使用贝塞尔曲线平滑笔迹
- 根据速度调整线宽
沿曲线运动的动画:
- 一个物体沿贝塞尔曲线移动
- 物体的朝向跟随曲线切线方向
下一章预告:在第4章中,我们将学习 Canvas 的样式系统——颜色、渐变、图案、线条样式和阴影效果。让你的图形更加丰富多彩!
文档版本:v1.0
字数统计:约 15,000 字
代码示例:40+ 个
