Steven
Steven

Reputation: 167

Canvas is drawing with inconsistent speed (requestAnimationFrame)

I have the most simple and straightforward animation with canvas:

const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
ctx.canvas.width  = 700;
ctx.canvas.height = 300;

var x = 0;

var update = function() {
  x = x + 6;
}

var draw = function() {
  ctx.clearRect(0, 0, canvas.width, canvas.height);
  ctx.fillRect(x, 10, 30, 30);
}

let lastRenderTime = 0
const frameRate = 60;

function main(currentTime) {
  window.requestAnimationFrame(main)
  const secondsSinceLastRender = (currentTime - lastRenderTime) / 1000
  if (secondsSinceLastRender < 1 / frameRate) return


  lastRenderTime = currentTime

  update()
  draw()
}

window.requestAnimationFrame(main)
<canvas id="canvas"></canvas>

It's just a rectangle moving from left to right.

However, even on my powerful PC, it's running inconsistently (you can see it's not smooth enough for 60 fps and also the speed is varying).

Is there something I'm doing wrong or is this just how canvas works?

Upvotes: 1

Views: 1398

Answers (1)

Kaiido
Kaiido

Reputation: 136698

Yes you are doing a few things wrong.

As a general rule, you should not increment the distance by a fixed amount, instead use a delta-time to determine by how much your object should have moved since the last frame.

This is because requestAnimationFrame(rAF) may not fire at regular intervals, for instance if the browser has a lot of things to do in parallel the next rAF loop may get delayed. And anyway, you can't be sure at which rate rAF callbacks will fire; this will depend on the user's monitor's refresh-rate.


Here you are trying to set up a maximum frame rate of 60FPS, which I assume you thought would allow you to use a fixed increment value, since this code is supposed to control the frame-rate.

But this code would work only where the frame-rate is a multiple of the target FPS (e.g 120Hz, 240Hz). Every other frame rate will suffer from this code, and since as we said before the frame-rate should not be thought as being stable, even 120Hz and 240Hz monitors would suffer from it.
(note that on monitors where the refresh rate is lower than 60Hz, this code won't help them catch up on their delay either.)

Let's take a 75Hz monitor as an example (because it's actually quite common and because it makes for a good example), without anything interfering with the page and thus a "stable" frame-rate.
Every frame should have a duration of 1s/75 -> ~13.33333ms. Before updating the object's position, your code checks if the duration of the frame is above 1s/60 -> ~16.66666ms.

On this 75Hz monitor every single frame will fail this condition, and thus the position will get updated only at the next frame:

1st frame 2nd frame 3rd frame 4th frame
clock time 13.33333ms 26.66666ms 39.99999ms 53.33332ms
last-paint 0ms 0ms 26.66666ms 26.66666ms
time-from-last-paint 13.33333ms 26.66666ms 13.33333ms 26.66666ms
status discarded painted discarded painted
x position 0px 6px 6px 12px

When on a 60Hz monitor with same stable conditions it would have been

1st frame 2nd frame 3rd frame 4th frame
clock time 16.66666ms 33.33333ms 49.99999ms 66.66666ms
last-paint 0ms 16.66666ms 33.33333ms 49.99999ms
time-from-last-paint 16.66666ms 16.66666ms 16.66666ms 16.66666ms
status painted painted painted painted
x position 6px 12px 18px 24px

So you can see how after 50ms, the 75Hz setup has its x value still at 6px when it should already be at 18px in optimal conditions, and how we end up painting only at 37.5FPS instead of the targeted 60FPS.


You may not be on 75Hz monitor, but on my macOS Firefox, which does calculate rAF's rate from the CPU instead of looking at the monitor's refresh-rate, I end up in a situation even worse, where frames takes about 16.65ms, meaning that to traverse the canvas it takes literally twice the time it would take without your frame-rate restriction.


In order to avoid that, use a delta-time to determine the position of your object. This way, no matter the delay between two frames, no matter the monitor's refresh-rate etc. your object will get rendered at the correct position and even if you drop a frame or two, your animation won't jump or get stuck.

const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
ctx.canvas.width  = 700;
ctx.canvas.height = 300;

var x = 0;
const px_per_frame_at_60Hz = 6;
const px_per_second = (px_per_frame_at_60Hz * 60);

var update = function( elapsed_time ) {
  const distance = elapsed_time * px_per_second;
  x = (x + distance) % canvas.width;
}

var draw = function() {
  ctx.clearRect(0, 0, canvas.width, canvas.height);
  ctx.fillRect(x, 10, 30, 30);
}

let lastRenderTime = 0
const frameRate = 60;

function main(currentTime) {
  const secondsSinceLastRender = (currentTime - lastRenderTime) / 1000
  update( secondsSinceLastRender );
  draw();
  lastRenderTime = currentTime;
  // better keep it at the end in case something throws in this callback,
  // we don't want it to throw every painting frames indefinitely  
  window.requestAnimationFrame(main)
}

window.requestAnimationFrame(main)
<canvas id="canvas"></canvas>

Upvotes: 6

Related Questions