《Cocos 游戏开发高级编程》6.3 动画性能与内存

动画的代价分两块:CPU 每帧采样曲线并混合,内存里躺着关键帧数据、烘焙关节纹理与合批图集。本节算清这笔账,讲明采样率、烘焙、图集三者如何互相兑换,用纯 TS 写一个可单测分位数的帧耗时统计器,并给出《方块冲刺》这类项目能直接落地的动画预算清单与排查顺序。

6.3 动画性能与内存

前两节讲了求值链路与蒙皮,这一节算总账。卷二 13.3 定的帧预算这里直接沿用:60 FPS 是 16.6 ms/帧,中低端机 30 FPS 是 33.3 ms/帧。动画的代价分 CPU 与内存两块,而且它们是一组可以互相兑换的量——多花内存换 CPU,或者反过来。搞清楚兑换比率,才知道什么时候该烘焙、什么时候该降采样。

与站内 saas/game-engine/ 那批自研引擎商业文的分工:那边讲「引擎该怎么设计」;这里讲 Cocos 3.8.8 里已有的旋钮,以及拧动它们的后果。

6.3.1 每帧求值的 CPU 账单

回到 6.1.2 的分工:AnimationState.update(delta) 只推进时间,sample() 才遍历曲线求值。CPU 开销几乎全在 sample() 与随后的混合上。单个状态一帧的成本大致是:

成本 ≈ Σ(每条 track) Σ(每个 channel) [曲线定位 + 插值] + 混合写入

关键点是它和「曲线条数」成正比,而不是和「动画时长」成正比。一条 3 秒的曲线和一条 0.3 秒的曲线,每帧求值次数一样——决定成本的是「有多少条曲线」,不是「播了多久」。

于是优化方向很明确:

  • 减少同时激活的状态数。同时播 5 条状态就是 5 份采样 + 一次加权混合。crossFade 期间会有短暂的两状态重叠,这是必要开销;但「常驻播放一堆用不上的状态」是纯浪费。
  • 减少曲线条数。一条 VectorTrack(position)比拆成三条 RealTrack(position.x/y/z)少两次定位。编辑器里能用一个向量轨道表达的,别拆成标量。
  • 避免每帧改属性。在 update 里写 state.time = ... 会强制下一帧重新 sample,等于把批量求值退化成逐帧脚本驱动。

还有一条隐形成本:节点写入。求值算出属性后要写回节点,触发变换脏标记与后续的矩阵更新。同一个节点被多条动画驱动时,写入会重复发生——这也是「一个角色尽量由一套动画组件驱动」的原因,而不是把位置交给一条动画、旋转交给另一条。

算一笔具体的账:一个角色 3 条轨道(position / rotation / scale)、每条 3 个通道,共 9 条曲线;同屏 20 个角色、每个 1 个活跃状态,就是每帧 180 次曲线定位加插值。单次插值在手机上大约几十纳秒量级(参考值,需在你的设备上复测),180 次不到 0.1 ms——纯 2D 节点动画通常不是瓶颈。真正吃预算的是 6.2 那套骨骼蒙皮:关节矩阵乘法与 GPU 上传才是大头。

两类动画的瓶颈位置完全不同:

类型主要 CPU 成本主要内存成本
2D 节点动画(Animation)曲线求值 + 节点属性写入曲线关键帧(很小)
3D 骨骼动画(SkeletalAnimation)关节矩阵计算 + 蒙皮 + GPU 上传关节矩阵纹理 / 烘焙纹理

《方块冲刺》是 2D,动画几乎不占预算;一旦引入 3D 角色展示或换装,整张预算表要重算。

源码位置:求值主体在 cocos/animation/animation-state.ts(tag 3.8.8),cc.d.ts 里它的 initialize(root, blendStateBuffer?, mask?) 签名暴露了混合缓冲这一层——所有活跃状态写进同一个缓冲,最后一次性刷到节点树,避免多次节点写入。

6.3.2 采样率与烘焙的取舍

