DEV Community

Unpublished Post. This URL is public but secret, so share at your own discretion.

The mystery of the flickering <canvas>

NOTES TO SELF:

  • add solution with preserveDrawingBuffer and manual clearing.

There's currently an issue in all browsers that causes <canvas> elements to unexpectedly flicker on resize, and it is likely to happen if you resize your canvas inside a ResizeObserver callback, but it isn't ResizeObserver's fault!


the mystery

I had some code that worked great when using Window resize events to update a canvas rendering to match the window size. Let's use the following demo as an example (click "edit on codepen" to open it in a new page, and resize it to see that resizing works fine):

Video:

I then decided to resize the scene based on the size of the container the scene was in, because I would need to place the scene in any element anywhere on the page, and the scene container might change size on its own independently of the window. For this, I chose to use the ResizeObserver API because that's the API for tracking size changes on any element we wish. So, I naively replaced the line

window.addEventListener('resize', resize);
Enter fullscreen mode Exit fullscreen mode

with the lines

const resizeObserver = new ResizeObserver(resize);
resizeObserver.observe(container);
Enter fullscreen mode Exit fullscreen mode

but then a mysterious flickering of the scene started to happen on resize (as before, open the demo in its own tab, try resizing it):

Video:

What in the world is going on here!? A Three.js bug? A Google Chrome bug? A bug in the demo?

It turned out to be "a bug in the demo" technically; i.e. my fault!; but after some investigation, it turned out that several web API specs that could play better together: ResizeObserver, requestAnimationFrame, HTMLCanvasElement, and the lack of an API to control this scenario in a simple way.

Here is what I found out.

END ARTICLE, below are ideas being moved up here.



the issue

The issue is that rendering to a canvas, then resizing the canvas in the same task, clears the canvas (I know right!?); but if the canvas is resized in a macrotask queued from the previous task where render happened, then the canvas is not cleared (that's confusing, right!?).

The word "task" here is loose. In our case, it happens to be the "paint cycle" in a browsers's processing model, which is a part of a browser's event loop. The pain cycle, er, I mean, the paint cycle, is what I call the part of the processing model that the spec refers to as "Update the rendering". This phase of a browser's processing model's event loop runs user window resize events, animation frames requested with requestAnimationFrame, ResizeObserver callbacks, IntersectionObserver callbacks, the browser's internal layout and paint steps, and other things.

The actions "rendered" and "resized" in the concept of a "canvas being rendered to before being resized in the same task" have to happen in that order, seemingly anywhere within a "paint cycle", not necessarily the exact same macrotask (requestAnimationFrame callbacks and ResizeObserver callbacks happen in separate macrotask within a paint cycle).

Aside, if you're not familiar with macrotasks, i.e. just "tasks", read Jake Archibald's excellent article on tasks and microtasks!

Here is a demo that shows visible flickering (click "edit on codepen", then resize the demo to see the scene go momentarily invisible):

Video:

This confusing problem is likely to show up when migrating render code from a Window resize event to a ResizeObserver callback, causing a canvas scene to flicker.

Typically Window resize events are useful only for canvases that fill the whole viewport. But this event is not useful for canvases that are inside of elements that can be resized independently of the window; for that there is ResizeObserver.

In the above example, the line

window.addEventListener('resize', resize);
Enter fullscreen mode Exit fullscreen mode

works fine, while the lines

const resizeObserver = new ResizeObserver(resize);
resizeObserver.observe(container);
Enter fullscreen mode Exit fullscreen mode

introduce the flicker.

At first I thought this was an issue with ResizeObserver, but it turns out that the difference in timing between requestAnimationFrame, ResizeObserver callbacks, and Window resize events, exposes the actual issue with HTMLCanvasElement.

the issue

The issue seems to be that, if we render to a canvas, and then resize the canvas in the same task (or more specifically with respect to the example, within the same processing model frame which includes animation frames, resize callbacks, etc), the canvas will be visibly cleared by the browser.

In the browser processing model, the order in which things fire is this (some things omitted):

  1. events (f.e. resize of the window)
  2. animation frames (requested with requestAnimationFrame)
  3. ResizeObserver callbacks
  4. browser's internal paint procedure

When using window resize events, there is no issue. That code typically looks like this:

requestAnimationFrame(function animation() {
  // ...update things...
  render() // draws to the canvas
})

window.addEventListener('resize', () => {
  canvas.width = /* new width based on window */
  canvas.height = /* new height based on window */
  // ...
})
Enter fullscreen mode Exit fullscreen mode

This works fine because window resize events happen in step 1. This means that:

  1. a resize event happens, and the canvas is resized
  2. an animation frame fires, and the app renders to the already-resized canvas
  3. nothing happens here, the app is not using ResizeObserver
  4. the browser updates the screen.

Here's a demo of it working with no flicker when using window resize events (click "edit on codepen", then resize the demo to test it):

Video:

https://youtu.be/apGdBI8BXig

When we switch to the lines that use ResizeOberver as in the first demo above, the flicker begins to happens, which is confusing and unintuitive. The reason is because the render() call that updates the canvas pixels happens before the canvas gets resized, like so:

  1. nothing happens, we're not using window resize anymore
  2. an animation frame fires, and the app renders to a not-yet-resized canvas
  3. a ResizeObserver callback fires and our code resizes the canvas
  4. the browser updates the screen, and shows a cleared (transparent) canvas as if we did not draw anything

The above demos use Three.js, but this issue isn't limited to Three. It happens with any lib that uses WebGL (presumably Canvas 2D, but I haven't test that).

