canvas 性能優化之 putImageData 的思考

作爲一名前端數據可視化工程師來說,canvas 的使用可以說是最最基礎的基本功了。

canvas 雖然只是一個 html 的標籤,但是可以乾的事情,卻遠遠比普通的 html 標籤大得多。

canvas 是一個畫布,提供給我們繪圖的功能,而且更神奇的是,它同時給我們提供了 2d 繪圖和 3d 繪圖的功能。

這個 2d 和 3d 當然是廣義的概念,不針對具體背後使用的技術原理。

如果真的要細分的話,可以分成 CanvasRenderingContext2D 和 WebGLRenderingContext 等兩種。

兩者都可以用來繪製 2d 和 3d 場景,但是通常情況下,我們一般用前者也就是 CanvasRenderingContext2D 來繪製 2d 場景,而用後者也就是 WebGLRenderingContext 來繪製 3d 場景。

之所以這樣做,是因爲,前者用的是 cpu 來進行運算的,而後者用 gpu 來進行運算的。

2d 場景較爲簡單,構建起來難度低,因此我們多用 CanvasRenderingContext2D 相關的 api 來實現,而且由於歷史原因,webgl 其實是一個相對比較新的技術,開始的時候,瀏覽器並不支持 WebGLRenderingContext,而且顯卡技術也是近些年才飛速發展的。

雖然早些年,也有用 CanvasRenderingContext2D 相關 api 來實現 3d 場景的構建的,這一點,可以通過研究 three.js 源碼的變化就可以看出來了。但是,隨着 WebGLRenderingContext 的出現和普及,構建 3d 場景的任務就更多的落在了後者的頭上了。

所以,雖然 canvas 也被規劃爲前端的範疇內,但是我更覺的 canvas 其實也能看作是一門編程語言,一門偏向應用型的編程語言。

canvas 雖然好用,但是如果用起來不得當的話,也是會造成性能問題的。

比如最近我用需要在 3d 場景裏面用到 2d 的貼圖,來創建一個公告牌(billboard)的效果。

就像下面這樣:
image.png

如果要是以往的話,我肯定會選擇使用圖片來實現這個功能。

無他,因爲最簡單粗暴。

但是,這樣做也是有缺點,最致命的缺點就是,公告牌上的字是固定的,並不能隨意定製。

因爲在項目實際運行的過程中,我們更希望看到的結果是,公告牌上的內容,是根據加載的數據的不同而動態的變更的。

所以,爲了實現定製性,難道我們就需要通過我們傳入的數據,動態的去給公告牌加載不同的貼圖麼?

而且還有一個問題是,既然我們都會用 webgl 了,爲什麼不給這個廣告牌,直接用 canvas 2d 繪製出來呢?

如果這樣做了的話,一下子就會把我們之前關心的幾大問題:複用性、定製性、資源最小化等,統統都解決掉了。

所以,爲了繪製出這麼一個 billboard,我們需要繪製一個底圖。

首先我們創建一個 canvas 元素,用來繪製我們的內容。

const canvas = document.createElement('canvas');
const w = 128;
const h = 64;
canvas.height = w;
canvas.height = h;

這地方,我們設置 canvas 畫布的寬高爲 128 * 64,是因爲,我們 3d 場景中的貼圖,寬高最好是 2 的冪,不然可能會出現貼圖閃爍的問題。

接着,拿到我們的 2d 繪圖畫筆:

const ctx = canvas.getContext('2d');

寫一個創建背景的方法:

