skip to Main Content

I followed an article about getting the best out of delta time in animation loops.
It’s the most advanced way to handle loop renders and updates I’ve ever read.
https://isaacsukin.com/news/2015/01/detailed-explanation-javascript-game-loops-and-timing

I implemented it within a class and it works.
However:

I would like to understand why I have sudden FPS drops when adjusting values:

  1. I am on an iMac Pro which has a refresh rate of 60hz. But, when I set "maxFPS" to 60 (or any value below 70), my frame rate drops in 5 seconds from 60 FPS to 32fps. So, I need to increase "maxFPS" and I only get a steady 60fps when I set it above 70, to any value.

  2. Whatever "maxFPS" I put, if I change the "if (timestamp > this.lastFpsUpdate + 1000)" statement to something slightly lower than 1000 (even 950), once, again, my frame rate drops dramatically around 32fps.

Messing with those 2 values lead to the EXACT same behavior.

FYI:

my canvas is 3840 x 2160 but, the 2 issues I mentioned here above are exactly the same at that resolution or at 960×540. The resolution doesn’t seem to affect the issues at all (I get the same frame rates and (lack) of performance no matter the resolution).

The fps drops seem so sudden for very specific parameter values that it feels like it’s a code issue rather than a performance issue. Indeed, I don’t see why a change from 1000ms to 950ms in the FPS display refresh rate would drop the FPS so dramatically all of a sudden. And I don’t see why setting the maxFPS to 60 is a problem on a 60hz screen while setting it to 70 or even 1500 is just fine. And these drops will consistently happen no matter if I actually render something or if my draw/update functions are completely empty.

Here is my loop class file:

class Loop{

    //--------------------------------------------------------------------

    constructor(maxFPS,displayFPS){

        this.fps              = 60;
        this.maxFPS           = maxFPS; //if lower than 70, the fps is dropping fast 
        this.displayFPS       = displayFPS;
        this.timestep         = 1000/this.fps;
        this.delta            = 0;
        this.lastFrameTimeMs  = 0;
        this.framesThisSecond = 0;
        this.lastFpsUpdate    = 0;

    }

    //--------------------------------------------------------------------

    mainLoop(timestamp) {
    
        if (timestamp < this.lastFrameTimeMs + (1000 / this.maxFPS)) {

            requestAnimationFrame(this.mainLoop.bind(this));
            return;

        }

        this.delta          += timestamp - this.lastFrameTimeMs;
        this.lastFrameTimeMs = timestamp;

        //any value lower than 1000ms is dropping the FPS here...
        if (timestamp > this.lastFpsUpdate + 1000) {

            this.fps              = 0.25 * this.framesThisSecond + 0.75 * this.fps;
            // same issue if I just put:
            //this.fps            = this.framesThisSecond;
            this.lastFpsUpdate    = timestamp;
            this.framesThisSecond = 0;

        }

        this.framesThisSecond++;

        var numUpdateSteps = 0;

        while (this.delta >= this.timestep) {
        
            // the update function of my working file
            update(this.timestep);

            this.delta -= this.timestep;

            if (++numUpdateSteps >= 240) {

                this.delta = 0;
                break;

            }

        }

        if(this.displayFPS == true){

            document.getElementById('frameRate').textContent = (Math.round(this.fps * 100) / 100).toFixed(2);

        }

        context.clearRect(0, 0, canvas.width, canvas.height);

        // the draw function of my working file
        draw(this.delta/this.timestep);

        requestAnimationFrame(this.mainLoop.bind(this));

    }

    //--------------------------------------------------------------------

    start(){

        // the setup function of my working file
        setup();
    
       requestAnimationFrame(this.mainLoop.bind(this));

    }

    //--------------------------------------------------------------------

}

Here is my working file:

//--------------------------------------------------------------------
// Initialize
//--------------------------------------------------------------------

const canvas = document.querySelector('canvas')
      canvas.width  = 3840; // tested: no impact on my issues 
      canvas.height = 2160; // tested: no impact on my issues 
const context       = canvas.getContext('2d');

const loop = new Loop(maxFPS=70, display=true);
      loop.start();

//--------------------------------------------------------------------
// Variables
//--------------------------------------------------------------------

var boxSize;
var boxPos;
var lastBoxPos; 
var boxVelocity;
var limit;

//--------------------------------------------------------------------
// Animation Test
//--------------------------------------------------------------------

function setup(){

    boxSize     = 200;
    boxPosX     = 10;
    boxPosY     = 10;
    lastBoxPosX = 10;
    boxVelocity = 1;
    limit       = canvas.width - boxSize;

}

//--------------------------------------------------------------------

function update(delta){
    
    boxLastPosX  = boxPosX;
    boxPosX     += boxVelocity * delta;
    if (boxPosX >= limit || boxPosX <= 0) {boxVelocity = -boxVelocity;}

}
//--------------------------------------------------------------------

