user128511
user128511

Reputation:

How do I use ResizeObserver with requestAnimationFrame correctly

Many examples show using ResizeObserver something like this

const canvas = document.querySelector('canvas');
const ctx = canvas.getContext('2d');

function draw() {
   const { width, height } = canvas;

   ctx.clearRect(0, 0, width, height);
   ctx.save();
   ctx.translate(width / 2, height / 2);
   const size = Math.min(width, height);
   ctx.beginPath();
   ctx.arc(0, 0, size / 3, 0, Math.PI * 2);
   ctx.fill();
   ctx.restore();
}

const observer = new ResizeObserver(entries => {
  for (const entry of entries) {
    const canvas = entry.target;
    canvas.width = entry.contentBoxSize[0].inlineSize;
    canvas.height = entry.contentBoxSize[0].blockSize;
    draw();
  }
})
observer.observe(canvas);
html, body {
 height: 100%;
 margin: 0;
}
canvas {
 width: 100%;
 height: 100%;
 display: block;
}
<canvas></canvas>

This works. The ResizeObserver will fire once when started and then anytime the canvas's display size changes

But then, if you switch to a requestAnimationFrame loop you'll see an issue

const canvas = document.querySelector('canvas');
const ctx = canvas.getContext('2d');

function draw() {
   const { width, height } = canvas;

   ctx.clearRect(0, 0, width, height);
   ctx.save();
   ctx.translate(width / 2, height / 2);
   const size = Math.min(width, height);
   ctx.beginPath();
   ctx.arc(0, 0, size / 3, 0, Math.PI * 2);
   ctx.fill();
   ctx.rotate(performance.now() * 0.001);
   ctx.strokeStyle = 'red';
   ctx.strokeRect(-5, -5, size / 4, 10);
   ctx.restore();
}

function rAFLoop() {
  draw();
  requestAnimationFrame(rAFLoop);
}
requestAnimationFrame(rAFLoop);

const observer = new ResizeObserver(entries => {
  for (const entry of entries) {
    const canvas = entry.target;
    canvas.width = entry.contentBoxSize[0].inlineSize;
    canvas.height = entry.contentBoxSize[0].blockSize;
  }
})
observer.observe(canvas);
html, body {
 height: 100%;
 margin: 0;
}
canvas {
 width: 100%;
 height: 100%;
 display: block;
}
<canvas></canvas>

Above, all I did was take the call to draw out of ResizeObserver and call it in a requestAnimationFrame loop (and added a little motion)

If you size the window now you'll see the circle flickers

How do I fix this?

Upvotes: 6

Views: 1353

Answers (1)

user128511
user128511

Reputation:

The issue here is the spec says that ResizeObserver callbacks happen after requestAnimationFrame callbacks. This means the order of operations in the example above is

  1. requestAnimationFrame callback
  2. draw circle
  3. resize observer callback
  4. set canvas size (setting the size clears the canvas)
  5. browser composites page.

There are several workarounds, all of them have issues or are semi-involved

Solution 1: Draw in the ResizeObserver too.

This will work. Unfortunately this means you're drawing twice in a single frame. If your drawing is heavy then that would lead to the resize feeling sluggish

Solution 2: Use an animation frame loop instead of ResizeObserver

You can look up the display size of the canvas in multiple ways. (1) canvas.clientWidth, canvas.clientHeight (returns integers), (2) canvas.getBoundingClientRect() (returns rational numbers)

The biggest downside of this is that if you didn't need or want a constantly running requestAnimationFrame loop, now you will have one, and it will be eating battery life even if you're not updating anything.

For example:

const canvas = document.querySelector('canvas');
const ctx = canvas.getContext('2d');

function draw() {
   const { width, height } = canvas;

   ctx.clearRect(0, 0, width, height);
   ctx.save();
   ctx.translate(width / 2, height / 2);
   const size = Math.min(width, height);
   ctx.beginPath();
   ctx.arc(0, 0, size / 3, 0, Math.PI * 2);
   ctx.fill();
   ctx.rotate(performance.now() * 0.001);
   ctx.strokeStyle = 'red';
   ctx.strokeRect(-5, -5, size / 4, 10);
   ctx.restore();
}

