Canvas效能優化

清夜發表於2018-09-21

最近對 html5小遊戲有點興趣,因為我感覺將來這個東西或許是前端一個重要的應用場景,例如現在每到某些節假日,像支付寶、淘寶或者其他的一些 APP可能會給你推送通知,然後點進去就是一個小遊戲,基本上點進去的人,只要不是太牴觸,都會玩上一玩的,如果要是恰好 get到使用者的 G點,還能進一步增強業務,無論是使用者體驗,還是對業務的發展,都是一種很不錯的提升方式。

另外,我說的這個 html5小遊戲是包括 WebGLWebVR等在內的東西,不僅限於遊戲,也可以是其他用到相關技術的場景,例如商品圖片 360°線上檢視這種,之所以從小遊戲入手,是因為小遊戲需要的技術包羅永珍,能把遊戲做好,再用相同的技術去做其他的事情,就比較信手拈來了

查詢資料,發現門道還是蠻多的,看了一圈下來,決定從基礎入手,先從較為簡單的 canvas 遊戲看起,看了一些相關文章和書籍,發現這個東西雖然用起來很簡單,但是真想用好,發揮其該有的能力還是有點難度的,最好從實戰入手

於是最近準備寫個 canvas小遊戲練手,相關 UI素材已經蒐集好了,不過俗話說 工欲善其事必先利其器,由於對這方面沒什麼經驗,所以為了避免過程中出現的各種坑點,特地又看了一些相關的踩坑文章,其中效能我感覺是必須要注意的地方,而且門道很多,所以整理了一下

使用 requestNextAnimationFrame進行動畫迴圈

setTimeoutsetInterval並非是專為連續迴圈產生的 API,所以可能無法達到流暢的動畫表現,故用 requestNextAnimationFrame,可能需要 polyfill

const raf = window.requestAnimationFrame
  || window.webkitRequestAnimationFrame
  || window.mozRequestAnimationFrame
  || window.oRequestAnimationFrame
  || window.msRequestAnimationFrame
  || function(callback) {
    window.setTimeout(callback, 1000 / 60)
  }
複製程式碼

利用剪輯區域來處理動畫背景或其他不變的影像

如果只是簡單動畫,那麼每一幀動畫擦除並重繪畫布上所有內容是可取的操作,但如果背景比較複雜,那麼可以使用 剪輯區域技術,通過每幀較少的繪製來獲得更好的效能

利用剪輯區域技術來恢復上一幀動畫所佔背景圖的執行步驟:

  • 呼叫 context.save(),儲存螢幕 canvas的狀態
  • 通過呼叫 beginPath來開始一段新的路徑
  • context物件上呼叫 arc()rect()等方法來設定路徑
  • 呼叫 context.clip()方法,將當前路徑設定為螢幕 canvas的剪輯區域
  • 擦除螢幕 canvas中的影像(實際上只會擦除剪輯區域所在的這一塊範圍)
  • 將背景影像繪製到螢幕 canvas上(繪製操作實際上只會影響剪輯區域所在的範圍,所以每幀繪製影像畫素數更少)
  • 恢復螢幕 canvas的狀態引數,重置剪輯區域

離屏緩衝區(離屏canvas)

先繪製到一個離屏 canvas中,然後再通過 drawImage把離屏 canvas 畫到主 canvas中,就是把離屏 canvas當成一個快取區。把需要重複繪製的畫面資料進行快取起來,減少呼叫 canvasAPI的消耗

const cacheCanvas = document.createElement(`canvas`)
const cacheCtx = cacheCanvas.getContext(`2d`)
cacheCtx.width = 200
cacheCtx.height = 200
// 繪製到主canvas上
ctx.drawImage(0, 0)
複製程式碼

雖然離屏 canvas在繪製之前視野內看不到,但其寬高最好設定得跟快取元素的尺寸一樣,避免資源浪費,也避免繪製多餘的不必要影像,同時在 drawImage時縮放影像也將耗費資源
必要時,可以使用多個離屏 canvas
另外,離屏 canvas不再使用時,最好把手動將引用重置為 null,避免因為 jsdom之間存在的關聯,導致垃圾回收機制無法正常工作,佔用資源

儘量利用 CSS

背景圖

如果有大的靜態背景圖,直接繪製到 canvas可能並不是一個很好的做法,如果可以,將這個大背景圖作為 background-image 放在一個 DOM元素上(例如,一個 div),然後將這個元素放到 canvas後面,這樣就少了一個 canvas的繪製渲染

transform變幻

