Increasing Canvas Performance with Offscreen Rendering

Content:

The HTML canvas element is a fantastic way to create dynamic content – check out our other tutorials to find out how.

It does, however, highlight one of the weaknesses of JavaScript. When you open a web page, the JavaScript running on the page will all run in the same thread.

Canvas drawing code can be computationally heavy, and cause significant load on the system. Bare in mind that users may have mobile devices – load that’s fine for a PC may cause a phone to struggle.

While there are ways to try and avoid this – check out this article on reducing frame rates – it doesn’t stop the initial impact of a script on the entire page.

Introducing Offscreen Rendering

A useful enhancement of the canvas API is the ability to render frames offscreen. This splits a given task into its own thread.

It makes use of the Web Worker API, which you can learn about in our web worker article. It’s strongly recommended to gain a decent understanding of web workers before going any further here.

By using an offscreen canvas, canvas content rendering is separate from the main browser process. This ensures simple browser tasks, such as clicking and scrolling, are not impacted by the canvas.

A further boost comes from the fact the offscreen canvas is completely detached from the DOM. This eliminates the overheads required to manipulate the canvas as part of the DOM.

Fortunately, the offscreen canvas API is relatively easy to use, not least because it borrows heavily from the web worker API.

Switching to an Offscreen Canvas

There are a few ways to replace a standard canvas to an offscreen one. You’ll need to consider the goal of your canvas when choosing which one to use.

Transferring Control using transferControlToOffscreen()

Probably the simplest way to create an offscreen canvas is to the use the transferControlToOffscreen() function.

This function takes a canvas element, and converts it to an offscreen canvas element.

const canvas = document.querySelector('canvas');
const offscreenCanvas = canvas.transferControlToOffscreen();

If you prefer, these two can be combined into one.

const offscreenCanvas = document.querySelector('canvas').transferControlToOffscreen();

With the canvas moved offscreen created, we can then create a web worker that will act on the canvas.

const worker = new Worker('canvasCode.js');

We then pass the offscreen canvas to the worker as a message.

worker.postMessage({canvas: offscreenCanvas}, [offscreenCanvas]);

Note that the offscreenCanvas is repeated in the postMessage() function. The second part, in the square brackets, is a transfer. This is how we pass the canvas element through for editing. It’s not enough to just add it to the message.

It’s worth noting, however, that there’s nothing extra to do on the other side to handle a transfer. We can access the transferred element through the message.

onmessage = function(e) {
    canvas = e.data.canvas;
    // Use the canvas
};

With the canvas now accessible in our web worker, it can be manipulated in the same way as a standard canvas.

The benefit of using this method is that any changes to the offscreen canvas are reflected in the onscreen counterpart. They’re essentially one and the same – but the canvas is drawn in another thread. In a sense, the canvas element in the DOM is acting as a window to the work being done offscreen.

With one function call, and a few tweaks to deal with the missing DOM in the worker, its fairly straightforward to gain a decent performance boost in your existing code.

Without the Web Worker

This is essentially an addendum to the point above. You can use the offscreen canvas without the web worker element – simply using transferControlToOffscreen() will split your canvas from the DOM.

This will provide a small performance boost, simply due to the lack of overhead

Creating a New Offscreen Canvas

An OffscreenCanvas is an object in its own right, and can be instantiated like any other object.

const offscreenCanvas = new OffscreenCanvas(150, 100);

The above code will create a new offscreen canvas, that’s 150px wide and 100px tall.

Now, in this instance, we don’t have an onscreen counterpart as we did before. We can pass this to our web worker and draw to it, but there’s no link to anything in the main thread for us to view the content.

Output to Another Canvas

What we can do, is create a bitmap image of our canvas once we’ve finished drawing to it, and use this to add an image where we need it.

const offscreenCanvas = new OffscreenCanvas(150, 100);

// Code to draw to the canvas, in whichever context we need

const bitmap = offscreenCanvas.transferToImageBitmap();

For example, to draw this image to another canvas, we can use transferFromImageBitmap().

const onscreenCanvas = document.querySelector('canvas').getContext('bitmaprenderer');
onscreenCanvas.transferFromImageBitmap(bitmap);

Make sure the onscreen canvas as the ‘bitmaprenderer’ context.

If you want to use this with a web worker, you can pass the transferToImageBitmap() result back from the worker by posting a message, and listening for the event in the main thread.

This is a more involved way of using an offscreen canvas, but is more flexible. For example, if you want to use the same element multiple times, you’d want to look at this method and using the result multiple times.

Output as an Image

You can also use the returned bitmap context as a standard image, using convertToBlob().

By default, convertToBlob() will return a .png representation of the canvas content.

const blob = offscreenCanvas.convertToBlob();
const url = URL.createObjectURL(blob);

You can then use the url variable created above as the src property of an img tag.

const img = document.querySelector('img');
img.src = url;

You could use this anywhere an image src/url can be used, for example, as a background image. This would be useful for creating more complex background gradients that are either tricky, or impossible, in pure CSS.

Things to Bare in Mind

The main issue, as with any web worker, is that the DOM is not accessible. If you want to use event listeners, or other DOM elements, in the web worker, you’ll need to use postMessage() to pass the data through.

For example, to handle window resize events, you would need

document.addEventListener('resize' => {
    offscreenCanvas.postMessage({
        'width': window.innerWidth,
        'height': window.innerHeight
    });
});

in your main file.

In your we worker, you would need to handle the onmessage event containing the new sizes, and respond as necessary.

On top of this, if you’re using transferControlToOffscreen(), you’ll also need to consider the canvas element visible in the DOM. Handling the window resize in the worker will not change the canvas size in the DOM, leading to a size mismatch between the two. You’ll want to mirror any changes made between the offscreen canvas, and onscreen equivalent, to keep them in sync.

If you like what we do, consider supporting us on Ko-fi