function draw(interpolation){
    
    context.beginPath();
    context.fillStyle = "#3e4856";
    context.fillRect((boxLastPosX + (boxPosX - boxLastPosX) * interpolation), boxPosY, boxSize, boxSize);
  
}

//--------------------------------------------------------------------

Thanks for your input.

2

Answers


  1. I’ll explain why, when you try to achieve 59 fps and your display is refreshing at 60 fps, that your technique actually achieves only 30 fps.

    Allow me to make an analogy to sending packages with a delivery service:

    Imagine you want to send a package once every 25 hours (yes, one more than 24 – that’s slightly less frequently than once per day). But the delivery person shows up each day on their schedule at exactly 9:00 am to collect any packages you want to send. (This is akin to your display’s frame rate).

    On Monday, you send your first package and feel like you’re off to a good start. Then, on Tuesday, the delivery person shows up at 9:00 am and you realize that only 24 of the desired 25 hours have elapsed, so you don’t give the delivery person any packages, and instead say "I have nothing for you right now, see you later!" The delivery person leaves, and then diligently shows up on their schedule on Wednesday at 9:00 am. At this point you feel slightly annoyed but hand them your (now 23-hours late) package anyway and it gets delivered. You sure would have liked to send it at 10:00 am on Tuesday, but that’s not the delivery person’s schedule. They show up at 9:00 every day no matter what.

    Then on Thursday the delivery person shows up again. And once more, you notice that it has been a mere 24 hours since you handed them your Wednesday delivery so you decline to give them a package to deliver on Thursday. And on Friday at 9:00 am they show up and you again hand them a (again very late) package.

    The cycle repeats and on average, with this technique, you are sending a package once every other day. That’s one package every 48 hours instead of one every 25 hours as you intended.

    By your record keeping, you are achieving a goal of not delivering two packages fewer than 25 hours apart. But you are not achieving a goal of delivering a package on average once every 25 hours over a longer period of time. To do so, you would need to keep track of your average rate (number of packages delivered divided by total time elapsed over some reasonably long period of time), not the instantaneous rate (absolute interval between two deliveries).

    (end of analogy)

    If you want to achieve a rate slightly less than 60 fps, then hopefully you can see the analogy between rendering (delivering a package) and the rate at which the delivery person shows up to request renders (the requestAnimationFrame request and the refresh rate of your display). And how, if the opportunities to render are happening once every 60th of a second, but you are declining to render on every second opportunity (because not enough time has elapsed – because you are using the instantanous accounting method described in the analogy), then you will achieve a rate of only 30 fps.

    To correct the problem, the instantaneous accounting technique of if (timestamp < this.lastFrameTimeMs + (1000 / this.maxFPS)) { is inadequate and you must instead keep track of total frames rendered over some total time period using a moving average technique.

    Login or Signup to reply.
  2. This is a function I once used in some prototyping task. It stores delays of several most recent frames in a buffer, and uses simple statistics to decide if the current frame should be rendered or dropped. It also detects "laggy frames" (like those slowdowns when you switch to another tab, or short inconsistent hangs while CPU is heavily loaded) and excludes them from calculations. This helps to avoid overcompensation which otherwise results in bursts of increased framerates.

    In most cases, the function achieves nearly stable arbitrary FPS, but doesn’t guarantee to create accurately regular patterns of allowed and dropped frames. Sometimes it even produces weird artifacts; like allowing 2 frames in a row, then dropping 2, then again allowing 2 (which should’ve been uniform alteration instead). It is not a production quality of course, and should be possible to create better algorithm.

    Also notice that FPS here is top-bounded (and sometimes is slightly exceeded) rather than averaged.

    The output of the following code doesn’t fit into the console wrapper, so better look in the actual browser console. There’re 5 sample loops, 3 seconds each.

    PS1: bufferAll is needed not for the algorithm, but for calculating the actual FPS generated by the function. It keeps delays of all allowed frames over the last second (BUFFER_ALL_MIN_DURATION).

    PS2: Values of all constants are there for fine-tuning. Current values might not be optimal, but they were fine for me the time I used this.

    function startRenderLoop (fps, renderCallback) {
    
        // buffer of all frames; used to calculate actual average FPS
        const bufferAll = [0];
        // buffer of fast (non-laggy) frames; used to determine frame drops
        const bufferFast = [0];
        // bufferAll: minimum total duration, in seconds
        const BUFFER_ALL_MIN_DURATION = 1;
        // bufferAll: minimum number of frames
        const BUFFER_ALL_MIN_ENTRIES = 1;
        // bufferFast: minimum total duration, in seconds
        const BUFFER_FAST_MIN_DURATION = 1;
        // bufferFast: minimum number of frames
        const BUFFER_FAST_MIN_ENTRIES = 20;
        // callback delay will be considered laggy if exceeds 110 % AND at least 5 ms longer than expected delay;
        // laggy frames are always rendered, but don't contribute to stabilization of subsequent fast frames;
        // laggy delay unit is 10 microseconds
        const LAGGY_DELAY = Math.max(
            Math.floor(1.1 * 100000 / fps),
            Math.floor(100000 / fps) + 500
        );
    
        var bufferAllDuration = 0;
        var bufferFastDuration = 0;
        var loopStartTime;
        var lastCallbackTime;
        var lastFrameTime;
        var lastFrameDrops = 0;
        var frameId = -1;
        var requestId = requestAnimationFrame(rafCallback);
        var proceed = true;
    
        function rafCallback (time) {
    
            // use 10 microsecond time resolution; force to integers to avoid rounding errors
            time = Math.floor(time * 100);
    
            // first frame
            if (frameId == -1) {
                frameId = 0;
                loopStartTime = lastCallbackTime = lastFrameTime = time;
                renderCallback(0, 0, 0, 0, false, 0);
            }
    
            // subsequent frames
            else {
                let frameDelay = time - lastFrameTime;
                let callbackDelay = time - lastCallbackTime;
    
                // bufferAll: update buffer and its delay accumulator
                bufferAllDuration -= bufferAll[bufferAll.length - 1];
                bufferAllDuration += frameDelay;
                bufferAll[bufferAll.length - 1] = frameDelay;
    
                // bufferAll: remove old entries
                while (bufferAll.length > BUFFER_ALL_MIN_ENTRIES && ((bufferAllDuration - bufferAll[0]) / 100000) >= BUFFER_ALL_MIN_DURATION) {
                    bufferAllDuration -= bufferAll.shift();
                }
    
                // this is a fast frame
                if (callbackDelay < LAGGY_DELAY) {
                    // bufferFast: update buffer and its delay accumulator
                    bufferFastDuration -= bufferFast[bufferFast.length - 1];
                    bufferFastDuration += frameDelay;
                    bufferFast[bufferFast.length - 1] = frameDelay;
    
                    // bufferFast: remove old entries
                    while (bufferFast.length > BUFFER_FAST_MIN_ENTRIES && ((bufferFastDuration - bufferFast[0]) / 100000) >= BUFFER_FAST_MIN_DURATION) {
                        bufferFastDuration -= bufferFast.shift();
                    }
    
                    // decrease statistical influence of the oldest frames (slightly improves stabilization)
                    let weightedDuration = 0;
                    let weightedDurationFactor = 0;
                    for (let i = 0; i < bufferFast.length; i++) {
                        let factor = (i < 16)? ((i + 1) / 16): 1;
                        weightedDuration += bufferFast[i] * factor;
                        weightedDurationFactor += factor;
                    }
    
                    let fastFPS = 100000 * weightedDurationFactor / weightedDuration;
                    // render if current frame fits into a desired FPS
                    var render = (fastFPS <= fps);
                }
    
                // this is a laggy frame
                else {
                    // bufferFast: discard the most recent frame
                    bufferFastDuration -= bufferFast.pop();
                    // render regardless
                    var render = true;
                }
    
                if (render) {
                    let averageFPS = 100000 * bufferAll.length / bufferAllDuration;
                    frameId++;
                    renderCallback(
                        // convert to milliseconds
                        (time - loopStartTime) / 100,
                        (time - lastFrameTime) / 100,
                        frameId,
                        lastFrameDrops,
                        callbackDelay >= LAGGY_DELAY,
                        averageFPS
                    );
                    lastFrameTime = time;
                    lastFrameDrops = 0;
                    bufferAll.push(0);
                    bufferFast.push(0);
                }
                else {
                    lastFrameDrops++;
                }
    
                lastCallbackTime = time;
            }
    
            if (proceed) {
                requestId = requestAnimationFrame(rafCallback);
            }
        }
    
        return function stopRenderLoop () {
            cancelAnimationFrame(requestId);
            proceed = false;
        };
    }
    
    function renderCallback (loopDuration, frameDelay, frameId, frameDrops, laggy, averageFPS) {
        console.log(
            `Loop: ${(loopDuration / 1000).toFixed(3)} sec ` +
            `/ ${averageFPS.toFixed(3)} FPS ` +
            `/ frame: ${frameId} ` +
            `/ delay: ${(frameDelay).toFixed(2)} ms ` +
            `/ dropped: ${frameDrops} ` +
            (laggy? '/ LAGGY': '')
        );
        if (loopDuration >= 3000) {
            stopRenderLoop();
            console.log('-'.repeat(80));
            if (fpsTestArray.length) {
                stopRenderLoop = startRenderLoop(fpsTestArray.shift(), renderCallback);
            }
        }
    }
    var fpsTestArray = [7.777, 33, 45, 59, 9000];
    var stopRenderLoop = startRenderLoop(fpsTestArray.shift(), renderCallback);
    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search