《游戏引擎实现原理》9.3 曲线、样条与缓动

实现 helio 的曲线模块:二次/三次贝塞尔、Catmull-Rom 样条与常用缓动函数。用采样点表说清贝塞尔不穿过中间控制点、Catmull-Rom 穿过却会过冲(实测会冲到 -0.0625),并给出四种缓动函数在同一组 t 上的取值对照。

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 的对照:

tcubicBezier(0,0,1,1,t)catmullRom(0,0,1,1,t)
0.000.0000.000
0.250.1560.203
0.500.5000.500
0.750.8440.797
1.001.0001.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 上四种常见缓动的取值:

teaseInQuadeaseOutQuadeaseInOutCubicsmoothstep
0.000.0000.0000.0000.000
0.250.0630.4380.0630.156
0.500.2500.7500.5000.500
0.750.5630.9380.9380.844
1.001.0001.0001.0001.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 浮点确定性与跨端一致性 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「engine」更多文章

  1. 《游戏引擎实现原理》工程与实战 目录
  2. 《游戏引擎实现原理》9.3 选型与自研的判断标准
  3. 《游戏引擎实现原理》9.2 同一个特性在不同引擎里的实现路径