前言
熟悉 canvas 的朋友想必都使用或者聽說過 Fabric.js,Fabric 算是一個元老級的 canvas 庫了,從第一個版本釋出到現在,已經有 8 年時間了。我近一年時間也在專案中使用,作為使用者簡單說說感受:
- 方便,只有想不到,沒有做不到
- 原始碼寫的真好,程式碼規範,註釋清晰
- 社群真匱乏,國內資源尤其少
- 看文件不如看原始碼
優缺點都很鮮明,但總的來說,如果你要做一個線上編輯類的專案,比如線上 PPT,線上製圖等應用,fabric 絕對是個很好的選擇。
那麼這一系列文章要寫什麼?這裡不會主要介紹如何使用 fabric,主要寫的內容是把在閱讀原始碼過程中,把涉及到原理相關的知識總結出來,比如相關圖形學知識、canvas 相關、fabric 中的設計思想等的相關知識。所以,如果你現在還對 fabric 不是很瞭解,建議先去官網找幾個 demo 試一下。
下面我們進入這次的正題,這篇文章主要介紹 fabric.canvas 涉及到的部分內容。
從建立畫布開始
fabric 建立畫布很簡單:
const canvas = new fabric.Canvas("domId", options);
在這樣一行程式碼背後,fabric 主要做了下面這幾件事情:
- 建立快取 canvas
- 構建兩層 canvas 元素:lower-canvas 和 upper-canvas
- 繫結事件
- 處理 retina 屏
- ...
下面我把相關內容一一闡述。
canvas 快取
介紹 canvas 快取,fabric 中的快取也是類似的道理,簡單來說,就是使用一個離屏 canvas 來做預渲染,在真實畫布上用 drawImage 代替直接繪製圖形。
我們先來看個 例子,大家可以把 FPS meter 開啟,切換按鈕可以看到,不使用快取和使用快取 FPS 值差距還是挺大的,我電腦在使用快取的時候基本在 60fps,不使用會降到 15fps 左右。大家可以開啟控制檯或者在 這裡 檢視程式碼。
下面列出主要的程式碼片段:
class Ball {
constructor(x, y, vx, vy, useCache = true) {
// ...
if (useCache) {
this.useCache = useCache;
this.cacheCanvas = document.createElement("canvas");
// 離屏 canvas 寬高取要渲染圖形的寬高,不可以取真實 canvas 的寬高,否則會渲染大量無用區域
this.cacheCanvas.width = 2 * (this.r + BORDER_WIDTH);
this.cacheCanvas.height = 2 * (this.r + BORDER_WIDTH);
this.cacheCtx = this.cacheCanvas.getContext("2d");
this.cache();
}
}
paint() {
// 使用快取直接使用建立的離屏canvas,否則直接繪製圖形
if (!this.useCache) {
ctx.save();
ctx.lineWidth = BORDER_WIDTH;
ctx.beginPath();
ctx.strokeStyle = this.color;
ctx.arc(this.x, this.y, this.r, 0, 2 * Math.PI);
ctx.stroke();
ctx.restore();
} else {
ctx.drawImage(
this.cacheCanvas,
this.x - this.r,
this.y - this.r,
this.cacheCanvas.width,
this.cacheCanvas.height
);
}
}
move() {
// ...
}
cache() {
// 繪製圖形
this.cacheCtx.save();
this.cacheCtx.lineWidth = BORDER_WIDTH;
this.cacheCtx.beginPath();
this.cacheCtx.strokeStyle = this.color;
this.cacheCtx.arc(
this.r + BORDER_WIDTH,
this.r + BORDER_WIDTH,
this.r,
0,
2 * Math.PI
);
this.cacheCtx.stroke();
this.cacheCtx.restore();
}
}
解釋一下二者區別:
- 使用快取:在例項化每個圖形的時候(渲染之前),先將圖形渲染到一個離屏的 canvas 上,在渲染的時候,直接用
drawImage
將離屏的 canvas 渲染。 - 不使用快取: 在渲染的時候直接繪製圖形
使用快取的時候,有一點需要注意的是要控制好離屏 canvas 的大小,不可以直接取和渲染 canvas 的實際寬高,否則會渲染很多無用的空間,比如上面例子中每個離屏 canvas 的寬高只需要和對應圖形的寬高一致。
this.cacheCanvas.width = 2 * (this.r + BORDER_WIDTH);
this.cacheCanvas.height = 2 * (this.r + BORDER_WIDTH);
上述程式碼中主要節省時間的地方在 paint
函式中使用 drawImage
會比直接繪製圖形節省時間,那麼是否所有場景都是這樣呢?我們再來看下面這個 例子.
這個例子和上面的只有繪製圖形的程式碼不同:
// 從複雜圖形變成了簡單圖形
cache() {
this.cacheCtx.save();
this.cacheCtx.lineWidth = BORDER_WIDTH;
this.cacheCtx.beginPath();
this.cacheCtx.strokeStyle = this.color;
this.cacheCtx.arc(
this.r + BORDER_WIDTH,
this.r + BORDER_WIDTH,
this.r,
0,
2 * Math.PI
);
this.cacheCtx.stroke();
this.cacheCtx.restore();
}
只是cache
方法中把複雜圖形變成了簡單的圖形。但實際效果相差甚遠,使用快取和不使用效能差距並不大,甚至不使用時 fps 值還更高一些。
所以看來圖形的複雜度,直接會影響 canvas 快取的效果,我們在開發過程中,也不能盲目引入快取,要權衡利弊。fabric 中快取是預設開啟的,同時也可以設定 objectCaching
為 false 禁用。
lower-canvas 和 upper-canvas
如果大家細心的話應該會發現,當我們執行new fabric.Canvas('domeId')
的時候,在頁面上 dom 元素就改變了,fabric 複製了一層 canvas 蓋在了我們定義的 canvas 上面:
fabric 這樣設計將渲染層和互動層做了分離,lower-canvas 只負責渲染元素;所有的互動,比如框選,事件處理都在 upper-canvas 上。
順便提一下,fabric 提供了渲染靜態畫布的方法,如果你的畫布不需要任何互動,只用來展示,那麼可以用new fabric.StaticCanvas('domId', options)
來初始化,這時候 dom 結構中就只有一個 canvas,沒有 upper-canvas 了。
說到這裡,很多同學可能會想到,事件是怎樣繫結的呢?其實兩個 canvas 大小等屬性都是一致的,所以座標也是可以對應上的,比如在 upper-canvas 上某個位置點選了一下,那麼就可以去 lower-canvas 上就可以用這個座標去找是否點選到了一個元素,那麼問題來了,如何判斷一個點在一個圖形中呢?
如何判斷點在圖形中
這個問題網上有個比較普遍的方案,就是通過畫一條射線,通過交點奇偶性來判斷。如下圖:
- 設目標點 P,使 P 點向任意一個方向畫一條射線,保證不與圖形的頂點相交;
- 記錄射線與圖形的交點數量 n;
- n 為奇數時,P 就在圖形內,反之則在圖形外。
而 fabric 中並沒有用這種方法,原因很簡單,這個演算法是有前提的:發出的射線不能與圖形任何頂點相交。 這個前提對於我們主觀來判斷是很簡單的,但程式中處理可能就需要大量的程式碼去判斷是否與交點相交,如果相交再重新生成一條射線。
fabric 中使用的演算法對上述演算法進行了改進,我們結合下圖來解釋:
其中 e1 ~ e5 分別為多邊形的邊,P 為目標點,黑色實心點為多邊形的頂點,r 為 P 延 X 軸發出的射線(不同於上面的方法,這裡我們約定 r 射線只能延 X 軸發出)。
- 設目標點 P,使 P 延 X 軸方向畫一條射線( y=Py ),設
intersectionCount = 0
-
遍歷多邊形的所有邊,設邊的頂點為 p1, p2
- 如果 p1y < Py,而且 p2y < Py,跳過(也就是這條邊在 P 點下方)
- 如果 p1y >= Py,而且 p2y >= Py,跳過(也就是這條邊在 P 點上方)
- 否則,設射線與這條邊的交點為 S,如果 Sx >= Px,
intersectionCount
加 1
- 最終如果
intersectionCount
為奇數,則在圖形內,反之則在圖形外。
判斷的部分用程式碼實現類似:
// point 目標點,lines多邊形的所有邊
function checkPoint(point, lines) {
let intersectionCount = 0;
let { x, y } = point;
for (let i = 0; i < lines.length; i++) {
let line = lines[i];
// 兩個頂點
let { p1, p2 } = line;
if ((p1.y < y && p2.y < y) || (p1.y >= y && p2.y >= y)) {
continue;
} else {
const sx = ((y - p1.y) / (p2.y - p1.y)) * (p2.x - p1.x) + p1.x;
if (sx >= x) {
intersectionCount++;
}
}
}
return intersectionCount % 2 === 0;
}
處理 Retina 屏
Retina 螢幕模糊的問題,直接給出處理方法,就不展開說了。
- canvas.width, canvas.height 放大至 dpi 倍
- canvas.style.width, canvas.style.height 設為原始 canvas 寬高
- ctx 縮放 dpi 倍
程式碼:
function initRetina(canvas, ctx) {
const dpi = window.devicePixelRatio;
canvas.style.width = canvas.width + "px";
canvas.style.height = canvas.height + "px";
canvas.setAttribute("width", canvas.width * dpi);
canvas.setAttribute("height", canvas.height * dpi);
ctx.scale(dpi, dpi);
}
小結
本篇文章主要針對fabric.canvas
模組,介紹了相關 canvas 快取,fabric 中判斷點在圖形中的演算法以及如何處理 retina 螢幕的知識,作為系列的第一篇文章,可能會有很多問題,如有錯誤及意見,歡迎批評指正。
參考文獻:
http://idav.ucdavis.edu/~okre...
http://www.geog.ubc.ca/course...
https://www.cnblogs.com/axes/...
http://fabricjs.com/docs/