WASM 在動畫引擎中的設計優化

🙋🏻‍♀️ 編者按:本文作者是螞蟻集團前端工程師阿侎,探討一個圖形動畫領域的性能優化:如何在 canvas/webgl 的動畫引擎設計上,使用 WASM 來優化性能?

前言

WebAssembly 技術日趨穩定和成熟,在許多場景下已經被運用,其重要特性之一的性能更是作爲用來被解決問題的手段。

關於其基礎原理、適應場景等本文不再贅述。動畫引擎本身原理這裏也作爲基礎跳過不說。

此篇主要探討一個圖形動畫領域的性能優化:如何在 canvas/webgl 的動畫引擎設計上,使用 WASM 來優化性能?

讓我們首先規定下動畫引擎:

具備類似 CSS Animation / Web Animation Api 的完備功能,而非簡單的幀時間計數器。

  1. 這需要有暫停、恢復、變速、跳轉、反向、取消、完成、輪播等一系列控制能力;

  2. 更完整的是需要有簡單 api,賦予一個類似 DOM 的對象任意動畫功能的編程能力,可以入參;

  3. 如果可以,最好支持 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 來寫,勢必會有成本問題:

  1. 無論 C++ 還是 Rust 還是其它,編寫難度成本都不容忽視;

  2. 有些甚至是擴展成本,比如 font、width、路徑、矢量動畫,它和渲染引擎耦合極重,WASM 等於還要再實現一遍渲染邏輯;

  3. 調試維護也是一種成本。

因此本文以最常見的 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 中。

這些在 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

繼續使用之前的一萬節點動畫性能測試。要求:

  1. 使用 10000 個包含不同文字、背景色的節點,如果是 CANVAS 模式,降級到 5000 個;

  2. 所有節點同時進行不同的隨機移動、旋轉、縮放動畫,無限往返;

  3. 統計 fps 對比,並附上測試 DEMO 源碼。

http://army8735.me/karasjs/karas/benchmark/karas-wasm-5k.htmlhttp://army8735.me/karasjs/karas/benchmark/karas-canvas-5k.htmlhttp://army8735.me/karasjs/karas/benchmark/pixi-canvas-5k.html

http://army8735.me/karasjs/karas/benchmark/karas-webgl-1w.htmlhttp://army8735.me/karasjs/karas/benchmark/karas-wasm-1w.htmlhttp://army8735.me/karasjs/karas/benchmark/pixi-webgl-1w.html

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 的更好的發展。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/pmH559vnTpQ7HLOssyKqiA