先纠正一个常见误解:AnimationClip.sample 不影响运行时的播放帧率。cc.d.ts 对它的注释是「动画帧率,单位为帧/秒。注意此属性仅用于编辑器动画编辑」。也就是说它只决定编辑器里怎么摆帧,运行时播放是按时间连续求值的,没有「每 1/30 秒取一帧」这回事。

真正影响运行时的是两个开关:

手段CPU内存 / 显存表达能力
实时求值(useBakedAnimation = false)每帧采样全部曲线只存关键帧完整:可混合、可变速、可叠加
预烘焙(useBakedAnimation = true)采样纹理,极低烘成关节矩阵纹理,占用随帧数×关节数增长受限:不支持编辑与混合

烘焙的交换逻辑是:把「每帧现算」换成「提前算好存纹理,播放时采样」。它把 CPU 的求值成本转移到显存与加载时间上。所以:

  • 角色动作固定、数量多、不需要动作间平滑过渡 → 烘焙划算。
  • 需要「走 → 跑 → 跳」之间 crossFade → 必须关烘焙(见 6.2.2,烘焙后混合无意义)。

SkeletalAnimation.useBakedAnimation 默认 true(cc.d.ts),这是引擎的默认选择:多数项目的多数角色动作是固定的,先给你省 CPU。要混合时手动关掉,并承担随之上升的 CPU 成本。

还要注意 crossFade 期间的成本是叠加的:过渡的 0.3 秒里两个状态都在 sample,权重从 0 爬到 1。如果过渡频繁(比如每 0.2 秒切一次),成本会长期维持在 2 倍。过渡时长别设得比动作本身还长——一个 0.2 秒的短动作配 0.3 秒过渡,等于整段都在混合。

6.3.3 内存占用:曲线、关节纹理与图集

动画的内存占用有三处,量级差别很大:

数据存放位置量级(参考值,需在你的设备上复测)
曲线关键帧内存每条通道几十~几百帧 × 每帧约 4 字节
实时关节矩阵纹理显存关节数 × 4 行,随骨骼数增长
烘焙关节纹理显存烘焙帧数 × 关节数 × 4 行,随动画时长增长
合批图集显存atlasSize² × 每像素字节,由 SkinnedMeshBatchRenderer.atlasSize 决定

两条工程结论:

  1. 曲线数据本身很小,不是瓶颈。一个 60 关节的角色,几百条曲线加起来也就几十 KB 量级。真正吃内存的是烘焙纹理与合批图集——它们随「帧数 × 关节数」膨胀。
  2. 烘焙帧率可以降。烘焙时把采样率从 30 降到 15,纹理体积直接减半,肉眼在手机上很难分辨。这是「内存换观感」里最划算的一笔,前提是动作本身不高速。

合批图集则是另一种膨胀源:atlasSize 按子模型数量预估,估小了两张图放不下,估大了显存空转。先按单元数开方再向上取到 2 的幂是个稳妥的起点,最后一定要在真机上量。

Cocos 里还有一条独立的 2D 骨骼路径:Spine。它的运行时代码在 cocos/spine/(tag 3.8.8),与本节讲的 SkeletalAnimation 不是同一套实现,也不共享烘焙机制。Spine 的预算要单独测,别把两者的开销混在一起看。

加载时间也是预算的一部分:烘焙纹理要随资源一起解码,大纹理在小游戏的首包体积与内存峰值上都会体现。把烘焙精度和纹理尺寸一起纳入加载预算,别只盯着运行时帧率——小游戏平台上,一次内存峰值超标就可能被系统直接回收。

6.3.4 用纯 TS 写一个帧耗时统计器(L1)

「动画卡不卡」不能靠感觉,要看分位数而不是平均值——平均 16 ms 但 p99 到 40 ms,照样会周期性掉帧。下面这个统计器与引擎无关,可直接单测,用于给动画开销建立可复现的基线。

// mini2d 侧:帧耗时环形缓冲,纯 TS,可单测
export class FrameStats {
  private readonly samples: number[];
  private cursor = 0;
  private filled = 0;

