第五章:参数方程与贝塞尔曲线

"数学是自然的语言,而曲线是数学的诗篇。"

在这一章里,我们将从最简单的圆开始,一步步走进参数方程的奇妙世界,最终抵达计算机图形学中最优雅的发明之一——贝塞尔曲线。别担心,我会像一位耐心的导师一样,带着你一步一步走过来。


目录

  1. 什么是参数方程?
  2. 用参数方程画圆弧
  3. 螺旋(阿基米德螺线)
  4. 星形线与星形曲线
  5. 极坐标系
  6. 玫瑰曲线、心形线与双纽线
  7. 二次贝塞尔曲线
  8. 三次贝塞尔曲线
  9. 为什么贝塞尔曲线无处不在?
  10. 常见问题 Q&A

1. 什么是参数方程?

1.1 生活中的类比:火车时刻表

想象你坐在一列从北京开往上海的火车上。列车长手里有一张时刻表

时间 t 位置(公里标)
0:00 0 km(北京)
1:00 120 km
2:00 250 km
... ...
5:00 1000 km(上海)

注意这里的关键:时间 t 是"自变量",而你的位置是"因变量"。列车长不需要知道"当我在 500 km 处时是几点",他只需要知道"当 t = 2.5 小时时,我在哪里"。

参数方程的本质就是这个思想:用一个独立的参数 t(通常从 0 到 1),来描述曲线上每一个点的坐标。

1.2 从函数到参数方程

在中学数学里,我们学过 y = f(x),比如 y = x^2。这种表示方法有一个限制:一个 x 只能对应一个 y。这意味着你无法用它画一个圆——因为圆在 x = 0 处有两个 y 值(上和下)!

参数方程打破了这个限制:

x = f(t)
y = g(t)

其中 t 是一个参数,通常取值范围是 [0, 1]。对于每一个 t,我们得到一个唯一的点 (x, y)。当 t 从 0 连续变化到 1 时,这些点连起来就形成了一条曲线。

1.3 核心代码:参数方程绘制器

在我们的 akira-graphics 项目中,有一个通用的参数方程绘制工具。理解它是理解所有后续内容的基础:

// common/lib/parametric.js
// 这是一个"参数方程绘制工厂"——传入你的公式,它返回一个可以绘制曲线的函数

function draw(points, context, {
  strokeStyle = 'black',
  fillStyle = null,
  close = false,
} = {}) {
  context.strokeStyle = strokeStyle;
  context.beginPath();
  context.moveTo(...points[0]);  // 移动到第一个点
  for(let i = 1; i < points.length; i++) {
    context.lineTo(...points[i]); // 用直线连接后续每个点
  }
  if(close) context.closePath();
  if(fillStyle) {
    context.fillStyle = fillStyle;
    context.fill();
  }
  context.stroke();
}

export function parametric(sFunc, tFunc, rFunc) {
  // sFunc: 计算 x 坐标的函数
  // tFunc: 计算 y 坐标的函数(注意这里 tFunc 是 y 的函数,不是参数 t)
  // rFunc: 可选的坐标转换函数(比如极坐标转直角坐标)

  return function (start, end, seg = 100, ...args) {
    const points = [];
    for(let i = 0; i <= seg; i++) {
      const p = i / seg;                    // p 从 0 均匀变化到 1
      const t = start * (1 - p) + end * p;  // t 从 start 线性插值到 end
      const x = sFunc(t, ...args);          // 计算该点的 x 坐标
      const y = tFunc(t, ...args);          // 计算该点的 y 坐标
      if(rFunc) {
        points.push(rFunc(x, y));           // 如果有转换函数,先转换再存储
      } else {
        points.push([x, y]);                // 直接存储直角坐标
      }
    }
    return {
      draw: draw.bind(null, points),  // 绑定好 points,返回一个可直接调用的 draw 方法
      points,                         // 也暴露原始点数据,方便调试
    };
  };
}

关键理解: 这个函数把"数学公式"和"绘制"分离开了。你只需关心 x = ?y = ?,它帮你处理采样、插值和连线。

1.4 逐步推导:线性插值

你可能注意到了这行代码:

const t = start * (1 - p) + end * p;

这叫线性插值(Linear Interpolation,简称 lerp)。让我推导一下为什么它是对的:

目标:p = 0 时,t = start;当 p = 1 时,t = end;当 p = 0.5 时,t 正好在 startend 中间。

推导:

我们希望 tp 的一次函数:

t = a * p + b

代入边界条件:

  • p = 0start = a * 0 + bb = start
  • p = 1end = a * 1 + starta = end - start

所以:

t = (end - start) * p + start
  = end * p - start * p + start
  = start * (1 - p) + end * p

这就是那行代码的来源!它也可以写成更直观的形式:

t = start + p * (end - start)

