6.1 动画系统内部与求值
卷二做《方块冲刺》时,玩家跑动只是一行 anim.play('run'),引擎在背后每帧把「一个时间值」翻译成「一组节点属性」。这一节把这个翻译过程拆开:数据长什么样、谁来算、混合怎么叠加。搞清这条链路,6.3 讲性能时你才知道 CPU 到底花在哪。
与站内 game/ 顶层那批动画概念文的分工:那边讲「什么叫状态机、什么叫过渡」;这里讲 Cocos 3.8.8 的 cc.d.ts 里真实存在的类型,以及它们之间怎么串起来。所有结论都能在 @cocos/creator-types@3.8.8 的声明里核到。
6.1.1 一条动画的数据结构:clip → track → channel → curve
从 3.3 版起,Cocos 的动画数据不再是「一堆散曲线」,而是四层嵌套。AnimationClip 里挂着若干 Track,Track 通过 channels() 暴露若干通道,通道再指向真正的曲线对象。cc.d.ts 里 AnimationClip.getTrack(index) / addTrack(track) 与 Track.channels() 就是这个结构的入口:
import { AnimationClip, animation } from 'cc';
function inspect(clip: AnimationClip): void {
const count = clip.tracksCount;
for (let i = 0; i < count; i++) {
const track: animation.Track = clip.getTrack(i);
const range = track.range();
let channels = 0;
for (const _channel of track.channels()) {
channels++;
}
console.log(`track ${i} path=${String(track.path)} 时间[${range.min}, ${range.max}] 通道数=${channels}`);
}
}
三个必须记住的点:
Track.path是目标寻址,不是节点名。它是一串路径段,依次描述层级、组件、属性,甚至数组下标。节点属性用toHierarchy,组件字段用toComponent+toProperty。- 旧的
AnimationClip.keys/curves/data在cc.d.ts里都标着@deprecated Since V3.3。它们仍能读,但新代码一律走 track/channel/curve,写回去会在下一次打开工程时被迁移。 - 每种目标类型有专门的 Track:
animation.RealTrack(标量)、VectorTrack(2/3/4 维向量)、QuatTrack(旋转)、ColorTrack、SizeTrack、ObjectTrack<T>。选错轨道类型,编辑器里连线时会直接报类型不匹配。
把四层和它们在 3.8.8 源码里的位置对上:
| 层 | 类型 | 源码文件(tag 3.8.8) |
|---|---|---|
| 剪辑 | AnimationClip | cocos/animation/animation-clip.ts |
| 轨道 | animation.Track | cocos/animation/tracks/track.ts |
| 通道 | Track.channels() 的元素 Channel | cocos/animation/tracks/track.ts |
| 曲线 | RealCurve / QuatCurve | cocos/core/curves/curve.ts |
顺带一个 2D 专用入口:AnimationClip.createWithSpriteFrames(spriteFrames, sample) 可以用一组 SpriteFrame 直接造一条序列帧剪辑,无需在编辑器里手动摆帧。它的第二个参数 sample 只是编辑器侧的采样率标记,运行时以 clip.sample 读取,不参与插值计算。
6.1.2 AnimationState:求值的执行者
Animation 组件本身不算曲线,它只是「一组状态的调度器」。真正干活的是 AnimationState——Animation 的注释写得很直白:「每个剪辑都会独自创建一个关联的动画状态对象」(源码 cocos/animation/animation-component.ts,tag 3.8.8)。
AnimationState 的关键成员(全部来自 cc.d.ts):
| 成员 | 语义 |
|---|---|
time | 当前累计播放时间(秒),累计而非本周期内 |
duration | 单次播放时长,只读 |
speed | 播放速率,默认 1.0 |
weight | 混合权重,多状态叠加时的系数 |
wrapMode | 循环方式,改它会重置 time 与 repeatCount |
playbackRange | 播放区间 {min, max},单位秒 |
update(delta) | 推进时间 |
sample() | 在当前时间上求出姿态 |
update 与 sample 的分工是理解性能的关键:update 只做时间推进(很便宜),sample 才真正遍历曲线求值(很贵)。引擎内部按帧先 update 全部活跃状态,再统一 sample 并混合。
wrapMode 的取值也别记错——它不是 0/1/2 的顺排。cc.d.ts 里 WrapMode.Loop = 2、Normal = 1、PingPong = 22、Reverse = 36、LoopReverse = 38、PingPongReverse = 54。这些数字是历史位掩码遗留,代码里写 AnimationClip.WrapMode.Loop,不要写数字。
6.1.3 混合:weight 与 crossFade
单条状态播放不需要混合,weight 无意义。混合发生在两个时机:
- crossFade 过渡。
Animation.crossFade(name, duration)在两个状态之间按duration秒做权重斜坡,默认 0.3 秒。实现类是CrossFade,源码在cocos/animation/cross-fade.ts(tag3.8.8)——cc.d.ts里它的私有类名是_cocos_animation_cross_fade__CrossFade,可直接佐证该文件。 - 多状态叠加。同时激活的状态按各自
weight线性加权求和。
混合结果的落点是一个姿态缓冲。AnimationState.initialize 的签名是:
initialize(root: Node, blendStateBuffer?: BlendStateBuffer, mask?: AnimationMask): void;
第二个参数名就是 blendStateBuffer。所有参与混合的状态把结果写进同一个缓冲,再由缓冲一次性刷到节点树上——这是「混合」不产生多次节点写入的原因,也是骨骼动画(6.2)能复用的同一套机制。
一个容易踩的语义:改 wrapMode 会重置 time 和 repeatCount(cc.d.ts 注释原文)。想「循环跑完一遍再切成待机」,别在 update 里每帧改 wrapMode,那会把进度清零。
曲线本身还有一层「外推」:RealCurve.preExtrapolation / postExtrapolation 决定时间小于第一帧或大于最后一帧时怎么取值,默认都是 ExtrapolationMode.CLAMP(取端点值)。另两个取值是 LINEAR = 0(按前两帧/后两帧的线性趋势外推)和 LOOP = 2(当作无限循环)。注意 WrapMode 管的是整条状态的时间轴,ExtrapolationMode 管的是单条曲线的端点,两者层级不同,别混用:把状态设成 Normal、曲线设成 CLAMP,播放结束后姿态会停在末帧而不是弹回起始帧。
6.1.4 用纯 TS 写一个关键帧求值器(L1)
引擎的求值核心其实不复杂:给一条按时间排序的关键帧序列和一个时间值,返回插值结果。下面这段与引擎无关,可直接单测,用于理解 RealCurve.evaluate 的语义。
// mini2d 侧的动画求值器,纯 TS,无 cc 依赖
export type Interpolation = 'linear' | 'constant' | 'cubic';
export interface Keyframe {
time: number;
value: number;
interp: Interpolation;
}
export class ScalarTrack {
private readonly keys: Keyframe[];
constructor(keys: Keyframe[]) {
this.keys = [...keys].sort((a, b) => a.time - b.time);
}
get duration(): number {
if (this.keys.length === 0) return 0;
return this.keys[this.keys.length - 1].time;
}
evaluate(time: number): number {
const keys = this.keys;
if (keys.length === 0) return 0;
if (time <= keys[0].time) return keys[0].value;
const last = keys[keys.length - 1];
if (time >= last.time) return last.value;
// 二分找到 time 落在 [lo, hi) 区间
let lo = 0;
let hi = keys.length - 1;
while (hi - lo > 1) {
const mid = (lo + hi) >> 1;
if (keys[mid].time <= time) lo = mid;
else hi = mid;
}
const a = keys[lo];
const b = keys[hi];
if (a.interp === 'constant') return a.value;
const span = b.time - a.time;
const t = span === 0 ? 0 : (time - a.time) / span;
if (a.interp === 'cubic') {
const s = t * t * (3 - 2 * t); // smoothstep 近似 hermite
return a.value + (b.value - a.value) * s;
}
return a.value + (b.value - a.value) * t;
}
}
对照引擎:RealCurve 继承自 KeyframeCurve<RealKeyframeValue>(cc.d.ts 声明,基类模块名 _cocos_core_curves_keyframe_curve__ 对应源码 cocos/core/curves/keyframe-curve.ts,tag 3.8.8)。插值模式枚举是 RealInterpolationMode.LINEAR = 0 / CONSTANT = 1 / CUBIC = 2,与上面三个分支一一对应。RealCurve.addKeyFrame 与 assignSorted 也都在 cc.d.ts 里可查。
6.1.5 用 vitest 断言求值语义
「能跑」不等于「语义对」。上面那个求值器最容易写错的是边界夹紧和 constant 段的归属——constant 表示「到下一帧之前一直用本帧的值」,不是「插值到下一帧」。用测试把这两条钉死:
import { describe, expect, it } from 'vitest';
import { ScalarTrack } from './scalar-track';
describe('ScalarTrack', () => {
const track = new ScalarTrack([
{ time: 0, value: 0, interp: 'linear' },
{ time: 1, value: 10, interp: 'linear' },
]);
it('线性插值取中点', () => {
expect(track.evaluate(0.5)).toBeCloseTo(5);
});
it('两端越界被夹紧', () => {
expect(track.evaluate(-1)).toBe(0);
expect(track.evaluate(2)).toBe(10);
});
it('constant 段保持左帧值直到下一帧', () => {
const step = new ScalarTrack([
{ time: 0, value: 3, interp: 'constant' },
{ time: 1, value: 9, interp: 'linear' },
]);
expect(step.evaluate(0.99)).toBe(3);
expect(step.evaluate(1)).toBe(9);
});
it('duration 等于末帧时间', () => {
expect(track.duration).toBe(1);
});
});
把 evaluate 里 if (a.interp === 'constant') return a.value; 删掉,第三条测试立刻变红——这就是「先写测试再写实现」在这里的价值:constant 的语义只有测试能替你记住。
6.1.6 在 Cocos 侧驱动与监听事件(L2)
组件层怎么用这套机制?Animation 提供 play / crossFade / pause / resume / stop / getState / createState。要拿到「这条动画播完了」,监听事件即可。这里有个高频错误:
import { _decorator, Animation, AnimationState, Component } from 'cc';
const { ccclass, property } = _decorator;
@ccclass('ClipDriver')
export class ClipDriver extends Component {
@property(Animation) anim: Animation | null = null;
start(): void {
if (!this.anim) return;
// 正确:事件常量是 Animation 类的静态成员
this.anim.on(Animation.EventType.FINISHED, this.onFinished, this);
this.anim.play('run');
}
private onFinished(type: string, state: AnimationState): void {
console.log('播完', type, state.name);
}
onDestroy(): void {
this.anim?.off(Animation.EventType.FINISHED, this.onFinished, this);
}
}
已知坑:AnimationState 上没有静态 EventType。 事件常量挂在 Animation 类上(static EventType: typeof AnimationStateEventType),取值为字符串:PLAY = "play"、STOP = "stop"、PAUSE = "pause"、RESUME = "resume"、LASTFRAME = "lastframe"、FINISHED = "finished"。写 AnimationState.EventType.FINISHED 会直接编译失败——AnimationState 只定义了播放逻辑,事件派发归 Animation。另外 AnimationState.on/emit 在 cc.d.ts 里标着 @deprecated Since V1.1.1,别再往状态对象上挂回调。
LASTFRAME 只在 repeatCount > 1 且播放到最后一帧时触发(cc.d.ts 原文),FINISHED 是整条动画结束。做「三段连招」时用 LASTFRAME 在循环末尾接下一段,比用定时器可靠。
6.1.7 工程决策
这一节讲完,真实项目里该记住的取舍:
- 能用 clip 表达的就别用代码驱动。引擎的求值是批量、连续、可混合的;你在
update里手写node.angle +=会绕开混合与姿态缓冲,跨平台表现也不一致。 - 过渡一律用
crossFade,别自己做权重斜坡。过渡时长给 0.1~0.3 秒;低于 0.1 秒肉眼看不出,高于 0.5 秒动作发飘。 - 不要在
update里改wrapMode或playbackRange,两者都会重置进度。 - 求值语义靠测试锁死。6.1.4 那个求值器虽然小,但它就是引擎
RealCurve.evaluate的可测替身,改了插值规则先用它验证。 - 同一目标别用两条轨道驱动。两条
RealTrack指向同一个position.x,后写入的会覆盖前者,而混合权重不起作用——轨道是「写」不是「加」,叠加要靠状态混合而不是堆轨道。 - 事件回调记得在
onDestroy里off。Animation.on把回调挂在组件上,节点销毁前不解除,回调闭包里捕获的对象会被一起拖住。
下一节换一种动画数据:骨骼动画。它的求值结果不再是几个节点属性,而是几十上百个关节的变换矩阵,还要和蒙皮、合批打交道。
阅读导航:上一节:目录 · 下一节:6.2 骨骼动画与蒙皮 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。