background

When developing a Canvas application, it is inevitable to choose a rendering engine (like PixiJS) or a more powerful game engine (like Cocos Creator, Layabox) for efficiency reasons.

Rendering engines often have the concept of sprites, and a complete interface is made up of many sprites. If you write a more complex interface, the code will be “repetitive code” for creating sprites, setting Sprite locations and styles, and you end up with extreme rendering performance at the expense of code readability.

Game engines usually come with an IDE, and the interface can be created by dragging and dropping, and the scene configuration file can be exported, which is great for UI development, but game engines are usually huge, and sometimes we just want to create a leaderboard of friends.

Based on the above analysis, if there is a rendering engine that can not only express the interface in the way of configuration files, but also be lightweight, it will greatly meet the scenario of developing lightweight Canvas applications.

This article details what it takes to develop a lightweight, configurable rendering engine. The code is open source at Github: github.com/wechat-mini… .

Configuration analysis

We first expect the page to be configurable, referring to the implementation of Cocos Creator: for a scene, one operation is done in the IDE, and the final scene configuration file looks like the following:

// omit n nodes {"__type__": "cc.Scene"."_opacity": 255,
    "_color": {
      "__type__": "cc.Color"."r": 255,
      "g": 255,
      "b": 255,
      "a": 255}."_parent": null,
    "_children": [{"__id__": 2}],},Copy the code

In a JSON configuration file, which contains the hierarchical structure and style of the node, the engine takes the configuration file and recursively generates the node tree and renders it. PixiJS is just a rendering engine, but like Cocos2D you can make an IDE that drags and generates UI, and then write a parser that claims to be PixiJS Creator😬.

This is a good solution, but the downside is that each engine has its own set of configuration rules, which can’t be generalized, and handwritten configuration files are anti-human without an IDE, so we need a more generic configuration.

Find a better solution

There are two main problems with game engine configuration:

  1. Poor handwriting readability, especially for deep node trees;
  2. No separation of style and node tree, redundant configuration files;
  3. The configuration is not universal.

For high readability and style separation, we were surprised to find that this is just the way Web development works: write HTML, CSS, throw it to the browser, and the interface comes out, saving time and effort.

Such a good use of posture, we need to find a solution to implement in the canvas!

Implementation analysis

Results the preview

Before we step through the implementation, let’s toss out the final implementation, write the XML and styles, and get the result:

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'}} // Initialize the render engine layout.init (template, style); // Perform the real render layout.layout (canvasContext);Copy the code

Program overview

Since we want to refer to the browser implementation, let’s take a look at what the browser does:

  • HTML tags are converted to the document Object Model (DOM); CSS markup to CSS Object Model (CSSOM)
  • The DOM tree is merged with the CSSOM tree to form the render tree.
  • The render tree contains only the nodes needed to render a web page.
  • The layout calculates the exact position and size of each object.
  • The final step is rendering, using the final render tree to render the pixels onto the screen.

In order to draw HTML+CSS to canvas in canvas, the above steps are indispensable.

Build layout trees and render trees

The overview of the scheme above is divided into two chunks, the first is the various parsing calculations before rendering, the second is the rendering itself and the subsequent work after rendering, first look at what needs to be done before rendering.

Parsing XML and building CSSOM

The first is to parse HTML (here we use XML) strings into node trees, which is equivalent to “HTML tags into document Object Model (DOM)” in the browser. Searching XML Parser in NPM, there are many excellent implementations available. Here we only pursue two points:

  1. Lightweight: Most libraries use hundreds of k for powerful functions, but we only need the most core XML parsing into JSON objects;
  2. High performance: In games, there are inevitably long list scrolling scenarios, in which the XML is very large, so try to control the XML parsing time;

Based on the above considerations, FAST-XML-Parser was selected, but some modifications and castrations were still made. Finally, the template was parsed to produce the following JSON object

