Search code examples
javascriptcanvasframe-ratetimedeltarequestanimationframe

framerate and monitor refresh rate causes lag


I've been doing some test coding on html5 and canvas for game development, and ran into a bug that I can't get passed by. What happens was when I ran a basic animation loop with requestAnimationFrame, the velocity of the object is updated, the movement is smooth and all, but then I ran the script on a computer with monitor that had a 144Hz refresh rate (from 60Hz to 144Hz), and my dreams just fell into the abyss.

So I started reading up on delta time and how it fixes the issue with fps in games, and it works, but not quite as expected.

function update(timestamp = Date.now()){
    if(!previous) previous = timestamp;
    dt = (timestamp - previous) / 1000;
    fps = 1000 / (timestamp - previous);
    previous = timestamp;
...
   window.requestAnimationFrame(update);
}
window.requestAnimationFrame(update);

I call update with requestAnimationFrame, but it can be done without as well (timestamp = Date.now()), get the correct information fps => 143.78... dt = 0.006978...

    this.vx = 2;

...

    this.x += this.vx * dT * fps;
    this.y += this.vy * dT * fps;
    this.vy += gravity;
...

Calculations check out for 144 and 60 Hz monitors ( 2 * 0.0069 * 144 = 2.001 and 2 * 0.016 * 60 = 2.001 ), but there is just too much lag when you run it on the 60Hz monitor. The 2px movement just isn't as smooth as it should be; which brings me to my question, is there a fix to this problem?


Solution

  • The easiest if you already have a working code made for 60Hz and you want to fix it to work at any framerate is to convert your current absolute values to speed values expressed in px per ms (or seconds, doesn't matter).

    For instance in your case, you would do

    const expectedFrameRate = 60;
    obj.vx = 2 * (expectedFrameRate / 1000); // px per ms
    

    Once you get all these values converted to speed, you just have to multiply it by the elapsed time since last frame:

    function animate(timestamp) {
      const dt = (timestamp - previous);
      previous = timestamp;
      
      update(dt);
      requestAnimationFrame(animate);
    }
    function update(dt) {
      // ...
      obj.x += obj.vx * dt; // no matter how it long it took
                            // to render the last frame
                            // it will be on the correct position
      // ...
    

    Here is a small demo showing how "speed-based" animations can keep their correct position no matter the precision of the timer:

    const canvases = document.querySelectorAll("canvas");
    canvases.forEach( (c) => c.height = 60);
    const expectedFrameRate = 60;
    
    class Obj {
      constructor(canvas, color) {
        this.canvas = canvas;
        this.ctx = canvas.getContext("2d");
        this.color = color;
        this.y = 0;
        this.x = 0;
        this.width = 60;
        this.height = 60;
        this.vx = 2;
        this.xPerMs = 2 * (expectedFrameRate / 1000);
      }
      draw() {
        const { ctx, color, x, y, width, height } = this;
        ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
        ctx.fillStyle = color;
        ctx.fillRect(x, y, width, height);
      }
    };
    
    // red is simple x += vx
    // completely dependent on the framerate
    // will be late if switching tab or if the browser can't keep up @60FPS
    {
      const canvas = canvases[0];
      const obj = new Obj(canvas, "red");
      const anim = (timestamp) => {
        obj.x = (obj.x + obj.vx) % canvas.width;
        obj.draw();
        requestAnimationFrame(anim);
      };
      requestAnimationFrame(anim);
    }
    // green uses speed, inside rAF callback
    // smooth and correct
    {
      const canvas = canvases[1];
      const obj = new Obj(canvas, "green");
      let previous = document?.timeline?.currentTime || performance.now();
      const anim = (timestamp) => {
        const dt = timestamp - previous;
        previous = timestamp;
        obj.x = (obj.x + obj.xPerMs * dt) % canvas.width;
        obj.draw();
        requestAnimationFrame(anim);
      };
      requestAnimationFrame(anim);
    }
    // blue uses speed, inside random timeout callback
    // expect hiccups, but "correct" overall position
    {
      const canvas = canvases[2];
      const obj = new Obj(canvas, "blue");
      let previous = performance.now();
      const anim = () => {
        const timestamp = performance.now();
        const dt = timestamp - previous;
        previous = timestamp;
        obj.x = (obj.x + obj.xPerMs * dt) % canvas.width;
        obj.draw();
        setTimeout(anim, Math.random() * 100);
      };
      setTimeout(anim, Math.random() * 100);
    }
    canvas { border: 1px solid; display: block }
    <canvas></canvas>
    <canvas></canvas>
    <canvas></canvas>