淺談 Canvas 渲染引擎設(shè)計(jì)
用過 Canvas 的都知道它的 API 比較多,使用起來也很麻煩,比如我想繪制一個(gè)圓形就要調(diào)一堆 API,對開發(fā)算不上友好。
為了解決這個(gè)痛點(diǎn),誕生了例如 PIXI、ZRender、Fabric 等 Canvas 庫,對 Canvas API 進(jìn)行了一系列的封裝。
今天主要介紹一下社區(qū)幾個(gè)比較有代表性的 Canvas 渲染引擎的設(shè)計(jì)原理。
這篇文中不會(huì)從源碼講起,更像是一篇科普文章,介紹 Canvas 一些有趣的點(diǎn)。
1. 特性
Canvas 渲染引擎一般包括下面幾個(gè)特點(diǎn):
- 封裝
將 Canvas API 的調(diào)用封裝成更簡單、清晰的形式,貼近于我們使用 DOM 的方式。
比如想畫一個(gè)圓,直接調(diào)用封裝好的繪制方法就行了,我們不需要關(guān)心是如何繪制的。
- 性能
雖然封裝之后的 API 很貼近 HTML 語法,但也意味著開發(fā)者很難去做一些底層的性能優(yōu)化。因此,大部分 Canvas 渲染引擎都會(huì)內(nèi)置了一些性能優(yōu)化手段。
常見的性能優(yōu)化手段有離屏渲染、臟區(qū)渲染、異步渲染等等。
- 跨平臺(tái)
一些渲染引擎為了更加通用,在底層做了更多抽象,不僅支持 Canvas Renderer,甚至還支持 WebGL、WebGPU、SVG、CanvasKit、小程序等等,真正實(shí)現(xiàn)了一套代碼多種渲染。
針對底層的渲染流程和類進(jìn)行抽象化,在不同平臺(tái)具象化去實(shí)現(xiàn)具體的渲染邏輯,從而可以一套代碼,只要切換渲染器就能實(shí)現(xiàn)多平臺(tái)渲染。
2. 封裝
2.1 虛擬節(jié)點(diǎn)
Canvas 是一張畫布,里面的內(nèi)容都是自己調(diào)用 API 繪制的,所以更像是我們拿起畫筆來作畫。
目前主流的 Canvas 渲染引擎都會(huì)將要繪制的圖形封裝成類,以方便開發(fā)者去調(diào)用,復(fù)用性也比較強(qiáng)。調(diào)用方式類似于 DOM,每個(gè)實(shí)例可以當(dāng)做一個(gè)虛擬節(jié)點(diǎn)。
使用 AntV/g 的例子:
在此基礎(chǔ)上,可以進(jìn)一步針對 React/Vue 語法進(jìn)行封裝,讓用戶對底層的實(shí)現(xiàn)無感知。
使用 React-Konva 的例子(通過 react-reconciler 實(shí)現(xiàn)):
除了內(nèi)置的圖形類,很多渲染引擎還會(huì)提供自定義繪制圖形類的能力。
以 Konva 為例,每個(gè)圖形類都需要實(shí)現(xiàn) sceneFunc 方法,在這個(gè)方法里面去調(diào)用 Canvas API 來進(jìn)行繪制。
如果需要自定義新的圖形,就可以繼承 Shape 來實(shí)現(xiàn) sceneFunc 方法。
Konva 里面圓形繪制類的實(shí)現(xiàn):
參照 DOM 樹的結(jié)構(gòu),每個(gè) Konva 應(yīng)用包括一個(gè)舞臺(tái) Stage、多個(gè)畫布 Layer、多個(gè)分組 Group,以及若干的葉子節(jié)點(diǎn) Shape,這些虛擬節(jié)點(diǎn)關(guān)聯(lián)起來最終形成了一棵樹。
在 Konva 中,一個(gè) Stage 就是根節(jié)點(diǎn),Layer 對應(yīng)一個(gè) Canvas 畫布,Group 是指多個(gè) Shape 的集合,它本身不會(huì)進(jìn)行繪制,但同一個(gè) Group 里面的 Shape 可以一起應(yīng)用旋轉(zhuǎn)、縮放等變換。
Shape 則是指具體的繪制節(jié)點(diǎn),比如 Rect、Circle、Text 等等。
2.2 包圍盒
既然有了虛擬節(jié)點(diǎn),那知道每個(gè)虛擬節(jié)點(diǎn)的位置和大小也比較重要,它會(huì)涉及到判斷兩個(gè)圖形是否相交、事件等等。
有時(shí)候元素的形狀不是很規(guī)則,如果直接對不規(guī)則元素進(jìn)行碰撞檢測會(huì)比較麻煩,所以就有了一個(gè)近似的算法,就是在物體外側(cè)加上包圍盒,如圖:
目前主流的包圍盒有 AABB 和 OBB 兩種。
AABB 包圍盒:
實(shí)現(xiàn)方式簡單,直接用最大最小的橫縱坐標(biāo)來生成包圍盒,但不會(huì)跟著元素旋轉(zhuǎn),因此空白區(qū)域比較多,也不夠準(zhǔn)確。
也是目前 Konva 和 AntV 使用的方式。(適合表格業(yè)務(wù))
OBB 包圍盒:
實(shí)現(xiàn)方式相對復(fù)雜,通過構(gòu)建協(xié)方差矩陣來計(jì)算出新的坐標(biāo)軸方向,將其頂點(diǎn)投射到坐標(biāo)軸上面來得到新的包圍盒。
所以 OBB 包圍盒更加準(zhǔn)確一些,也是 cocos2d 使用的方式。
碰撞檢測:
兩個(gè)包圍盒在所有軸(與邊平行)上的投影都發(fā)生重疊,則判定為碰撞;否則,沒有發(fā)生碰撞。
2.3 排版系統(tǒng)
繪制 Canvas 的時(shí)候一般是通過相對坐標(biāo)來確定當(dāng)前要繪制的位置,所以都是通過各種計(jì)算來拿到 x、y。
即使是 Konva 也是依賴于 x、y 來做相對定位。
因此,在 AntV 和 SpriteJS 這類 Canvas 渲染引擎里面,都內(nèi)置支持了盒模型的語法糖,底層會(huì)將盒模型屬性進(jìn)行一次計(jì)算轉(zhuǎn)換成 x、y。
以 AntV 為例子,排版能力是基于 Facebook 開源的 Yoga 排版引擎(React Native)來實(shí)現(xiàn)的,支持一套非常完整的盒模型和 Flex 布局語法。
在騰訊開源的 Hippy 里面自己實(shí)現(xiàn)了一套類似 Yoga 的排版引擎,叫做 Titank。
在飛書文檔多維表格里面,排版語法更加接近于 Flutter,實(shí)現(xiàn)了 Padding、Column、Row、Margin、Expanded、Flex、GridView 等 Widget。
下面的示例是 Flutter 的:
實(shí)現(xiàn)了盒模型和 Flex 布局,可以讓 Canvas 的排版能力更上一層樓。
不僅可以減少代碼中的大量計(jì)算,也可以讓大家從 DOM 開發(fā)無縫銜接進(jìn)來,值得我們參考。
canvas-flexbox - CodeSandbox
3. 事件
Canvas 本身是一塊畫布,所以里面的內(nèi)容都是畫出來的,在 DOM 樹里面也只是一個(gè) Canvas 的節(jié)點(diǎn),所以如何才能知道當(dāng)前點(diǎn)擊的是哪個(gè)圖形呢?
由于 Canvas 渲染引擎都會(huì)封裝虛擬節(jié)點(diǎn),每個(gè)節(jié)點(diǎn)都有自己的包圍盒,所以為實(shí)現(xiàn) Canvas 的事件系統(tǒng)提供了可能性。
主流的 Canvas 渲染引擎都是針對 Canvas 節(jié)點(diǎn)或者上層節(jié)點(diǎn)進(jìn)行事件委托,監(jiān)聽用戶相關(guān)的事件(mouseDown、click、touch等等)之后,匹配到當(dāng)前觸發(fā)的元素,將事件分發(fā)出去,并且擁有一套向上冒泡的機(jī)制。
目前主流的兩種事件實(shí)現(xiàn)方式分別是取色值法和幾何法。
3.1 取色值法
取色值法是 Konva 采用的實(shí)現(xiàn)方式,它的實(shí)現(xiàn)方式非常簡單,匹配精確度很高,適合不規(guī)則圖形的匹配。
取色值法的原理如下:
- 在主 Canvas 繪制一個(gè)圖形的時(shí)候,會(huì)為這個(gè)圖形生成一個(gè)隨機(jī)的 colorKey(十六進(jìn)制的顏色),同時(shí)建立類似于 Map<colorKey, Shape> 的映射。
- 繪制的同時(shí)會(huì)在內(nèi)存里的 hitCanvas 同樣位置繪制一個(gè)一模一樣的圖形,填充色是剛才的 colorKey。
- 當(dāng)用戶鼠標(biāo)點(diǎn)擊 Canvas 畫布的時(shí)候,可以拿到鼠標(biāo)觸發(fā)的 x、y,將其傳給內(nèi)存里面的 Canvas。
- 內(nèi)存里面的 Canvas 通過 getImageData 來獲取到當(dāng)前的顏色,進(jìn)而通過 colorKey 來匹配到對應(yīng)的圖形。
從上述原理可以看出來,Konva 對于不規(guī)則圖形的匹配依然很精確,但缺點(diǎn)也很明顯,每次都需要繪制兩份,導(dǎo)致繪制性能變差。
同時(shí),getImageData 耗時(shí)比較高,在頻繁觸發(fā)的場景(onWheel)會(huì)導(dǎo)致幀率下降嚴(yán)重。
3.2 幾何法
幾何法有很多種實(shí)現(xiàn)方式,這里主要講解引射線法,因?yàn)樾枰M(jìn)行一系列幾何計(jì)算,所以這里我稱之為幾何法。
幾何法是 AntV 和飛書文檔采用的實(shí)現(xiàn)方式,實(shí)現(xiàn)方式相對復(fù)雜一些,針對不規(guī)則圖形的匹配效率偏低。
幾何法的實(shí)現(xiàn)原理如下:
- 基于當(dāng)前虛擬節(jié)點(diǎn)的包圍盒來構(gòu)建一棵 R Tree
- 當(dāng)用戶觸發(fā)事件的時(shí)候,利用 R Tree 來進(jìn)行空間索引查找,依據(jù) z-index 找到最頂層的一個(gè)圖形。
- 從目標(biāo)點(diǎn)出發(fā)向一側(cè)發(fā)出一條射線,看這條射線和多邊形所有邊的交點(diǎn)數(shù)目。
- 如果有奇數(shù)個(gè)交點(diǎn),則說明在內(nèi)部,如果有偶數(shù)個(gè)交點(diǎn),則說明在外部。
為什么奇數(shù)是在內(nèi)部,偶數(shù)是在外部呢?我們假設(shè)射線與這個(gè)圖形的交點(diǎn),進(jìn)入圖形叫做穿入,離開圖形叫做穿出。
在圖形內(nèi)部發(fā)出的射線,一定會(huì)有穿出但沒有穿入的情況。但在外部發(fā)出的射線,穿入和穿出是相對的。
但是射線剛好穿過頂點(diǎn)的情況比較特殊,因此需要單獨(dú)進(jìn)行判斷。
幾何法的優(yōu)勢在于不需要在內(nèi)存里面進(jìn)行重復(fù)繪制,但依賴于復(fù)雜的幾何計(jì)算,因此不適合有大量不規(guī)則圖形的情況。
在 AntV 里面支持對不規(guī)則圖形的匹配,但飛書文檔由于是表格業(yè)務(wù),所以可以將所有圖形都當(dāng)做矩形來處理,反而更簡單一些。
4. 性能
由于 Canvas 渲染引擎都會(huì)進(jìn)行大量的封裝,所以開發(fā)者想針對底層做性能優(yōu)化是非常難的,需要渲染引擎自身去支持一些優(yōu)化。
4.1 異步批量渲染
在飛書文檔 Bitable 和 Konva 里面都支持異步渲染,將大量繪制進(jìn)行批量處理。
由于每次修改圖形的屬性或者添加、銷毀子節(jié)點(diǎn)都會(huì)觸發(fā)渲染,為了避免同時(shí)修改多個(gè)屬性時(shí)導(dǎo)致的重復(fù)渲染,因此約定每次在下一幀進(jìn)行批量繪制。
這種渲染方式類似于 React 的 setState,避免短時(shí)間內(nèi)多次 setState 導(dǎo)致多次 render。
4.2 離屏渲染
離屏渲染我們應(yīng)該都比較熟悉了,就是兩個(gè) Canvas 來回用 drawImage 繪制可復(fù)用部分,從而減少繪制的耗時(shí)。
這里主要講解 Konva 和飛書 Bitable 里面的離屏渲染。
在 Konva 中的離屏渲染主要是針對 Group 級(jí)別來做的,通過調(diào)用 cache 方法就能實(shí)現(xiàn)離屏渲染。
基于 Group 來做離屏渲染的原理是:
- 調(diào)用 cache 方法,創(chuàng)建一個(gè)離屏 Canvas 節(jié)點(diǎn)。
- 遍歷 Group 子節(jié)點(diǎn)進(jìn)行繪制,同時(shí)將其繪制到離屏 Canvas 上面。
- 下次 batchDraw 的時(shí)候判斷是否有緩存,如果有,那么直接走 drawImage 的形式。
這種離屏渲染的調(diào)用方式比較簡單,Group 的粒度可以由開發(fā)者自己決定,但也有一定的問題。
- 比較難應(yīng)用于表格這種形式的業(yè)務(wù)
- Konva 沒有臟檢測能力,即使 Group 里面的 Shape 屬性改變了,依然不會(huì)更新離屏 Canvas。
- 由于使用色值法來匹配圖形,導(dǎo)致開啟了離屏渲染,實(shí)際上至少要繪制四份(主canvas、事件 hitCanvas、離屏 cacheCanvas、離屏事件 cacheHitCanvas)。
為什么需要繪制四份呢?因?yàn)殡x屏渲染是 drawImage 的形式,這樣就不會(huì)有 colorKey 和 Shape 對應(yīng)的情況了,所以離屏 Canvas 也要有一個(gè)自己的 hitCanvas 來做 getImageData,也就是 cacheHitCanvas。
另一種場景的離屏渲染就是飛書 Bitable 里面的實(shí)現(xiàn)。
飛書在底層之上封裝了虛擬列表的 Widget,也就是基于業(yè)務(wù)定制的 Widget,這也是一種有趣的思路。
- 創(chuàng)建一個(gè)虛擬列表的 Widget 類,將列表數(shù)據(jù)傳入
- 實(shí)現(xiàn)列表每一項(xiàng)的繪制方法,將列表繪制出來
- 滾動(dòng)的時(shí)候虛擬列表內(nèi)部進(jìn)行節(jié)點(diǎn)的回收創(chuàng)建,但不會(huì)進(jìn)行異步批量渲染,針對可復(fù)用的部分進(jìn)行離屏渲染
- 更新階段,通過 key 對比來決定是回收、創(chuàng)建還是復(fù)用。
在多維表格看板視圖里面,每個(gè)分組都是一個(gè)虛擬列表,多個(gè)分組(虛擬列表)又組合成一個(gè)大的虛擬列表。
多選單元格編輯器也可以基于虛擬列表實(shí)現(xiàn)。
虛擬列表 Widget 類適合多維表格這種業(yè)務(wù),多個(gè)視圖都需要有自己的滾動(dòng)容器,不同視圖都需要處理節(jié)點(diǎn)的回收、復(fù)用、新建,通過公用 Widget 可以一步到位去支持,也方便在內(nèi)部去做更多性能優(yōu)化。
4.3 臟區(qū)渲染
對于 Konva 來說,每次重新渲染都是對整個(gè) Canvas 做 clearRect 清除,然后重新繪制,性能相對比較差。
更好的做法是檢測到當(dāng)前的改動(dòng)影響到的范圍,計(jì)算出重繪范圍后,只清除重繪區(qū)的內(nèi)容重新進(jìn)行繪制。
在 Canvas 中可以通過 rect 和 clip 限制繪制區(qū)域,從而做到只對部分區(qū)域重繪。
以前 ECharts 底層的 ZRender 為例來講解:
- 根據(jù)圖形前后變化,來計(jì)算出重繪區(qū)域,比如上圖的區(qū)域,在飛書文檔中會(huì)將整個(gè)移動(dòng)的路徑當(dāng)做重繪區(qū)域。
- 如果有多個(gè)重繪區(qū)域,那么優(yōu)先嘗試將相交(包圍盒)的重繪區(qū)進(jìn)行合并,并且優(yōu)先合并相交面積最大的重繪區(qū)。
- 如果合并完成后,當(dāng)前剩余的重繪區(qū)數(shù)量大于5,則進(jìn)一步進(jìn)行合并,直到數(shù)量只剩5。
- 依次遍歷這些重繪區(qū)域,先清除掉原有的內(nèi)容,再進(jìn)行繪制。
飛書文檔多維表格沒有做 Canvas 渲染分層,但對各種交互響應(yīng)速度非??欤彩堑靡嬗诘讓愉秩疽鎸εK矩形渲染的支持,它的性能也是所有同類產(chǎn)品里面最好的。
除了上述的這些,還有在文檔這邊使用的一些優(yōu)化手段,比如合并相同屬性的圖形繪制(線、矩形、文本等)、Canvas 分層等等,這些就不多做闡述了。
5. 跨平臺(tái)
很多 Canvas 渲染引擎并不滿足于只做 Canvas,一般還會(huì)支持一些其他的渲染模式,比如 SVG 渲染、WebGL 渲染、WebGPU 渲染等等。
在 AntV 里面通過引入對應(yīng)的 package 來實(shí)現(xiàn)加載渲染器的,在 ZRender 中則是通過 register 來注冊不同的渲染器。
AntV 中使用 CanvasKit 渲染:
關(guān)于跨平臺(tái)的架構(gòu)這里不做講解,主要是抹平不同平臺(tái)的差異,這里主要講解一下針對于服務(wù)端渲染的不同處理。
主流的服務(wù)端渲染方式有兩種,一種是用 node-canvas 來輸出一張圖片,在 echarts 等庫中都有使用,缺陷在于文本排版不夠準(zhǔn)確,對于自適應(yīng)瀏覽器窗口的情況無法處理。因此它不適用于文檔直出的場景。
另一種就是通過 SVG 來模擬 Canvas 的效果,輸出 SVG DOM 字符串。但它的實(shí)現(xiàn)會(huì)比較麻煩,也無法 100% 還原 Canvas 的效果。
但很多 Canvas 渲染引擎本身也支持 SVG 渲染,即使不支持,也可以通過 canvas2svg 這個(gè)庫來進(jìn)行轉(zhuǎn)換。
對于更加通用的場景來說,在瀏覽器端使用 Canvas 渲染,服務(wù)端使用 SVG 渲染是更合理的形式。
在新版 ECharts 里面,針對 SVG 服務(wù)端渲染的能力,還支持了 Virtual DOM 來代替 JSDOM,最后轉(zhuǎn)換成 DOM 字符串。
在飛書文檔中使用了一種完全獨(dú)立于 node-canvas 和 SVG 的解決方式,非常值得我們借鑒。
由于飛書多維表格底層統(tǒng)一了渲染引擎,所有繪制元素都是 Widget(對齊 Flutter),可以脫水轉(zhuǎn)換成下面 FVG 格式。
一般來說,文檔業(yè)務(wù)首屏加載是下面這么幾步:
獲取首屏數(shù)據(jù) -> 資源加載 -> 首屏數(shù)據(jù)反序列化 -> 初始化 Model 層 -> 計(jì)算排版數(shù)據(jù) -> Canvas 渲染
在飛書文檔里面直出渲染層 Widget 的數(shù)據(jù)結(jié)構(gòu),這個(gè)數(shù)據(jù)結(jié)構(gòu)是最后提供給 Canvas 渲染的數(shù)據(jù),也就是已經(jīng)經(jīng)過了計(jì)算排版數(shù)據(jù)階段。
當(dāng)渲染層 JS 資源加載完成后,直接省略反序列化、初始化 Model、計(jì)算排版數(shù)據(jù)等階段,將 FVG 轉(zhuǎn)換成 Widget 進(jìn)行 Canvas 渲染,這一步非常接近于 React 的 hydrate,很巧妙。