CSStransform效能優於 canvastransfomr API,因為前者基於可以很好地利用 GPU,所以如果可以,transform變幻請使用 CSS來控制

關閉透明度

建立 canvas上下文的 API存在第二個引數:

canvas.getContext(contextType, contextAttributes)
複製程式碼

contextType 是上下文型別,一般值都是 2d,除此之外還有 webglwebgl2bitmaprenderer三個值,只不過後面三個瀏覽器支援度太低,一般不用

contextAttributes 是上下文屬性,用於初始化上下文的一些屬性,對於不同的 contextTypecontextAttributes的可取值也不同,對於常用的 2dcontextAttributes可取值有:

  • alpha

boolean型別值,表明 canvas包含一個 alpha通道. 預設為 true,如果設定為 false, 瀏覽器將認為 canvas背景總是不透明的, 這樣可以加速繪製透明的內容和圖片

  • willReadFrequently

boolean型別值,表明是否有重複讀取計劃。經常使用 getImageData(),這將迫使軟體使用 2D canvas 並節省記憶體(而不是硬體加速)。這個方案適用於存在屬性 gfx.canvas.willReadFrequently的環境。並設定為 true (預設情況下,只有B2G / Firefox OS)

支援度低,目前只有 Gecko核心的瀏覽器支援,不常用

  • storage

string 這樣表示使用哪種方式儲存(預設為:持久(persistent))

支援度低,目前只有 Blink核心的瀏覽器支援,不常用

上面三個屬性,看常用的 alpha就行了,如果你的遊戲使用畫布而且不需要透明,當使用 HTMLCanvasElement.getContext() 建立一個繪圖上下文時把alpha 選項設定為 false ,這個選項可以幫助瀏覽器進行內部優化

const ctx = canvas.getContext(`2d`, { alpha: false })
複製程式碼

儘量不要頻繁地呼叫比較耗時的API

例如

shadow相關 API,此類 API包括 shadowOffsetXshadowOffsetYshadowBlurshadowColor

繪圖相關的 API,例如 drawImageputImageData,在繪製時進行縮放操作也會增加耗時時間

當然,上述都是儘量避免 頻繁呼叫,或用其他手段來控制效能,需要用到的地方肯定還是要用的

避免浮點數的座標

利用 canvas進行動畫繪製時,如果計算出來的座標是浮點數,那麼可能會出現 CSS Sub-pixel的問題,也就是會自動將浮點數值四捨五入轉為整數,那麼在動畫的過程中,由於元素實際運動的軌跡並不是嚴格按照計算公式得到,那麼就可能出現抖動的情況,同時也可能讓元素的邊緣出現抗鋸齒失真
這也是可能影響效能的一方面,因為一直在做不必要的取證運算

渲染繪製操作不要頻繁呼叫

渲染繪製的 api,例如 stroke()filldrawImage,都是將 ctx狀態機裡面的狀態真實繪製到畫布上,這種操作也比較耗費效能

例如,如果你要繪製十條線段,那麼先在 ctx狀態機中繪製出十天線段的狀態機,再進行一次性的繪製,這將比每條線段都繪製一次要高效得多

for (let i = 0; i < 10; i++) {
  context.beginPath()
  context.moveTo(x1[i], y1[i])
  context.lineTo(x2[i], y2[i])
  // 每條線段都單獨呼叫繪製操作,比較耗費效能
  context.stroke()
}

for (let i = 0; i < 10; i++) {
  context.beginPath()
  context.moveTo(x1[i], y1[i])
  context.lineTo(x2[i], y2[i])
}
// 先繪製一條包含多條線條的路徑,最後再一次性繪製,可以得到更好的效能
context.stroke()
複製程式碼

儘量少的改變狀態機 ctx的裡狀態

ctx可以看做是一個狀態機,例如 fillStyleglobalAlphabeginPath,這些 api都會改變 ctx裡面對於的狀態,頻繁改變狀態機的狀態,是影響效能的

可以通過對操作進行更好的規劃,減少狀態機的改變,從而得到更加的效能,例如在一個畫布上繪製幾行文字,最上面和最下面文字的字型都是 30px,顏色都是 yellowgreen,中間文字是 20px pink,那麼可以先繪製最上面和最下面的文字,再繪製中間的文字,而非必須從上往下依次繪製,因為前者減少了一次狀態機的狀態改變

const c = document.getElementById("myCanvas")
const ctx = c.getContext("2d")

ctx.font = `30 sans-serif`
ctx.fillStyle = `yellowgreen`
ctx.fillText("大家好,我是最上面一行", 0, 40)