Here's the same problem depicted with TWGL.js, a tiny wrapper on top of plain WebGL (same deal, open in a new window and resize):

Video:

Circling back to the beginning of the post: the canvas is cleared when render happens before resize. Here is another demo that shows that if we put our render before canvas resize within a window resize event, that still doesn't work (this has nothing to do with ResizeObserver specifically, but it is the order of render and resize within a task that matters):

Here it works if we move the render to after the resize, still in a window resize event:

the issue in the wild

This difference in timing has caused confusion in the wild:

When resize canvas , there is a white background flickering #3395

I resize canvas with a simple standalone function , like this :

function resizeCanvas(width, height) {
    game.canvas.width = width;
    game.canvas.height = height;
    game.canvas.style.width = width / game.resolution + 'px';
    game.canvas.style.height = height / game.resolution + 'px';
}
Enter fullscreen mode Exit fullscreen mode

And when game bootup , I do something like this :

game.renderer = new PIXI.WebGLRenderer(
    game.canvas.width,
    game.canvas.height,
    {
         view: game.canvas,
         backgroundColor: 0x000000,
     }
);

// recompute viewport size & canvas size with  browser window size 
game.scaler.update();  

// resize  renderer with new size.
game.renderer.resize(game.viewportWidth, game.viewportHeight);
Enter fullscreen mode Exit fullscreen mode

the test case is very simple : draw a small png image.

Browser: Chrome 55 for macOS 10.12

Safari is OK.

I know maybe this is a issues of Chrome , but I hop there is a hack way for fix it.

Thanks

BabylonJs React scene component - getEngine().resize() on canvas resize - Questions - Babylon.js

Hi all, Sorry for the ultranoobish question. Still learning React, Babylon, and react-babylonjs. Currently implementing the boilerplate SceneComponent in How to use Babylon.js with ReactJS - Babylon.js Documentation. The parent container of my SceneComponent may be resized without the window being resized. When this happens, the scene’s engine does not automatically run getEngine().resize(). I was hoping to add an event listener for onresize of canvas within the SceneComponent (attempting to ...

favicon forum.babylonjs.com

workarounds and solutions

It isn't immediately intuitive why moving logic from window resize to a resize observer causes the problem, but there are ways to avoid this, while still using ResizeObserver.

Circling back to the beginning of the post, this adds to the confusion: if we resize a canvas in a macrotask scheduled to follow the task in which we rendered to the canvas, the problem no longer happens, despite that the ordering is the same. Here's that demo, and notice we used setTimeout(0) to delay the canvas resize, in in this case there is no more flicker because the canvas is no longer cleared:

As this demo shows, it also isn't intuitive as to why a canvas is cleared if resized after rendering only sometimes (f.e. no clearing happens if the resize is delayed with setTimeout).

Another way to work around the issue

Top comments (0)