意思是:从 start 出发,沿着 start → end 的方向走 p 比例的路程。

1.5 常见误区

误区一:"参数 t 必须代表时间"

不是的。t 只是一个抽象的参数。它可以代表时间、角度、进度,或者什么都不是——只是一个让曲线"动起来"的把手。

误区二:"t 必须从 0 到 1"

在我们的代码中,t 的范围由 startend 决定。比如画圆时,start = 0, end = 2π。但把任意区间映射到 [0, 1] 是一种常见的标准化做法。

1.6 动手试一试

// 试着画一条抛物线:x = t, y = t^2
const para = parametric(
  t => 25 * t,      // x = 25t
  t => 25 * t ** 2, // y = 25t^2
);

// t 从 -5.5 到 5.5,采样 100 个点
para(-5.5, 5.5).draw(ctx);

练习: 修改上面的代码,画出 y = x^3 的曲线。提示:让 x = ty = t^3


2. 用参数方程画圆弧

2.1 生活中的类比:时钟的指针

想象时钟的秒针。它从 12 点位置(正上方)开始,匀速旋转。秒针尖端的位置就是圆上的一个点。

  • 秒针长度 = 圆的半径 r
  • 旋转角度 = θ(从正右方开始,逆时针增加)

2.2 本质:把角度翻译成坐标

圆的定义是"到圆心距离恒为 r 的所有点"。但我们要的是每个点的坐标,这就需要三角函数了。

画一个直角三角形:

        .(x, y)
       /|
    r / | y
     /  |
    /___|
   圆心  x

根据三角函数的定义:

cos(θ) = 邻边 / 斜边 = x / r   →   x = r * cos(θ)
sin(θ) = 对边 / 斜边 = y / r   →   y = r * sin(θ)

这就是圆的参数方程!

2.3 完整代码

// parametric/app.js
const canvas = document.querySelector('canvas');
const ctx = canvas.getContext('2d');
const {width, height} = canvas;
ctx.translate(0.5 * width, 0.5 * height); // 把原点移到画布中心
ctx.scale(1, -1);                          // Y轴翻转,让数学坐标系和Canvas一致

function draw(points, strokeStyle = 'black', fillStyle = null) {
  ctx.strokeStyle = strokeStyle;
  ctx.beginPath();
  ctx.moveTo(...points[0]);
  for(let i = 1; i < points.length; i++) {
    ctx.lineTo(...points[i]);
  }
  ctx.closePath();
  if(fillStyle) {
    ctx.fillStyle = fillStyle;
    ctx.fill();
  }
  ctx.stroke();
}

const TAU_SEGMENTS = 60;  // 一个完整圆(2π)分成60段
const TAU = Math.PI * 2;  // TAU = 2π,一个完整的圆周

function arc(x0, y0, radius, startAng = 0, endAng = Math.PI * 2) {
  const ang = Math.min(TAU, endAng - startAng);  // 实际要画的角度范围
  const ret = ang === TAU ? [] : [[x0, y0]];     // 如果不是整圆,从圆心开始
  const segments = Math.round(TAU_SEGMENTS * ang / TAU);  // 按比例计算段数

  for(let i = 0; i <= segments; i++) {
    // 关键:把参数 i/segments 映射到角度范围 [startAng, endAng]
    const x = x0 + radius * Math.cos(startAng + ang * i / segments);
    const y = y0 + radius * Math.sin(startAng + ang * i / segments);
    ret.push([x, y]);
  }
  return ret;
}

// 画一个圆心在原点、半径100的圆
draw(arc(0, 0, 100));

2.4 逐步推导:为什么用 startAng + ang * i / segments

我们要把 i(从 0 到 segments)映射到角度 [startAng, endAng]

用线性插值公式:

θ(i) = startAng + (i / segments) * (endAng - startAng)
     = startAng + ang * i / segments

i = 0θ = startAng(起点) 当 i = segmentsθ = startAng + ang = endAng(终点)

完美!

2.5 常见误区

误区:"Canvas 的 Y 轴向下,所以不需要翻转"

如果不做 ctx.scale(1, -1),Canvas 的 Y 轴是向下的。这意味着 sin(θ) 会让圆上下颠倒。翻转 Y 轴后,数学上的 (x, y) 和 Canvas 上的像素位置才能一一对应。

2.6 动手试一试

练习 1: 修改代码画出三个月牙形状(三个不同 startAng/endAng 的弧)。

练习 2: 画一个"笑脸"——两个眼睛用小圆,嘴巴用圆弧。


3. 螺旋(阿基米德螺线)

3.1 生活中的类比:蚊香与螺旋楼梯

你见过蚊香吗?或者螺旋楼梯?它们的特点是:转得越远,离中心越远

