Skip to content

第3章:路径与贝塞尔曲线

3.1 章节概述

在前两章中,我们学习了如何绑制矩形、圆形、直线等基础图形。但你可能已经注意到,这些都是"规则"的几何形状。在实际应用中,我们经常需要绘制更复杂、更自由的形状——比如平滑的曲线、自定义的图标、流畅的动画轨迹等。

这一章将带你深入理解 Canvas 最核心的绘图机制——路径系统(Path System),以及创建平滑曲线的利器——贝塞尔曲线(Bézier Curve)

本章内容包括:

  • 路径的本质:理解 Canvas 绘图的核心机制
  • Path2D 对象:可复用的路径容器
  • 二次贝塞尔曲线:一个控制点的曲线
  • 三次贝塞尔曲线:两个控制点的曲线
  • 贝塞尔曲线的数学原理:理解曲线是如何生成的
  • 实战应用:绘制平滑曲线、波浪、签名等

学完本章后,你将能够绘制任意复杂的形状,并理解计算机图形学中曲线表示的基本原理。


3.2 深入理解路径系统

3.2.1 什么是路径?

在 Canvas 中,路径(Path) 是绑制复杂形状的基础。你可以把路径想象成一支隐形的笔在画布上移动留下的轨迹。

让我们用一个生活中的类比来理解:

想象你在沙滩上用树枝画画

  1. 你拿起树枝(beginPath())—— 开始一条新路径
  2. 你把树枝移动到某个位置,但没有按下去(moveTo(x, y))—— 移动到起点
  3. 你把树枝按在沙滩上,拖动画线(lineTo(x, y))—— 绘制线段
  4. 你继续拖动,画出曲线(quadraticCurveTo(...) 等)—— 绘制曲线
  5. 你回到起点,形成封闭图形(closePath())—— 闭合路径
  6. 最后,你在画好的图形里填上颜色或用墨水描边(fill()stroke())—— 渲染路径

关键理解:路径本身是不可见的!它只是一系列点和线的数学描述。只有调用 fill()stroke() 时,路径才会被"渲染"出来。

3.2.2 路径的生命周期

让我们详细了解路径从创建到渲染的完整过程:

┌──────────────────────────────────────────────────────────────┐
│                      路径的生命周期                           │
├──────────────────────────────────────────────────────────────┤
│                                                              │
│  ① beginPath()        开始新路径,清除之前的路径数据          │
│       ↓                                                      │
│  ② 构建路径            moveTo、lineTo、arc、curveTo 等        │
│       ↓                                                      │
│  ③ closePath()        可选,自动连接回起点                    │
│       ↓                                                      │
│  ④ fill() / stroke()  渲染路径,使其可见                     │
│                                                              │
└──────────────────────────────────────────────────────────────┘

代码示例

javascript
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() 是路径系统中最重要的方法之一。它做了两件事:

  1. 清除当前路径:丢弃之前的所有路径数据
  2. 开始新路径:准备接收新的路径命令

如果忘记调用 beginPath() 会怎样?

javascript
// 错误示例:忘记 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() 的结果:

    ╭───────╮         ╭───────╮
   ╱         ╲───────╱         ╲
  │           │连接线│           │
   ╲         ╱───────╲         ╱
    ╰───────╯         ╰───────╯
    
两个圆被一条意外的线连接起来

正确做法

javascript
// 正确示例:每个图形使用独立的路径

// 画第一个圆
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() 会自动绘制一条从当前点回到路径起点的直线,从而形成一个闭合图形。

javascript
// 不使用 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 手动画回起点,效果不是一样吗?

javascript
// 方法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() 都会开始一个新的子路径:

javascript
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?

在前面的例子中,我们每次绘制图形都需要重新构建路径。如果同一个图形需要多次绘制,这就很低效了:

javascript
// 低效方式:每次都重新构建路径
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 对象允许我们预先构建路径,然后多次复用

javascript
// 高效方式:使用 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,手动添加路径

javascript
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 路径字符串创建

javascript
// 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 复制

javascript
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 支持几乎所有用于构建路径的方法:

javascript
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/mx yMoveTo - 移动到点
L/lx yLineTo - 画直线到点
H/hx水平线
V/vy垂直线
Z/z闭合路径
Q/qcx cy x y二次贝塞尔曲线
C/cc1x c1y c2x c2y x y三次贝塞尔曲线
A/arx ry rotation large-arc sweep x y椭圆弧

大写命令使用绝对坐标,小写命令使用相对坐标

示例

javascript
// 绘制一个心形
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 属性:

html
<!-- SVG 文件中的路径 -->
<path d="M 10 80 Q 95 10 180 80" fill="none" stroke="black"/>
javascript
// 在 Canvas 中使用
const myPath = new Path2D('M 10 80 Q 95 10 180 80');
ctx.stroke(myPath);

3.3.5 addPath() - 组合路径

addPath() 方法可以将一个 Path2D 添加到另一个中,还可以同时应用变换:

javascript
// 创建一个基础形状
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 实战:创建可复用的图标库

javascript
// 创建一个图标库
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 年代为雷诺汽车公司开发了这种曲线用于汽车车身设计。

贝塞尔曲线的核心思想:用一组控制点来定义一条平滑的曲线。

  • 起点终点:曲线经过的两个端点
  • 控制点:不在曲线上,但"拉动"曲线向自己弯曲