  constructor(capacity: number) {
    this.samples = new Array<number>(capacity).fill(0);
  }

  push(ms: number): void {
    this.samples[this.cursor] = ms;
    this.cursor = (this.cursor + 1) % this.samples.length;
    if (this.filled < this.samples.length) this.filled++;
  }

  get average(): number {
    if (this.filled === 0) return 0;
    let sum = 0;
    for (let i = 0; i < this.filled; i++) sum += this.samples[i];
    return sum / this.filled;
  }

  // p 取 0.5 / 0.95 / 0.99,分别对应中位、长帧、卡顿
  percentile(p: number): number {
    if (this.filled === 0) return 0;
    const sorted = this.samples.slice(0, this.filled).sort((a, b) => a - b);
    const index = Math.min(sorted.length - 1, Math.floor(p * sorted.length));
    return sorted[index];
  }
}
import { describe, expect, it } from 'vitest';
import { FrameStats } from './frame-stats';

describe('FrameStats', () => {
  it('未填满时按已填样本计算', () => {
    const s = new FrameStats(100);
    s.push(10);
    s.push(20);
    expect(s.average).toBeCloseTo(15);
  });

  it('环形覆盖后只保留最近 capacity 帧', () => {
    const s = new FrameStats(3);
    [1, 2, 3, 4, 5].forEach((v) => s.push(v));
    expect(s.average).toBeCloseTo(4); // 只剩 3,4,5
  });

  it('p95 反映长帧而非平均值', () => {
    const s = new FrameStats(100);
    for (let i = 0; i < 95; i++) s.push(16);
    for (let i = 0; i < 5; i++) s.push(40);
    expect(s.percentile(0.5)).toBeCloseTo(16);
    expect(s.percentile(0.95)).toBe(40);
  });

  it('空缓冲返回 0', () => {
    expect(new FrameStats(8).percentile(0.99)).toBe(0);
  });
});

把「帧耗时」的采样点插在动画更新前后,就能量出动画单独占了多少毫秒。方法是在 Animation 组件所在节点上包一层计时,或者用引擎自带的性能面板(下一节)。基线一旦建立,任何「感觉变慢了」都要先跑一次统计再下结论。

这套统计器只做一件事:把「感觉」变成「数字」。接到工程里时,在动画更新前后各打一次时间戳,差值 push 进去;跑 5 分钟压力场景后读 p95。低于 16.6 ms 的 p95 才算健康,平均值好看没有意义——平均值掩盖的正是那些让玩家感到卡顿的长帧。

6.3.5 用引擎自带的 profiler 观察(L2)

引擎内置性能面板由 profiler 单例控制(cc.d.ts 里 export const profiler: Profiler):

import { _decorator, Animation, Component, profiler } from 'cc';
const { ccclass, property } = _decorator;

@ccclass('AnimBudget')
export class AnimBudget extends Component {
  @property(Animation) anim: Animation | null = null;
  @property visible = true;

  onEnable(): void {
    profiler.showStats();          // 打开面板,可看 FPS / Draw Call / 内存
    if (!this.visible && this.anim) {
      this.anim.pause();           // 屏幕外就停掉求值,别白算
    }
  }

  onDisable(): void {
    profiler.hideStats();
  }
}

Profiler 提供 showStats() / hideStats() / isShowingStats() / stats(cc.d.ts 中 stats 返回 IProfilerState,旧的 _stats 已标 @deprecated)。注意它是开发期工具:面板本身有绘制开销,正式包要关掉,别把它当性能数据来源——真实数字用 6.3.4 的统计器在真机上量。

按需播放是另一个低成本优化。Animation.playOnLoad 默认让默认剪辑自动播放(cc.d.ts 注释:若在组件 start 前调用了 crossFade 或 play,该字段不生效)。对屏幕外的角色,把 playOnLoad 关掉、在进入视野时 play()、离开时 pause(),能直接省掉整段求值。配合 6.1.6 的 Animation.EventType.FINISHED,一次性特效播完即停,不必常驻。

