日日操夜夜添-日日操影院-日日草夜夜操-日日干干-精品一区二区三区波多野结衣-精品一区二区三区高清免费不卡

公告:魔扣目錄網為廣大站長提供免費收錄網站服務,提交前請做好本站友鏈:【 網站目錄:http://www.ylptlb.cn 】, 免友鏈快審服務(50元/站),

點擊這里在線咨詢客服
新站提交
  • 網站:51998
  • 待審:31
  • 小程序:12
  • 文章:1030137
  • 會員:747

當渲染層 JS 資源加載完成后,直接省略反序列化、初始化 Model、計算排版數據等階段,將 FVG 轉換成 Widget 進行 Canvas 渲染,這一步非常接近于 React 的 hydrate,很巧妙。

用過 Canvas 的都知道它的 API 比較多,使用起來也很麻煩,比如我想繪制一個圓形就要調一堆 API,對開發算不上友好。

為了解決這個痛點,誕生了例如 PIXI、ZRender、Fabric 等 Canvas 庫,對 Canvas API 進行了一系列的封裝。

今天主要介紹一下社區幾個比較有代表性的 Canvas 渲染引擎的設計原理。

這篇文中不會從源碼講起,更像是一篇科普文章,介紹 Canvas 一些有趣的點。

1. 特性

Canvas 渲染引擎一般包括下面幾個特點:

  1. 封裝

將 Canvas API 的調用封裝成更簡單、清晰的形式,貼近于我們使用 DOM 的方式。

比如想畫一個圓,直接調用封裝好的繪制方法就行了,我們不需要關心是如何繪制的。

  1. 性能

雖然封裝之后的 API 很貼近 html 語法,但也意味著開發者很難去做一些底層的性能優化。因此,大部分 Canvas 渲染引擎都會內置了一些性能優化手段。

常見的性能優化手段有離屏渲染、臟區渲染、異步渲染等等。

  1. 跨平臺

一些渲染引擎為了更加通用,在底層做了更多抽象,不僅支持 Canvas Renderer,甚至還支持 WebGL、WebGPU、SVG、CanvasKit、小程序等等,真正實現了一套代碼多種渲染。

針對底層的渲染流程和類進行抽象化,在不同平臺具象化去實現具體的渲染邏輯,從而可以一套代碼,只要切換渲染器就能實現多平臺渲染。

2. 封裝

2.1 虛擬節點

Canvas 是一張畫布,里面的內容都是自己調用 API 繪制的,所以更像是我們拿起畫筆來作畫。

目前主流的 Canvas 渲染引擎都會將要繪制的圖形封裝成類,以方便開發者去調用,復用性也比較強。調用方式類似于 DOM,每個實例可以當做一個虛擬節點。

使用 AntV/g 的例子:

import { Circle, Canvas, CanvasEvent } from '@antv/g';
import { Renderer as CanvasRenderer } from '@antv/g-canvas';
// or
// import { Renderer as WebGLRenderer } from '@antv/g-webgl';
// import { Renderer as SVGRenderer } from '@antv/g-svg';

// 創建畫布
const canvas = new Canvas({
    container: 'container',
    width: 500,
    height: 500,
    renderer: new CanvasRenderer(), // 選擇一個渲染器
});

// 創建一個圓
const circle = new Circle({
    style: {
        cx: 100,
        cy: 100,
        r: 50,
        fill: 'red',
        stroke: 'blue',
        lineWidth: 5,
    },
});

canvas.addEventListener(CanvasEvent.READY, function () {
    // 加入畫布
    canvas.AppendChild(circle);

    // 監聽 `click` 事件
    circle.addEventListener('click', function () {
        this.style.fill = 'green';
    });
});
 

在此基礎上,可以進一步針對 React/Vue 語法進行封裝,讓用戶對底層的實現無感知。

使用 React-Konva 的例子(通過 react-reconciler 實現):

import React, { Component } from 'react';
import { render } from 'react-dom';
import { Stage, Layer, Rect, Text } from 'react-konva';
import Konva from 'konva';

class ColoredRect extends React.Component {
  state = {
    color: 'green',
  };
  handleClick = () => {
    this.setState({
      color: Konva.Util.getRandomColor(),
    });
  };
  render() {
    return (
      <Rect
        x={20}
        y={20}
        width={50}
        height={50}
        fill={this.state.color}
        shadowBlur={5}
        onClick={this.handleClick}
      />
    );
  }
}

class App extends Component {
  render() {
    return (
      <Stage width={window.innerWidth} height={window.innerHeight}>
        <Layer>
          <Text text="Try click on rect" />
          <ColoredRect />
        </Layer>
      </Stage>
    );
  }
}

