Comparison of ways to draw sprites in canvas

Introduction

Not long ago I helped my brother make a project for his coursework. It was necessary to create a client-server application, and it was decided to create a small browser game with multiplayer. The coursework was passed successfully, and I had a desire to compare various possible methods for rendering images HTML5 Canvasin order to find optimal solutions. My research was conducted out of curiosity and does not offer anything revolutionary, but the information in the article may be useful, or at least interesting.

Test method

To determine the difference in rendering, let’s take a PNG image and draw it on the canvas in different ways. Using each method, we will display this image in an amount of 27 (128) to 220 (1'048'576), with a step equal to a power of two, at a time and compare the time that my computer had to process this render. It is worth mentioning that to measure the execution time, we will carry out each iteration of testing the render function 10 times to obtain an average result, since the built-in functions for measuring execution duration will give the result with an error of about 2ms (this error is specially built into the browser to protect users).

Let's do the preparatory work: Create a page with canvas, set the rendering area resolution to 800x450px to get, for beauty, a 16:9 aspect ratio. We will set the dimensions of the element on the page accordingly (also for beauty). Let's get a 2d context. Load the image into the object Image and write it to a variable.

For testing, let’s write a function that will run the render five times per iteration in a loop and record the average time for each iteration in an array:

function test (renderFunction, from, to, samplesCount) {
    const result = [];
    for (let digit = from; digit <= to; digit++) {
        const count = 2 ** digit;
        const itterationResults = [];
        
        for (let sample = 1; sample <= samplesCount; sample++) {
            context.reset()
            const startTime = performance.now();
            renderFunction(count);
            const endTime = performance.now();
            const runTime = endTime - startTime;
            itterationResults.push(runTime);
        }

        const sumTime = itterationResults.reduce((acc, value) => {
            return acc + value;
        }, 0);
        result.push({
            count: count,
            time: sumTime / samplesCount
        });
    }
    return result;
}

Method 1: drawImage

The easiest way to draw the original image is to use the function context.drawImage()into which we will pass our image as an object Imagelocation on the canvas, and the size of the image to which we want to scale it.

My original dimensions of the picture are 450×450, and we will draw it in size 50×50. To select the drawing coordinates we will use random. To do this, let's write a simple function based on Math.random():

function random (value) {
    return Math.random() * value;
}

Now it's time to write the render function itself:

function render1 (count) {
    for (let i=0; i < count; i++) {
        context.drawImage(sprite, random(800), random(450), 50, 50);
    }
}

We run the test and get the data. For convenience, I collected the results in a graph

Dependence of the time spent on the number of sprites drawn by the context.drawImage function

Dependence of the time spent on the number of sprites drawn by the context.drawImage function

It is worth mentioning here that the function of obtaining a random number can provide additional load. You can create an array of random numbers in advance and use ready-made coordinate values ​​when drawing.

Here is a graph without the load introduced by “real-time randomization”

Dependence of the time spent on the number of sprites drawn by the context.drawImage function with given coordinates

Dependence of the time spent on the number of sprites drawn by the context.drawImage function with given coordinates

Although the changes are insignificant, in the further comparison of rendering methods, we will use time values ​​without taking into account randomization (the values ​​of the red graph).

Method 2: optimized drawImage

In the current rendering implementation, we ask each time to render a large image with the size reduced to 50×50 pixels. Instead of forcing the CPU to shrink the same image thousands of times to the same size, we can change the size ourselves and draw the sprite unchanged.

Let's create a new one Imagein which we will place our picture, but reduced to the required size:

let imaginaryCanvas = document.createElement("canvas");
imaginaryCanvas.width = 50;
imaginaryCanvas.height = 50;
let imaginaryContext = imaginaryCanvas.getContext("2d");
imaginaryContext.drawImage(sprite, 0, 0, 50, 50);
let resizedSprite = await new Promise(resolve => {
    const img = document.createElement("img");
    img.onload = () => {
        resolve(img);
    };
    img.src = imaginaryCanvas.toDataURL();
});

As a result, the render function will look like this:

function render (count) {
    for (let i=0; i < count; i++) {
        context.drawImage(resizedSprite, rands[i][0], rands[i][1]);
    }
}

