8.2 补间与缓动曲线的数学
8.1 的帧动画适合「换姿态」,但很多动画其实是同一个对象的一串属性随时间连续变化:金币从 scale=1 弹到 1.2 再回落、血条从满到空、UI 面板从屏幕外滑入。这类动画没有离散的帧,只有一个起点值、一个终点值和一条从 0 到 1 的进度曲线。把「从起点插值到终点」这件事抽象出来,就是补间(tween)。
本节把 mini2d 推进到「能插值」:从线性插值讲到缓动函数,给出九族缓动的公式与实现,再手写一个支持链式与并行的补间队列。最后对照 Cocos Creator 3.8 的
tween()、Tween类与TweenEasing类型,看这套数学在引擎里如何被封装成一串链式调用。
8.2.1 补间的本质:一个插值公式
补间的最小模型只有三个量:起点 from、终点 to、进度 t(0 到 1)。线性插值(lerp)就是:
function lerp(from: number, to: number, t: number): number {
return from + (to - from) * t;
}
t = 0 时返回 from,t = 1 时返回 to,中间线性过渡。整段动画就是把真实流逝的秒数换算成 t:
// duration 是这段补间的总时长(秒)
const t = Math.min(elapsed / duration, 1);
const value = lerp(from, to, t);
Math.min(..., 1) 是必须的钳制——elapsed 可能因为掉帧一次跨过 duration,不钳制会插值到终点之外。这个「归一化进度」是所有补间库的共同基础。
8.2.2 归一化时间与缓动函数
线性插值的问题是「太机械」——所有物体都以匀速起步、匀速刹车,像被绳子拖着。真实世界里有惯性:东西加速起来、减速停下。解决办法是不直接用 t,而是先把它喂给一个缓动函数 ease,用返回值去插值:
const value = lerp(from, to, ease(t));
缓动函数是 [0,1] → R 的映射,必须满足 ease(0) = 0、ease(1) = 1(端点对齐,否则动画起止值会跳),但中间可以越界——比如「回弹(back)」会先往负方向过冲再回来,「弹跳(bounce)」会在终点附近反复弹。这正是它们比线性生动的原因。
export type EasingFn = (k: number) => number;
一句话记住:补间管「从 A 到 B」,缓动管「怎么从 A 到 B」。两者正交,可以自由组合。
8.2.3 缓动函数家族:公式与实现
缓动家族按「多项式次数」和「形状特征」分类。下表给出核心几族的公式(k 为 0–1 的归一化时间):
| 族 | In 公式 | 形状特征 |
|---|---|---|
| linear | k | 匀速 |
| quad | k² | 起步慢,二次加速 |
| cubic | k³ | 加速更猛 |
| sine | 1 - cos(kπ/2) | 平滑正弦起步 |
| expo | k === 0 ? 0 : 2^(10(k-1)) | 极慢起步后猛冲 |
| circ | 1 - sqrt(1 - k²) | 圆弧式加速 |
| back | k²·((s+1)k - s) | 先反向过冲再冲过终点 |
| elastic | 带阻尼正弦振荡 | 弹簧式来回振荡 |
| bounce | 分段抛物线 | 落地反弹 |
注意 expo 在 k = 0 处的特判:2^(10(0-1)) = 2^-10 ≈ 0.00098 不为 0,不特判会导致起点跳变。同理 k = 1 处也要返回 1。把这几族写成纯 TypeScript:
// src/anim/easing.ts
export type EasingFn = (k: number) => number;
const PI2 = Math.PI * 2;
const BACK_S = 1.70158;
const BACK_S2 = BACK_S * 1.525;
const ELASTIC_C = (2 * Math.PI) / 3;
const ELASTIC_C2 = (2 * Math.PI) / 4.5;
export const easing: Record<string, EasingFn> = {
linear: (k) => k,
quadIn: (k) => k * k,
quadOut: (k) => k * (2 - k),
quadInOut: (k) => (k < 0.5 ? 2 * k * k : 1 - Math.pow(-2 * k + 2, 2) / 2),
cubicIn: (k) => k * k * k,
cubicOut: (k) => 1 - Math.pow(1 - k, 3),
cubicInOut: (k) => (k < 0.5 ? 4 * k * k * k : 1 - Math.pow(-2 * k + 2, 3) / 2),
sineIn: (k) => 1 - Math.cos((k * Math.PI) / 2),
sineOut: (k) => Math.sin((k * Math.PI) / 2),
sineInOut: (k) => -(Math.cos(Math.PI * k) - 1) / 2,
expoIn: (k) => (k === 0 ? 0 : Math.pow(2, 10 * k - 10)),
expoOut: (k) => (k === 1 ? 1 : 1 - Math.pow(2, -10 * k)),
circIn: (k) => 1 - Math.sqrt(1 - k * k),
circOut: (k) => Math.sqrt(1 - Math.pow(k - 1, 2)),
backIn: (k) => BACK_S2 * k * k * k - BACK_S * k * k,
backOut: (k) => 1 + BACK_S2 * Math.pow(k - 1, 3) + BACK_S * Math.pow(k - 1, 2),
elasticIn: (k) => (k === 0 ? 0 : k === 1 ? 1 : -Math.pow(2, 10 * k - 10) * Math.sin((k * 10 - 10.75) * ELASTIC_C)),
elasticOut: (k) => (k === 0 ? 0 : k === 1 ? 1 : Math.pow(2, -10 * k) * Math.sin((k * 10 - 0.75) * ELASTIC_C) + 1),
bounceOut: (k) => {
const n1 = 7.5625;
const d1 = 2.75;
if (k < 1 / d1) return n1 * k * k;
if (k < 2 / d1) return n1 * (k -= 1.5 / d1) * k + 0.75;
if (k < 2.5 / d1) return n1 * (k -= 2.25 / d1) * k + 0.9375;
return n1 * (k -= 2.625 / d1) * k + 0.984375;
},
};
/** 未命中名字时退回线性,保证补间不会因拼错缓动名而崩溃 */
export function resolveEasing(name?: string | EasingFn): EasingFn {
if (typeof name === 'function') return name;
if (name && easing[name]) return easing[name];
return easing.linear;
}
bounceOut 里的 (k -= 1.5 / d1) 是「边自减边用」的写法,注意这不是笔误——它把 k 平移到当前弹跳段的局部坐标再求抛物线。写实现时最稳妥的验证是跑几个采样点,确认 ease(0) === 0、ease(1) === 1。
8.2.4 In / Out / InOut 的方向语义
同一族缓动有三个方向:
- In:慢起步——用于「物体从静止开始动」,如冲刺起步。
- Out:慢结束——用于「物体停下来」,如面板滑入到位。
- InOut:两头慢中间快——用于往复运动,如摄像机平移。
三者之间不是独立的公式,而是可互相推导:Out 是 In 的时间反转,out(k) = 1 - in(1 - k)。InOut 则把前后两半分别用 In 和 Out 拼起来:
export function toOut(inFn: EasingFn): EasingFn {
return (k) => 1 - inFn(1 - k);
}
理解这层关系,就不必把每个方向都背下来——库里写全只是为了省一次运行时的组合开销。
8.2.5 补间队列
一段动画很少只有一个补间。金币的「弹一下」可能是「放大 → 缩小 → 停顿 → 旋转」。把这些补间按时间串起来执行,就是补间队列。队列的最小实现是一条按顺序消费的动作列表:
// src/anim/tween.ts
import { EasingFn, resolveEasing } from './easing';
export interface TweenTarget {
[key: string]: unknown;
}
interface TweenAction {
kind: 'to' | 'delay';
duration: number;
props?: Record<string, number>;
ease?: EasingFn;
elapsed: number;
}
export class Tween {
private target: TweenTarget;
private actions: TweenAction[] = [];
private index = 0;
private finished = false;
constructor(target: TweenTarget) {
this.target = target;
}
to(duration: number, props: Record<string, number>, easeName?: string): this {
this.actions.push({ kind: 'to', duration, props, ease: resolveEasing(easeName), elapsed: 0 });
return this;
}
delay(duration: number): this {
this.actions.push({ kind: 'delay', duration, elapsed: 0 });
return this;
}
get isFinished(): boolean {
return this.finished;
}
update(deltaTime: number): void {
if (this.finished) return;
const action = this.actions[this.index];
if (!action) {
this.finished = true;
return;
}
action.elapsed += deltaTime;
const t = Math.min(action.elapsed / action.duration, 1);
if (action.kind === 'to' && action.props) {
const e = action.ease ? action.ease(t) : t;
for (const key in action.props) {
const to = action.props[key];
const from = (this.target[key] as number) ?? 0;
this.target[key] = from + (to - from) * e;
}
}
if (t >= 1) {
this.index += 1;
if (this.index >= this.actions.length) this.finished = true;
}
}
}
这里有个容易被忽略的细节:to 的起点是执行到该动作时的当前值,而不是链开始时的值。所以 to(1, {x: 100}).to(1, {x: 0}) 能正确地「先到 100 再回 0」——第二个动作读到的 from 是 100。这也是链式补间能自然衔接的原因。
8.2.6 链式与并行
**链式(sequence)**是上面的队列:动作一个接一个,总时长是各段之和。**并行(parallel)**则是多个补间同时跑,总时长取最长的一段。两者的组合就能表达任意时间结构:
// 并行:位置滑动的同时透明度淡入
const slide = new Tween(node).to(0.4, { x: 320 }, 'cubicOut');
const fade = new Tween(node).to(0.4, { opacity: 255 }, 'sineOut');
// 用一个统一调度器每帧推进两者
class TweenRunner {
private running: Tween[] = [];
add(t: Tween): void {
this.running.push(t);
}
update(deltaTime: number): void {
for (const t of this.running) t.update(deltaTime);
this.running = this.running.filter((t) => !t.isFinished);
}
}
注意 update 末尾用 filter 剔除已完成的补间——避免每帧遍历一个只增不减的列表,否则一局游戏跑下来会积累上千个死对象。这个「及时回收」的习惯和 8.1 的对象池思路一致。
一个常见的坑是同一属性被两个并行补间同时写:比如两个 to({ x: ... }) 并行,后 update 的那个会覆盖前一个的结果,表现为抖动。生产代码里应对「目标属性」加一把锁,或干脆用一条链表达。
8.2.7 Cocos 对照:tween、Tween 与 TweenEasing
Cocos Creator 3.8 把这套模型封装成了 tween 工厂函数 + Tween 类。tween(target) 返回一个 Tween 实例,所有动作方法都返回 this,因此可以链式书写:
// assets/scripts/CoinView.ts
import { _decorator, Component, Node, tween, Vec3, Tween } from 'cc';
const { ccclass } = _decorator;
@ccclass('CoinView')
export class CoinView extends Component {
private t: Tween<Node> | null = null;
onEnable() {
this.t = tween(this.node)
.to(0.12, { scale: new Vec3(1.3, 1.3, 1) }, { easing: 'backOut' })
.to(0.10, { scale: new Vec3(1, 1, 1) }, { easing: 'sineOut' })
.delay(0.05)
.by(0.6, { position: new Vec3(0, 120, 0) }, { easing: 'quadOut' })
.call(() => { console.log('金币飞完了'); })
.start();
}
onDisable() {
this.t?.stop();
this.t = null;
}
}
对照 mini2d 的实现,逐个映射:
| mini2d | Cocos Creator 3.8 | 说明 |
|---|---|---|
Tween.to(d, props, easeName) | tween().to(d, props, { easing }) | to 到绝对值 |
| 无 | tween().by(d, props, opts) | by 是相对量(在现有值上叠加) |
Tween.delay(d) | .delay(d) | 停顿 |
| 自定义回调 | .call(fn) | 到点回调 |
resolveEasing(name) | TweenEasing 字符串或自定义函数 | opts.easing |
手动 update | .start() / .stop() / .pause() / .resume() | 由引擎驱动 |
TweenRunner | .sequence(...) / .parallel(...) / .union(id) | 组合与合并 |
几个必须点明的差异:
opts.easing接受字符串或函数,类型是TweenEasing | ((k: number) => number)。字符串集合就是TweenEasing类型定义的四十余个名字:'linear'、'smooth'、'quadIn'、'cubicOut'、'sineInOut'、'expoOut'、'circInOut'、'backOut'、'elasticOut'、'bounceOut'等,涵盖我们手写的全部族与方向。to与by的区别:to是绝对目标值,by是相对增量。mini2d 里我们只实现了绝对版,by相当于把to的目标值改成当前值 + 增量。ITweenOption还带生命周期回调:onStart、onUpdate(target, ratio)、onComplete,以及可自定义的progress插值函数。这比我们的监听器更细,能拿到每一帧的归一化进度。- 组合能力:
.sequence(a, b)串行、.parallel(a, b)并行、.union(id)把多个补间合并成一条统一控制,.repeat(n)/.repeatForever()循环。这些都是我们在 8.2.6 用TweenRunner手工拼出来的结构。 easing命名空间:引擎同时导出一个easing命名空间,里面是linear、quadIn、cubicOut、sineInOut、elasticOut、bounceOut、smooth等纯函数(签名(k: number) => number)。想在补间之外单独调用缓动(比如自己写插值时),直接easing.sineOut(t)即可,与我们导出的easing表一一对应。
一句话总结:mini2d 用「一张缓动表 + 一个补间类 + 一个调度器」表达时间结构;Cocos 把它们封装成 tween() 工厂、Tween 的链式 API 与 TweenEasing 类型,并在 ITweenOption 里补齐了回调与自定义插值。理解了插值公式与归一化进度这两块地基,链式 API 就只是语法糖。
下一节把帧动画与补间合到一处:让角色的表现由状态机驱动,处理 idle / run / jump / hurt 之间的过渡混合与打断。
阅读导航:上一节:8.1 帧动画与精灵表播放 · 下一节:8.3 属性动画与状态驱动表现 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。