const createBackground = (ctx, x, y, width, height, lineWidth, radius) => {
  ctx.fillStyle = '#3e76aa';
  ctx.strokeStyle = '#1dceb7';
  ctx.lineWidth = lineWidth;

  let offset = lineWidth + 2;
  width -= offset;
  height -= offset;
  x += offset / 2;
  y += offset / 2;

  const maxRadius = Math.min(width, height) / 2;
  if (radius > maxRadius) {
    radius = maxRadius;
  }

  const interval = 10;
  var rx = x + width;
  var ry = y + height - interval;

  ctx.beginPath();
  ctx.moveTo(x, y + radius);
  ctx.lineTo(x, ry - radius);
  ctx.arcTo(x, ry, x + radius, ry, radius);

  ctx.lineTo((x + rx) / 2 - interval, ry);
  ctx.lineTo((x + rx) / 2, ry + interval - offset / 2);
  ctx.lineTo((x + rx) / 2 + interval, ry);
  ctx.lineTo(rx - radius, ry);

  ctx.arcTo(rx, ry, rx, ry - radius, radius);
  ctx.lineTo(rx, y + radius);
  ctx.arcTo(rx, y, rx - radius, y, radius);
  ctx.lineTo(x + radius, y);
  ctx.arcTo(x, y, x, y + radius, radius);
  ctx.closePath();
  ctx.fill();

  ctx.stroke();
}

然後我們測試一下,我們的代碼寫的對不對,打開瀏覽器,運行我們的代碼:

image.png

可以看到,運行的很成功,我們成功的繪製出了我們的背景圖片。至於內容的繪製呢,就不貼代碼了,同樣是很容易的操作。

我場景裏面有很多這種公告牌,每個公告牌上的文字都是不一樣的,所以對於每個公告牌,我都必須要重新繪製一個 canvas 去承載其內容。

現在我想討論的關鍵問題是,在這個創建公告牌的過程中,有沒有什麼途徑,去優化這段邏輯呢?

這個回答是肯定的。

我們可以發現,所有公告牌的背景都是一樣的。所以我們就可以不需要在每個公告牌創建的時候,都調用 createBackground 方法去創建一遍背景了。

這塊邏輯其實不復雜,在我們這個例子中,其實不需要做性能優化。

但是如果以後碰到很複雜的繪製邏輯,同樣是需要優化的,不然每次都來繪製一遍,大大的降低了 canvas 的性能。

基於這種優化的思路,我想出了以下幾種解決方案:

  1. 用背景圖片代替 canvas 背景圖繪製代碼
  2. 先畫出來,然後用 putImageData 方法,將背景圖的數據信息填充到每個公告牌的 canvas 中去
  3. 先畫出來,然後用 drawImage 的方式將 canvas 背景圖畫到每個公告牌的 canvas 中去

稍微思索了一番以後,我馬上就將第一種方案給排除掉了,因爲這種方案可行,但是還是跟我的初衷相違背,我想要的是背景圖的可定製性,你一旦給我一張圖片,換圖片就太麻煩了,非常不靈活。

比如我想換下配色,我還得找設計師重新設計一張新的圖片,太過於繁瑣了。

既然排除了第一種方案,那麼接下來就需要從第二種方案和第三種方案裏面選擇一種比較好的方案了。

在比較了第二種方案和第三種方案以後,我想當然的認爲,第二種方案應該是這個問題裏面的最優解了。

畢竟我拿到背景圖上每個像素的數據以後,逐個填充到一個新的 canvas 上去的話,難道不必我繪製一張 canvas 圖片到新的 canvas 上面去更快麼?

事實證明,確實不如我想象中的這般,drawImage 真的就比 putImageData 更快。

我分別用兩種方法進行了測試,最後是 drawImage 勝出。

至於爲什麼 drawImage 比 putImageData 更快,我當然是無法回答的,只得求助於 Google 來解決這個問題了。

經過一番搜素,我找到了在 stackoverflow 上,同樣有過我這種困惑的童鞋問的問題:https://stackoverflow.com/questions/3952856/why-is-putimagedata-so-slow

不得不說,stackoverflow 真是程序員必備的網站,很多你想到的或者意想不到的問題,在這個上面統統能找到相似的討論。

題主也發現了用 putImageData 繪圖速度很慢,沒有 drawImage 速度快,所以他提出了這個問題。

有興趣的童鞋可以去原頁面去查看一下討論。

所以結論就是,如果你需要緩存 canvas 上的某一部分內容的時候,用一個 canvas 作爲緩存,在使用的時候,通過 drawImage 再還原回去就行了。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章