Let's call the method “drawImage*” and place it on the graph for comparison

Comparison of the drawImage method with and without scaling optimization

Comparison of the drawImage method with and without scaling optimization

Despite the manipulations, we managed to reduce the rendering time of a million sprites by only 400ms (~7%). This is approximately 0.0004ms per sprite. The improvements are not impressive, so other solutions must be found.

Method 3: putImageData

When using drawImagewe force the canvas to read data from the object Image. What if pixel data was sent as an RGBA array of numbers? The context has a method that allows you to draw areas pixel by pixel using an object ImageData. Let's create this object with our sprite data:

let imaginaryCanvas = document.createElement("canvas");
imaginaryCanvas.width = 50;
imaginaryCanvas.height = 50;
let imaginaryContext = imaginaryCanvas.getContext("2d");
imaginaryContext.drawImage(sprite, 0, 0, 50, 50);
let spriteData = imaginaryContext.getImageData(0, 0, 50, 50);

Let's make changes to the render function:

function render (count) {
    for (let i=0; i < count; i++) {
        context.putImageData(spriteData, rands[i][0], rands[i][1]);
    }
}

Let's run the test:

Graph with putImageData method

Graph with putImageData method

To put it bluntly, the results are catastrophically terrible. To be honest, I waited for the test calculation for more than 20 minutes. Apparently, due to the fact that we “feed” the GPU “raw” pixels, without metadata, the video card cannot cache this data, so to draw each sprite we have to re-request the same array of pixels from RAM each time.

I propose to discard such an inappropriate method and try differently. We will not even take this method into account in the comparison on the graph – to maintain clarity.

Method 4: drawImage with OffScreenCanvas

If the need to use the graphics accelerator cache affects performance so much, then you can go even further. As far as I know, modern browsers try to store state canvas not in RAM but in the video memory of the video card. Sprite drawing method context.drawImage can receive not only an object as input Imagebut also some other types of image presentation in the browser. We are interested in the ability drawImage accept other canvases as input. This way we can create an additional canvas, draw our sprite on it, and the data for this rendering will not be in RAM, but in the video accelerator’s own memory.

Let's create OffscreenCanvas. He has all the properties of the ordinary, but has no idea in DOMso it would be more correct. Let's upload our image into it.

let offscreen = new OffscreenCanvas(50, 50);
let offscreenContext = offscreen.getContext("2d");
offscreenContext.drawImage(resizedSprite, 0, 0);

Let's rewrite the render function once again:

function render (count) {
    for (let i=0; i < count; i++) {
        context.drawImage(offscreen, rands[i][0], rands[i][1]);
    }
}

Let's run the test. Let's display the results on a comparison graph:

Graph using OffscreenCanvas

Graph using OffscreenCanvas

Oh, miracle! We see that reducing calls to RAM for rendering gives us a huge increase in rendering speed. DrawImage from object Canvas spends 2000ms less than drawImage from object Image (~38%).

Conclusion

context.drawImage from Image
Pros: The easiest way out of the box. Supports context.tranform.
Cons: Far from the fastest. Non-affine transformations are not possible.

context.drawImage from Image with preliminary preparation of sprites
Pros: Slightly (about 7%) faster than normal drawImage from Image. Supports context.tranform.
Cons: Requires some pre-calculation and sprite processing. Non-affine transformations are not possible.

context.putImageData
Pros: It's hard to say. Makes sense only for deep pixel-by-pixel rendering. Convenient for editing parts of what has already been drawn canvas. Affine transformations are possible.
Cons: Requires preliminary preparation. Does not support context.transform. It works terribly slowly (conventionally, 200 renderings take almost 15ms, which is critical for maintaining a stable 60 fps).

context.drawImage from Canvas (OffscreenCanvas)
Pros: Probably the fastest way to work with 2D graphics in canvas. Supports context.tranform.
Cons: Requires preliminary preparation. Non-affine transformations are fundamentally impossible.

It turns out that it is most practical to use drawImage With Image for small renderings, since this does not require significant effort from the programmer, and if it is necessary to optimize the speed and resource intensity of rendering (for example, for games), use drawImage With OffscreenCanvas.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *