手把手教你打造一款輕量級canvas渲染引擎

背景

當咱們開發一個canvas應用的時候,出於效率的考量,免不了要選擇一個渲染引擎(好比PixiJS)或者更強大一點的遊戲引擎(好比Cocos Creator、Layabox)。css

渲染引擎一般會有Sprite的概念,一個完整的界面會由不少的Sprite組成,若是編寫複雜一點的界面,代碼裏面會充斥建立精靈設置精靈位置和樣式的「重複代碼」,最終咱們獲得了極致的渲染性能卻犧牲了代碼的可讀性。html

遊戲引擎一般會有配套的IDE,界面經過拖拽便可生成,最終導出場景配置文件,這大大方便了UI開發,可是遊戲引擎通常都很龐大,有時候咱們僅僅想開發個好友排行榜。node

基於以上分析,若是有一款渲染引擎,既能用配置文件的方式來表達界面,又能夠作到輕量級,將會大大知足咱們開發輕量級canvas應用的場景。react

本文會詳細介紹開發一款可配置化輕量級渲染引擎須要哪些事情,代碼開源至Github:https://github.com/wechat-miniprogram/minigame-canvas-enginegit

配置化分析

咱們首先指望頁面可配置化,來參考下Cocos Creator的實現:對於一個場景,在IDE裏面一頓操做,最後場景配置文件大體長下面的樣子:github

// 此處省略n個節點
  {
    "__type__": "cc.Scene",
    "_opacity": 255,
    "_color": {
      "__type__": "cc.Color",
      "r": 255,
      "g": 255,
      "b": 255,
      "a": 255
    },
    "_parent": null,
    "_children": [
      {
        "__id__": 2
      }
    ],
  },

在一個JSON配置文件裏面,同時包含了節點的層級結構樣式,引擎拿到配置文件後遞歸生成節點樹而後渲染便可。PixiJS雖然只是個渲染引擎,但一樣能夠和cocos2d同樣作一個IDE去拖拽生成UI,而後寫一個解析器,聲稱本身是PixiJS Creator😬。web

這個方案很好,但缺點是每一個引擎有一套本身的配置規則,無法作到通用化,並且在沒有IDE的狀況下,手寫配置文件也會顯得反人類,咱們還須要更加通用一點的配置。算法

尋找更優方案

遊戲引擎的配置方案若是要用起來主要有兩個問題:npm

  1. 手寫可讀性差,特別是對於層級深的節點樹;
  2. 樣式和節點樹沒有分離,配置文件冗餘;
  3. 配置不通用;

對於高可讀性樣式分離,咱們驚訝的發現,這不就是Web開發的套路麼,編寫HTML、CSS丟給瀏覽器,界面就出來了,省時省力。canvas

new.jpeg

如此優秀的使用姿式,咱們要尋求方案在canvas裏面實現一次!

實現分析

結果預覽

在逐步分析實現方案以前,咱們先拋個最終實現,編寫XML和樣式,就能夠獲得結果:

let template = `
<view id="container">
  <text id="testText" class="redText" value="hello canvas"> </text>
</view>
`;

let style = {
    container: {
         width: 200,
         height: 100,
         backgroundColor: '#ffffff',
         justContent: 'center',
         alignItems: 'center',
     },
     testText: {
         color: '#ff0000',
         width: 200,
         height: 100,
         lineHeight: 100,
         fontSize: 30,
         textAlign: 'center',
     }
}
// 初始化渲染引擎
Layout.init(template, style);
// 執行真正的渲染
Layout.layout(canvasContext);

方案總覽

既然要參考瀏覽器的實現,咱們不妨先看看瀏覽器是怎麼作的:
render-tree-construction.png
如上圖所示,瀏覽器從構建到渲染界面大體要通過下面幾步:

  • HTML 標記轉換成文檔對象模型 (DOM);CSS 標記轉換成 CSS 對象模型 (CSSOM)
  • DOM 樹與 CSSOM 樹合併後造成渲染樹。
  • 渲染樹只包含渲染網頁所需的節點。
  • 佈局計算每一個對象的精確位置和大小。
  • 最後一步是繪製,使用最終渲染樹將像素渲染到屏幕上。

