🙋🏻♀️ 编者按:本文作者是蚂蚁集团前端工程师阿侎,探讨一个图形动画领域的性能优化:如何在 canvas/webgl 的动画引擎设计上,使用 WASM 来优化性能?
前言
WebAssembly 技术日趋稳定和成熟,在许多场景下已经被运用,其重要特性之一的性能更是作为用来被解决问题的手段。
关于其基础原理、适应场景等本文不再赘述。动画引擎本身原理这里也作为基础跳过不说。
此篇主要探讨一个图形动画领域的性能优化:如何在 canvas/webgl 的动画引擎设计上,使用 WASM 来优化性能?
让我们首先规定下动画引擎:
具备类似 CSS Animation / Web Animation Api 的完备功能,而非简单的帧时间计数器。
- 这需要有暂停、恢复、变速、跳转、反向、取消、完成、轮播等一系列控制能力;
- 更完整的是需要有简单 api,赋予一个类似 DOM 的对象任意动画功能的编程能力,可以入参;
- 如果可以,最好支持 CSS 的单位(rem、vw)、停留模式、事件。
以上 3 点层级依次递增,根据完整度不同。
第 1 点是基础,第 2 点较普遍,第 3 点比较苛刻可以有选择实现(和渲染强关联)。
特点
很多分享中都能见到,WASM 适合密集计算型,能提升相对原本 js 的小几倍。这对于渲染或动画来说已经很难得了。
但是 WASM 需要规避频繁调用和数据交换,一帧一次渲染内 api 数据交互需要保证数量很少才行。这也和动画 api 的使用设计相关。如果没注意这个,可能最后性能的改进还不如数据交互消耗得多。
比如动画每帧内的计算都在 WASM 内部完成,一帧中选取适合的时机一次性吐出。而初始化动画等行为是少数性甚至一次性操作,这个特点可以忽视掉,不会有调用次数过多场景。
另:在实测中同时尝试了几种编程语言的 WASM 版本,最初很想用前端熟悉的 Assembly Script。但在性能测试中 AS 提升极为有限,甚至没啥区别。
最终采用了 Rust:https://github.com/karasjs/wasm
限制
先说说限制。
动画引擎和渲染引擎强关联,很多优化逻辑都要和渲染引擎绑定。
动画的种类也有很多种,最常见的是 transform 和 opacity,无论是写 CSS 还是播放一段 Lottie,设计师最常用基础手段便是这 2 个。
再比较常见一些的则是 visibility、color、border、z-index、font 等。
再往后不是那么频繁的则是width、margin、perspective、路径、矢量形状等。
这里不讨论高级或封装的如骨骼、粒子、shader 效果。
如果这些全部用 WASM 来写,势必会有成本问题:
- 无论 C++ 还是 Rust 还是其它,编写难度成本都不容忽视;
- 有些甚至是扩展成本,比如 font、width、路径、矢量动画,它和渲染引擎耦合极重,WASM 等于还要再实现一遍渲染逻辑;
- 调试维护也是一种成本。
因此本文以最常见的 transform 和 opacity 为例(这 2 个可以说一模一样,和渲染逻辑解耦脱钩,下文举例统称为 transform),其它的如果想继续实现可以举一反三。
数据结构
只关注 transform 的话,那么所有渲染相关的数据存取都要关注分离。1 个 Node 节点(可以理解为一个舞台对象)本身是个 JS 对象,还会对应 1 个 WASM 的对象,我们称之为 WASM Node。那么这 2 个属性也自然跟随 WASM Node。
1 个 Node 会有任意个动画对象,和这2项相关的是纯 WASM Animation(红色);不相关的是纯 JS Animation(蓝色)。也时常会出现一个动画中同时有 WASM 和 JS 的混合情况(绿色)。
// 纯JS动画,x可以理解为css的left node.animate([ { x: 0 }, { x: 100 }, ], { duration: 1000, }); // 纯WASM动画,rotateZ即平面旋转 node.animate([ { rotateZ: 0 }, { rotateZ: 90 }, ], { duration: 1000, }); // 混合动画都有的情况 node.animate([ { x: 0, rotateZ: 0 }, { x: 100, rotateZ: 90 }, ], { duration: 1000, });
这在数据结构上对设计提出了挑战,已有的 JS 动画引擎部分最好修改少,且能适配 WASM 动画。
笔者采用了所有动画依旧是 JS 为入口,在初始化过后,如果有和 transform 相关的数据(指帧数据),这个 JS 动画对象会新建一个 WASM 动画对象并产生关联,将 transform 的数据传递给 WASM,JS 里删除。
红色的 WASM 动画都被包含在蓝色的 JS 的动画中了,如果是个完全的 WASM 动画,那么 JS 很像个纯代理,可以设置个 ignore 标识。
流程时钟
先说下 JS 动画引擎的一些流程,它以及它的前置条件或知识。
任意的数据更新,都应该是同步的,这点毋庸置疑。
// 类CSS/WAA伪代码,节点向右平移100px距离 node.style.translateX = 100; console.log(node.style.translateX); // 输出100
但渲染却未必是同步的,因为 1 次修改不可能立刻同步重绘,假如有 1000 个节点变化,立刻同步更新 1000 次,怎么优化也不可能达到流畅的效果。
所以渲染一定要设计成异步的,同步的代码执行更新后,下一帧再进行绘制。
// 类CSS/WAA伪代码,很多节点同时更新 for(let i = 0; i < 1000; i++) { nodes[i].style.translateX = 100; } // 渲染引擎在每个节点更新后会收到一条通知,下帧更新,无论多少节点都是同步通知,但下帧只重绘1次 requestAnimationFrame(() => { draw(); // 只有1次渲染 });
动画引擎所引发的渲染更新也是如此,但有所不同的是,动画引擎有事件或回调。
事件或回调触发时,所有的动画引擎都应该在帧内完成数据更新,甚至是渲染更新好。事件或回调中甚至会触发控制其它动画或新的动画,所以这里的时钟顺序要想好。
例:2 个动画对象,这一帧,A 更改 translateX 为 10,B 更改 translateY 为 5。实际编码就是循环遍历已有的这 2 个动画对象,依次执行。
如果有 frame 事件,即动画每帧更新事件,那么不能在 A 更新结束后 B 还未更新就立刻触发,因为此时有歧义:translateY 应该是多少?是否应该是更新后的5?
frame 帧事件显然应该是所有数据更新后再触发的。
将一帧内的动画分为 2 个前后时序,before 阶段执行所有的动画的数据更新,after 阶段执行所有的动画事件回调。时间复杂度从 O(n)变为O(2n),可以接受。
在 after 阶段,其实很多逻辑都包含在内:判断是否结束、下一轮、结束停留状态等。
如果做得更好没有歧义,可以考虑将帧渲染环节放在 before 和 after 之间,这样事件触发时,画面甚至是与数据更新同步对应的。
变化
现在 WASM 进场了。
前面说了,WASM 适合密集型计算,频繁通信会变得更慢。显然不能让蓝色 JS 动画执行时再去代理调用对应的红色 WASM 动画。假如动画有许多个,可能几十的数量就开始卡顿了。
因此,画布根节点 Root 对象必然持有所有 Node 节点的引用和动画对象的引用,且是排好序能高效访问的,无论在 JS 端还是 WASM 端都一样。
这样,在执行一帧更新时,before 阶段,Root 先通知 WASM Root 遍历所有的 WASM 动画,再遍历所有的 JS 动画对象(严格来说 WASM 和 JS 遍历前后顺序并不严格要求,甚至所有动画前后都顺序都不严格要求,只是实现可以按照顺序队列)。目前暂时只和 WASM 通信一次。
当 WASM 动画或 JS 动画真实产生更新时(有时动画两帧之间并无变化,可节省重绘成本),重绘。
再然后,after 阶段,Root 先通知 WASM Root 遍历所有的 WASM 动画,进行状态等数据检查,并保存到一块 SharedBuffer 上(有指针地址),再遍历所有的 JS 动画,将 SharedBuffer 的数据给到JS动画,执行 JS 动画的 after。目前再和 WASM 通信一次。
例:有如下 A、B、C、D 共 4 个动画,其中 C、D是 WASM 动画:
一帧内执行更新的时钟周期:
为什么 after 中要由红色 WASM 的 C 和 D 给到蓝色 JS 的 C 和 D 数据?因为此刻时间的计算都在 WASM 中。
- 当前时间应该是第几帧?
- 是这一帧的哪个位置(百分比)?
- 如果有缓动怎么计算(easing)?
- 播放到第几轮了?
- 是否是反向那一轮(CSS/WAA 的 direction 为 alternative)?
- 是否刚好到最后一帧结束停止了?
- 是还原还是停留(CSS/WAA 的 fill 为 forwards)?
这些在 WASM 中计算会非常快,再加上帧数据计算本身。如果 JS 来算的话,一是会重复,二是本身性能优化的意义就失去了。
after 中蓝色的 C、D 通过 SharedBuffer 拿到数据后,就可以确定当前动画的一些状态数据,这是非常快的。
// 伪代码,通过SharedBuffer地址拿WASM的数据 let n = wasmRoot.after(); // n返回有多少个wasm动画 let states = new Uint8Array(wasm.instance.memory.buffer, wasmRoot.states_ptr(), n);
复杂情况
上述情况还是太简单了,连混合绿色的动画都没有出现过。如果包含绿色混合动画,那么上面提到的那些在 WASM 和 JS 中是会重复计算的(但不包括帧数据计算本身)。这时候要考虑重复的这些性能损耗有多少,和 WASM 带来的提升相比如何。好在据经验来看,出现的频率以及损耗都较小,可以接受。
再看另外一个情况,如果 A、B、C、D 的顺序并不规整怎么办?
很容易出现这样的情况,JS 动画和 WASM 动画并不完整连续。
在 before 阶段,并没有什么影响,依旧是 WASM 独立执行遍历,然后 JS 再遍历。
在 after 阶段,有些不同。先是已知的 WASM,再是 JS,可 SharedBuffer 却要注意了。此刻 SharedBuffer 有 2 项数据,分别是 C 和 D 的。但 JS 拿到时并不知道是 A、B、C、D 哪 2 个的。前面简化例子中,我们假定了它是按顺序后出现的 C、D,这太过理想化。
这时候需要加个判断逻辑,先是 JS 代理的动画对象(有 WASM 动画引用)需要有个标识。然后 after 遍历 JS 动画对象时,没有代理的要忽略并计数,有代理的要根据索引 index 和当前计数形成偏移量 offset,来取 SharedBuffer。
// 伪代码,通过SharedBuffer地址拿WASM的数据 let n = wasmRoot.after(); // n返回有多少个wasm动画 let states = new Uint8Array(wasm.instance.memory.buffer, wasmRoot.states_ptr(), n); // 偏移计数器 let offset = 0; // 循环JS动画 for(let i = 0; i < len; i++) { let ja = jsAnimations[i]; // 有代理 if(ja.wasmAnimation) { let state = states[i - offset]; // 处理传递数据 } else { offset++; } // 执行js的after ja.after(); }
刷新重绘
before 步骤执行完成后,after 之前是刷重绘。这里会出现第 3 次 WASM 交互,主要是 matrix 计算。
由于节点是个树形结构,有父子关系(兄弟关系对于 matrix 计算几乎无影响,除了 mask 节点这种相邻),对于 matrix 来说要算预乘。
较好的遍历方式是扁平化,将树形结构打平,形成 for 循环模式先序遍历,这点不在讨论范围内。
3 阶或 4 阶矩阵计算在 WASM 中也比 JS 快,不过性能的提升并没有动画引擎改写那么大。V8 等 JS 引擎对于这种简单热代码优化得要好些。
之后亦是通过 SharedBuffer 拿到一个 marix 队列,因为 matrix 是 16 长度的,所以长度注意 *16:
let matrix = new Float64Array(wasm.instance.memory.buffer, wasmRoot.matrix_ptr(), len * 16);
查看 performance,也会发现占大头的还是动画中每个对象的执行:
(红色动画引擎执行 before,蓝色重绘计算 matrix)
如果想进一步优化矩阵乘法,WASM 中可以使用 SIMD 指令,但只在较新浏览器中实现,比如 chrome91:https://chromestatus.com/feature/6533147810332672
另外像顶点计算等类似的东西,都可以放进 WASM 中,不仅性能更好,也没有垃圾回收的负担。这些优化初版暂时没有,后续考虑补上。
精度
有个计算细节,在 WASM 中所有数字运算都应该先转为 f64 后再进行,因为 JS 中便是如此。如果使用 f32,那么便会出现精度不一致的现象。虽然表面上看起来肉眼无区别,但在一些细致化场景或者对精度要求高的情况,可能会出现意想不到的情况,且非常难以排查。
Rust 使用的 wasm-bindgen 在进行地址交换的时候,编译器有个对齐字节的操作。f64 和 f32 会使得对象的指针解引用时有所不同,f32 的 offset 是 4 个字节,f64 是 8 个字节,这种坑不注意也会困扰人许久。
benchmark
继续使用之前的一万节点动画性能测试。要求:
- 使用 10000 个包含不同文字、背景色的节点,如果是 CANVAS 模式,降级到 5000 个;
- 所有节点同时进行不同的随机移动、旋转、缩放动画,无限往返;
- 统计 fps 对比,并附上测试 DEMO 源码。
https://skottie.skia.org/662e5267a8b58896c5812c24ed61ef90?h=800&w=800
神奇的是,pixi 在 pc 和 mobile 上表现差距极大,可能移动端有什么特殊优化。
实际上 pixi 的 DEMO 并不是完整的动画引擎,只是个计时器 tick,连层级 1 都不到,完整实现的话性能会大打折扣。
skottie 只有 webgl+wasm 模式,且功能受限(只有层级 1)不好比较,约等于 pixi。
其他
目前初级版本还有一些细节可以优化,甚至还有新的想法可以尝试。
比如大头占比的动画执行,是否也可以考虑使用 SIMD?数量众多的动画对象,亦有些并发的味道,是否可以将那些帧数据中的样式数据(即 transform/matrix/opacity 某个变化)平铺到一些矩阵当中,直接用并行矩阵计算来加速?
期待 WASM 的更好的发展。