{
    "name":"view"."attr": {"id":"container"
    },
    "children":[
        {
            "name":"text"."attr": {"id":"testText"."class":"redText"."value":"hello canvas"
            },
            "children":[]}]}Copy the code

Next we build CSSOM. To reduce parsing steps, we manually build a JSON object with a key named the node ID or class to bind to the XML node:

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

The DOM tree is merged with the CSSOM tree to form the render tree

After the DOM tree and CSSOM are constructed, they are still two independent parts. We need to build them into renderTree. Since the key of style is associated with the node of XML, we can simply write a recursive processing function to achieve this: This function takes two arguments, the first being a tree of nodes that has been parsed by an XML parser, and the second being a style object, equivalent to DOM and CSSOM.

Const constructorMap = {view: view, text: text, image: image, scrollView: 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 || ' '; // Instantiate the parameters needed for the tag, 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
            }

            returnobj; }, {}) // for subsequent element query args.idname = id; args.className = attr.class ||' '; const element = new _constructor(args) element.root = this; ForEach (childNode => {const childElement = create. Call (this, childNode, style); element.add(childElement); });return element;
}
Copy the code

After recursive parsing, a renderTree with styled nodes is formed.

Computational layout tree

After rendering the tree, it is time to build the layout tree. How to calculate the position and size of each node after interacting with each other is a headache. Still, we’re not worried, because we’ve seen the same problems with frameworks like React Native and Weex that have been so popular in recent years:

Weex is a framework for developing high performance native applications using popular Web development experiences. React Native uses JavaScript and React to write Native mobile applications

These frameworks also need to compile HTML and CSS into client-readable layout trees. Can they be abstracted from their related modules without repeating the wheel? At first I thought this section would be huge or strongly coupled to the framework, but thankfully it abstracts out to only about 1000 lines, which is csS-Layout, the early layout engine for Week and React Native. Here is a very good article, directly quoted, not to repeat: Weex Layout Engine Powered by FlexBox Algorithm.

NPM can be found above csS-Layout, it exposes the computeLayout method, just need to get the above layout tree to it, after calculation, layout tree each node will bring layout attribute, it contains the node location and size information!

// 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"}}Copy the code

CSS layout is a standard Flex layout. If you are not familiar with CSS or Flex layout, you can refer to this article for a quick start: “Flex Layout Tutorial: Syntax.” It is also worth mentioning that, as a user of CSS-layout, it is a good habit to give each node width and height attributes 😀.

Apply colours to a drawing

Base Style Rendering

Before dealing with rendering, let’s take a look at the tags we use heavily in Web development:

The label function
div Usually used as containers, containers can also have styles such as border and background colors
img Image tags, which embed an image into a web page, usually add a borderRadius to the image to implement a round head
p/span Text label, used to display paragraphs or inline text

In the process of building a node tree, there will be different classes to deal with different types of nodes. The above three tags correspond to View, Image and Text classes, and each class has its own render function.

The render function only needs to do one thing: the layout property calculated according to CSS-Layout and the style property of the node itself are drawn to the canvas in the form of canvas API.

This may sound like a lot of work, but it’s not that difficult. For example, here’s how to draw text, implement text font, size, left and right alignment, etc.

 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; }}Copy the code

But it’s not that simple, because there are effects that you have to combine layers of computation to get the effect, such as borderRadius implementation, text textOverflow implementation, if you are interested in the source code.

And of further interest, look at how the game engine handles it, which turns out to be over 1000 lines in a Text class: LayaAir’s Text implementation 😯.

Rearrange and redraw

When an interface is rendered, we don’t want it to be static, but we can handle click events, such as clicking on a button to hide elements, or changing the color of a button.

In browsers, there are concepts called rearrangement and redraw:

From the article: “Detailed explanation of Web Performance Management”

When a web page is generated, it is rendered at least once. As the user visits, it is constantly re-rendered. To rerender, you need to regenerate the layout and redraw. The former is called “reflow” and the latter is “repaint”.

