1. 程式人生 > >Canvas 最佳實踐(效能篇)

Canvas 最佳實踐(效能篇)

Canvas 想必前端同學們都不陌生,它是 HTML5 新增的「畫布」元素,允許我們使用 JavaScript 來繪製圖形。目前,所有的主流瀏覽器都支援 Canvas。

Canvas 最常見的用途是渲染動畫。渲染動畫的基本原理,無非是反覆地擦除和重繪。為了動畫的流暢,留給我渲染一幀的時間,只有短短的 16ms。在這 16ms 中,我不僅需要處理一些遊戲邏輯,計算每個物件的位置、狀態,還需要把它們都畫出來。如果消耗的時間稍稍多了一些,使用者就會感受到「卡頓」。所以,在編寫動畫(和遊戲)的時候,我無時無刻不擔憂著動畫的效能,唯恐對某個 API 的呼叫過於頻繁,導致渲染的耗時延長。

為此,我做了一些實驗,查閱了一些資料,整理了平時使用 Canvas 的若干心得體會,總結出這一片所謂的「最佳實踐」。如果你和我有類似的困擾,希望本文對你有一些價值。

本文僅討論 Canvas 2D 相關問題。

計算與渲染

把動畫的一幀渲染出來,需要經過以下步驟:

  1. 計算:處理遊戲邏輯,計算每個物件的狀態,不涉及 DOM 操作(當然也包含對 Canvas 上下文的操作)。
  2. 渲染:真正把物件繪製出來。 
    2.1. JavaScript 呼叫 DOM API(包括 Canvas API)以進行渲染。 
    2.2. 瀏覽器(通常是另一個渲染執行緒)把渲染後的結果呈現在螢幕上的過程。

之前曾說過,留給我們渲染每一幀的時間只有 16ms。然而,其實我們所做的只是上述的步驟中的 1 和 2.1,而步驟 2.2 則是瀏覽器在另一個執行緒(至少幾乎所有現代瀏覽器是這樣的)裡完成的。動畫流暢的真實前提是,以上所有工作都在 16ms 中完成,所以 JavaScript 層面消耗的時間最好控制在 10ms 以內。

雖然我們知道,通常情況下,渲染比計算的開銷大很多(3~4 個量級)。除非我們用到了一些時間複雜度很高的演算法(這一點在本文最後一節討論),計算環節的優化沒有必要深究。

我們需要深入研究的,是如何優化渲染的效能。而優化渲染效能的總體思路很簡單,歸納為以下幾點:

  1. 在每一幀中,儘可能減少呼叫渲染相關 API 的次數(通常是以計算的複雜化為代價的)。
  2. 在每一幀中,儘可能呼叫那些渲染開銷較低的 API。
  3. 在每一幀中,儘可能以「導致渲染開銷較低」的方式呼叫渲染相關 API。

Canvas 上下文是狀態機

Canvas API 都在其上下文物件 context 上呼叫。

var context = canvasElement.getContext('2d');

我們需要知道的第一件事就是, context 是一個狀態機。你可以改變 context 的若干狀態,而幾乎所有的渲染操作,最終的效果與 context 本身的狀態有關係。比如,呼叫 strokeRect 繪製的矩形邊框,邊框寬度取決於 context 的狀態 lineWidth ,而後者是之前設定的。

context.lineWidth = 5;
context.strokeColor = 'rgba(1, 0.5, 0.5, 1)';

context.strokeRect(100, 100, 80, 80);

說到這裡,和效能貌似還扯不上什麼關係。那我現在就要告訴你,對 context.lineWidth 賦值的開銷遠遠大於對一個普通物件賦值的開銷,你會作如何感想。

當然,這很容易理解。Canvas 上下文不是一個普通的物件,當你呼叫了 context.lineWidth = 5 時,瀏覽器會需要立刻地做一些事情,這樣你下次呼叫諸如 stroke 或 strokeRect 等 API 時,畫出來的線就正好是 5 個畫素寬了(不難想象,這也是一種優化,否則,這些事情就要等到下次 stroke 之前做,更加會影響效能)。

我嘗試執行以下賦值操作 10 6 次,得到的結果是:對一個普通物件的屬性賦值只消耗了 3ms,而對 context 的屬性賦值則消耗了 40ms。值得注意的是,如果你賦的值是非法的,瀏覽器還需要一些額外時間來處理非法輸入,正如第三/四種情形所示,消耗了 140ms 甚至更多。

somePlainObject.lineWidth = 5;  // 3ms (10^6 times)
context.lineWidth = 5;  // 40ms
context.lineWidth = 'Hello World!'; // 140ms
context.lineWidth = {}; // 600ms

