9.3 曲线、样条与缓动
9.2 插的是旋转,这一节插的是位置与数值:相机的运镜路径、道具的抛物线、UI 弹出的缓动、昼夜循环的权重过渡。它们背后是同一件事——给一个归一化参数 t ∈ [0, 1],算出对应的值。
但「给 t 算值」有好几种做法,脾气各不相同:贝塞尔曲线不穿过中间控制点,Catmull-Rom 样条穿过却会过冲,缓动函数则干脆不碰控制点、只把 t 重新映射一遍。这一节把三者都实现出来,用采样点表把它们的差别量化。
三条曲线,三种脾气
| 做法 | 穿过控制点吗 | 典型用途 | 要小心什么 |
|---|---|---|---|
| 二次 / 三次贝塞尔 | 只穿首尾,不穿中间 | UI 运动、单段路径、字体轮廓 | 调形状靠拖控制点,直觉但费手 |
| Catmull-Rom 样条 | 穿过每一个控制点 | 相机路径、轨道、动画关键帧 | 会过冲到控制点范围之外 |
| 缓动函数 | 不涉及控制点 | 淡入淡出、UI 弹出、数值过渡 | 端点必须精确、要单调 |
贝塞尔用伯恩斯坦多项式做权重,t 与 1−t 的幂次决定它是几次;Catmull-Rom 则是每段用四个控制点 p0..p3 插值 p1 → p2,切向量由相邻点差分给出。两者最大的区别就是上表第二列——过不过控制点。
可运行产物:curve.ts
下面是完整可编译的 src/core/curve.ts,标量形式,向量按分量套用即可。层级 L1,Node 26 直跑。
// file: src/core/curve.ts
// tier: L1
// verify: npx vitest run src/core/curve.test.ts
//
// helio 内核:贝塞尔、Catmull-Rom 样条与常用缓动函数(标量形式,向量按分量套用)。
// t 一律归一化到 [0, 1]。
function clamp01(t: number): number {
return t < 0 ? 0 : t > 1 ? 1 : t;
}
/** 二次贝塞尔:只经过首尾两个控制点。 */
export function quadraticBezier(p0: number, p1: number, p2: number, t: number): number {
const u = 1 - t;
return u * u * p0 + 2 * u * t * p1 + t * t * p2;
}
/** 三次贝塞尔:不经过中间控制点 p1、p2。 */
export function cubicBezier(
p0: number, p1: number, p2: number, p3: number, t: number,
): number {
const u = 1 - t;
return u * u * u * p0 + 3 * u * u * t * p1 + 3 * u * t * t * p2 + t * t * t * p3;
}
/**
* Catmull-Rom 样条:t = 0 落在 p1、t = 1 落在 p2,
* 即曲线精确穿过每一段的首尾控制点(与贝塞尔相反)。
*/
export function catmullRom(
p0: number, p1: number, p2: number, p3: number, t: number,
): number {
const t2 = t * t;
const t3 = t2 * t;
return 0.5 * (
2 * p1 +
(-p0 + p2) * t +
(2 * p0 - 5 * p1 + 4 * p2 - p3) * t2 +
(-p0 + 3 * p1 - 3 * p2 + p3) * t3
);
}
// —— 缓动函数:输入 t ∈ [0, 1],输出通常也在 [0, 1],但不要求 ——
export function easeInQuad(t: number): number {
return t * t;
}
export function easeOutQuad(t: number): number {
return t * (2 - t);
}
export function easeInOutCubic(t: number): number {
const x = clamp01(t);
return x < 0.5 ? 4 * x * x * x : 1 - Math.pow(-2 * x + 2, 3) / 2;
}
/** Hermite 平滑阶跃:两端一阶导为 0,常用来做淡入淡出与权重过渡。 */
export function smoothstep(t: number): number {
const x = clamp01(t);
return x * x * (3 - 2 * x);
}
catmullRom 里那串系数看着吓人,其实是从四个控制点各取一半权重凑出来的 Hermite 基;记住它的语义就行:t = 0 落在 p1,t = 1 落在 p2,曲线穿过中间两点。缓动函数则完全是另一回事——它们不含任何控制点,只是把输入 t 通过一条单调曲线重新映射成输出。
采样点表:贝塞尔不穿过控制点,Catmull-Rom 会过冲
光看公式不如看数字。下面是 src/core/curve-samples.ts,跑一遍打印出采样点表。层级 L1。
// file: src/core/curve-samples.ts
// tier: L1
// verify: node src/core/curve-samples.ts
//
// 打印曲线与缓动的采样点表,供正文直接引用。
import {
catmullRom,
cubicBezier,
easeInOutCubic,
easeInQuad,
easeOutQuad,
smoothstep,
} from './curve.ts';
const TS = [0, 0.25, 0.5, 0.75, 1];
const f3 = (x: number) => x.toFixed(3).padStart(6);
console.log('cubicBezier(0,0,1,1,t) vs catmullRom(0,0,1,1,t)');
console.log(' t bezier catmull');
for (const t of TS) {
console.log(`${f3(t)} ${f3(cubicBezier(0, 0, 1, 1, t))} ${f3(catmullRom(0, 0, 1, 1, t))}`);
}
console.log('\ncatmullRom 过控制点:p=(0,1,2,3)');
console.log(` t=0 -> ${catmullRom(0, 1, 2, 3, 0)} t=1 -> ${catmullRom(0, 1, 2, 3, 1)}`);
console.log(` 冲过范围:catmullRom(0,0,0,1,0.5) = ${catmullRom(0, 0, 0, 1, 0.5)}`);
console.log('\n缓动函数采样');
console.log(' t easeInQuad easeOutQuad easeInOutCubic smoothstep');
for (const t of TS) {
console.log(
`${f3(t)} ${f3(easeInQuad(t))} ${f3(easeOutQuad(t))} ${f3(easeInOutCubic(t))} ${f3(smoothstep(t))}`,
);
}
本机实测 node src/core/curve-samples.ts,贝塞尔与 Catmull-Rom 的对照:
| t | cubicBezier(0,0,1,1,t) | catmullRom(0,0,1,1,t) |
|---|---|---|
| 0.00 | 0.000 | 0.000 |
| 0.25 | 0.156 | 0.203 |
| 0.50 | 0.500 | 0.500 |
| 0.75 | 0.844 | 0.797 |
| 1.00 | 1.000 | 1.000 |
两条曲线端点相同、中点都过 0.5,但在 1/4 与 3/4 处明显分叉:t = 0.25 时 Catmull-Rom(0.203)跑得比贝塞尔(0.156)快,到 t = 0.75 反过来(0.797 对 0.844)。这就是「穿过控制点」带来的形状差异——Catmull-Rom 的切向量由相邻点差分决定,起步更冲。
再看 Catmull-Rom 的两个特性,实测数字写得很直白:
| 断言 | 实测值 | 说明 |
|---|---|---|
catmullRom(0,1,2,3, t=0) | 1 | 精确落在 p1 |
catmullRom(0,1,2,3, t=1) | 2 | 精确落在 p2 |
catmullRom(0,0,0,1, t=0.5) | −0.0625 | 控制点全在 [0, 1],曲线却冲到 0 以下 |
前两行说明「穿过控制点」,第三行说明「会过冲」:四个控制点是 0, 0, 0, 1,取值范围本应是 [0, 1],但 t = 0.5 处曲线到了 −0.0625。如果这条曲线驱动的是相机高度,镜头就会在过渡时突然下探一下——这不是 bug,是样条的性质。要它不过冲,就得换单调的插值方案(比如带张力参数、或干脆用缓动)。
缓动函数:把 t 重新映射
缓动不碰控制点,只把 t 映射成另一条单调曲线。同一组 t 上四种常见缓动的取值:
| t | easeInQuad | easeOutQuad | easeInOutCubic | smoothstep |
|---|---|---|---|---|
| 0.00 | 0.000 | 0.000 | 0.000 | 0.000 |
| 0.25 | 0.063 | 0.438 | 0.063 | 0.156 |
| 0.50 | 0.250 | 0.750 | 0.500 | 0.500 |
| 0.75 | 0.563 | 0.938 | 0.938 | 0.844 |
| 1.00 | 1.000 | 1.000 | 1.000 | 1.000 |
读法:easeInQuad 是 t²,前段慢(0.25 处才 0.063),适合「加速进入」;easeOutQuad 是 t(2−t),前段快(0.25 处已经 0.438),适合「减速停稳」;easeInOutCubic 两端都慢、中段冲(0.25 处 0.063、0.75 处 0.938),是默认的 UI 过渡;smoothstep 是 t²(3−2t),两端一阶导为 0,过渡最「软」,常用来做权重淡入淡出。四者端点都精确落在 0 与 1——这是缓动函数的硬要求,端点飘了,动画就会跳。
curve.test.ts 把这些性质全部钉成断言:端点、中点取值、以及 101 个采样点上的单调性。
// file: src/core/curve.test.ts
// tier: L1
// verify: npx vitest run src/core/curve.test.ts
import { describe, expect, it } from 'vitest';
import {
catmullRom,
cubicBezier,
easeInOutCubic,
easeInQuad,
easeOutQuad,
quadraticBezier,
smoothstep,
} from './curve.ts';
const close = (a: number, b: number, eps = 1e-9) => Math.abs(a - b) < eps;
describe('贝塞尔曲线', () => {
it('二次贝塞尔只经过首尾控制点', () => {
expect(quadraticBezier(0, 1, 2, 0)).toBe(0);
expect(quadraticBezier(0, 1, 2, 1)).toBe(2);
expect(close(quadraticBezier(0, 1, 2, 0.5), 1)).toBe(true);
});
it('三次贝塞尔端点精确、中间控制点不经过', () => {
expect(cubicBezier(0, 0, 1, 1, 0)).toBe(0);
expect(cubicBezier(0, 0, 1, 1, 1)).toBe(1);
// t = 0.5 只到 0.5,控制点 p1 = 0、p2 = 1 并未落在曲线上
expect(close(cubicBezier(0, 0, 1, 1, 0.5), 0.5)).toBe(true);
});
});
describe('Catmull-Rom 样条', () => {
it('t = 0 落在 p1,t = 1 落在 p2(穿过控制点)', () => {
expect(catmullRom(0, 1, 2, 3, 0)).toBe(1);
expect(catmullRom(0, 1, 2, 3, 1)).toBe(2);
});
it('会冲过控制点范围(过冲)', () => {
// 控制点都在 [0, 1],但 t = 0.5 处冲到 -0.0625
expect(catmullRom(0, 0, 0, 1, 0.5)).toBeLessThan(0);
expect(close(catmullRom(0, 0, 0, 1, 0.5), -0.0625)).toBe(true);
});
});
describe('缓动函数', () => {
it('端点都是 0 与 1', () => {
for (const e of [easeInQuad, easeOutQuad, easeInOutCubic, smoothstep]) {
expect(e(0)).toBe(0);
expect(e(1)).toBe(1);
}
});
it('t = 0.5 的取值符合定义', () => {
expect(close(easeInQuad(0.5), 0.25)).toBe(true);
expect(close(easeOutQuad(0.5), 0.75)).toBe(true);
expect(close(easeInOutCubic(0.5), 0.5)).toBe(true);
expect(close(smoothstep(0.5), 0.5)).toBe(true);
});
it('全部单调不减', () => {
for (const e of [easeInQuad, easeOutQuad, easeInOutCubic, smoothstep]) {
let prev = -Infinity;
for (let i = 0; i <= 100; i++) {
const v = e(i / 100);
expect(v).toBeGreaterThanOrEqual(prev);
prev = v;
}
}
});
});
本机实测 npx vitest run src/core/curve.test.ts:7 个用例全部通过。
常见坑
- 以为贝塞尔会穿过控制点。它只穿首尾;想「经过某个点」就换 Catmull-Rom,或反解控制点。
- 忘了 Catmull-Rom 会过冲。控制点范围不等于取值范围(本节实测冲到 −0.0625)。用于相机、颜色、音量这类有物理范围的量时要夹紧或换方案。
- 缓动端点不精确。
easeInOutCubic若在t = 1处算出0.9999,动画结束会有一帧回跳。端点必须用expect(e(1)).toBe(1)钉死。 - 在
[0, 1]之外取值。曲线与缓动都假定t归一化;传了 1.5 要先clamp,否则 Catmull-Rom 会算到段外。 t的推进方式不对。曲线只负责「给 t 算值」,t 怎么随时间推进是另一回事——线性推进配缓动函数,还是直接给曲线喂缓动后的 t,决定了运动的「手感」。- 每帧重建样条。相机路径的控制点在整段行程里是常量,应当建一次缓存切向量,而不是每帧重算差分。
数学的三节到这里收口:9.1 讲了旋转表示为什么会死锁,9.2 用四元数把它做稳,9.3 把插值从旋转扩展到位置与数值。但它们都建立在一个默认前提上——浮点数在不同平台上是可复现的。10.1 就来拆掉这个前提,看看同一段数学在 x86 与 ARM 上为什么会算出不同的结果。
阅读导航:上一节:9.2 四元数运算与最短弧插值 · 下一节:10.1 浮点确定性与跨端一致性 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。