render(<App />, document.getElementById('root'));
 

除了內置的圖形類,很多渲染引擎還會提供自定義繪制圖形類的能力。

以 Konva 為例,每個圖形類都需要實現 sceneFunc 方法,在這個方法里面去調用 Canvas API 來進行繪制。

如果需要自定義新的圖形,就可以繼承 Shape 來實現 sceneFunc 方法。

Konva 里面圓形繪制類的實現:

export class Circle extends Shape<CircleConfig> {
  _sceneFunc(context) {
    context.beginPath();
    context.arc(0, 0, this.attrs.radius || 0, 0, Math.PI * 2, false);
    context.closePath();
    context.fillStrokeShape(this);
  }
}

參照 DOM 樹的結構,每個 Konva 應用包括一個舞臺 Stage、多個畫布 Layer、多個分組 Group,以及若干的葉子節點 Shape,這些虛擬節點關聯起來最終形成了一棵樹。

 

圖片

 

在 Konva 中,一個 Stage 就是根節點,Layer 對應一個 Canvas 畫布,Group 是指多個 Shape 的集合,它本身不會進行繪制,但同一個 Group 里面的 Shape 可以一起應用旋轉、縮放等變換。

Shape 則是指具體的繪制節點,比如 Rect、Circle、Text 等等。

2.2 包圍盒

既然有了虛擬節點,那知道每個虛擬節點的位置和大小也比較重要,它會涉及到判斷兩個圖形是否相交、事件等等。

有時候元素的形狀不是很規則,如果直接對不規則元素進行碰撞檢測會比較麻煩,所以就有了一個近似的算法,就是在物體外側加上包圍盒,如圖:

目前主流的包圍盒有 AABB 和 OBB 兩種。

AABB 包圍盒:

實現方式簡單,直接用最大最小的橫縱坐標來生成包圍盒,但不會跟著元素旋轉,因此空白區域比較多,也不夠準確。

也是目前 Konva 和 AntV 使用的方式。(適合表格業務)

OBB 包圍盒:

實現方式相對復雜,通過構建協方差矩陣來計算出新的坐標軸方向,將其頂點投射到坐標軸上面來得到新的包圍盒。

所以 OBB 包圍盒更加準確一些,也是 cocos2d 使用的方式。

碰撞檢測:

兩個包圍盒在所有軸(與邊平行)上的投影都發生重疊,則判定為碰撞;否則,沒有發生碰撞。

2.3 排版系統

繪制 Canvas 的時候一般是通過相對坐標來確定當前要繪制的位置,所以都是通過各種計算來拿到 x、y。

即使是 Konva 也是依賴于 x、y 來做相對定位。

因此,在 AntV 和 SpriteJS 這類 Canvas 渲染引擎里面,都內置支持了盒模型的語法糖,底層會將盒模型屬性進行一次計算轉換成 x、y。

以 AntV 為例子,排版能力是基于 Facebook 開源的 Yoga 排版引擎(React Native)來實現的,支持一套非常完整的盒模型和 Flex 布局語法。

const container = new Rect({
   style: {
       width: 500, // Size
       height: 300,
       display: 'flex', // Declaring the use of flex layouts
       justifyContent: 'center',
       alignItems: 'center',
       x: 0,
       y: 0,
       fill: '#C6E5FF',
   },
});

在騰訊開源的 Hippy 里面自己實現了一套類似 Yoga 的排版引擎,叫做 Titank。

在飛書文檔多維表格里面,排版語法更加接近于 Flutter,實現了 Padding、Column、Row、Margin、Expanded、Flex、GridView 等 Widget。

下面的示例是 Flutter 的:

 

Row(
  mainAxisSize: MainAxisSize.min,
  mainAxisAlignment: MainAxisAlignment.spaceEvenly, // 對齊方式
  children: [
    Icon(Icons.star, color: Colors.green[500]),
    Icon(Icons.star, color: Colors.green[500]),
    Icon(Icons.star, color: Colors.green[500]),
    const Icon(Icons.star, color: Colors.black),
    const Icon(Icons.star, color: Colors.black),
  ],
);

Align(
  alignment: Alignment.bottomRight,
  child: Container(width: 100, height: 100, color: red),
)

Padding(
  padding: EdgeInsets.fromLTRB(30, 30, 0, 30),
  child: Image.NETwork(
    "https://timgsa.baidu.com/timg?image&quality=80&size=b9999_10000&sec=1581413255772&di=52021e3e656744094d0339e7016994bb&imgtype=0&src=http%3A%2F%2Fimg8.zol.com.cn%2Fbbs%2Fupload%2F19571%2F19570481.jpg",
    fit: BoxFit.cover,
  ),
)