對 context 而言,對不同屬性的賦值開銷也是不同的。 lineWidth 只是開銷較小的一類。下面整理了為 context 的一些其他的屬性賦值的開銷,如下所示。

屬性開銷開銷(非法賦值)
line[Width/Join/Cap] 40+ 100+
[fill/stroke]Style 100+ 200+
font 1000+ 1000+
text[Align/Baseline] 60+ 100+
shadow[Blur/OffsetX] 40+ 100+
shadowColor 280+ 400+

與真正的繪製操作相比,改變 context 狀態的開銷已經算比較小了,畢竟我們還沒有真正開始繪製操作。我們需要了解,改變 context 的屬性並非是完全無代價的。我們可以通過適當地安排呼叫繪圖 API 的順序,降低 context 狀態改變的頻率。

分層 Canvas

分層 Canvas 在幾乎任何動畫區域較大,動畫較複雜的情形下都是非常有必要的。分層 Canvas 能夠大大降低完全不必要的渲染效能開銷。分層渲染的思想被廣泛用於圖形相關的領域:從古老的皮影戲、套色印刷術,到現代電影/遊戲工業,虛擬現實領域,等等。而分層 Canvas 只是分層渲染思想在 Canvas 動畫上最最基本的應用而已。

分層 Canvas 的出發點是,動畫中的每種元素(層),對渲染和動畫的要求是不一樣的。對很多遊戲而言,主要角色變化的頻率和幅度是很大的(他們通常都是走來走去,打打殺殺的),而背景變化的頻率或幅度則相對較小(基本不變,或者緩慢變化,或者僅在某些時機變化)。很明顯,我們需要很頻繁地更新和重繪人物,但是對於背景,我們也許只需要繪製一次,也許只需要每隔 200ms 才重繪一次,絕對沒有必要每 16ms 就重繪一次。

對於 Canvas 而言,能夠在每層 Canvas 上保持不同的重繪頻率已經是最大的好處了。然而,分層思想所解決的問題遠不止如此。

使用上,分層 Canvas 也很簡單。我們需要做的,僅僅是生成多個 Canvas 例項,把它們重疊放置,每個 Canvas 使用不同的 z-index 來定義堆疊的次序。然後僅在需要繪製該層的時候(也許是「永不」)進行重繪。

var contextBackground = canvasBackground.getContext('2d');
var contextForeground = canvasForeground.getContext('2d');

function render(){
  drawForeground(contextForeground);
  if(needUpdateBackground){
    drawBackground(contextBackground);
  }
  requestAnimationFrame(render);
}

記住,堆疊在上方的 Canvas 中的內容會覆蓋住下方 Canvas 中的內容。

繪製圖像

目前,Canvas 中使用到最多的 API,非 drawImage 莫屬了。(當然也有例外,你如果要用 Canvas 寫圖表,自然是半句也不會用到了)。

drawImage 方法的格式如下所示:

context.drawImage(image, sx, sy, sWidth, sHeight, dx, dy, dWidth, dHeight);

資料來源與繪製的效能

由於我們具備「把圖片中的某一部分繪製到 Canvas 上」的能力,所以很多時候,我們會把多個遊戲物件放在一張圖片裡面,以減少請求數量。這通常被稱為「精靈圖」。然而,這實際上存在著一些潛在的效能問題。我發現,使用 drawImage 繪製同樣大小的區域,資料來源是一張和繪製區域尺寸相仿的圖片的情形,比起資料來源是一張較大圖片(我們只是把資料扣下來了而已)的情形,前者的開銷要小一些。可以認為,兩者相差的開銷正是「裁剪」這一個操作的開銷。

我嘗試繪製 10 4 次一塊 320x180 的矩形區域,如果資料來源是一張 320x180 的圖片,花費了 40ms,而如果資料來源是一張 800x800 圖片中裁剪出來的 320x180 的區域,需要花費 70ms。

雖然看上去開銷相差並不多,但是 drawImage 是最常用的 API 之一,我認為還是有必要進行優化的。優化的思路是,將「裁剪」這一步驟事先做好,儲存起來,每一幀中僅繪製不裁剪。具體的,在「離屏繪製」一節中再詳述。

視野之外的繪製

有時候,Canvas 只是遊戲世界的一個「視窗」,如果我們在每一幀中,都把整個世界全部畫出來,勢必就會有很多東西畫到 Canvas 外面去了,同樣呼叫了繪製 API,但是並沒有任何效果。我們知道,判斷物件是否在 Canvas 中會有額外的計算開銷(比如需要對遊戲角色的全域性模型矩陣求逆,以分解出物件的世界座標,這並不是一筆特別廉價的開銷),而且也會增加程式碼的複雜程度,所以關鍵是,是否值得。

