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 Canvas
in 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 Image
location 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
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”
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 Image
in 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
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 drawImage
we 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:
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 Image
but 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 DOM
so 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:
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
.