Canvas 效能優化
最近對 html5
小遊戲有點興趣,因為我感覺將來這個東西或許是前端一個重要的應用場景,例如現在每到某些節假日,像支付寶、淘寶或者其他的一些 APP
可能會給你推送通知,然後點進去就是一個小遊戲,基本上點進去的人,只要不是太抵觸,都會玩上一玩的,如果要是恰好 get
到使用者的 G
點,還能進一步增強業務,無論是使用者體驗,還是對業務的發展,都是一種很不錯的提升方式。
另外,我說的這個 html5
小遊戲是包括 WebGL
、WebVR
等在內的東西,不僅限於遊戲,也可以是其他用到相關技術的場景,例如商品圖片 360°
線上檢視這種,之所以從小遊戲入手,是因為小遊戲需要的技術包羅永珍,能把遊戲做好,再用相同的技術去做其他的事情,就比較信手拈來了
查詢資料,發現門道還是蠻多的,看了一圈下來,決定從基礎入手,先從較為簡單的 canvas
遊戲看起,看了一些相關文章和書籍,發現這個東西雖然用起來很簡單,但是真想用好,發揮其該有的能力還是有點難度的,最好從實戰入手
於是最近準備寫個 canvas
小遊戲練手,相關 UI
素材已經蒐集好了,不過俗話說 工欲善其事必先利其器,由於對這方面沒什麼經驗,所以為了避免過程中出現的各種坑點,特地又看了一些相關的踩坑文章,其中效能我感覺是必須要注意的地方,而且門道很多,所以整理了一下
使用 requestNextAnimationFrame
進行動畫迴圈
setTimeout
和 setInterval
並非是專為連續迴圈產生的 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
當成一個快取區。把需要重複繪製的畫面資料進行快取起來,減少呼叫 canvas
的 API
的消耗
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
,避免因為 js
和 dom
之間存在的關聯,導致垃圾回收機制無法正常工作,佔用資源
儘量利用 CSS
背景圖
如果有大的靜態背景圖,直接繪製到 canvas
可能並不是一個很好的做法,如果可以,將這個大背景圖作為 background-image
放在一個 DOM
元素上(例如,一個 div
),然後將這個元素放到 canvas
後面,這樣就少了一個 canvas
的繪製渲染
transform變幻
CSS
的 transform
效能優於 canvas
的 transfomr API
,因為前者基於可以很好地利用 GPU
,所以如果可以,transform
變幻請使用 CSS
來控制
關閉透明度
建立 canvas
上下文的 API
存在第二個引數:
canvas.getContext(contextType, contextAttributes)
contextType
是上下文型別,一般值都是 2d
,除此之外還有 webgl
、webgl2
、bitmaprenderer
三個值,只不過後面三個瀏覽器支援度太低,一般不用
contextAttributes
是上下文屬性,用於初始化上下文的一些屬性,對於不同的 contextType
,contextAttributes
的可取值也不同,對於常用的 2d
,contextAttributes
可取值有:
- 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
包括 shadowOffsetX
、shadowOffsetY
、shadowBlur
、shadowColor
繪圖相關的 API
,例如 drawImage
、putImageData
,在繪製時進行縮放操作也會增加耗時時間
當然,上述都是儘量避免 頻繁呼叫,或用其他手段來控制性能,需要用到的地方肯定還是要用的
避免浮點數的座標
利用 canvas
進行動畫繪製時,如果計算出來的座標是浮點數,那麼可能會出現 CSS Sub-pixel
的問題,也就是會自動將浮點數值四捨五入轉為整數,那麼在動畫的過程中,由於元素實際運動的軌跡並不是嚴格按照計算公式得到,那麼就可能出現抖動的情況,同時也可能讓元素的邊緣出現抗鋸齒失真
這也是可能影響效能的一方面,因為一直在做不必要的取證運算
渲染繪製操作不要頻繁呼叫
渲染繪製的 api
,例如 stroke()
、fill
、drawImage
,都是將 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
可以看做是一個狀態機,例如 fillStyle
、globalAlpha
、beginPath
,這些 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
遊戲似乎需要的製作時間有點長,每天除了上班之外,剩下的時間實在是不多,不知道什麼時候能搞完,如果一切順利,我倒是還想再用一些遊戲引擎,例如 Egret
、LayaAir
、Cocos Creator
將其重製一遍,以熟悉這些遊戲引擎的用法,然後到時候寫個系列教程出來……
誒,這麼看來,似乎是要持久戰了啊