我做了一個實驗,繪製一張 320x180 的圖片 10 4 次,當我每次都繪製在 Canvas 內部時,消耗了 40ms,而每次都繪製在 Canvas 外時,僅消耗了 8ms。大家可以掂量一下,考慮到計算的開銷與繪製的開銷相差 2~3 個數量級,我認為通過計算來過濾掉哪些畫布外的物件,仍然是很有必要的。

離屏繪製

上一節提到,繪製同樣的一塊區域,如果資料來源是尺寸相仿的一張圖片,那麼效能會比較好,而如果資料來源是一張大圖上的一部分,效能就會比較差,因為每一次繪製還包含了裁剪工作。也許,我們可以先把待繪製的區域裁剪好,儲存起來,這樣每次繪製時就能輕鬆很多。

drawImage 方法的第一個引數不僅可以接收 Image 物件,也可以接收另一個 Canvas 物件。而且,使用 Canvas 物件繪製的開銷與使用 Image 物件的開銷幾乎完全一致。我們只需要實現將物件繪製在一個未插入頁面的 Canvas 中,然後每一幀使用這個 Canvas 來繪製。

// 在離屏 canvas 上繪製
var canvasOffscreen = document.createElement('canvas');
canvasOffscreen.width = dw;
canvasOffscreen.height = dh;
canvasOffscreen.getContext('2d').drawImage(image, sx, sy, sw, sh, dx, dy, dw, dh);

// 在繪製每一幀的時候,繪製這個圖形
context.drawImage(canvasOffscreen, x, y);

離屏繪製的好處遠不止上述。有時候,遊戲物件是多次呼叫 drawImage 繪製而成,或者根本不是圖片,而是使用路徑繪製出的向量形狀,那麼離屏繪製還能幫你把這些操作簡化為一次 drawImage 呼叫。

第一次看到 getImageData 和 putImageData 這一對 API,我有一種錯覺,它們簡直就是為了上面這個場景而設計的。前者可以將某個 Canvas 上的某一塊區域儲存為 ImageData 物件,後者可以將 ImageData 物件重新繪製到 Canvas 上面去。但實際上, putImageData 是一項開銷極為巨大的操作,它根本就不適合在每一幀裡面去呼叫。

避免「阻塞」

所謂「阻塞」,可以理解為不間斷執行時間超過 16ms 的 JavaScript 程式碼,以及「導致瀏覽器花費超過 16ms 時間進行處理」的 JavaScript 程式碼。即使在沒有什麼動畫的頁面裡,阻塞也會被使用者立刻察覺到:阻塞會使頁面上的物件失去響應——按鈕按不下去,連結點不開,甚至標籤頁都無法關閉了。而在包含較多 JavaScript 動畫的頁面裡,阻塞會使動畫停止一段時間,直到阻塞恢復後才繼續執行。如果經常出現「小型」的阻塞(比如上述提及的這些優化沒有做好,渲染一幀的時間超過 16ms),那麼就會出現「丟幀」的情況,

CSS3 動畫( transition 與 animate )不會受 JavaScript 阻塞的影響,但不是本文討論的重點。

偶爾的且較小的阻塞是可以接收的,頻繁或較大的阻塞是不可以接受的。也就是說,我們需要解決兩種阻塞:

  • 頻繁(通常較小)的阻塞。其原因主要是過高的渲染效能開銷,在每一幀中做的事情太多。
  • 較大(雖然偶爾發生)的阻塞。其原因主要是運行復雜演算法、大規模的 DOM 操作等等。

對前者,我們應當仔細地優化程式碼,有時不得不降低動畫的複雜(炫酷)程度,本文前幾節中的優化方案,解決的就是這個問題。

而對於後者,主要有以下兩種優化的策略。

  • 使用 Web Worker,在另一個執行緒裡進行計算。
  • 將任務拆分為多個較小的任務,插在多幀中進行。

Web Worker 是好東西,效能很好,相容性也不錯。瀏覽器用另一個執行緒來執行 Worker 中的 JavaScript 程式碼,完全不會阻礙主執行緒的執行。動畫(尤其是遊戲)中難免會有一些時間複雜度比較高的演算法,用 Web Worker 來執行再合適不過了。

然而,Web Worker 無法對 DOM 進行操作。所以,有些時候,我們也使用另一種策略來優化效能,那就是將任務拆分成多個較小的任務,依次插入每一幀中去完成。雖然這樣做幾乎肯定會使執行任務的總時間變長,但至少動畫不會卡住了。