在canvas裏面要實現將HTML+CSS繪製到canvas上面,上面的步驟缺一不可。

構建佈局樹和渲染樹

上面的方案總覽又分兩大塊,第一是渲染以前的各類解析計算,第二是渲染自己以及渲染以後的後續工做,先看看渲染以前須要作的事情。

解析XML和構建CSSOM

首先是將HTML(這裏咱們採用XML)字符串解析成節點樹,等價於瀏覽器裏面的「HTML 標記轉換成文檔對象模型 (DOM)」,在npm搜索xml parser),能夠獲得不少優秀的實現,這裏咱們只追求兩點:

  1. 輕量:大部分庫爲了功能強大動輒幾百k,而咱們只須要最核心的xml解析成JSON對象;
  2. 高性能:在遊戲裏面不可避免有長列表滾動的場景,這時候XML會很大,要儘可能控制XML解析時間;

綜合以上考量,選擇了fast-xml-parser,可是仍然作了一些閹割和改造,最終模板通過解析會獲得下面的JSON對象

{
    "name":"view",
    "attr":{
        "id":"container"
    },
    "children":[
        {
            "name":"text",
            "attr":{
                "id":"testText",
                "class":"redText",
                "value":"hello canvas"
            },
            "children":[

            ]
        }
    ]
}

接下來是構建CSSOM,爲了減小解析步驟,咱們手工構建一個JSON對象,key的名字爲節點的id或者class,以此和XML節點造成綁定關係:

let style = {
    container: {
         width: 200,
         height: 100
     },
}

DOM 樹與 CSSOM 樹合併後造成渲染樹

DOM樹和CSSOM構建完成後,他們還是獨立的兩部分,須要將他們構建成renderTree,因爲style的key和XML的節點有關聯,這裏簡單寫個遞歸處理函數就能夠實現:該函數接收兩個參數,第一個參數爲通過XML解析器解析吼的節點樹,第二個參數爲style對象,等價於DOM和CSSOM。

// 記錄每個標籤應該用什麼類來處理
const constructorMap = {
    view      : View,
    text      : Text,
    image     : Image,
    scrollview: ScrollView,
}
const create = function (node, style) {
    const _constructor = constructorMap[node.name];

    let children = node.children || [];

    let attr = node.attr || {};
    const id = attr.id || '';
    // 實例化標籤須要的參數,主要爲收集樣式和屬性
    const args = Object.keys(attr)
        .reduce((obj, key) => {
            const value = attr[key]
            const attribute = key;

            if (key === 'id' ) {
                obj.style = Object.assign(obj.style || {}, style[id] || {})
                return obj
            }

            if (key === 'class') {
                obj.style = value.split(/\s+/).reduce((res, oneClass) => {
                return Object.assign(res, style[oneClass])
                }, obj.style || {})

                return obj
            }
            
            if (value === 'true') {
                obj[attribute] = true
            } else if (value === 'false') {
                obj[attribute] = false
            } else {
                obj[attribute] = value
            }

            return obj;
        }, {})

    // 用於後續元素查詢
    args.idName    = id;
    args.className = attr.class || '';

    const element  = new _constructor(args)
    element.root = this;
    
    // 遞歸處理
    children.forEach(childNode => {
        const childElement = create.call(this, childNode, style);

        element.add(childElement);
    });

    return element;
}

通過遞歸解析,構成了一顆節點帶有樣式的renderTree。

計算佈局樹

渲染樹搞定以後,要着手構建佈局樹了,每一個節點在相互影響以後的位置和大小如何計算是一個很頭疼的問題。但仍然不慌,由於咱們發現近幾年很是火的React Native、weex之類的框架必然會面臨一樣的問題:

Weex 是使用流行的 Web 開發體驗來開發高性能原生應用的框架。
React Native 使用JavaScript和React編寫原生移動應用