Widget _buildGrid() => GridView.extent(
    maxCrossAxisExtent: 150,
    padding: const EdgeInsets.all(4),
    mainAxisSpacing: 4,
    crossAxisSpacing: 4,
    children: _buildGridTileList(30));
 

實現了盒模型和 Flex 布局,可以讓 Canvas 的排版能力更上一層樓。

不僅可以減少代碼中的大量計算,也可以讓大家從 DOM 開發無縫銜接進來,值得我們參考。

canvas-flexbox - CodeSandbox

3. 事件

Canvas 本身是一塊畫布,所以里面的內容都是畫出來的,在 DOM 樹里面也只是一個 Canvas 的節點,所以如何才能知道當前點擊的是哪個圖形呢?

由于 Canvas 渲染引擎都會封裝虛擬節點,每個節點都有自己的包圍盒,所以為實現 Canvas 的事件系統提供了可能性。

主流的 Canvas 渲染引擎都是針對 Canvas 節點或者上層節點進行事件委托,監聽用戶相關的事件(mouseDown、click、touch等等)之后,匹配到當前觸發的元素,將事件分發出去,并且擁有一套向上冒泡的機制。

目前主流的兩種事件實現方式分別是取色值法和幾何法。

3.1 取色值法

取色值法是 Konva 采用的實現方式,它的實現方式非常簡單,匹配精確度很高,適合不規則圖形的匹配。

取色值法的原理如下:

  1. 在主 Canvas 繪制一個圖形的時候,會為這個圖形生成一個隨機的 colorKey(十六進制的顏色),同時建立類似于 Map<colorKey, Shape> 的映射。
getRandomColor() {
    var randColor = ((Math.random() * 0xffffff) << 0).toString(16);
    while (randColor.length < 6) {
      randColor = ZERO + randColor;
    }
    return HASH + randColor;
  },
 
  1. 繪制的同時會在內存里的 hitCanvas 同樣位置繪制一個一模一樣的圖形,填充色是剛才的 colorKey。
  2. 當用戶鼠標點擊 Canvas 畫布的時候,可以拿到鼠標觸發的 x、y,將其傳給內存里面的 Canvas。
  3. 內存里面的 Canvas 通過 getImageData 來獲取到當前的顏色,進而通過 colorKey 來匹配到對應的圖形。

從上述原理可以看出來,Konva 對于不規則圖形的匹配依然很精確,但缺點也很明顯,每次都需要繪制兩份,導致繪制性能變差。

同時,getImageData 耗時比較高,在頻繁觸發的場景(onWheel)會導致幀率下降嚴重。

3.2 幾何法

幾何法有很多種實現方式,這里主要講解引射線法,因為需要進行一系列幾何計算,所以這里我稱之為幾何法。

幾何法是 AntV 和飛書文檔采用的實現方式,實現方式相對復雜一些,針對不規則圖形的匹配效率偏低。

幾何法的實現原理如下:

  1. 基于當前虛擬節點的包圍盒來構建一棵 R Tree
  2. 當用戶觸發事件的時候,利用 R Tree 來進行空間索引查找,依據 z-index 找到最頂層的一個圖形。
  3. 從目標點出發向一側發出一條射線,看這條射線和多邊形所有邊的交點數目。
  4. 如果有奇數個交點,則說明在內部,如果有偶數個交點,則說明在外部。

為什么奇數是在內部,偶數是在外部呢?我們假設射線與這個圖形的交點,進入圖形叫做穿入,離開圖形叫做穿出。

在圖形內部發出的射線,一定會有穿出但沒有穿入的情況。但在外部發出的射線,穿入和穿出是相對的。

但是射線剛好穿過頂點的情況比較特殊,因此需要單獨進行判斷。

幾何法的優勢在于不需要在內存里面進行重復繪制,但依賴于復雜的幾何計算,因此不適合有大量不規則圖形的情況。

在 AntV 里面支持對不規則圖形的匹配,但飛書文檔由于是表格業務,所以可以將所有圖形都當做矩形來處理,反而更簡單一些。

4. 性能

由于 Canvas 渲染引擎都會進行大量的封裝,所以開發者想針對底層做性能優化是非常難的,需要渲染引擎自身去支持一些優化。

4.1 異步批量渲染

在飛書文檔 Bitable 和 Konva 里面都支持異步渲染,將大量繪制進行批量處理。

