SpriteJS:圖形庫造輪子的那些事兒
從 2017 年到 2020 年,我花了大約 4 年的時間,從零到一,實現(xiàn)了一個可切換 WebGL 和 Canvas2D 渲染的,跨平臺支持瀏覽器、SSR、小程序,基于 DOM 結(jié)構(gòu)和支持響應式的,高性能支持批量渲染、針對可視化場景優(yōu)化、支持 WebWorker 的圖形系統(tǒng)——SpriteJS。
?在這個“造輪子”過程中,我一步步將一個很簡陋的渲染庫,變成一個能夠支撐可視化應用和游戲開發(fā)的,還算不錯的一個圖形庫,其中有許多積累,也有許多思考。因為畢竟是兩年多前的研究,有些細節(jié)可能記得不是特別清晰,其中有些特性也許已經(jīng)有點過時,但我想,還是有不少內(nèi)容能給大家?guī)韰⒖己蛦l(fā)。
1. 原始需求:和渲染無關(guān)
2017 年底的時候,我還在奇虎 360 負責奇舞團。奇舞團是一個中臺前端團隊,支持很多 360 的業(yè)務需求,其中包括一些 toB 的需求,這些需求中有不少可視化圖表和態(tài)勢感知大屏。大概在 2015-2016 年,我們的同學就開始用 D3 來完成可視化項目,因為 D3 具有很高的靈活性。有些同學將 D3 簡單歸類為一種可視化渲染框架,實際上這種想法是錯誤的。D3 并不是可視化框架,而是一個數(shù)據(jù)驅(qū)動引擎。
嚴格來說,D3 關(guān)心的是數(shù)據(jù)的組織,它并不關(guān)心數(shù)據(jù)最終渲染的結(jié)果,但是,D3 的數(shù)據(jù)組織形式是基于樹狀結(jié)構(gòu)的,因為它天然契合樹狀結(jié)構(gòu)的渲染形式。正因為如此,所以一般來說,D3 的官方例子都是用 DOM 或 SVG 渲染,這是因為基于 DOM 樹的渲染和 D3 的樹狀數(shù)據(jù)組織形式是絕配。
- 使用 DOM 渲染的 D3 柱狀圖:
查看代碼:https://code.juejin.cn/pen/7160491257892962339
- 使用 SpriteJS 渲染:
查看代碼:https://code.juejin.cn/pen/7160553901123436557
1.1 與 DOM 的一致性
為了達到上面的效果,SpriteJS 參考瀏覽器 DOM API,進行了適配:
- ??https://github.com/spritejs/spritejs/blob/master/src/node/node.js??
- ??https://github.com/spritejs/spritejs/blob/master/src/node/group.js??
- ??https://github.com/spritejs/spritejs/blob/master/src/attribute/node.js??
- ??https://github.com/spritejs/spritejs/blob/master/src/document/index.js??
- ??https://github.com/spritejs/spritejs/blob/master/src/selector/index.js??
1.2 SpriteJS & DOM & D3
理論上,操作 SpriteJS 元素和操作 DOM 元素完全一樣,二者差異極小。
查看代碼:https://code.juejin.cn/pen/7160568056672944159
這種一致性使得 SpriteJS 完全可以和 D3 配合使用,靈活解決非常復雜的可視化問題:??http://spritejs.com/#/zh-cn/guide/d3??
2. 設計一個圖形系統(tǒng)的“骨架”
2.1 坐標系的選擇
在圖形系統(tǒng)的設計中,首先要確定默認坐標系。理論上講,任何一種直角坐標系,甚至非直角坐標系(比如極坐標)都可以作為默認坐標系,在歐式幾何中,這些坐標系都可以自由轉(zhuǎn)換。不過,考慮與 DOM 的一致性,采用瀏覽器默認的坐標系是一個極好的選擇。
對于 WebGL 渲染來說,我們需要將頂點坐標轉(zhuǎn)換成 WebGL 坐標,在這里,我們采用根據(jù) canvas 的坐標動態(tài)設置 projectionMatrix 即可:??https://github.com/mesh-js/mesh.js/blob/master/src/renderer.js#L181???
2.2 圖層、樹形結(jié)構(gòu)與元素類型
SpriteJS 用 Scene 表示場景,一個 Layer 表示一個圖層,在這里,我的設計是一個 Layer 對應一個畫布,即默認每個 Layer 都是獨立的 Canvas 元素。這么做有優(yōu)點也有缺點,是一種設計上的取舍。
優(yōu)點是,每個 Layer 彼此獨立,Layer 間不必考慮繪制次序,可以充分利用 WebWorker 這樣的多線程來并行繪制,而且邏輯上比較簡單,如果需要在多層響應事件,只需要注意事件處理的次序。缺點是如果分多層繪制,有可能產(chǎn)生較多 Canvas 對象實例,比較耗內(nèi)存。
- 多線程繪制
查看代碼:https://code.juejin.cn/pen/7089291575993303071
前面說過,SpriteJS 采用類似樹狀結(jié)構(gòu)來管理元素,Scene、Layer 和 Group 都是容器,而其他類型的圖形元素掛載在容器上。
SpriteJS 的元素類型比較多,一共有超過十五種圖形元素,如下圖所示。
這些元素可以分為兩類,一類是 Block 元素,包括 Sprite、Label 和 Group,一類是 Path 元素,包括各種圖形。這兩類元素中,Block 比較類似于 DOM 元素,占據(jù)矩形區(qū)域,有盒模型,有 border、padding、margin,可以計算大小;Path 比較類似于 SVG 元素,通過 Path2D 構(gòu)成矢量形狀,有 stroke 和 fill 兩類渲染,但不計算大?。ú还?Path 還是 Block 都能計算 boundingClientRect)。
Group 比較特殊,SpriteJS v3 里,它默認不計算大小,但繼承它的 Layer 和 Scene 會計算大小。在 v2 中,Group 計算大小,而且能夠做區(qū)域剪裁和設置 clipPath。v3 里,Group 主要的作用是給分組元素設置統(tǒng)一的 transform。之所以這樣設計,牽扯到 WebGL 的渲染模型。在后續(xù)會詳細解釋。
考慮到擴展性,用戶可以通過 spritejs.registerNode 注冊自定義節(jié)點元素。??https://github.com/spritejs/spritejs/blob/master/src/document/index.js#L15??
registerNode 的作用是注冊一個唯一的 nodeName 到 spritejs 的文檔樹上,這樣節(jié)點掛載之后,通過 getElementById、querySelector 等等就可以找到這個節(jié)點。
2.3 屬性更新和重繪機制
SpriteJS 與一般的圖形庫不同,通常情況下,一般的圖形庫會使用一個動畫定時器來以固定幀率刷新畫布。但 SpriteJS 采用的是屬性變化時的異步更新機制。
- ??https://github.com/spritejs/spritejs/blob/master/src/attribute/node.js#L190??
- ??https://github.com/spritejs/spritejs/blob/master/src/node/node.js#L430??
具體原理如下圖所示:
這里有些需要注意的細節(jié):
- 不是所有的屬性改變都會觸發(fā) render,比如 className、ID 等改變不會觸發(fā)。
- 有些屬性改變不僅觸發(fā) render,還需要觸發(fā)其他操作,比如 anchor、border 等屬性的變化,需要重新計算圖形元素的輪廓(后面會講);zIndex 的變化,導致對 group 的 children 的 renderOrder 進行重排。
這樣設計的好處顯而易見,可以盡量減少不必要的重繪和其他計算,從而提高整體性能。
2.4 外部 Ticker
雖然 SpriteJS 有自己的更新機制,但是一些外部庫,比如 ThreeJS 或者 ClayGL,有自己的更新邏輯,所以 SpriteJS 增加了手動控制的設計,以方便與外部庫配合。??http://spritejs.com/#/zh-cn/guide/ticker??
2.5 跨平臺
SpriteJS 在實現(xiàn)的時候,盡量不使用瀏覽器原生提供的能力,除非是標準的 Canvas 和 WebGL API。針對瀏覽器、NodeJS、微信小程序、微信小游戲等不同的環(huán)境,通過 polyfill 進行適配。??https://github.com/spritejs/spritejs/tree/master/src/platform??
為了在 NodeJS 中集成 WebGL 和 Canvas 環(huán)境,做了下面這個庫:??https://github.com/akira-cn/node-canvas-webgl??
3. 盒模型、事件、動畫等
3.1 盒模型設計
對 Block 類型的元素,SprteJS 采用標準的 DOM 盒模型,可以設置 border、padding 各屬性,并可以通過 boxSizing 屬性切換盒模型方式。
查看代碼:https://code.juejin.cn/pen/7160923382119137317
3.2 事件機制
- 事件模型、坐標轉(zhuǎn)換
??https://github.com/spritejs/spritejs/blob/master/src/event/event.js??
??https://github.com/spritejs/spritejs/blob/master/src/event/pointer-events.js??
視口寬高:[viewportWidth, viewportHeight]
畫布寬高:[resolutionWidth, resolutionHeight]
偏移量:[offsetLeft, offsetTop]
為什么會產(chǎn)生偏移量,詳細見屏幕適配。
- 事件派發(fā)和命中
??https://github.com/spritejs/spritejs/blob/master/src/node/layer.js#L179??
??https://github.com/spritejs/spritejs/blob/d8d7b8f232fe3c44ace11c5775892371bed44a1e/src/node/node.js#L419??
??https://github.com/mesh-js/mesh.js/blob/master/src/mesh2d.js#L840??
采用對每個三角網(wǎng)格進行命中檢測(此處有優(yōu)化空間,可以先排序用二分查找快速確定范圍):?
3.3 動畫的設計
- Sprite-Timeline
??https://github.com/spritejs/sprite-timeline??
為了實現(xiàn)可以在時間軸按照任意速度播放動畫,包括正向播放和回放,在任意時間點可以跳躍,實時切換播放狀態(tài)和時間軸狀態(tài),設計了 sprite-timeline 庫。
這個庫的設計是:
- 創(chuàng)建一個 Timeline 對象,它基于當前時間線和 playbackRate 來計算時間,playbackRate 可以是任意數(shù),所以時間可以停止,也可以回溯。playbackRate 的設置和改變會影響 Timeline 對象的 currentTime。
- 除了 currentTime 屬性,Timeline 對象還有一個 entropy(熵)屬性,它和 currentTime 的不同是,如果 playbackRate 為負數(shù),currentTime 會回溯,但 entropy 始終增加。
- Timeline 對象可以 fork,fork 出的新對象以被 fork 的 Timeline 對象的 currentTime 為時間線。這意味著 Timeline 對象可以嵌套,在 SpriteJS 中,所有元素會默認 fork 它的 parent 的 timeline 對象,所以當我們把 layer 的 timeline 的 playbackRate 設置為 0 的時候,這個 layer 中所有的動畫就都會暫停。
查看代碼:https://code.juejin.cn/pen/7160950394573553695
- Sprite-animator
基于 timeline 封裝,參考 Web Animations API - Web APIs | MDN( ??https://developer.mozilla.org/en-US/docs/Web/API/Web_Animations_API?? ) - Animation & Transition
??http://spritejs.com/#/zh-cn/effect?id=%e8%bf%87%e6%b8%a1-transition?? - Transition-reverse
查看代碼:https://code.juejin.cn/pen/7089261885949739016
- Path Transition
查看代碼:https://code.juejin.cn/pen/7160959750509690921
- Play Animations
查看代碼:https://code.juejin.cn/pen/7088265547250401293
- Async frame animations
查看代碼:https://code.juejin.cn/pen/7088238218914562088
4. 從 2D 到 WebGL
在 Sprite 1.0 和 2.0 的時候,主要是使用 Canvas2D 渲染,直到 3.0,我重寫了底層引擎,開始默認采用 WebGL 渲染。
4.1 輪廓和網(wǎng)格
為了便于 WebGL 處理幾何圖形,尤其是 Path 的解析,我實現(xiàn)了一個底層渲染引擎 GitHub - mesh-js/mesh.js: A graphics system born for visualization( ??https://github.com/mesh-js/mesh.js?? ),將 2D 幾何圖形分解成輪廓和網(wǎng)格對象,這有點像是 ThreeJS 中的 Geometry 和 Material,只不過因為我們要處理的實際上是 2D 圖形,所以模型更加簡單。
在 mesh.js 中,要繪制一個幾何圖形,我們先構(gòu)建該元素的輪廓(Figure/Contours),然后再根據(jù)輪廓創(chuàng)建網(wǎng)格對象。經(jīng)過這樣兩個步驟之后,我們就可以將幾何圖形繪制出來,這個過程其實比較像 Canvas2D,只是比 Canvas2D 稍復雜一點點。
查看代碼:https://code.juejin.cn/pen/7160967356489924622
4.2 三角剖分
眾所周知,WebGL 的基本圖元只有點、線、三角形等,要繪制多邊形,我們需要將圖形進行三角剖分。對任意多邊形進行三角剖分,有許多成熟算法,我選擇的是 GLU Tessellator。
- ??https://github.com/mesh-js/mesh.js/blob/master/src/tess2/index.js??
- ??https://github.com/mesh-js/mesh.js/blob/master/src/triangulate-contours/index.js??
我通過一系列工具庫 parse-svg-path、normalize-svg-path、svg-path-contours(??https://github.com/mesh-js/mesh.js/tree/master/src/svg-path-contours??)將 SVGPath 轉(zhuǎn)換成多邊形的頂點列表,這里就不重復造輪子了,有些工具庫有點小 bug,我給順手修了一下。
獲得頂點之后,對頂點進行三角剖分,就可以得到三角網(wǎng)格的拓撲結(jié)構(gòu),通過這個拓撲結(jié)構(gòu)創(chuàng)建 mesh2d 對象。
4.3 Stroke
如果不常用 WebGL 渲染,很難想象,對 Canvas2D 來說非常簡單的繪制帶寬度折線這類需求,會難住 WebGL 開發(fā)者。
其實這個問題已經(jīng)有比較經(jīng)典的解決方案,就是用擠壓(extrude polyline)曲線技術(shù)來實現(xiàn)。有兩種方法,一種是用 JS 算頂點,另一種是在 shader 中進行處理。為了靈活實現(xiàn) Canvas2D 中的“線帽(lineCap)”效果,SpriteJS 采用 JS 計算的方式來處理。
如上圖所示,黑色折線是原始的 1 個像素寬度的折線,藍色虛線組成的是我們最終要生成的帶寬度曲線,紅色虛線是頂點移動的方向。因為折線兩個端點的擠壓只和一條線段的方向有關(guān),而轉(zhuǎn)角處頂點的擠壓和相鄰兩條線段的方向都有關(guān),所以頂點移動的方向,我們要分兩種情況討論。
首先,是折線的端點。假設線段的向量為(x, y),因為它移動方向和線段方向垂直,所以我們只要沿法線方向移動它就可以了。根據(jù)垂直向量的點積為 0,我們很容易得出頂點的兩個移動方向為(-y, x)和(y, -x)。如下圖所示:
端點擠壓方向確定了,接下來要確定轉(zhuǎn)角的擠壓方向了,我們還是看示意圖。
如上圖,我們假設有折線 abc,b 是轉(zhuǎn)角。我們延長 ab,就能得到一個單位向量 v1,反向延長 bc,可以得到另一個單位向量 v2,那么擠壓方向就是向量 v1+v2 的方向,以及相反的 -(v1+v2) 的方向。
現(xiàn)在我們得到了擠壓方向,接下來就需要確定擠壓向量的長度。
首先是折線端點的擠壓長度,它等于 lineWidth 的一半。而轉(zhuǎn)角的擠壓長度就比較復雜了,我們需要再計算一下。
綠色這條輔助線應該等于 lineWidth 的一半,而它又恰好是 v1+v2 在綠色這條向量方向的投影,所以,我們可以先用向量點積求出紅色虛線和綠色虛線夾角的余弦值,然后用 lineWidth 的一半除以這個值,得到的就是擠壓向量的長度了。
具體用 JavaScript 實現(xiàn)的代碼如下所示:??https://github.com/mesh-js/mesh.js/blob/master/src/extrude-contours/stroke.js??
4.4 批量繪制
因為我們繪制 2D 圖形,通常這些圖形可視為同一材質(zhì),所以我們能夠?qū)⑦@些圖形網(wǎng)格數(shù)據(jù)全部壓縮到一個大的類型數(shù)組中進行批量繪制。
??https://github.com/mesh-js/mesh.js/blob/master/src/utils/compress.js??
4.5 Shader & Pass
SpriteJS 可以使用自定義 shader 創(chuàng)建 Program,將 Program 賦給繪圖元素進行繪制。
查看代碼:https://code.juejin.cn/pen/7088623553993506852
我們可以在渲染管線中應用多個 shader 組成管道進行渲染,有一種特定的渲染管道叫做后期處理通道,SpriteJS 支持定義后期處理通道。
查看代碼:https://code.juejin.cn/pen/7088626022244941839
5. 關(guān)于性能優(yōu)化的那些事兒
5.1 性能的直觀感受
SpriteJS 針對可視化場景進行了性能優(yōu)化??梢暬瘓鼍爸杏写罅恐貜突蝾愃菩螤畹膸缀螆D形,因此用合并頂點批量渲染的方式會很有效。
查看代碼:https://code.juejin.cn/pen/7088268165032968223
查看代碼:https://code.juejin.cn/pen/7088274902167322631
5.2 auto Blending 和輪廓更新
WebGL 在顏色混合的時候比較消耗性能,因此 mesh-js 對元素做了判斷,如果當前繪制的元素都沒有 alpha 通道(透明度),那么不會開啟顏色混合,否則再開啟顏色混合。
在 SpriteJS 中,元素的大部分樣式改變,比如 transform、position、bgcolor 等等,不涉及輪廓的變化,這些情況下,我們不用重新計算輪廓,所以我們將元素輪廓計算好之后緩存起來,大部分情況下我們不需要重復計算。只有一些特殊屬性,比如 Path 的 d、lineWidth、lineCap、Block 的 border 等改變,才需要重新計算輪廓。
5.3 Seal & Cloud
??http://spritejs.com/#/zh-cn/guide/performance??
Seal 是一種特殊的方式,當我們使用一個 group 來組合一組圖形時,如果只是需要使用固定的圖形拓撲結(jié)構(gòu),我們可以使用 group 的 seal 方法將子元素的幾何圖形合并成為 group 的幾何圖形。這樣 group 的幾何圖形將被合并的幾何圖形替代,成為一個單一的元素被渲染,并且不再能夠改變幾何圖形(但是依然可以改變位置、transform、顏色等等屬性)。
seal 生效的時候,原子元素的屬性將失效,由 group 的屬性替代。
當我們用 group 構(gòu)建組合圖形的時候,這種特殊方式能夠大大提升渲染性能。
查看代碼:https://code.juejin.cn/pen/7088273623122706466
對于繪制完全重復的幾何圖形,我們還可以利用 WebGL 的來進行渲染。
查看代碼:https://code.juejin.cn/pen/7088273623122706466
查看代碼:https://code.juejin.cn/pen/7088274222738505732
5.4 關(guān)于 Shader 的性能開銷
有一條需要格外注意:盡量使用條件編譯代替條件分支。