這些框架也須要將html和css編譯成客戶端可讀的佈局樹,可否避免重複造輪子將它們的相關模塊抽象出來使用呢?起初我覺得這部分會很龐大或者和框架強耦合,可喜的是這部分抽象出來僅僅只有1000來行,他就是week和react native早起的佈局引擎css-layout。這裏有一篇文章分析得很是好,直接引用至,再也不贅述:《由 FlexBox 算法強力驅動的 Weex 佈局引擎》

npm上面能夠搜到css-layout,它對外暴露了computeLayout方法,只須要將上面獲得的佈局樹傳給它,通過計算以後,佈局樹的每一個節點都會帶上layout屬性,它包含了這個節點的位置和尺寸信息!

// create an initial tree of nodes
var nodeTree = {
    "style": {
      "padding": 50
    },
    "children": [
      {
        "style": {
          "padding": 10,
          "alignSelf": "stretch"
        }
      }
    ]
  };
 
// compute the layout
computeLayout(nodeTree);
 
// the layout information is written back to the node tree, with
// each node now having a layout property: 
 
// JSON.stringify(nodeTree, null, 2);
{
  "style": {
    "padding": 50
  },
  "children": [
    {
      "style": {
        "padding": 10,
        "alignSelf": "stretch"
      },
      "layout": {
        "width": 20,
        "height": 20,
        "top": 50,
        "left": 50,
        "right": 50,
        "bottom": 50,
        "direction": "ltr"
      },
      "children": [],
      "lineIndex": 0
    }
  ],
  "layout": {
    "width": 120,
    "height": 120,
    "top": 0,
    "left": 0,
    "right": 0,
    "bottom": 0,
    "direction": "ltr"
  }
}

這裏須要注意的是,css-layout實現的是標準的Flex佈局,若是對於CSS或者Flex佈局不是很熟悉的同窗,能夠參照這篇文章進行快速的入門:《Flex 佈局教程:語法篇》。再值得一提的是,做爲css-layout的使用者,好的習慣是給每一個節點都賦予width和height屬性😀。

渲染

基礎樣式渲染

在處理渲染以前,咱們先分析下在Web開發中咱們重度使用的標籤:

標籤 功能
div 一般做爲容器使用,容器也能夠有一些樣式,好比border和背景顏色之類的
img 圖片標籤,向網頁中嵌入一幅圖像,一般咱們會對圖片添加borderRadius實現圓形頭像
p/span 文本標籤,用於展現段落或者行內文字

在構建節點樹的過程當中,對於不一樣類型的節點會有不一樣的類去處理,上述三個標籤對應了ViewImageText類,每一個類都有本身的render函數。

render函數只須要作好一件事情:根據css-layout計算獲得的layout屬性和節點自己樣式相關的style屬性,經過canvas API的形式繪製到canvas上;

這件事情聽起來工做量很大,但其實也沒有這麼難,好比下面演示如何處理文本的繪製,實現文本的字體、字號、左對齊右對齊等。

function renderText() {  
    let style = this.style || {};

    this.fontSize = style.fontSize || 12;
    this.textBaseline = 'top';
    this.font = `${style.fontWeight  || ''} ${style.fontSize || 12}px ${DEFAULT_FONT_FAMILY}`;
    this.textAlign = style.textAlign || 'left';
    this.fillStyle = style.color     || '#000';
    
    if ( style.backgroundColor ) {
        ctx.fillStyle = style.backgroundColor;
        ctx.fillRect(drawX, drawY, box.width, box.height)
    }

    ctx.fillStyle = this.fillStyle;

    if ( this.textAlign === 'center' ) {
        drawX += box.width / 2;
    } else if ( this.textAlign === 'right' ) {
        drawX += box.width;
    }

    if ( style.lineHeight ) {
        ctx.textBaseline = 'middle';
        drawY += style.lineHeight / 2;
    }
}

