《Cocos 游戏开发高级编程》6.1 动画系统内部与求值

Cocos 动画由 AnimationClip 的 track/channel/curve 三层数据驱动,AnimationState 按时间采样并混合出当前姿态。本节拆开整条求值链路:关键帧插值、weight 混合、crossFade 过渡,用纯 TS 写一个可单测的关键帧求值器,并核对事件常量到底挂在哪个类上。

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)
剪辑AnimationClipcocos/animation/animation-clip.ts
轨道animation.Trackcocos/animation/tracks/track.ts
通道Track.channels() 的元素 Channelcocos/animation/tracks/track.ts
曲线RealCurve / QuatCurvecocos/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 无意义。混合发生在两个时机:

  1. crossFade 过渡。Animation.crossFade(name, duration) 在两个状态之间按 duration 秒做权重斜坡,默认 0.3 秒。实现类是 CrossFade,源码在 cocos/animation/cross-fade.ts(tag 3.8.8)——cc.d.ts 里它的私有类名是 _cocos_animation_cross_fade__CrossFade,可直接佐证该文件。
  2. 多状态叠加。同时激活的状态按各自 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 骨骼动画与蒙皮 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「cocos」更多文章

  1. 《Cocos 游戏联机实战》9.3 延迟补偿与体验权衡
  2. 《Cocos 游戏联机实战》9.2 网络状态 UI 与提示
  3. 《Cocos 游戏联机实战》9.1 同步表现与插值动画