想象一只小蚂蚁从圆心出发,一边匀速转圈,一边匀速向外爬。它留下的轨迹就是螺旋线。

3.2 本质:半径随角度增长

普通圆的参数方程中,半径 r 是常数。螺旋线的关键创新是:让半径随角度增长

r = l * t        (半径与角度成正比)
x = r * cos(t) = l * t * cos(t)
y = r * sin(t) = l * t * sin(t)

其中 l 是一个比例系数,控制螺旋的"松紧"。

3.3 完整代码

// parametric2/app.js(节选)
import {parametric} from '../common/lib/parametric.js';

const helical = parametric(
  (t, l) => l * t * Math.cos(t),  // x = l * t * cos(t)
  (t, l) => l * t * Math.sin(t),  // y = l * t * sin(t)
);

// t 从 0 到 50,采样 500 个点,l = 5
helical(0, 50, 500, 5).draw(ctx, {strokeStyle: 'blue'});

3.4 逐步推导

Step 1: 从圆的参数方程出发

x = r * cos(t)
y = r * sin(t)

Step 2: 让半径随角度线性增长

r = l * t    (l 是常数,t 是角度)

Step 3: 代入

x = l * t * cos(t)
y = l * t * sin(t)

这就是阿基米德螺线(Archimedean Spiral)!

3.5 常见误区

误区:"螺旋线和圆的区别只是参数范围不同"

不是的。如果只是把圆的参数范围从 [0, 2π] 扩展到 [0, 50],你得到的是很多圈重叠的圆。螺旋线的关键是 r 也在变化。

3.6 动手试一试

练习: 画出对数螺线(Logarithmic Spiral),其公式为 r = a * e^(b*t)。这种螺线在自然界中很常见,比如鹦鹉螺的壳、星系的旋臂。

const logarithmicSpiral = parametric(
  (t, a, b) => a * Math.exp(b * t) * Math.cos(t),
  (t, a, b) => a * Math.exp(b * t) * Math.sin(t),
);

4. 星形线与星形曲线

4.1 生活中的类比:星星的轮廓

想象你在用一根线绕着一个正方形框的内侧滑动,铅笔在线的中点,画出的轨迹就是星形线(Astroid)。它看起来像一颗有四个尖角的星星。

4.2 本质:三角函数的"幂次游戏"

星形线的参数方程非常优雅:

x = l * cos(t)^3
y = l * sin(t)^3

为什么三次方会创造出尖角?让我们思考一下:

  • t = 0cos(0) = 1,所以 x = lsin(0) = 0,所以 y = 0。点在右侧尖端。
  • t 稍微增大:cos(t) 稍微减小,但 cos(t)^3 减小得更快(因为小于1的数,幂次越高越小)。
  • 这导致曲线在尖端处"急转",形成锐利的角。

4.3 完整代码

// parametric2/app.js(节选)
const star = parametric(
  (t, l) => l * Math.cos(t) ** 3,  // x = l * cos³(t)
  (t, l) => l * Math.sin(t) ** 3,  // y = l * sin³(t)
);

// t 从 0 到 2π,采样 50 个点,l = 150
star(0, Math.PI * 2, 50, 150).draw(ctx, {strokeStyle: 'red'});

4.4 逐步推导:从圆到星形

Step 1: 普通圆的参数方程

x = l * cos(t)
y = l * sin(t)

Step 2: 对三角函数取三次方

x = l * cos(t)^3
y = l * sin(t)^3

Step 3: 验证关键点

t cos(t) cos(t)^3 sin(t) sin(t)^3 点 (x, y)
0 1 1 0 0 (l, 0) 右尖端
π/2 0 0 1 1 (0, l) 上尖端
π -1 -1 0 0 (-l, 0) 左尖端
3π/2 0 0 -1 -1 (0, -l) 下尖端

4.5 常见误区

误区:"星形线有五个角"

不,参数方程 x = l*cos³(t), y = l*sin³(t) 画出的是四尖星形(Astroid)。如果你想画五角星,需要不同的公式,通常是分段线段的组合。

4.6 动手试一试

练习: 尝试修改幂次,画出 x = l*cos(t)^5, y = l*sin(t)^5。你会得到什么形状?(提示:更多尖角!)


5. 极坐标系

5.1 生活中的类比:雷达与GPS

想象你是雷达操作员。屏幕上显示的不是"敌机在 (x, y)",而是"敌机在距离 100 公里、方位角 30 度的位置"。

或者想象 GPS 告诉你:"目标在你的正前方 500 米处"。这也是极坐标的思维方式——用距离和方向定位,而不是用横向和纵向距离。

5.2 本质:另一种描述位置的方式

直角坐标系说:"向东走 3 步,向北走 4 步"。 极坐标系说:"朝东北方向走 5 步"。

两种描述的是同一个点!

