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 决定 |
两条工程结论:
- 曲线数据本身很小,不是瓶颈。一个 60 关节的角色,几百条曲线加起来也就几十 KB 量级。真正吃内存的是烘焙纹理与合批图集——它们随「帧数 × 关节数」膨胀。
- 烘焙帧率可以降。烘焙时把采样率从 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 曲线,又在做蒙皮矩阵运算,而且没烘焙——三笔开销一起爆。定位顺序应当固定下来:
- 先确认
useBakedAnimation没被误关(关掉后每帧都在算矩阵)。 - 再看同时激活的状态数是不是超了预期(
crossFade残留、忘记stop)。 - 最后才怀疑关节数与蒙皮本身,考虑 LOD 或降级骨骼。
顺序反了,就会在「明明是没烘焙」的问题上花时间调骨骼精度。
| 检查项 | 目标 | 手段 |
|---|---|---|
| 单帧动画 CPU | 占总预算 < 20% | 减少同时激活状态数、合并轨道 |
| 长帧(p95) | 不超过 16.6 ms | 用 6.3.4 统计器定位 |
| 骨骼关节数 | 移动端 ≤ 60 | LOD 或降级骨骼 |
| 烘焙纹理 | 按需,不常驻 | 关掉不用的 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 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。