看下面這個 Demo ,我們的動畫是使一個紅色的 div 向右移動。Demo 中是通過每一幀改變其 transform 屬性完成的(Canvas 繪製操作也一樣)。

然後,我建立了一個會阻塞瀏覽器的任務:獲取 4x10 6 次 Math.random() 的平均值。點選按鈕,這個任務就會被執行,其結果也會列印在螢幕上。

如你所見,如果直接執行這個任務,動畫會明顯地「卡」一下。而使用 Web Worker 或將任務拆分,則不會卡。

以上兩種優化策略,有一個相同的前提,即任務是非同步的。也就是說,當你決定開始執行一項任務的時候,你並不需要立刻(在下一幀)知道結果。比如,即使戰略遊戲中使用者的某個操作觸發了尋路演算法,你完全可以等待幾幀(使用者完全感知不到)再開始移動遊戲角色。另外,將任務拆分以優化效能,會帶來顯著的程式碼複雜度的增加,以及額外的開銷。有時候,我覺得也許可以考慮優先砍一砍需求。

小結

正文就到這裡,最後我們來稍微總結一下,在大部分情況下,需要遵循的「最佳實踐」。

  1. 將渲染階段的開銷轉嫁到計算階段之上。
  2. 使用多個分層的 Canvas 繪製複雜場景。
  3. 不要頻繁設定繪圖上下文的 font 屬性。
  4. 不在動畫中使用 putImageData 方法。
  5. 通過計算和判斷,避免無謂的繪製操作。
  6. 將固定的內容預先繪製在離屏 Canvas 上以提高效能。
  7. 使用 Worker 和拆分任務的方法避免複雜演算法阻塞動畫執行

相關推薦

Canvas 最佳實踐效能

Canvas 想必前端同學們都不陌生,它是 HTML5 新增的「畫布」元素,允許我們使用 JavaScript 來繪製圖形。目前,所有的主流瀏覽器都支援 Canvas。 Canvas 最常見的用途是渲染動畫。渲染動畫的基本原理,無非是反覆地擦除和重繪。為了動畫的流暢,留給我渲染一幀的時間,只有短短的 1

css最佳實踐reset.css

frame pla family after thead article tle fieldset san html, body, div, span, object, iframe,h1, h2, h3, h4, h5, h6, p, blockquote, pre,ab

小工具Java分組排序通用程式實踐終結

本文是前兩篇文章的終結篇,由前兩篇文章介紹的通用程式,可以組合出分組排序以及提供排列名次的通用程式功能。 還記得這篇介紹mysql分組排序顯示序列號(排列名次)的技巧嗎?其實當時這種sql的處理有個漏洞,就是第1,2名的分數相同時候,該sql的

android開發效能

今天想說的重點是Android APP效能優化,也就是在開發應用程式時應該注意的點有哪些,如何更好地提高使用者體驗。一個好的應用,除了要有吸引人的功能和互動之外,在效能上也應該有高的要求,即時應用非常具有特色,在產品前期可能吸引了部分使用者,但是使用者體驗不好的話,也會給產品帶來不好的口碑。那麼一

微信公眾號最佳實踐 9.1會員卡

會員卡 會員卡一般是指企業發行的用於識別顧客身份的卡片,普片用於服務行業,他是公司宣傳的理想載體,能夠吸引新顧客,留住老顧客,增強顧客忠誠度的作用,還能實現打折,積分等功能。 頁面佈局 準備素材,包括會員卡的正面和反面圖片 - - 大小固

微信公眾號最佳實踐 4.6生成帶引數二維碼

<?php $appid = "wxd1fc65357aca6efa"; $appsecret = "55a5a3a73b3f1603240a1c27cb0b270f"; $url = "https://api.weixin.qq.com/cgi-bin/token?grant

微信公眾號最佳實踐 3.2 被動回覆使用者訊息

<?php // // 響應使用者訊息 // 微信公眾賬號響應給使用者的不同訊息型別 // define("TOKEN", "weixin"); $wechatObj = new wechatCallbackapiTest(); if (!isset($_GET['echostr'])) {

Vue.js最佳實踐五招

第一招:化繁為簡的Watchers場景還原:created(){this.fetchPostList()},watch: {searchInputValue(){this.fetchPostList()}}元件建立的時候我們獲取一次列表,同時監聽input框,每當發生變化的時候重新獲取一次篩選後的列表這個場景

k8s容器灰度發布最佳實踐基於spinnaker

