Introduction

When using a <canvas> element to render a complex scene, a common optimisation is to use multiple layered canvases rather than a single canvas. In this post I discuss an issue I found in Chrome when using the Canvas API to render to layered canvases in a Web Worker.

Note: The <canvas> element also supports displaying 3D graphics using the WebGL API, but in this post I have only used the 2D Canvas API.

Web Workers and layered canvases

I wanted to try the new and experimental transferControlToOffscreen method. You can use this method to transfer control of a <canvas> element from the main thread to a Web Worker. This allows you to move your rendering code to the worker, hopefully improving performance and responsiveness. The method is currently supported in Chromium-based browsers. I decided to convert an image editor I have been working on to use a Web Worker for canvas rendering.

The image editor uses the layered canvases optimisation. Imagine that you are creating an image editor that supports many layers. In this editor, the user can only draw to the currently selected layer. One approach is to use a single <canvas> element to display all the layers, but this will be inefficient. You will have to redraw all layers when the user draws to the current layer. This is despite the fact that the layers above and the layers below have not changed. You could instead use three <canvas> elements: one for the layers below, one for the current layer, and one for the layers above. Now you have to redraw only the current layer's <canvas> element when the user draws to it. You only need to redraw all canvases when the user performs an action like zooming in or out of the image.

Problems with update timings

I soon saw a problem with the updated image editor. When all the layered canvases need to be redrawn, the updates do not always occur on the same frame. This happens even though I issue all the updates in the same requestAnimationFrame callback. The result can be tearing: one canvas layer get updated with the new image data but the other canvas layers do not. The other layers continue to show the old image data until the next frame. This tearing does not happen at all when performing the rendering on the main thread.

To show this, I have created a widget that consists of two equal-sized canvases, one on top of the other. Using the Canvas API I fill the lower canvas with grey, except for a square in the centre. I then draw a square in the centre of the upper canvas that exactly covers the empty area on the lower canvas:

How the two layered canvases combine to create a totally grey combined image
Canvas test with two layered canvases

I use a slider to control the size of the squares, simulating zooming in and out of the image. The colour behind the canvases is rebeccapurple. When I move the slider, both canvases get updated to show the grey/empty square at its new size. If the two canvases always get updated on the same frame then the purple background will never be visible. The square on the upper canvas and the hole in the bottom canvas will always match up exactly. If the canvases get updated on different frames then there will be a momentary mismatch. One canvas will show the new square size while the other canvas will still show the old image. If this happens when zooming out, the purple background may become visible for a moment.

I tried this test in Chrome v84 on macOS v10.15 Catalina and Windows 10. I first tried it when performing the canvas updates on the main thread and then when performing the updates on the Web Worker. I never saw the purple background when using the main thread, but I did when using the Web Worker, as shown in this video:

A recording of tearing when using a Web Worker for canvas updates
Tearing when using a Web Worker for canvas updates

I also sometimes saw nasty rendering glitches when the canvases flashed white:

A recording of rendering glitches when using a Web Worker for canvas updates
Flashes when using a Web Worker for canvas updates

If you want to try this test for yourself then the HTML file I used is available here. It contains two demonstration widgets. The first one uses a Web Worker to update the canvases and the second one uses the main thread. The file will only work in a browser that supports transferControlToOffscreen.

Conclusion

I found a timing issue in Chrome when using the Canvas API to render to a canvas from a Web Worker. It impacts the common rendering optimisation of using layered canvases for complex scenes. This is something to be aware of if you are looking to move rendering off of the main thread and onto a Web Worker. That said, rendering to a canvas from a Web Worker is an experimental feature and hopefully this issue will get resolved.


Changelog

  • 2020-08-24 Initial version
  • 2020-08-25 Plain English improvements

# Comments

Comments on this site are implemented using GitHub Issues. To add your comment, please add it to this GitHub Issue. It will then appear below.