极坐标的定义:

r = 点到原点的距离(半径)
θ = 点与正X轴的夹角(角度,逆时针为正)

5.3 坐标转换的推导

从极坐标到直角坐标:

画一个直角三角形:

        .P(r, θ)
       /|
    r / | y
     /  |
    /___|
   原点  x
cos(θ) = x / r  →  x = r * cos(θ)
sin(θ) = y / r  →  y = r * sin(θ)

从直角坐标到极坐标:

r = √(x² + y²)
θ = atan2(y, x)   // 注意用 atan2,它能正确处理四个象限

5.4 完整代码

// parametric-polar/app.js(节选)

// 极坐标转直角坐标的转换函数
const fromPolar = (r, theta) => {
  return [r * Math.cos(theta), r * Math.sin(theta)];
};

// 画一个圆弧:r = 200(常数),θ = t(变化)
const arc = parametric(
  t => 200,    // r = 200
  t => t,      // θ = t
  fromPolar,   // 用 fromPolar 把 (r, θ) 转成 (x, y)
);

// θ 从 0 到 π,画一个半圆
arc(0, Math.PI).draw(ctx);

注意这里的巧妙之处:parametric 函数的前两个参数通常返回 xy,但当传入第三个参数 rFunc 时,前两个参数返回的就被解释为 rθ,然后由 rFunc 转换成 (x, y)

5.5 常见误区

误区一:"极坐标中的 r 可以是负数"

在数学上,负的 r 表示"朝反方向走 |r| 的距离"。但在我们的代码中,通常假设 r ≥ 0。如果公式可能产生负 r,需要特别处理。

误区二:"θ 的范围总是 0 到 2π"

很多极坐标曲线需要 θ 超过 2π 才能画完整。比如玫瑰曲线 r = a*cos(k*θ),当 k 是奇数时,范围 [0, π] 就够了;当 k 是偶数时,需要 [0, 2π]

5.6 动手试一试

练习: 用极坐标画一个同心圆环。提示:画两个不同半径的圆,一个填充,一个挖空。


6. 玫瑰曲线、心形线与双纽线

6.1 玫瑰曲线(Rose Curve)

生活中的类比:花瓣

想象你手中有一朵玫瑰花。从侧面看,它的花瓣呈放射状排列。玫瑰曲线就是用数学公式画出的"花瓣"。

本质:半径随角度做余弦振荡

r = a * cos(k * θ)
  • a 控制花瓣的大小
  • k 控制花瓣的数量

代码

const rose = parametric(
  (t, a, k) => a * Math.cos(k * t),  // r = a * cos(k*θ)
  t => t,                             // θ = t
  fromPolar,
);

// 画一个 5 瓣玫瑰(k=5 是奇数,花瓣数 = k)
rose(0, Math.PI, 100, 200, 5).draw(ctx, {strokeStyle: 'blue'});

推导与规律

为什么 k=5 时有 5 个花瓣?

因为 cos(5θ)[0, π] 内完成 5 个完整周期。每个周期对应一个花瓣。

花瓣数量的规律:

k 的值 花瓣数量 θ 的范围
奇数 k k [0, π]
偶数 k 2k [0, 2π]

原因:当 k 是奇数时,cos(k*(θ+π)) = cos(kθ + kπ) = -cos(kθ)(因为 k 是奇数),负号意味着"反向",和原来的花瓣重叠。所以 [0, π] 就画完了所有花瓣。

当 k 是偶数时,cos(k*(θ+π)) = cos(kθ),这意味着 [π, 2π] 会画出新的花瓣。

6.2 心形线(Cardioid)

生活中的类比:爱心

这个曲线画出来就是一个完美的心形!它是极坐标中最浪漫的曲线之一。

本质:一个圆在另一个等大的圆上"滚动"时,圆周上一点的轨迹

r = a * (1 - sin(θ))

代码

const heart = parametric(
  (t, a) => a - a * Math.sin(t),  // r = a * (1 - sin(θ))
  t => t,
  fromPolar,
);

// θ 从 0 到 2π
heart(0, 2 * Math.PI, 100, 100).draw(ctx, {strokeStyle: 'red'});

推导

Step 1: 从心形线的几何定义出发

想象两个半径相等的圆,一个固定,另一个绕其滚动。滚动圆圆周上一点的轨迹就是心形线。

Step 2: 极坐标方程

经过几何推导(涉及旋转向量和距离公式),得到:

r = a * (1 - sin(θ))

Step 3: 验证关键点

θ sin(θ) r = a(1-sin(θ)) 说明
0 0 a 最右侧
π/2 1 0 底部尖端
π 0 a 最左侧
3π/2 -1 2a 顶部(离原点最远)

6.3 双纽线(Lemniscate / Folium)

生活中的类比:蝴蝶结或无穷符号

