Desynchronized canvas pattern bug
📅
I wanted to add a moving background to the field in the paddle and ball game. I knew I could use a repeating pattern image, displacing it so it would create the effect of continuous movement, but I was unsure of the result. So, instead of branching the repository of the code behind the working prototype, I decided to prototype it. I wrote a few lines of code and it seemed to work as expected, but it failed silently when I tested it in a specific browser.
This is the process I followed.
Creating a pattern
Image repetition is easily achieved with CanvasRenderingContext2D
’s createPattern
method:
[It] creates a pattern using the specified image and repetition. This method returns a
CanvasPattern
.[… It] doesn’t draw anything to the canvas directly. The pattern it creates must be assigned to theCanvasRenderingContext2D.fillStyle
[property][…], after which it is applied to any subsequent drawing.
In a document that has a canvas
element with the identifier canvas
, the required resources declaration could be:
/** @type {HTMLCanvasElement} */
const canvas = document.querySelector( "#canvas" );
// Set the canvas dimensions
canvas.width = canvas.clientWidth;
canvas.height = canvas.clientHeight;
/** @type {Object} */
const contextAttributes = { alpha: false, desynchronized: true };
/** @type {CanvasRenderingContext2D} */
const context = canvas.getContext( "2d", contextAttributes );
/** @type {HTMLImageElement} */
const image = document.createElement( "img" );
/** @type {CanvasPattern?} */
let pattern = null;
In this case the context attributes have been set to try to speed up the drawing and reduce latency:
alpha
: A boolean value that indicates if the canvas contains an alpha channel. If set tofalse
, the browser now knows that the backdrop is always opaque, which can speed up drawing of transparent content and images.
desynchronized
: A boolean value that hints the user agent to reduce the latency by desynchronizing the canvas paint cycle from the event loop.
The title of this text and the context provided should make it apparent what attribute reveals the bug, but let’s not get ahead of ourselves. Instead, let’s get ahead of ourselves.
Drawing with the pattern
Before initialising the pattern, I establish a crude animation loop, without limiting the framerate, defining previously a couple of constants I can tweak to alter the displacement’s direction and speed. It is also convenient to define variables for values that only need be calculated once outside of the loop.
The code is simple, but comments are provided for context:
/** @type {Number} */
const speed = 0.064;
/** @type {Object} */
const direction = { x: 0.707, y: -0.707 };
/** @type {Number} */
let patternRectangleWidth = 0;
/** @type {Number} */
let patternRectangleHeight = 0;
/**
* Paint a rectangle on `context`, filled with `pattern` and displaced in
* proportion to `timestamp`. To be used with `window.requestAnimationFrame()`.
* @param {Number} timestamp
*/
function handleAnimationFrame( timestamp ) {
requestAnimationFrame( handleAnimationFrame );
// Paint black background
context.fillStyle = "#000";
context.fillRect( 0, 0, canvas.width, canvas.height );
// Determine how much to increment the pattern's displacement by
const increment = speed * timestamp;
// Calculate the displacement along each axis
const displacementX = 0
- image.width // OFfset: put completely "out of frame"
+ ( increment * direction.x ) % image.width // Put partially "in frame"
;
const displacementY = 0
- image.height // Offset: put completely "out of frame"
+ ( increment * direction.y ) % image.height // Put partially "in frame"
;
// Draw a displaced `pattern`-filled rectangle.
context.save();
context.translate( displacementX, displacementY );
context.fillStyle = pattern;
context.fillRect( 0, 0, patternRectangleWidth, patternRectangleHeight );
context.restore();
}
Loading an image into a pattern and starting the animation
Simple enough:
image.onload = function( loadEvent ) {
pattern = context.createPattern( loadEvent.target, "repeat" );
// Establish the pattern-filled rectangle dimensions, taking into account
// the maximum offset of the displacement (twice the image's width/height).
patternRectangleWidth = canvas.width + 2 * loadEvent.target.width;
patternRectangleHeight = canvas.height + 2 * loadEvent.target.height;
requestAnimationFrame( handleAnimationFrame );
}
image.src = "https://galdecoa.com/favicon.svg";
All of this worked as expected, until I switched browsers.
Testing for compatibility
Linux has been my main OS for the last 20+ years. Also, I use Mozilla Firefox mostly. Having the strong impression that my choices are not the most popular by most metrics helps being aware of the almost-necessity to test in other environments.
My CI/CD implementation is lacking. I would love to design and develop a pipeline that would incorporate Playwright, but I am uncertain if I can justify the time I would need to invest. So, for the time being, I resort to running my prototypes in different browsers/OSs from time to time. Insufficient in other contexts, passable for a personal project.
The code failed when I tried it in Chromium. Nothing happened. No error messages, no content on the canvas, nothing. From the DevTools I could run the code step by step but, still, nothing came up. Having a Windows machine at hand, I decided to try it on the other Chromium-based browsers I sometimes use, Google Chrome (114.0.5735.134) and Microsoft Edge (114.0.1823.51). Same result. After some fiddling, the solution was to either not set desynchronized
to true
, or not using a CanvasPattern
for the context’s fillStyle
.
I did use Playwright to test the original desynchronised code with WebKit, which did not have any troubles.
Letting go, for the time being
A quick search in Chromium’s issue tracking tool did not come up with anything relevant. I did consider reporting it but I thought it might not be productive. The team behind Chromium have a lot on their plate, and this is far from a necessity.
Even more, insisting on using desynchronized
set to true
is pointless for me, at this stage, for two reasons:
There is no data on gains from this setting in the context I intend to use it.
The canvas paint cycle in my interactive game is synchronised with the event loop. Setting
desynchronized
totrue
in the prototype was a mistake.
I will revisit this if I find reproducible evidence of a performance boost to be had, and only if desynchronising the canvas and the event loop does not cause other problems.