function rAFLoop() {
  // Only set canvas.width and canvas.height
  // if they need to be set because setting them is a
  // heavy operation
  if (canvas.width !== canvas.clientWidth ||
      canvas.height !== canvas.clientHeight) {
    canvas.width = canvas.clientWidth;
    canvas.height = canvas.clientHeight;
  }

  draw();
  requestAnimationFrame(rAFLoop);
}
requestAnimationFrame(rAFLoop);
html, body {
 height: 100%;
 margin: 0;
}
canvas {
 width: 100%;
 height: 100%;
 display: block;
}
<canvas></canvas>

Size the window and you'll see it no longer flickers

This solution will work but the those methods (canvas.clientWidth/height and/or getBoundingClientRect) only return CSS pixels, not device pixels. You can multiply either number by devicePixelRatio but that will also not give you the correct answer all of the time. You can read about why in this answer: https://stackoverflow.com/a/72611819/128511

Solution 3: Check in rAF if the size of the canvas is going to change, if it is going to change then do not render and instead render in the ResizeObserver callback.

We can guess if the canvas is going to resize if getBoundingClientRect changes size. Even though we can't correctly convert from getBoundingClientRect to device pixels, the value will change from the previous time we called it if the canvas has been resized.

Downsides: just like the previous idea you now have an endless loop eating battery, and also this does not work if you are making a framework with 3rd party code doing unknown resizes and unknown times.

const canvas = document.querySelector('canvas');
const ctx = canvas.getContext('2d');

function draw() {
   const { width, height } = canvas;

   ctx.clearRect(0, 0, width, height);
   ctx.save();
   ctx.translate(width / 2, height / 2);
   const size = Math.min(width, height);
   ctx.beginPath();
   ctx.arc(0, 0, size / 3, 0, Math.PI * 2);
   ctx.fill();
   ctx.rotate(performance.now() * 0.001);
   ctx.strokeStyle = 'red';
   ctx.strokeRect(-5, -5, size / 4, 10);
   ctx.restore();
}

let lastSize = {};

function rAFLoop() {
  const rect = canvas.getBoundingClientRect();
  const willResize = lastSize.width !== rect.width || lastSize.height !== rect.height;
  if (willResize) {
    // don't draw since we'll draw in the resizeObserver callback
    // but do record our size
    lastSize = rect;
  } else {
    draw();
  }
  requestAnimationFrame(rAFLoop);
}
requestAnimationFrame(rAFLoop);

const observer = new ResizeObserver(entries => {
  for (const entry of entries) {
    const canvas = entry.target;
    canvas.width = entry.devicePixelContentBoxSize[0].inlineSize;
    canvas.height = entry.devicePixelContentBoxSize[0].blockSize;
    draw();
  }
})
observer.observe(canvas, { box: 'device-pixel-content-box' });
html, body {
 height: 100%;
 margin: 0;
}
canvas {
 width: 100%;
 height: 100%;
 display: block;
}
<canvas></canvas>

With this solution we only render once per frame and we get the actual device pixel size

Note: the 3rd solution changed what it's observing from 'content-box', which is the default, to 'device-pixel-content-box'. If you don't make this change, then, if the user zooms in or out you will not get a resize callback since from the POV the webpage, the size of the element doesn't change on zoom. It's still just a certain number of CSS pixels.

Solution 4: Record the size in the ResizeObserver callback, resize the canvas in requestAnimationFrame if the size changed

This solution means, given the order of operations at the top of this answer, you'll render the wrong size for 1 frame. Often that doesn't matter. In fact, run the example below and size the window and you likely won't see an issue.

The biggest downside of this one is that, if do not want to have an endless rAF loop, and your rendering depends on sizing in various ways (f.e. a UI), then you'll introduce 1-frame-behind visual glitches to your users.

const canvas = document.querySelector('canvas');
const ctx = canvas.getContext('2d');

function draw() {
   const { width, height } = canvas;

   ctx.clearRect(0, 0, width, height);
   ctx.save();
   ctx.translate(width / 2, height / 2);
   const size = Math.min(width, height);
   ctx.beginPath();
   ctx.arc(0, 0, size / 3, 0, Math.PI * 2);
   ctx.fill();
   ctx.rotate(performance.now() * 0.001);
   ctx.strokeStyle = 'red';
   ctx.strokeRect(-5, -5, size / 4, 10);
   ctx.restore();
}

const desiredSize = { width: 300, height: 150 };  // initial size of canvas