双纽线看起来像一个横着的"8"字,或者一个蝴蝶结。

本质:到两个定点的距离乘积为常数的点的轨迹

r² = 2a² * cos(2θ)

所以:

r = ±√(2a² * cos(2θ))

注意这里有正负两个分支!

代码

// 右半部分(正根)
const foliumRight = parametric(
  (t, a) => Math.sqrt(2 * a ** 2 * Math.cos(2 * t)),
  t => t,
  fromPolar,
);

// 左半部分(负根)
const foliumLeft = parametric(
  (t, a) => -Math.sqrt(2 * a ** 2 * Math.cos(2 * t)),
  t => t,
  fromPolar,
);

// θ 的范围:-π/4 到 π/4(超出这个范围 cos(2θ) < 0,r 为虚数)
foliumRight(-Math.PI / 4, Math.PI / 4, 100, 100).draw(ctx, {strokeStyle: 'green'});
foliumLeft(-Math.PI / 4, Math.PI / 4, 100, 100).draw(ctx, {strokeStyle: 'green'});

推导

Step 1: 双纽线的定义

到两个定点 F1(-a, 0)F2(a, 0) 的距离乘积等于 的点的轨迹:

|PF1| * |PF2| = a²

Step 2: 转换为极坐标

用距离公式和极坐标关系,经过代数运算(展开、化简),得到:

r² = 2a² * cos(2θ)

Step 3: 确定有效范围

因为 r² ≥ 0,所以 cos(2θ) ≥ 0

解得:2θ ∈ [-π/2, π/2],即 θ ∈ [-π/4, π/4]

这就是 θ 的有效范围。

6.4 常见误区

误区:"心形线方程只有一种形式"

心形线有多种等价形式:

  • r = a(1 - sin(θ)) —— 心尖朝下
  • r = a(1 + sin(θ)) —— 心尖朝上
  • r = a(1 - cos(θ)) —— 心尖朝右
  • r = a(1 + cos(θ)) —— 心尖朝左

6.5 动手试一试

练习 1: 修改玫瑰曲线的 k 值,观察花瓣数量的变化规律。

练习 2: 尝试画出 r = a * sin(2θ)r = a * sin(3θ),比较它们的花瓣数量和方向。


7. 二次贝塞尔曲线

7.1 生活中的类比:橡皮筋与磁铁

想象你有一根橡皮筋,两端分别钉在桌子上(起点 P0 和终点 P2)。现在你用一根手指在中间某处拉起橡皮筋(控制点 P1)。橡皮筋形成的弧线就是二次贝塞尔曲线。

或者想象 P1 是一个磁铁,它"吸引"着原本应该从 P0 直线走到 P2 的路径。

7.2 本质:从线性插值到二次插值

Step 1:回顾线性插值

两个点 P0 和 P1 之间的线性插值:

P(t) = (1-t) * P0 + t * P1,   t ∈ [0, 1]

当 t=0 时在 P0,t=1 时在 P1,t=0.5 时在中点。

Step 2:二次贝塞尔的构造思路

二次贝塞尔有三个点:P0(起点)、P1(控制点)、P2(终点)。

构造方法(称为 de Casteljau 算法):

  1. 在 P0→P1 上做线性插值,得到点 Q0(t)
  2. 在 P1→P2 上做线性插值,得到点 Q1(t)
  3. 在 Q0→Q1 上再做线性插值,得到最终点 B(t)

Step 3:公式推导

第一层插值:

Q0(t) = (1-t) * P0 + t * P1
Q1(t) = (1-t) * P1 + t * P2

第二层插值:

B(t) = (1-t) * Q0(t) + t * Q1(t)

展开:

B(t) = (1-t) * [(1-t) * P0 + t * P1] + t * [(1-t) * P1 + t * P2]
     = (1-t)² * P0 + t(1-t) * P1 + t(1-t) * P1 + t² * P2
     = (1-t)² * P0 + 2t(1-t) * P1 + t² * P2

这就是二次贝塞尔曲线的标准公式!

分量形式

x(t) = (1-t)² * x0 + 2t(1-t) * x1 + t² * x2
y(t) = (1-t)² * y0 + 2t(1-t) * y1 + t² * y2

7.3 系数分析

让我们看看三个系数随 t 的变化:

t (1-t)² 2t(1-t) 说明
0 1 0 0 完全受 P0 影响
0.25 0.5625 0.375 0.0625 P0 影响最大
0.5 0.25 0.5 0.25 P1 影响最大
0.75 0.0625 0.375 0.5625 P2 影响最大
1 0 0 1 完全受 P2 影响

注意:三个系数之和恒为 1(验证:(1-t)² + 2t(1-t) + t² = 1 - 2t + t² + 2t - 2t² + t² = 1)。这保证了曲线是三个点的加权平均,不会"跑飞"。

