🙋🏻♀️ 编者按:本文作者是蚂蚁集团前端工程师阿侎,探讨一个图形动画领域的性能优化:如何在 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:github.com/karasjs/was…
限制
先说说限制。
动画引擎和渲染引擎强关联,很多优化逻辑都要和渲染引擎绑定。
动画的种类也有很多种,最常见的是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:
chromestatus.com/feature/653…
另外像顶点计算等类似的东西,都可以放进WASM中,不仅性能更好,也没有垃圾回收的负担。这些优化初版暂时没有,后续考虑补上。
精度
有个计算细节,在WASM中所有数字运算都应该先转为f64后再进行,因为JS中便是如此。如果使用f32,那么便会出现精度不一致的现象。虽然表面上看起来肉眼无区别,但在一些细致化场景或者对精度要求高的情况,可能会出现意想不到的情况,且非常难以排查。
Rust使用的wasm-bindgen在进行地址交换的时候,编译器有个对齐字节的操作。f64和f32会使得对象的指针解引用时有所不同,f32的offset是4个字节,f64是8个字节,这种坑不注意也会困扰人许久。
benchmark
继续使用之前的一万节点动画性能测试。
要求:
- 使用10000个包含不同文字、背景色的节点,如果是CANVAS模式,降级到5000个;
- 所有节点同时进行不同的随机移动、旋转、缩放动画,无限往返;
- 统计fps对比,并附上测试DEMO源码。
army8735.me/karasjs/kar… army8735.me/karasjs/kar… army8735.me/karasjs/kar…
army8735.me/karasjs/kar… army8735.me/karasjs/kar… army8735.me/karasjs/kar…
skottie.skia.org/662e5267a8b…
神奇的是,pixi在pc和mobile上表现差距极大,可能移动端有什么特殊优化。
实际上pixi的DEMO并不是完整的动画引擎,只是个计时器tick,连层级1都不到,完整实现的话性能会大打折扣。
skottie只有webgl+wasm模式,且功能受限(只有层级1)不好比较,约等于pixi。
其他
目前初级版本还有一些细节可以优化,甚至还有新的想法可以尝试。
比如大头占比的动画执行,是否也可以考虑使用SIMD?数量众多的动画对象,亦有些并发的味道,是否可以将那些帧数据中的样式数据(即transform/matrix/opacity某个变化)平铺到一些矩阵当中,直接用并行矩阵计算来加速?
期待WASM的更好的发展。