const rect = new Rect({ /... });
// 多次修改屬性,可能會觸發多次渲染
rect.x(100);
rect.fill('red');
rect.y(100);

 

由于每次修改圖形的屬性或者添加、銷毀子節點都會觸發渲染,為了避免同時修改多個屬性時導致的重復渲染,因此約定每次在下一幀進行批量繪制。

batchDraw() {
    if (!this._waitingForDraw) {
      this._waitingForDraw = true;
      Util.requestAnimFrame(() => {
        this.draw();
        this._waitingForDraw = false;
      });
    }
    return this;
  }

這種渲染方式類似于 React 的 setState,避免短時間內多次 setState 導致多次 render。

4.2 離屏渲染

離屏渲染我們應該都比較熟悉了,就是兩個 Canvas 來回用 drawImage 繪制可復用部分,從而減少繪制的耗時。

這里主要講解 Konva 和飛書 Bitable 里面的離屏渲染。

在 Konva 中的離屏渲染主要是針對 Group 級別來做的,通過調用 cache 方法就能實現離屏渲染。

基于 Group 來做離屏渲染的原理是:

  1. 調用 cache 方法,創建一個離屏 Canvas 節點。
  2. 遍歷 Group 子節點進行繪制,同時將其繪制到離屏 Canvas 上面。
  3. 下次 batchDraw 的時候判斷是否有緩存,如果有,那么直接走 drawImage 的形式。

這種離屏渲染的調用方式比較簡單,Group 的粒度可以由開發者自己決定,但也有一定的問題。

  1. 比較難應用于表格這種形式的業務
  2. Konva 沒有臟檢測能力,即使 Group 里面的 Shape 屬性改變了,依然不會更新離屏 Canvas。
  3. 由于使用色值法來匹配圖形,導致開啟了離屏渲染,實際上至少要繪制四份(主canvas、事件 hitCanvas、離屏 cacheCanvas、離屏事件 cacheHitCanvas)。

為什么需要繪制四份呢?因為離屏渲染是 drawImage 的形式,這樣就不會有 colorKey 和 Shape 對應的情況了,所以離屏 Canvas 也要有一個自己的 hitCanvas 來做 getImageData,也就是 cacheHitCanvas。

另一種場景的離屏渲染就是飛書 Bitable 里面的實現。

飛書在底層之上封裝了虛擬列表的 Widget,也就是基于業務定制的 Widget,這也是一種有趣的思路。

  1. 創建一個虛擬列表的 Widget 類,將列表數據傳入
  2. 實現列表每一項的繪制方法,將列表繪制出來
  3. 滾動的時候虛擬列表內部進行節點的回收創建,但不會進行異步批量渲染,針對可復用的部分進行離屏渲染
  4. 更新階段,通過 key 對比來決定是回收、創建還是復用。

在多維表格看板視圖里面,每個分組都是一個虛擬列表,多個分組(虛擬列表)又組合成一個大的虛擬列表。

多選單元格編輯器也可以基于虛擬列表實現。

虛擬列表 Widget 類適合多維表格這種業務,多個視圖都需要有自己的滾動容器,不同視圖都需要處理節點的回收、復用、新建,通過公用 Widget 可以一步到位去支持,也方便在內部去做更多性能優化。

4.3 臟區渲染

對于 Konva 來說,每次重新渲染都是對整個 Canvas 做 clearRect 清除,然后重新繪制,性能相對比較差。

更好的做法是檢測到當前的改動影響到的范圍,計算出重繪范圍后,只清除重繪區的內容重新進行繪制。

在 Canvas 中可以通過 rect 和 clip 限制繪制區域,從而做到只對部分區域重繪。

以前 ECharts 底層的 ZRender 為例來講解:

  1. 根據圖形前后變化,來計算出重繪區域,比如上圖的區域,在飛書文檔中會將整個移動的路徑當做重繪區域。
  2. 如果有多個重繪區域,那么優先嘗試將相交(包圍盒)的重繪區進行合并,并且優先合并相交面積最大的重繪區。
  3. 如果合并完成后,當前剩余的重繪區數量大于5,則進一步進行合并,直到數量只剩5。
  4. 依次遍歷這些重繪區域,先清除掉原有的內容,再進行繪制。

飛書文檔多維表格沒有做 Canvas 渲染分層,但對各種交互響應速度非常快,也是得益于底層渲染引擎對臟矩形渲染的支持,它的性能也是所有同類產品里面最好的。

除了上述的這些,還有在文檔這邊使用的一些優化手段,比如合并相同屬性的圖形繪制(線、矩形、文本等)、Canvas 分層等等,這些就不多做闡述了。