但這件事情又沒有這麼簡單,由於有些效果你必須層層組合計算才能得出效果,好比borderRadius的實現、文本的textOverflow實現,有興趣的同窗能夠看看源碼

再者還有更深的興趣,能夠翻翻遊戲引擎是怎麼處理的,結果功能過於強大以後,一個Text類就有1000多行:LayaAir的Text實現😯。

重排和重繪

當界面渲染完成,咱們總不但願界面只是靜態的,而是能夠處理一些點擊事件,好比點擊按鈕隱藏一部分元素,亦或是改變按鈕的顏色之類的。

在瀏覽器裏面,有對應的概念叫重排和重繪:

引自文章: 《網頁性能管理詳解》

網頁生成的時候,至少會渲染一次。用戶訪問的過程當中,還會不斷從新渲染。從新渲染,就須要從新生成佈局和從新繪製。前者叫作"重排"(reflow),後者叫作"重繪"(repaint)。

那麼哪些操做會觸發重排,哪些操做會觸發重繪呢?這裏有個很簡單粗暴的規則:只要涉及位置和尺寸修改的,一定要觸發重排,好比修改width和height屬性,在一個容器內作和尺寸位置無關的修改,只須要觸發局部重繪,好比修改圖片的連接、更改文字的內容(文字的尺寸位置固定),更具體的能夠查看這個網站csstriggers.com

在咱們這個渲染引擎裏,若是執行觸發重排的操做,須要將解析和渲染完整執行一遍,具體來說是修改了xml節點或者與重排相關的樣式以後,重複執行初始化和渲染的操做,重排的時間依賴節點的複雜度,主要是XML節點的複雜度。

// 該操做須要重排以實現界面刷新
style.container.width = 300;
// 重排前的清理邏輯
Layout.clear();
// 完整的初始化和渲染流程
Layout.init(template, style);
Layout.layout(canvasContext);

對於重繪的操做,暫時提供了動態修改圖片連接和文字的功能,原理也很簡單:經過Object.defineProperty,當修改佈局樹節點的屬性時,拋出repaint事件,重繪函數就會局部刷新界面。

Object.defineProperty(this, "value", {
    get : function() {
        return this.valuesrc;
    },
    set : function(newValue){
        if ( newValue !== this.valuesrc) {
            this.valuesrc = newValue;
            // 拋出重繪事件,在回調函數裏面在canvas的局部擦除layoutBox區域而後從新繪製文案
            this.emit('repaint');
        }
    },
    enumerable   : true,
    configurable : true
});

那怎麼調用重繪操做呢?引擎只接收XML和style就繪製出了頁面,要想針對單個元素執行操做還須要提供查詢接口,這時候佈局樹再次排上用場。在生成renderTree的過程當中,爲了匹配樣式,須要經過id或者class來造成映射關係,節點也順帶保留了id和class屬性,經過遍歷節點,就能夠實現查詢API:

function _getElementsById(tree, list = [], id) {
    Object.keys(tree.children).forEach(key => {
        const child = tree.children[key];

        if ( child.idName === id ) {
            list.push(child);
        }

        if ( Object.keys(child.children).length ) {
            _getElementsById(child, list, id);
        }
    });

    return list;
}

此時,能夠經過查詢API來實現實現重繪邏輯,該操做的耗時能夠忽略不計。

let img = Layout.getElementsById('testimgid')[0];
img.src = 'newimgsrc';

事件實現

查詢到節點以後,天然是但願能夠綁定事件,事件的需求很簡單,能夠監聽元素的觸摸和點擊事件以執行一些回調邏輯,好比點擊按鈕換顏色之類的。

咱們先來看看瀏覽器裏面的事件捕獲和事件冒泡機制:

引自文章 《JS中的事件捕獲和事件冒泡》
捕獲型事件(event capturing):事件從最不精確的對象(document 對象)開始觸發,而後到最精確(也能夠在窗口級別捕獲事件,不過必須由開發人員特別指定)。
冒泡型事件:事件按照從最特定的事件目標到最不特定的事件目標(document對象)的順序觸發。