7.4 完整代码

// bezier/app.js
import {parametric} from '../common/lib/parametric.js';
import {Vector2D} from '../common/lib/vector2d.js';

const canvas = document.querySelector('canvas');
const ctx = canvas.getContext('2d');
const {width, height} = canvas;
const w = 0.5 * width, h = 0.5 * height;
ctx.translate(w, h);
ctx.scale(1, -1);

// 画坐标轴
function drawAxis() {
  ctx.save();
  ctx.strokeStyle = '#ccc';
  ctx.beginPath();
  ctx.moveTo(-w, 0); ctx.lineTo(w, 0);
  ctx.stroke();
  ctx.beginPath();
  ctx.moveTo(0, -h); ctx.lineTo(0, h);
  ctx.stroke();
  ctx.restore();
}

drawAxis();

// 定义二次贝塞尔曲线
const quadricBezier = parametric(
  (t, [{x: x0}, {x: x1}, {x: x2}]) => (1 - t) ** 2 * x0 + 2 * t * (1 - t) * x1 + t ** 2 * x2,
  (t, [{y: y0}, {y: y1}, {y: y2}]) => (1 - t) ** 2 * y0 + 2 * t * (1 - t) * y1 + t ** 2 * y2,
);

// 创建三个点
const p0 = new Vector2D(0, 0);     // 起点
const p1 = new Vector2D(100, 0);   // 控制点(初始在 x 轴上)
p1.rotate(0.75);                   // 把控制点旋转 0.75 弧度
const p2 = new Vector2D(200, 0);   // 终点

// 画 30 条旋转的贝塞尔曲线,形成美丽的图案
const count = 30;
for(let i = 0; i < count; i++) {
  p1.rotate(2 / count * Math.PI);  // 控制点旋转
  p2.rotate(2 / count * Math.PI);  // 终点旋转
  quadricBezier(0, 1, 100, [p0, p1, p2]).draw(ctx);
}

7.5 常见误区

误区一:"控制点 P1 在曲线上"

除非特殊情况,控制点不在曲线上!曲线从 P0 出发,"受 P1 吸引",最终到达 P2。P1 只是"引力源"。

误区二:"贝塞尔曲线是圆弧的近似"

二次贝塞尔曲线是抛物线弧段,不是圆弧。虽然看起来有点像,但数学本质不同。

7.6 动手试一试

练习 1: 修改代码,让 P0 也参与旋转,观察图案的变化。

练习 2: 实现一个交互式二次贝塞尔曲线:用鼠标拖动 P0、P1、P2,实时显示曲线。


8. 三次贝塞尔曲线

8.1 生活中的类比:更灵活的橡皮筋

二次贝塞尔像一个控制点"拉"着曲线。但如果我想让曲线先向左弯、再向右弯呢?一个控制点做不到——它只能提供一个"引力方向"。

三次贝塞尔有两个控制点 P1 和 P2,就像有两个磁铁在吸引曲线。这让曲线可以形成更复杂的形状:S 形、先抑后扬、各种流畅的转折。

8.2 本质:de Casteljau 算法的三层嵌套

Step 1:从二次扩展到三次

三次贝塞尔有四个点:P0(起点)、P1、P2(两个控制点)、P3(终点)。

de Casteljau 算法:

  1. P0→P1 插值得 Q0
  2. P1→P2 插值得 Q1
  3. P2→P3 插值得 Q2
  4. Q0→Q1 插值得 R0
  5. Q1→Q2 插值得 R1
  6. R0→R1 插值得 B(t)

Step 2:公式推导

第一层:

Q0 = (1-t)P0 + tP1
Q1 = (1-t)P1 + tP2
Q2 = (1-t)P2 + tP3

第二层:

R0 = (1-t)Q0 + tQ1
R1 = (1-t)Q1 + tQ2

第三层:

B(t) = (1-t)R0 + tR1

全部展开(这需要耐心):

先展开 R0 和 R1:

R0 = (1-t)[(1-t)P0 + tP1] + t[(1-t)P1 + tP2]
   = (1-t)²P0 + t(1-t)P1 + t(1-t)P1 + t²P2
   = (1-t)²P0 + 2t(1-t)P1 + t²P2

R1 = (1-t)[(1-t)P1 + tP2] + t[(1-t)P2 + tP3]
   = (1-t)²P1 + 2t(1-t)P2 + t²P3

然后展开 B(t):

B(t) = (1-t)R0 + tR1
     = (1-t)[(1-t)²P0 + 2t(1-t)P1 + t²P2] + t[(1-t)²P1 + 2t(1-t)P2 + t²P3]
     = (1-t)³P0 + 2t(1-t)²P1 + t²(1-t)P2 + t(1-t)²P1 + 2t²(1-t)P2 + t³P3