function rAFLoop() {
  const { width, height } = desiredSize;
  
  // only resize if we really need to since setting canvas.width/height
  // can be a heavy operation
  if (canvas.width !== width || canvas.height !== height) {
    canvas.width = width;
    canvas.height = height;
  }

  draw();
  requestAnimationFrame(rAFLoop);
}
requestAnimationFrame(rAFLoop);

const observer = new ResizeObserver(entries => {
  for (const entry of entries) {
    const canvas = entry.target;
    desiredSize.width = entry.devicePixelContentBoxSize[0].inlineSize;
    desiredSize.height = entry.devicePixelContentBoxSize[0].blockSize;
  }
})
observer.observe(canvas, { box: 'device-pixel-content-box' });
html, body {
 height: 100%;
 margin: 0;
}
canvas {
 width: 100%;
 height: 100%;
 display: block;
}
<canvas></canvas>

If you size the window you likely won't see any issues with this solution. On the other hand, let's say you have sudden large changes. For example imagine you have an editor and you have an option to show/hide a pane so that the canvas area changes sizes drastically. Then, for one frame, you'll see an image the wrong size. We can demo this

const canvas = document.querySelector('canvas');
const ctx = canvas.getContext('2d');

function draw() {
   const { width, height } = canvas;

   ctx.clearRect(0, 0, width, height);
   ctx.fillStyle = 'red';
   ctx.save();
   ctx.translate(width / 2, height / 2);
   const size = Math.min(width, height);
   ctx.beginPath();
   ctx.arc(0, 0, size / 3, 0, Math.PI * 2);
   ctx.fill();
   ctx.stokeStyle = 'red';
   ctx.lineWidth = 20;
   ctx.strokeRect(-size / 2.5, -size / 2.5, size / 1.25, size / 1.25);
   ctx.lineWidth = 3;
   ctx.rotate(performance.now() * 0.001);
   ctx.strokeStyle = '#000';
   ctx.strokeRect(-5, -5, size / 4, 10);
   ctx.restore();
}

const desiredSize = { width: 300, height: 150 };  // initial size of canvas

function rAFLoop() {
  const { width, height } = desiredSize;
  
  // only resize if we really need to since setting canvas.width/height
  // can be a heavy operation
  if (canvas.width !== width || canvas.height !== height) {
    canvas.width = width;
    canvas.height = height;
  }
  draw();
  requestAnimationFrame(rAFLoop);
}
requestAnimationFrame(rAFLoop);

const observer = new ResizeObserver(entries => {
  for (const entry of entries) {
    const canvas = entry.target;
    desiredSize.width = entry.devicePixelContentBoxSize[0].inlineSize;
    desiredSize.height = entry.devicePixelContentBoxSize[0].blockSize;
  }
})
observer.observe(canvas, { box: 'device-pixel-content-box' });

const uiElem = document.querySelector('#ui');
setInterval(() => {
  ui.style.display = ui.style.display === '' ? 'none' : '';
}, 1000);
html, body {
 height: 100%;
 margin: 0;
}
canvas {
 width: 100%;
 height: 100%;
 display: block;
}
#outer {
 display: flex;
 width: 100%;
 height: 100%;
}
#outer>* {
 flex: 1 1 50%;
 min-width: 0;
}
#ui {
  display: flex;
  justify-content: center;
  align-items: center;
  background-color: #888;
}
<div id="outer">
  <div><canvas></canvas></div>
  <div id="ui"><div>ui pane</div></div>
</div>

If you watch the example above closely you'll see one frame that's the wrong size when the ui pane is hidden or shown

Which solution you choose is up to your needs. If your drawing is not that heavy, drawing twice, once in rAF, once in the resize observer might be fine. Or, if you don't expect the user to resize often (note: this isn't about resizing just the window, plenty of webpages have panes with sliders that let you adjust the size of areas where all the same issues exist)

If you don't care about pixel perfection then the 2nd solution is fine too. For example, most 3d games, AFAIK, don't care about pixel perfection. They are already rendering bilinear filtered textures and often rendering to lower-resolutions and rescaling.

If you do care about pixel perfection then the 3rd solution works.

note: As of 2014-01-18 Safari still does not support devicePixelContentBoxSize

It would be great to have some basic web APIs to alleviate this problem and similar problems. Please leave your comments or upvotes on these issues to help DOM observation and code order/timing problems to be solved:

Upvotes: 3

Related Questions