第五章:参数方程与贝塞尔曲线
"数学是自然的语言,而曲线是数学的诗篇。"
在这一章里,我们将从最简单的圆开始,一步步走进参数方程的奇妙世界,最终抵达计算机图形学中最优雅的发明之一——贝塞尔曲线。别担心,我会像一位耐心的导师一样,带着你一步一步走过来。
目录
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 正好在 start 和 end 中间。
推导:
我们希望 t 是 p 的一次函数:
t = a * p + b
代入边界条件:
- 当
p = 0:start = a * 0 + b→b = start - 当
p = 1:end = a * 1 + start→a = 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 的范围由 start 和 end 决定。比如画圆时,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 = t,y = 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 = 0:cos(0) = 1,所以x = l;sin(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 函数的前两个参数通常返回 x 和 y,但当传入第三个参数 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) 的距离乘积等于 a² 的点的轨迹:
|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 算法):
- 在 P0→P1 上做线性插值,得到点 Q0(t)
- 在 P1→P2 上做线性插值,得到点 Q1(t)
- 在 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) | 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 算法:
- P0→P1 插值得 Q0
- P1→P2 插值得 Q1
- P2→P3 插值得 Q2
- Q0→Q1 插值得 R0
- Q1→Q2 插值得 R1
- 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 的系数:
t³
所以:
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) | 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 为什么偏偏是贝塞尔?
贝塞尔曲线有几个不可替代的优点:
- 计算简单:只需几次乘法和加法
- 局部控制:移动一个控制点只影响曲线的一部分
- 仿射不变性:对控制点做旋转/缩放/平移,等于对整条曲线做同样的变换
- 一定在控制点的凸包内:曲线不会"乱跑"到意想不到的地方
- 易于拼接:两段贝塞尔曲线可以平滑连接(通过控制点位置约束)
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)
导数向量就是切线方向。
总结
这一章我们走过了很长的路:
- 参数方程 —— 用一个参数 t 描述曲线,打破了
y = f(x)的限制 - 圆弧 ——
x = r*cos(t), y = r*sin(t),最基础的参数方程 - 螺旋线 —— 让半径随角度增长:
r = l*t - 星形线 —— 三角函数的幂次游戏:
x = l*cos³(t) - 极坐标 —— 用
(r, θ)代替(x, y),适合描述放射状图形 - 玫瑰曲线、心形线、双纽线 —— 极坐标中的美丽曲线
- 二次贝塞尔 —— 一个控制点,
(1-t)²P0 + 2t(1-t)P1 + t²P2 - 三次贝塞尔 —— 两个控制点,
(1-t)³P0 + 3t(1-t)²P1 + 3t²(1-t)P2 + t³P3 - 贝塞尔的应用 —— 字体、SVG、CSS 动画无处不在
核心思想: 参数方程的精髓是分离。把"如何计算坐标"和"如何绘制"分开,把"形状的定义"和"具体的实现"分开。这种分层思维是计算机图形学中最重要的设计原则之一。
给初学者的话:
不要试图一次性记住所有公式。重要的是理解推导过程和几何直觉。当你忘记公式时,能从 de Casteljau 算法或三角函数的定义重新推导出来,你就真正掌握了这些内容。
打开你的编辑器,修改代码中的参数,看看曲线如何变化。这是最好的学习方式。
下一章,我们将用这些曲线知识,走进更精彩的图形世界。加油!