合并同类项:

  • P0 的系数:(1-t)³
  • P1 的系数:2t(1-t)² + t(1-t)² = 3t(1-t)²
  • P2 的系数:t²(1-t) + 2t²(1-t) = 3t²(1-t)
  • P3 的系数:

所以:

B(t) = (1-t)³P0 + 3t(1-t)²P1 + 3t²(1-t)P2 + t³P3

这就是三次贝塞尔曲线的标准公式!

8.3 系数分析

t (1-t)³ 3t(1-t)² 3t²(1-t)
0 1 0 0 0
0.25 0.422 0.422 0.141 0.016
0.5 0.125 0.375 0.375 0.125
0.75 0.016 0.141 0.422 0.422
1 0 0 0 1

注意在 t=0.5 时,两个控制点的权重相等(都是 0.375),这就是为什么两个控制点能"共同塑造"曲线的中段。

8.4 完整代码

// bezier/app2.js
import {parametric} from '../common/lib/parametric.js';
import {Vector2D} from '../common/lib/vector2d.js';

const canvas = document.querySelector('canvas');
const ctx = canvas.getContext('2d');
const {width, height} = canvas;
const w = 0.5 * width, h = 0.5 * height;
ctx.translate(w, h);
ctx.scale(1, -1);

function drawAxis() {
  ctx.save();
  ctx.strokeStyle = '#ccc';
  ctx.beginPath();
  ctx.moveTo(-w, 0); ctx.lineTo(w, 0);
  ctx.stroke();
  ctx.beginPath();
  ctx.moveTo(0, -h); ctx.lineTo(0, h);
  ctx.stroke();
  ctx.restore();
}

drawAxis();

// 定义三次贝塞尔曲线
const cubicBezier = parametric(
  (t, [{x: x0}, {x: x1}, {x: x2}, {x: x3}]) =>
    (1 - t) ** 3 * x0 + 3 * t * (1 - t) ** 2 * x1 + 3 * (1 - t) * t ** 2 * x2 + t ** 3 * x3,
  (t, [{y: y0}, {y: y1}, {y: y2}, {y: y3}]) =>
    (1 - t) ** 3 * y0 + 3 * t * (1 - t) ** 2 * y1 + 3 * (1 - t) * t ** 2 * y2 + t ** 3 * y3,
);

// 创建四个点
const p0 = new Vector2D(0, 0);
const p1 = new Vector2D(100, 0);
p1.rotate(0.75);      // 第一个控制点向上翘
const p2 = new Vector2D(150, 0);
p2.rotate(-0.75);     // 第二个控制点向下翘
const p3 = new Vector2D(200, 0);

// 画 30 条旋转的三次贝塞尔曲线
const count = 30;
for(let i = 0; i < count; i++) {
  p1.rotate(2 / count * Math.PI);
  p2.rotate(2 / count * Math.PI);
  p3.rotate(2 / count * Math.PI);
  cubicBezier(0, 1, 100, [p0, p1, p2, p3]).draw(ctx);
}

8.5 常见误区

误区:"控制点越多,曲线越平滑"

实际上,一个高次的贝塞尔曲线(很多控制点)反而可能出现不自然的波动。工程中更常见的做法是:用多段三次贝塞尔曲线拼接,保证连接处的连续性。这就是 TrueType 字体和 PostScript 的做法。

8.6 动手试一试

练习: 实现一个函数,用两段三次贝塞尔曲线画一个"S"形。提示:第一段从 (0,0) 到 (50,50),第二段从 (50,50) 到 (100,0),调整控制点使连接处平滑。


9. 为什么贝塞尔曲线无处不在?

9.1 字体:TrueType 和 PostScript

计算机字体需要描述字母的轮廓。用直线段描述 "O" 需要成百上千条线段,文件很大。用贝塞尔曲线,"O" 只需几个控制点!

  • TrueType 使用二次贝塞尔曲线
  • PostScript / OpenType 使用三次贝塞尔曲线

9.2 SVG 路径

SVG 的 <path> 元素用命令描述形状:

<!-- 二次贝塞尔 -->
<path d="M 10 10 Q 50 100 90 10" />
<!-- M = move to, Q = quadratic Bezier (控制点 50,100, 终点 90,10) -->

<!-- 三次贝塞尔 -->
<path d="M 10 10 C 30 100 70 0 90 10" />
<!-- C = cubic Bezier (两个控制点 30,100 和 70,0, 终点 90,10) -->

9.3 CSS 动画

CSS 的 cubic-bezier() 函数定义动画的缓动效果:

.animation {
  animation-timing-function: cubic-bezier(0.25, 0.1, 0.25, 1.0);
  /* 四个数字分别是 P1.x, P1.y, P2.x, P2.y */
  /* P0 固定为 (0,0),P3 固定为 (1,1) */
}

