同一段动画,在 Animate 里按 Ctrl+Enter 播放很流畅,导出成 HTML5 Canvas 放到浏览器里却掉帧。素材没变、帧率没变,差别在于两者的渲染模型完全不同:Animate 的预览是矢量渲染器直接绘制,而导出后的运行时是 CreateJS 在一张 canvas 上每帧重画整个画面。理解这一层,才知道该优化什么,以及为什么”把复杂图形转成位图”是这条管线里最有效的一招。
导出产物是一棵可执行的显示对象树
HTML5 Canvas 文档导出后,时间轴不再是数据表,而是被编译成 JavaScript。库里的每个元件变成一个构造函数,继承自 createjs.MovieClip;元件时间轴上的关键帧、补间和实例摆放,变成对该对象时间轴的一系列调用。舞台上的层级关系变成显示对象树:
Stage
└─ Container(元件实例)
├─ Shape (矢量图形)
├─ Bitmap (位图 / 雪碧图切片)
├─ Text
└─ MovieClip(嵌套元件,自带时间轴)
这里有个和时间轴与符号模型那篇直接相关的结论:Graphic 元件在导出时,其取帧规则可以按父帧算出来,因此通常被展平进父时间轴;Movie Clip 因为有独立播放头,会在运行时保留成一个真正的 MovieClip 对象。这就是为什么大量使用 Movie Clip 的工程,导出后的对象数量会显著更多。
每一帧都在重画整张画布
canvas 2D 是立即模式:它不保存”场景里有哪些对象”,只接受绘制指令。CreateJS 在每次 stage.update() 时遍历显示对象树,把每个对象重新画一遍。
对 Bitmap 来说,这是一次 drawImage,代价基本只和像素面积有关。对 Shape 来说,这是把矢量路径重新光栅化一次:解析路径、填充、描边、抗锯齿。路径越复杂,每帧的代价越高,而且这个代价每帧都要付,即使图形完全没有变化。
这解释了一个反直觉的现象:一个静止不动的复杂矢量背景,可能比一个正在移动的位图角色更耗性能。它不动,但它仍然每帧被重新光栅化一次。
缓存是把”每帧付”换成”付一次”
解决办法是缓存。调用 cache() 会把该显示对象及其子树渲染到一张离屏 canvas 上,之后每帧只需把这张离屏图 drawImage 过来。
shape.cache(x, y, width, height, scale);
// 内部内容变化后必须显式更新:
shape.updateCache();
三个参数陷阱决定了缓存是帮你还是坑你:
- 范围要覆盖整个可见内容。缓存区域是以对象自身坐标给出的矩形,超出部分会被裁掉。描边宽度、滤镜外扩都要算进去,否则边缘会被切平。
scale决定分辨率上限。缓存是位图,按 1 倍缓存再放大到 2 倍就会糊。如果对象会被放大或者页面在高 DPI 屏上显示,缓存时就要传入对应的 scale。- 内容变了必须
updateCache()。这是最常见的故障:给一个内部还在播放的元件加了缓存,结果它在页面上冻在了缓存那一刻的画面。缓存本质上是快照。
由此得到一条判据:缓存适合”内部不变、整体在动”的对象——复杂但静止的背景、只做平移旋转的复杂图标、加了滤镜的静态元件。反过来,内部每帧都在变的元件不该缓存,因为每帧 updateCache() 的代价比直接画还高(多了一次离屏绘制加一次拷贝)。
滤镜必须配合缓存
CreateJS 的滤镜作用在位图数据上,因此只有被缓存的对象才会显示滤镜效果。给一个没缓存的对象设 filters 而不调用 cache(),画面上不会有任何变化——这不是滤镜没生效,是它根本没有可处理的像素。
obj.filters = [new createjs.BlurFilter(8, 8, 1)];
obj.cache(0, 0, w, h); // 没有这一行,滤镜不可见
模糊类滤镜还会向外扩散像素,缓存矩形要相应放大,否则模糊边缘被裁出一条硬边。
帧率有两个,它们可能不一致
Animate 文档有一个帧率设置,CreateJS 的 Ticker 也有一个。导出的模板通常会用文档帧率去设置 Ticker,但一旦手工改过模板、或者页面上另有代码改了 Ticker,两者就可能对不上,表现为动画整体偏快或偏慢。
createjs.Ticker.timingMode = createjs.Ticker.RAF;
createjs.Ticker.framerate = 24;
RAF 模式跟随浏览器刷新率,实际间隔不保证等于 1000 / framerate。这与 关键帧插值那篇的结论一致:任何按”帧数”推进的逻辑,在掉帧时都会和真实时间脱节。时间轴动画本身按帧推进问题不大(慢就是慢),但如果你自己写了跟随鼠标、物理或计时的代码,就必须用真实经过时间。
怎么量,而不是猜
不要凭感觉优化。浏览器性能面板能直接给出答案:
- 录一段性能剖析,看主线程。时间集中在 Scripting 说明是脚本或时间轴逻辑的问题;集中在 Rendering / Painting 说明是光栅化太重,那才轮到缓存和位图化。
- 逐段屏蔽验证。把可疑的复杂图层临时
visible = false,重新测量。帧时间明显下降,就锁定了它。 - 对比缓存前后。对同一个对象加
cache()再测一次,确认帧时间真的下降。缓存不是无条件更快,对简单图形反而更慢。 - 检查画布尺寸与 DPI。高 DPI 屏上 canvas 的实际像素可能是 CSS 尺寸的 2–3 倍,填充率成倍上升。这一项经常被忽略,但它是全局性的。
什么时候干脆不用矢量
如果一段动画的矢量复杂度实在压不下来,还有一条退路:导出成雪碧图(Sprite Sheet),用逐帧位图播放。代价是文件体积和内存占用上升、放大后会糊、无法在运行时改颜色;收益是每帧只剩 drawImage,性能可预测,而且和矢量复杂度完全脱钩。
实践中常见的是混合方案:角色主体用位图序列保证帧率,UI 和需要动态改色的部分保留矢量。判断依据是”这个元素会不会被缩放、会不会需要运行时改属性”——两者都不需要的,位图化几乎总是划算。
本文讨论的是导出之后的运行时行为;编辑态里元件类型如何影响时间轴求值,见 Animate 时间轴与符号模型。整个专栏的顺序可以在 2D 动画原理页面查看。