1576225959480.jpg

前提:每一個節點都存在事件監聽器on和發射器emit;每一個節點都有個屬性layoutBox,它代表了元素的在canvas上的盒子模型:

layoutBox: {
    x: 0,
    y: 0,
    width: 100,
    height: 100
}

canvas要實現事件處理與瀏覽器並沒有不一樣,核心在於:給定座標點,遍歷節點樹的盒子模型,找到層級最深的包圍該座標的節點。
image.png

當點擊事件發生在canvas上,能夠拿到觸摸點的x座標和y座標,該座標位於根節點的layoutBox內,當根節點仍然有子節點,對子節點進行遍歷,若是某個子節點的layoutBox仍然包含了該座標,再次重複執行以上步驟,直到包含該座標的節點再無子節點,這個過程稱之爲事件捕獲

// 給定根節點樹和觸摸點的位置經過遞歸便可實現事件捕獲
function getChildByPos(tree, x, y) {
    let list = Object.keys(tree.children);

    for ( let i = 0; i < list.length;i++ ) {
        const child = tree.children[list[i]];
        const box   = child.realLayoutBox;

        if (   ( box.realX <= x && x <= box.realX + box.width  )
            && ( box.realY <= y && y <= box.realY + box.height ) ) {
            if ( Object.keys(child.children).length ) {
                return getChildByPos(child, x, y);
            } else {
                return child;
            }
        }
    }

    return tree;
}

層級最深的節點被找到以後,調用emit接口觸發該節點的ontouchstart事件,若是事先有對ontouchstart進行監聽,事件回調得以觸發。那麼怎麼實現事件冒泡呢?在事件捕獲階段咱們並無記錄捕獲的鏈條。這時候佈局樹的優點又體現出來了,每一個節點都保存了本身的父節點和子節點信息,子節點emit事件以後,同時調用父節點的emit接口拋出ontouchstart事件,而父節點又繼續對它本身的父節點執行一樣的操做,直至根節點,這個過程稱之爲事件冒泡

// 事件冒泡邏輯
['touchstart', 'touchmove', 'touchcancel', 'touchend', 'click'].forEach((eventName) => {
    this.on(eventName, (e, touchMsg) => {
        this.parent && this.parent.emit(eventName, e, touchMsg);
    });
});

滾動列表實現

屏幕區域內,展現的內容是有限的,而瀏覽器的頁面一般都很長,能夠滾動。這裏咱們實現scrollview,若是標籤內子節點的總高度大於scrollview的高度,就能夠實現滾動。

1.對於在容器scrollview內的全部一級子元素,計算高度之合;

function getScrollHeight() {
    let ids  = Object.keys(this.children);
    let last = this.children[ids[ids.length - 1]];

    return last.layoutBox.top + last.layoutBox.height;
}

2.設定分頁大小,假設每頁的高度爲2000,根據上面計算獲得的ScrollHeight,就能夠當前滾動列表總共須要幾頁,爲他們分別建立用於展現分頁數據的canvas:

this.pageCount = Math.ceil((this.scrollHeight + this.layoutBox.absoluteY) / this.pageHeight);

3.遞歸遍歷scrollview的節點樹,經過每一個元素的absoluteY值判斷應該坐落在哪一個分頁上,這裏須要注意的是,有些子節點會同時坐落在兩個分頁上面,在兩個分頁都須要繪製一遍,特別是圖片類這種異步加載而後渲染的節點

function renderChildren(tree) {
    const children = tree.children;
    const height   = this.pageHeight;

    Object.keys(children).forEach( id => {
        const child   = children[id];
        let originY   = child.layoutBox.originalAbsoluteY;
        let pageIndex = Math.floor(originY / height);
        let nextPage  = pageIndex + 1;

        child.layoutBox.absoluteY -= this.pageHeight * (pageIndex);

        // 對於跨界的元素,兩邊都繪製下
        if ( originY + child.layoutBox.height > height * nextPage ) {
            let tmpBox = Object.assign({}, child.layoutBox);
            tmpBox.absoluteY = originY - this.pageHeight * nextPage;

            if ( child.checkNeedRender() ) {
                this.canvasMap[nextPage].elements.push({
                    element: child, box: tmpBox
                });
            }
        }

        this.renderChildren(child);
        });
    }