profiler 面板适合「看趋势」——FPS 掉没掉、Draw Call 涨没涨。但它的采样精度不足以支撑「动画占了几毫秒」这种结论,面板本身也会引入绘制开销。结论要落到 6.3.4 的统计器上,在目标机型跑真实场景,用数字说话。

另一个容易忽略的点是动画更新的时序。引擎在一帧里先推进时间、再统一求值与混合,最后才把结果刷到节点。如果你的逻辑在 update 里读某个被动画驱动的 node.position,可能读到的是上一帧的值;依赖动画结果的逻辑(比如按角色脚下位置发射尘土)放到 lateUpdate 更稳。这不是性能问题,但它是「动画看起来慢半拍」这类 bug 的常见来源。

6.3.6 动画预算清单

落地到《方块冲刺》这类项目,按下表逐项检查:

回到 toc 里那句「骨骼动画一多就掉帧」,它对应的是 6.3.1 与 6.3.2 的叠加:同屏 N 个角色,每个既在 sample 曲线,又在做蒙皮矩阵运算,而且没烘焙——三笔开销一起爆。定位顺序应当固定下来:

  1. 先确认 useBakedAnimation 没被误关(关掉后每帧都在算矩阵)。
  2. 再看同时激活的状态数是不是超了预期(crossFade 残留、忘记 stop)。
  3. 最后才怀疑关节数与蒙皮本身,考虑 LOD 或降级骨骼。

顺序反了,就会在「明明是没烘焙」的问题上花时间调骨骼精度。

检查项目标手段
单帧动画 CPU占总预算 < 20%减少同时激活状态数、合并轨道
长帧(p95)不超过 16.6 ms用 6.3.4 统计器定位
骨骼关节数移动端 ≤ 60LOD 或降级骨骼
烘焙纹理按需,不常驻关掉不用的 useBakedAnimation 纹理
合批图集只对同步单位启用确认 units 真的共享骨骼与进度
屏幕外角色零求值playOnLoad = false + pause()

几条容易忽略的工程决策:

  • 不要为了省 CPU 把一切都烘焙掉。烘焙后动画不可混合,一旦产品要求「受击时上半身抖动」,整条动画得重做。
  • 采样率是最后才动的旋钮。先减状态数、再减曲线数,都不够用才降烘焙帧率。
  • 动画内存要在加载期就规划。角色换装系统如果每个皮肤都带一份烘焙纹理,包体和显存会失控;用合批图集共享骨骼是正解。
  • 别在真机发布包里留 profiler.showStats()。它每帧绘制文本,开销比你想的大。
  • 批量创建时错开播放起点。同屏 20 个角色在同一帧 play(),求值会全部挤在一帧里;用 state.time 或延迟启动错开几帧,能把峰值削平。
  • 过渡时长与动作时长成比例。短动作配短过渡,别让整段动作都在混合里度过。
  • 给动画单独留一个监控指标。把 6.3.4 统计器的采样范围限定在动画更新前后,长期挂在开发构建里,回归时一眼就能看出哪次改动让 p95 涨了。

最后把上面所有旋钮的优先级记住:先砍状态数,再砍曲线数,再考虑烘焙与降采样,最后才动骨骼精度。前三项是零成本甚至负成本(顺带省内存),后两项要拿观感去换——顺序反了,就是在用画质换本来就不需要花的开销。

把这套优先级和 6.3.4 的统计器绑在一起用:每次只动一个旋钮,跑一遍压力场景,对比 p95 有没有降。动画调优没有一劳永逸的参数,只有可复现的基线。基线不在,任何「优化」都只是猜测。

至此第 6 章把动画从「数据」讲到「代价」。下一章换一个更底层的主题:内存模型与 GC——动画曲线、烘焙纹理、节点对象最终都要落到那套内存管理里。

阅读导航:上一节:6.2 骨骼动画与蒙皮 · 下一节:7.1 内存模型与 GC 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「cocos」更多文章

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