什麽 exp rate blog ava fig ext com 圖片 k8s中的容器一般是通過deployment管理的,那麽一次滾動升級理論上會更新所有pod,這由deployment資源特性保證的,但在實際的工作場景下,需要灰度發布進行服務驗證,即只發布部分節點,這似

高德APP啟動耗時剖析與優化實踐iOS

前言最近高德地圖APP完成了一次啟動優化專項,超預期將雙端啟動的耗時都降低了65%以上,iOS在iPhone7上速度達到了400毫秒以內。就像產品們用後說的,快到不習慣。算一下每天為使用者省下的時間,還是蠻有成就感的,本文做個小結。 (文中配圖均為多才多藝的技術哥哥手繪)   啟動階段效能多維度

高德SD地圖資料生產自動化技術的路線與實踐道路

一、背景及現狀 近些年,國內道路交通及相關設施的基礎建設日新月異。廣大使用者日常出行需求旺盛,對所使用到的電子地圖產品的資料質量和現勢性提出了更高的要求。傳統的地圖資料採集和生產過程,即通過採集裝置實地採集後對採集資料進行人工處理的模式,其資料更新慢、加工成本高等問題矛盾日益突顯。 高德地圖憑藉視覺AI和大資

最佳實踐2:iOS開發

這份文件就像軟體專案一樣,如果我們不維護它就會逐漸腐壞。歡迎大家跟我們一起來維護它——只需提交 issue 或者發 pull request 即可! 為什麼要寫這篇文件? iOS 開發在上手時可能會有些令人生畏。無論是 Objective-C 還是 Swift 在別處都沒有廣

SpringCloud的最佳實踐個人觀點,讀書總結,請多指教!

vcg back 實踐 一個 1.2 設計 -s ron dmg 前言 這個綜合例子創建了 6個微服務應用 一個服務註冊中心 SvcReg(EurekaServer,可以作為ConfigClient) 一個配置中心 CfgMgr + git目錄存儲配置(ConfigSe

項目管理全過程最佳實踐

管理昨天參加了部門組織的項目管理培訓,培訓主題是「項目管理全過程最佳實踐」,培訓時間為1天,講師是現代卓越的鄭曉龍老師,整體感覺,教授氛圍和互動方式都很輕松,讓人印象深刻,有不少收獲,分享給大家。 會分2篇文章分享,整體分享點如下: 教授方式 概述 定目標 拜真佛 編計劃 要資源 抓落實 一頁紙項目管理

Java Exception最佳實踐

理解 異常 resource 開發 lock 結束線程 用戶名 文檔 each https://www.dubby.cn/detail.html?id=9033 1.異常介紹 2.Java中的異常介紹 3.自定義異常 4.幾個建議 1)不要生吞異常 2)申明具體的異常

Spring Boot 最佳實踐快速入門

我想 並不是 系統 exe 輸出 開發環境 模型 hot 根據 一、關於Spring Boot 在開始了解Spring Boot之前,我們需要先了解一下Spring,因為Spring Boot的誕生和Spring是息息相關的,Spring Boot是Spring發展到一定程

Spring Boot 最佳實踐集成Jsp與生產環境部署

內容 tro conf 相關 安裝 packaging exc 詳細介紹 更新 一、簡介 提起Java不得不說的一個開發場景就是Web開發,也是Java最熱門的開發場景之一,說到Web開發繞不開的一個技術就是JSP,因為目前市面上仍有很多的公司在使用JSP,所以本文就來介紹

Spring Boot 最佳實踐模板引擎Thymeleaf集成

data 圖層 int app 創建模板 原因 xmlns make 使用場景 一、Thymeleaf介紹 Thymeleaf是一種Java XML / XHTML / HTML5模板引擎,可以在Web和非Web環境中使用。它更適合在基於MVC的Web應用程序的視圖層提供X

車聯網上雲最佳實踐

ice http請求 ack lse .html 1.2 ive bms 的人 摘要: 我們對傳統IDC應用架構進行分析之後,我們發現之前的系統架構存在一些不合理的地方導致了很多的痛點,為了解決這些痛點我們最終考慮上雲。開始思考怎樣利用雲上產品來解決目前遇到的痛點。例如 雲

高效運維最佳實踐03:Redis叢集技術及Codis實踐 (轉)

專欄介紹 “高效運維最佳實踐”是InfoQ在2015年推出的精品專欄,由觸控科技運維總監蕭田國撰寫,InfoQ總編輯崔康策劃。 前言 誠如開篇文章所言,高效運維包括管理的專業化和技術的專業化。前兩篇我們主要在說些管理相關的內容,本篇說一下技術專業化。希望讀者朋友們能適應這個轉