4.將scrollview理解成遊戲裏面的Camera,只把能拍攝到的區域展現出來,那麼全部的分頁數據從上而下拼接起來就是遊戲場景,在列表滾動過程當中,只「拍攝」尺寸爲scrollviewWidth*scrollViewHeight的區域,就實現了滾動效果。拍攝聽起來很高級,在這裏其實就是經過drawImage實現就行了:

// ctx爲scrollview所在的canvas,canvas爲分頁canvas
this.ctx.drawImage(
    canvas,
    box.absoluteX, clipY, box.width, clipH,
    box.absoluteX, renderY, box.width, clipH,
);

5.當scrollview上觸發了觸摸事件,會改變scrollview的top屬性值,按照步驟4不斷根據top去裁剪可視區域,就實現了滾動。

上述方案爲空間換時間方案,也就是在每次重繪過程當中,由於內容已經繪製到分頁canvas上了(這裏可能會比較佔空間),每次重繪,渲染時間獲得了最大優化。

其餘

至此,一個類瀏覽器的輕量級canvas渲染引擎出具模型:

  1. 給定XML+style對象能夠渲染界面;
  2. 支持一些特定的標籤:view、text、image和scrollview;
  3. 支持查詢節點反向修改節點屬性和樣式;
  4. 支持事件綁定;

文章篇幅有限,不少細節和難點仍然無法詳細描述,好比內存管理(內存管理不當很容易內存持續增漲致使應用crash)、scrollview的滾動事件實現細節、對象池使用等。有興趣的能夠看看源碼:https://github.com/wechat-miniprogram/minigame-canvas-engine/tree/master/src
下圖再補一個滾動好友排行列表demo:
screenshot.gif

調試及應用場景

做爲一個完整的引擎,沒有IDE怎麼行?這裏爲了提升UI調試的效率(實際上不少時候遊戲引擎的工做流很長,調試UI,改個文案之類的是個很麻煩的事情),提供一個簡版的在線調試器,調UI是徹底夠用了:https://wechat-miniprogram.github.io/minigame-canvas-engine/
image.png

最後要問,費了這麼大勁搞了個渲染引擎有什麼應用場景呢?固然是有的:

  1. 跨遊戲引擎的遊戲周邊插件:頗有遊戲周邊功能好比簽到禮包、公告頁面等都是偏H5頁面的周邊系統,若是經過本渲染引擎渲染到離屏canvas,每一個遊戲引擎都將離屏canvas當成普通精靈渲染便可實現跨遊戲引擎插件;
  2. 極致的代碼包追求:若是你對微信小遊戲有所瞭解,就會發現現階段在開放數據域要繪製UI,若是不想裸寫UI,就得再引入一份遊戲引擎,這對代碼包體積影響是很大的,而大部分時候僅僅是想繪製個好友排行榜;
  3. 屏幕截圖:這點在普通和H5和遊戲裏面都比較常見,將一些用戶暱稱和文案之類的與背景圖合併成爲截圖,這裏能夠輕鬆實現。
  4. 等等等......

參考資料

1.由 FlexBox 算法強力驅動的 Weex 佈局引擎:https://www.jianshu.com/p/d085032d4788
2.網頁性能管理詳解:https://www.ruanyifeng.com/blog/2015/09/web-page-performance-in-depth.html
3.渲染性能:https://developers.google.cn/web/fundamentals/performance/rendering
4.簡化繪製的複雜度、減少繪製區域:https://developers.google.com/web/fundamentals/performance/rendering/simplify-paint-complexity-and-reduce-paint-areas?hl=zh-CN

相關文章
相關標籤/搜索