这里的 "曲线" 不是空间中的曲线,而是时间-进度曲线:x 轴是时间(0 到 1),y 轴是动画进度(0 到 1)。曲线越陡,动画越快;越平缓,动画越慢。

9.4 为什么偏偏是贝塞尔?

贝塞尔曲线有几个不可替代的优点:

  1. 计算简单:只需几次乘法和加法
  2. 局部控制:移动一个控制点只影响曲线的一部分
  3. 仿射不变性:对控制点做旋转/缩放/平移,等于对整条曲线做同样的变换
  4. 一定在控制点的凸包内:曲线不会"乱跑"到意想不到的地方
  5. 易于拼接:两段贝塞尔曲线可以平滑连接(通过控制点位置约束)

10. 常见问题 Q&A

Q1:参数方程和普通函数 y = f(x) 有什么区别?

A: y = f(x) 中,每个 x 只能对应一个 y,所以不能画自相交的曲线(比如螺旋线)。参数方程用独立的参数 t,x 和 y 都是 t 的函数,没有这种限制。

Q2:采样点数 seg 越多越好吗?

A: 越多越平滑,但计算量越大。对于直线变化的部分可以少采样,对于曲率大的部分需要多采样。实际工程中常用自适应采样:根据曲率动态决定采样密度。

Q3:为什么 Canvas 要 scale(1, -1)

A: Canvas 的 Y 轴向下(屏幕坐标系),而数学上的 Y 轴向上(笛卡尔坐标系)。翻转后,数学公式算出的 (x, y) 可以直接画到 Canvas 上,不用每次手动改 y 的符号。

Q4:贝塞尔曲线的控制点可以随便放吗?

A: 可以!控制点决定了曲线的形状。但有一些技巧:

  • 想让曲线经过某个点,需要解方程反推控制点位置
  • 想让两段曲线平滑连接,需要让连接点两侧的控制点共线

Q5:参数 t 在贝塞尔曲线中代表什么?

A: t 不代表"沿曲线的距离比例"。在 t=0.5 时,你通常不在曲线的中点(按弧长算)。t 只是一个数学参数,均匀变化时点在曲线上的移动速度是不均匀的。

Q6:为什么极坐标曲线有时需要正负两个分支?

A: 因为 r² = ... 的公式中,r 可以是正的或负的。正的 r 朝 θ 方向画,负的 r 朝 θ 的反方向画。两个分支合起来才是完整的曲线。

Q7:贝塞尔曲线最高可以有多少次?

A: 理论上任意次。但工程中几乎只用二次和三次,因为:

  • 二次足够简单,用于 TrueType 字体
  • 三次足够灵活,用于 PostScript / SVG
  • 更高次的曲线难以控制和计算

Q8:如何计算贝塞尔曲线上某点的切线方向?

A: 对 B(t) 求导!

二次贝塞尔的导数:

B'(t) = 2(1-t)(P1 - P0) + 2t(P2 - P1)

三次贝塞尔的导数:

B'(t) = 3(1-t)²(P1 - P0) + 6t(1-t)(P2 - P1) + 3t²(P3 - P2)

导数向量就是切线方向。


总结

这一章我们走过了很长的路:

  1. 参数方程 —— 用一个参数 t 描述曲线,打破了 y = f(x) 的限制
  2. 圆弧 —— x = r*cos(t), y = r*sin(t),最基础的参数方程
  3. 螺旋线 —— 让半径随角度增长:r = l*t
  4. 星形线 —— 三角函数的幂次游戏:x = l*cos³(t)
  5. 极坐标 —— 用 (r, θ) 代替 (x, y),适合描述放射状图形
  6. 玫瑰曲线、心形线、双纽线 —— 极坐标中的美丽曲线
  7. 二次贝塞尔 —— 一个控制点,(1-t)²P0 + 2t(1-t)P1 + t²P2
  8. 三次贝塞尔 —— 两个控制点,(1-t)³P0 + 3t(1-t)²P1 + 3t²(1-t)P2 + t³P3
  9. 贝塞尔的应用 —— 字体、SVG、CSS 动画无处不在

核心思想: 参数方程的精髓是分离。把"如何计算坐标"和"如何绘制"分开,把"形状的定义"和"具体的实现"分开。这种分层思维是计算机图形学中最重要的设计原则之一。


给初学者的话:

不要试图一次性记住所有公式。重要的是理解推导过程几何直觉。当你忘记公式时,能从 de Casteljau 算法或三角函数的定义重新推导出来,你就真正掌握了这些内容。

打开你的编辑器,修改代码中的参数,看看曲线如何变化。这是最好的学习方式。

下一章,我们将用这些曲线知识,走进更精彩的图形世界。加油!