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);
with the lines
const resizeObserver = new ResizeObserver(resize);
resizeObserver.observe(container);
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);
works fine, while the lines
const resizeObserver = new ResizeObserver(resize);
resizeObserver.observe(container);
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):
- events (f.e.
resize
of the window) - animation frames (requested with
requestAnimationFrame
) -
ResizeObserver
callbacks - 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 */
// ...
})
This works fine because window resize
events happen in step 1. This means that:
- a
resize
event happens, and the canvas is resized - an animation frame fires, and the app renders to the already-resized canvas
- nothing happens here, the app is not using
ResizeObserver
- 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:
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:
- nothing happens, we're not using window
resize
anymore - an animation frame fires, and the app renders to a not-yet-resized canvas
- a
ResizeObserver
callback fires and our code resizes the canvas - 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';
}
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);
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
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)