ctx.font = `20 sans-serif`
ctx.fillStyle = `red`
ctx.fillText("大家好,我是中間一行", 0, 80)

ctx.font = `30 sans-serif`
ctx.fillStyle = `yellowgreen`
ctx.fillText("大家好,我是最下面一行", 0, 130)
複製程式碼

下面的程式碼實現的效果和上面相同,但是程式碼量更少,同時比上述程式碼少改變了一次狀態機,效能會更好

ctx.font = `30 sans-serif`
ctx.fillStyle = `yellowgreen`
ctx.fillText("大家好,我是最上面一行", 0, 40)
ctx.fillText("大家好,我是最下面一行", 0, 130)

ctx.font = `20 sans-serif`
ctx.fillStyle = `red`
ctx.fillText("大家好,我是中間一行", 0, 80)
複製程式碼

儘量少的呼叫 canvas API

嗯,canvas也是通過操縱 js來繪製的,但是相比於正常的 js操作,呼叫 canvas API將更加消耗資源,所以在繪製之前請做好規劃,通過 適量 js原生計算減少 canvas API的呼叫是一件比較划算的事情

當然,請注意 適量二字,如果減少一行 canvas API呼叫的代價是增加十行 js計算,那這事可能就沒必要做了

避免阻塞

在進行某些耗時操作,例如計算大量資料,一幀中包含了太多的繪製狀態,大規模的 DOM操作等,可能會導致頁面卡頓,影響使用者體驗,可以通過以下兩種手段:

web worker

web worker最常用的場景就是大量的頻繁計算,減輕主執行緒壓力,如果遇到大規模的計算,可以通過此 API分擔主執行緒壓力,此 API相容性已經很不錯了,既然 canvas可以用,那 web worker也就完全可以考慮使用

分解任務

將一段大的任務過程分解成數個小型任務,使用定時器輪詢進行,想要對一段任務進行分解操作,此任務需要滿足以下情況:

  • 迴圈處理操作並不要求同步
  • 資料並不要求按照順序處理

分解任務包括兩種情形:

  • 根據任務總量分配

例如進行一個千萬級別的運算總任務,可以將其分解為 10個百萬級別的運算小任務

// 封裝 定時器分解任務 函式
function processArray(items, process, callback) {
  // 複製一份陣列副本
  var todo=items.concat();
  setTimeout(function(){
    process(todo.shift());
    if(todo.length>0) {
      // 將當前正在執行的函式本身再次使用定時器
      setTimeout(arguments.callee, 25);
    } else {
      callback(items);
    }
  }, 25);
}

// 使用
var items=[12,34,65,2,4,76,235,24,9,90];
function outputValue(value) {
  console.log(value);
}
processArray(items, outputValue, function(){
  console.log(`Done!`);
});
複製程式碼

優點是任務分配模式比較簡單,更有控制權,缺點是不好確定小任務的大小

有的小任務可能因為某些原因,會耗費比其他小任務更多的時間,這會造成執行緒阻塞;而有的小任務可能需要比其他任務少得多的時間,造成資源浪費

  • 根據執行時間分配

例如執行一個千萬級別的運算總任務,不直接確定分配為多少個子任務,或者分配的顆粒度比較小,在每一個或幾個計算完成後,檢視此段運算消耗的時間,如果時間小於某個臨界值,比如 10ms,那麼就繼續進行運算,否則就暫停,等到下一個輪詢再進行進行

function timedProcessArray(items, process, callback) {
  var todo=items.concat();
  setTimeout(function(){
    // 開始計時
    var start = +new Date();
    // 如果單個資料處理時間小於 50ms ,則無需分解任務
    do {
      process(todo.shift());
    } while (todo.length && (+new Date()-start < 50));

    if(todo.length > 0) {
      setTimeout(arguments.callee, 25);
    } else {
      callback(items);
    }
  });
}
複製程式碼

優點是避免了第一種情況出現的問題,缺點是多出了一個時間比較的運算,額外的運算過程也可能影響到效能

總結

我準備做的 canvas遊戲似乎需要的製作時間有點長,每天除了上班之外,剩下的時間實在是不多,不知道什麼時候能搞完,如果一切順利,我倒是還想再用一些遊戲引擎,例如 EgretLayaAirCocos Creator 將其重製一遍,以熟悉這些遊戲引擎的用法,然後到時候寫個系列教程出來……

誒,這麼看來,似乎是要持久戰了啊

相關文章