So which operations trigger rearrangement, which operations trigger redraw? Here’s a simple and crude rule: Rearrangement must be triggered whenever position and size changes are involved, such as changing width and height attributes. Changes in a container that are independent of the size position are only needed to trigger partial redraw, such as changing the link of the image, changing the content of the text (the size position of the text is fixed). For more details, check out csstriggers.com.

In rendering engine that we live in, if you execute trigger the rearrangement of the operation, the need to parse and render full execution again, specifically is to modify the XML node or associated with the rearrangement of style, after repeated initialization and rendering of the operation, rearrangement of time dependent on the complexity of the nodes, is mainly the complexity of the XML node.

Style.container. Width = 300; // This operation needs to be rearranged to refresh the interface. // Clear logic layout.clear (); // Complete the initialization and rendering process layout.init (template, style); Layout.layout(canvasContext);Copy the code

For redraw operations, there is a temporary ability to dynamically modify image links and text. The principle is simple: With Object.defineProperty, when modifying the attributes of a layout tree node, the repaint event is thrown and the redraw function partially repaints the interface.

Object.defineProperty(this, "value", {
    get : function() {
        return this.valuesrc;
    },
    set : function(newValue){
        if( newValue ! == this.valuesrc) { this.valuesrc = newValue; // Raise the redraw event, in the callback function on the canvas to erase the layoutBox area and redraw the text this.emit('repaint');
        }
    },
    enumerable   : true,
    configurable : true
});
Copy the code

So how do you call the redraw operation? Once the engine has drawn the page with only the XML and style, it needs to provide a query interface to perform operations on individual elements, where the layout tree again comes in handy. When renderTree is generated, to match the style, it needs to be mapped by id or class, and the nodes also retain the ID and class attributes. By traversing the nodes, the query API can be implemented:

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;
}
Copy the code

In this case, the redraw logic can be implemented by querying the API, which takes negligible time.

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

Event implementation

Once a node is queried, it is natural to want to bind events, which are simple enough to listen for elements’ touch and click events to perform some callback logic, such as clicking a button to change colors.

Let’s take a look at event capture and event bubbling in the browser:

From the article “Event Capturing and Event bubbling in JS” event capturing: Events fire from the least precise object (document object) to the most precise (events can also be captured at the window level, but must be specified by the developer). Bubbling events: Events fire in order from the most specific event target to the least specific event target (the Document object).

** Prerequisites: ** Each node has an event listener on and emitter emit; Each node has a property called layoutBox, which represents the element’s box model on the canvas:

layoutBox: {
    x: 0,
    y: 0,
    width: 100,
    height: 100
}
Copy the code

Canvas is no different from browser to realize event processing. The core lies in: given coordinate point, traverse the box model of node tree to find the node with the deepest level surrounding the coordinate.

When the click event happens on canvas, the x and Y coordinates of the touch point can be obtained, which are located in the layoutBox of the root node. When the root node still has child nodes, traversal the child nodes. If the layoutBox of a child node still contains the coordinates, repeat the above steps again. Until the node containing the coordinate has no children, this process is called event capture.

// Given the root node tree and the position of the touch point, event capture can be implemented recursivelyfunction getChildByPos(tree, x, y) {
    let list = Object.keys(tree.children);

    for ( leti = 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 {
                returnchild; }}}return tree;
}
Copy the code

After the deepest node is found, the emit interface is called to trigger the onTouchStart event for that node. If onTouchStart is listened for in the first place, the event callback is triggered. So how do you implement event bubbling? We do not record the capture chain during the event capture phase. Each node stores its parent node and child node. After emit the event, the child node calls the emit interface of the parent node to throw the onTouchStart event. The parent node continues to perform the same operation on its parent node until it reaches the root node. This process is called event bubbling.

// Event bubbling logic ['touchstart'.'touchmove'.'touchcancel'.'touchend'.'click'].forEach((eventName) => {
    this.on(eventName, (e, touchMsg) => {
        this.parent && this.parent.emit(eventName, e, touchMsg);
    });
});
Copy the code