为什么贝塞尔曲线如此重要?

  1. 直观控制:通过移动控制点可以直观地调整曲线形状
  2. 数学简洁:可以用简单的参数方程精确描述
  3. 计算高效:易于细分和渲染
  4. 应用广泛:从字体设计到动画曲线,无处不在

在 Canvas 中,我们主要使用两种贝塞尔曲线:

  • 二次贝塞尔曲线:1 个控制点
  • 三次贝塞尔曲线:2 个控制点

3.4.2 贝塞尔曲线的"控制"原理

在深入数学之前,让我们先建立直觉。

想象一根有弹性的线

  1. 你固定线的两端(起点和终点)
  2. 然后用手指按住线的某个位置,把它拉向一侧
  3. 线就会形成一条平滑的曲线
  4. 你手指的位置就是"控制点"
直线状态:

起点 ●─────────────────────● 终点


加入控制点后:

                 控制点



起点 ●─────────╱            ● 终点
              ╲            ╱
               ╲──────────╱
                 曲线被"拉向"控制点

控制点越远,曲线弯曲越明显

控制点近:                控制点远:
    
      ○                        ○
     ╱                        ╱
●───╱───●                ●───╱────●

                               ╲──────╱
                                 ╲  ╱
                                  ╲╱
                            曲线更弯曲

3.5 二次贝塞尔曲线

3.5.1 quadraticCurveTo 方法

Canvas 提供 quadraticCurveTo(cpx, cpy, x, y) 方法绑制二次贝塞尔曲线:

javascript
ctx.quadraticCurveTo(cpx, cpy, x, y);

参数说明

参数含义
cpx控制点的 X 坐标
cpy控制点的 Y 坐标
x终点的 X 坐标
y终点的 Y 坐标

注意:起点是当前路径位置(由前一个命令或 moveTo 确定)。

基础示例

javascript
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 实现

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 算法

算法步骤

  1. 连接 P₀-P₁ 和 P₁-P₂
  2. 在每条线上取同样比例 t 的点(记为 Q₀ 和 Q₁)
  3. 连接 Q₀-Q₁
  4. 在 Q₀-Q₁ 上取比例 t 的点,这就是曲线上的点
t = 0.5 时的构造过程:

      P₁ (控制点)

      ╱╲
     ╱  ╲
    Q₀────Q₁     ← 两条控制线的中点
   ╱  ●    ╲
  ╱   B     ╲    ← Q₀-Q₁ 的中点,就是曲线上的点
 ╱           ╲
P₀            P₂
(起点)       (终点)

交互式可视化代码

javascript
/**
 * 可视化 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:绘制平滑的圆角

javascript
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:绘制对话气泡

javascript
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 方法

三次贝塞尔曲线使用两个控制点,可以创建更复杂的曲线形状:

javascript
ctx.bezierCurveTo(cp1x, cp1y, cp2x, cp2y, x, y);

参数说明

参数含义
cp1x, cp1y第一个控制点坐标
cp2x, cp2y第二个控制点坐标
x, y终点坐标

起点仍然是当前路径位置。

基础示例

javascript
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 实现

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 主要影响曲线的前半部分

javascript
// 控制点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 形曲线

javascript
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:绘制心形

javascript
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:绘制波浪线

javascript
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 绘制平滑的折线(样条曲线)

当我们有一系列点,想要用平滑曲线连接时,可以使用"样条曲线"技术:

javascript
/**
 * 通过一系列点绘制平滑曲线
 * 使用 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 手写签名绘制

javascript
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 动画路径

使贝塞尔曲线成为动画路径:

javascript
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 贝塞尔曲线编辑器

创建一个交互式的贝塞尔曲线编辑器:

javascript
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 曲线长度估算

贝塞尔曲线的精确长度没有简单的公式,通常需要数值积分。这里提供一个近似方法:

javascript
/**
 * 估算三次贝塞尔曲线的长度
 * @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 曲线上等距取点

在动画或虚线绘制中,我们经常需要在曲线上等距离取点:

javascript
/**
 * 在贝塞尔曲线上等距取点
 */
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)

有时需要将一条贝塞尔曲线分割成两段:

javascript
/**
 * 在 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 练习题

基础练习

  1. 绘制一个云朵:使用多个圆弧和贝塞尔曲线组合成云朵形状

  2. 绘制一片树叶:使用两条对称的三次贝塞尔曲线

  3. 使用 Path2D 创建:一个简单的图标库(主页、设置、用户)

进阶练习

  1. 实现贝塞尔曲线编辑器

    • 可以拖动控制点
    • 实时显示曲线
    • 显示控制线
  2. 实现平滑折线

    • 给定一组点
    • 使用贝塞尔曲线平滑连接

挑战练习

  1. 实现签名板组件

    • 支持触摸和鼠标
    • 使用贝塞尔曲线平滑笔迹
    • 根据速度调整线宽
  2. 沿曲线运动的动画

    • 一个物体沿贝塞尔曲线移动
    • 物体的朝向跟随曲线切线方向

下一章预告:在第4章中,我们将学习 Canvas 的样式系统——颜色、渐变、图案、线条样式和阴影效果。让你的图形更加丰富多彩!


文档版本:v1.0
字数统计:约 15,000 字
代码示例:40+ 个

如有转载或 CV 的请标注本站原文地址