5. 跨平臺

很多 Canvas 渲染引擎并不滿足于只做 Canvas,一般還會支持一些其他的渲染模式,比如 SVG 渲染、WebGL 渲染、WebGPU 渲染等等。

在 AntV 里面通過引入對應的 package 來實現加載渲染器的,在 ZRender 中則是通過 register 來注冊不同的渲染器。

AntV 中使用 CanvasKit 渲染:

import { Renderer as CanvaskitRenderer } from '@antv/g-canvaskit';
const canvaskitRenderer = new CanvaskitRenderer();

關于跨平臺的架構這里不做講解,主要是抹平不同平臺的差異,這里主要講解一下針對于服務端渲染的不同處理。

主流的服務端渲染方式有兩種,一種是用 node-canvas 來輸出一張圖片,在 echarts 等庫中都有使用,缺陷在于文本排版不夠準確,對于自適應瀏覽器窗口的情況無法處理。因此它不適用于文檔直出的場景。

const { createCanvas, loadImage } = require('canvas')
const canvas = createCanvas(200, 200)
const ctx = canvas.getContext('2d')

// Write "Awesome!"
ctx.font = '30px Impact'
ctx.rotate(0.1)
ctx.fillText('Awesome!', 50, 100)

// Draw line under text
var text = ctx.measureText('Awesome!')
ctx.strokeStyle = 'rgba(0,0,0,0.5)'
ctx.beginPath()
ctx.lineTo(50, 102)
ctx.lineTo(50 + text.width, 102)
ctx.stroke()

// Draw cat with lime helmet
loadImage('examples/images/lime-cat.jpg').then((image) => {
  ctx.drawImage(image, 50, 0, 70, 70)

  console.log('<img src="' + canvas.toDataURL() + '" />')
})
 

另一種就是通過 SVG 來模擬 Canvas 的效果,輸出 SVG DOM 字符串。但它的實現會比較麻煩,也無法 100% 還原 Canvas 的效果。

但很多 Canvas 渲染引擎本身也支持 SVG 渲染,即使不支持,也可以通過 canvas2svg 這個庫來進行轉換。

var ctx = new C2S(500,500);

//draw your canvas like you would normally
ctx.fillStyle="red";
ctx.fillRect(100,100,100,100);

//serialize your SVG
var mySerializedSVG = ctx.getSerializedSvg(); 

//If you really need to you can access the shadow inline SVG created by calling:
var svg = ctx.getSvg();

對于更加通用的場景來說,在瀏覽器端使用 Canvas 渲染,服務端使用 SVG 渲染是更合理的形式。

在新版 ECharts 里面,針對 SVG 服務端渲染的能力,還支持了 Virtual DOM 來代替 JSDOM,最后轉換成 DOM 字符串。

在飛書文檔中使用了一種完全獨立于 node-canvas 和 SVG 的解決方式,非常值得我們借鑒。

由于飛書多維表格底層統一了渲染引擎,所有繪制元素都是 Widget(對齊 Flutter),可以脫水轉換成下面 FVG 格式。

 

圖片

 

一般來說,文檔業務首屏加載是下面這么幾步:

獲取首屏數據 -> 資源加載 -> 首屏數據反序列化 -> 初始化 Model 層 -> 計算排版數據 -> Canvas 渲染

在飛書文檔里面直出渲染層 Widget 的數據結構,這個數據結構是最后提供給 Canvas 渲染的數據,也就是已經經過了計算排版數據階段。

當渲染層 JS 資源加載完成后,直接省略反序列化、初始化 Model、計算排版數據等階段,將 FVG 轉換成 Widget 進行 Canvas 渲染,這一步非常接近于 React 的 hydrate,很巧妙。

分享到:
標簽:Canvas
用戶無頭像

網友整理

注冊時間:

網站:5 個   小程序:0 個  文章:12 篇

  • 51998

    網站

  • 12

    小程序

  • 1030137

    文章

  • 747

    會員

趕快注冊賬號,推廣您的網站吧!
最新入駐小程序

數獨大挑戰2018-06-03

數獨一種數學游戲,玩家需要根據9

答題星2018-06-03

您可以通過答題星輕松地創建試卷

全階人生考試2018-06-03

各種考試題,題庫,初中,高中,大學四六

運動步數有氧達人2018-06-03

記錄運動步數,積累氧氣值。還可偷

每日養生app2018-06-03

每日養生,天天健康

體育訓練成績評定2018-06-03

通用課目體育訓練成績評定