Scroll list implementation

There is a limited amount of content that can be displayed within the screen area, whereas browser pages are usually long and scrollable. Here we implement scrollView, and if the total height of the children inside the label is greater than the height of the ScrollView, we can implement scrolling.

1. For all first-level child elements in the container scrollView, calculate the sum of heights;

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

    return last.layoutBox.top + last.layoutBox.height;
}
Copy the code

2. Set the page size, assuming that the height of each page is 2000, according to the ScrollHeight calculated above, the total number of pages needed to scroll the list, and create a canvas for each page to display the data:

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

3. Recursively traverses the node tree of scrollView and determines which page each element should be located on according to the absoluteY value. It should be noted that some child nodes will be located on two pages at the same time and need to be drawn in both pages, especially the nodes that are asynchronously loaded and rendered in the image class.

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);
        letnextPage = pageIndex + 1; child.layoutBox.absoluteY -= this.pageHeight * (pageIndex); // For elements that cross boundaries, draw on both sidesif ( 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);
        });
    }
Copy the code

4. Scrollview is understood as the Camera in the game, and only the areas that can be shot are displayed. Then all the paging data are splice together from top to bottom to form the game scene. You have the scrolling effect. Shooting sounds pretty advanced, but in this case we’ll just use drawImage:

// CTX is the canvas of the scrollView, Canvas this.ctx.drawImage(Canvas, box.absoluteX, clipY, box.width, clipH, box.absoluteX, renderY, box.width, clipH, );Copy the code

5. When the touch event is triggered on the ScrollView, the value of the top property of the ScrollView will be changed. According to Step 4, the visual area will be trimmed according to the top continuously, and the scrolling will be realized.

The above scheme is a space for time scheme, that is, during each redraw, the rendering time is optimized because the content is already drawn on the paging canvas (which may take up space here).

other

At this point, a lightweight Canvas rendering engine like a browser issues the model:

  1. Given an XML+style object, the interface can be rendered;
  2. Support for specific tags: View, Text, Image, and ScrollView;
  3. You can query nodes and modify node attributes and styles.
  4. Support event binding;

Due to the limited space of the article, many details and difficulties cannot be described in detail, such as memory management (improper memory management can easily lead to continuous memory increase and application crash), the implementation details of scrollView’s rolling events, and the use of object pool, etc. Interested can look at the source: github.com/wechat-mini… Here is another scroll friend ranking demo:

Debugging and application scenarios

As a complete engine, how can you do without an IDE? Here in order to improve the efficiency of the UI debugging (in fact, most of the time the game engine workflow is very long, debug UI, instead of a copy and so on) is a very troublesome thing, to provide a version of the online debugger, simply adjust the UI is quite enough: wechat – miniprogram. Making. IO/minigame – ca…

Finally, what are the scenarios for all this work on a rendering engine? Of course there is:

  1. Game peripheral plug-ins across game engines: many game peripheral functions, such as check-in packages, announcement pages, are peripheral systems similar to H5 pages. If this rendering engine is used to render to off-screen canvas, each game engine will render off-screen canvas as a common Sprite to achieve cross-game engine plug-ins.
  2. The pursuit of the ultimate code package: If you have some understanding of wechat games, you will find that at this stage in the open data field to draw UI, if you do not want to write UI naked, you have to introduce a game engine, which has a great impact on the volume of the code package, and most of the time just want to draw a friend list;
  3. Screenshots: This is common in both regular and H5 games. It is easy to combine background images with nicknames and copywriting.
  4. Etc etc…

The resources

1. Driven by strong FlexBox algorithm Weex layout engine: www.jianshu.com/p/d085032d4… 2. The web page performance management a: www.ruanyifeng.com/blog/2015/0… 3. The rendering performance: developers. Google. Cn/web/fundame… 4. Simplify drawing, reducing the complexity of drawing area: developers.google.com/web/fundame…