Will Pierce
Will Pierce

Reputation: 107

Consistent way to get a browser's refresh rate

I'm using delta times to measure how much time has passed between frames in my loop. The value of the time variable is used as a multiplier for animation speeds. This makes sure that a user who has a 60 Hz display will experience the same animation speed as a user with a 144 Hz display (albeit at different frame rates). Without this multiplier, the 144 Hz user would experience animations that are way faster than the person with a 60 Hz display. This is how I achieve this:

let time;
let last = window.performance.now();

function loop(current) {
    time = current - last;
    last = current;
    
    // run game logic and render
    
    requestAnimationFrame(loop);
}

Even though this measurement works great, it also has its flaws. Browsers aren't as consistent as native apps and the time variable's value will vary greatly during stutters, resulting in "jumpy" animations.

Are there any better ways to detect a browser's frame rate without relying on requestAnimationFrame's inconsistent numbers?

Upvotes: 3

Views: 3200

Answers (1)

Blindman67
Blindman67

Reputation: 54069

Frame rate by running mean.

As the timers on browsers are not very accurate it is best to use a running mean.

A running mean is the average over the previous n frames.

From the running mean we can estimate the frame rate by selecting the closest frame rate above the mean

Example

The example implements the running mean via the object FrameRate

  • To create const rate = FrameRate(sample) where samples os the number of sample to take the mean of.

  • To add a sample assign any value to the property rate.tick it use performance.now to measure time.

  • rate.rate holds the current mean in fps. If there are not enough samples it will return a rate of 1

  • rate.FPS holds the number calculated frames per second. This is an estimate and will set the number of dropped frames when you get the value.

  • rate.dropped Is the estimated number of frames dropped per sample count at the current frame rate returned by rate.FPS Note dropped should be queried after FPS

function FrameRate(samples = 20) {
    const times = [];
    var s = samples;
    while(s--) { times.push(0) }
    var head = 0, total = 0, frame = 0, previouseNow = 0, rate = 0, dropped = 0;
    const rates = [0, 10, 12, 15, 20, 30, 60, 90, 120, 144, 240];
    const rateSet = rates.length;
    const API = {
        sampleCount: samples,
        reset() {
            frame = total = head = 0;
            previouseNow = performance.now();
            times.fill(0);
        },
        set tick(soak) {
            const now = performance.now()
            total -= times[head];
            total += (times[head++] = now - previouseNow);
            head %= samples;
            frame ++;
            previouseNow = now
        },
        get rate() { return frame > samples ? 1000 / (total / samples) : 1 },
        get FPS() {
            var r = API.rate, rr = r | 0, i = 0;
            while (i < rateSet && rr > rates[i]) { i++ }
            rate = rates[i];
            dropped = Math.round((total - samples * (1000 / rate)) / (1000 / rate));
            return rate;
        },
        get dropped() { return dropped },
    };
    return API;
}

const fRate = FrameRate();
var frame = 0;
requestAnimationFrame(loop);
fRate.reset();
function loop() {
    frame++;
    fRate.tick = 1;
    meanRateEl.textContent = "Mean FPS: " + fRate.rate.toFixed(3);
    FPSEl.textContent = "FPS: " + fRate.FPS;
    droppedEl.textContent = "Dropped frames: " + fRate.dropped + " per " + fRate.sampleCount + " samples" ;
    requestAnimationFrame(loop);
}
body {user-select: none;}
<div id="meanRateEl"></div>
<div id="FPSEl"></div>
<div id="droppedEl"></div>

If you get a consistent FPS of 90, 120, 144, or 240 and you have not disabled frame limiting and/or Vsnyc and are not using FireFox please make a comment. It is currently unclear how many have these setting as the default

Example 2

  • The example lets you set 3 frame rates. It does this by calling multiple frame requests. For 240FPS it request 4 frames per sync

  • If you have turned of frame vsync on the browser the frame rates 120 and 240 will not report accurately. To see actual frame rate just use the 60FPS setting.

  • You can add a load to the frame. The load is in fraction of requested frame rate's frame time.

var forceRate = 60;
var current = 60;
var load = 0, loadTime = 0;

function FrameRate(samples = 20) {
    const times = [];
    var s = samples;
    while(s--) { times.push(0) }
    var head = 0, total = 0, frame = 0, previouseNow = 0, rate = 0, dropped = 0;
    const rates = [0, 10, 12, 15, 20, 30, 60, 90, 120, 144, 240];
    const rateSet = rates.length;
    const API = {
        sampleCount: samples,
        reset() {
            frame = total = head = 0;
            previouseNow = performance.now();
            times.fill(0);
        },
        set tick(soak) {
            const now = performance.now()
            total -= times[head];
            total += (times[head++] = now - previouseNow);
            head %= samples;
            frame ++;
            previouseNow = now
        },
        get rate() { return frame > samples ? 1000 / (total / samples) : 1 },
        get FPS() {
            var r = API.rate, rr = r | 0, i = 0;
            while (i < rateSet && rr > rates[i]) { i++ }
            rate = rates[i];
            dropped = Math.round((total - samples * (1000 / rate)) / (1000 / rate));
            return rate;
        },
        get dropped() { return dropped },
    };
    return API;
}

const fRate = FrameRate();
var frame = 0;
requestAnimationFrame(loop);
fRate.reset();
function loop() {
    frame++;
    fRate.tick = 1;
    if (load) {
        const pnow = performance.now() + loadTime;
        while (performance.now() < pnow);        
    }
    meanRateEl.textContent = "Mean FPS: " + fRate.rate.toFixed(3);
    FPSEl.textContent = "FPS: " + fRate.FPS;
    droppedEl.textContent = "Dropped frames: " + fRate.dropped + " per " + fRate.sampleCount + " samples" ;
    
    if (current > forceRate) {
        current /= 2;
    } else {
        requestAnimationFrame(loop);
        if (current < forceRate) {
            current *= 2;
            requestAnimationFrame(loop);
        }
    }

}
options.addEventListener("click", (e) => {
    if (e.target.dataset.load !== undefined) {
        load = Number(e.target.dataset.load);
        loadTime = (1000 / forceRate) * load;
        loadEl.textContent = "Load " + loadTime.toFixed(3) + "ms " + (load * 100).toFixed(0) + "%";
        return;
    }
    if (e.target.dataset.rate !== undefined && e.target.dataset.rate != forceRate) {
        forceRate = e.target.dataset.rate | 0;
        forcedRateEl.textContent = "Requested " + forceRate + "FPS";
        loadTime = (1000 / forceRate) * load;
        loadEl.textContent = "Load " + loadTime.toFixed(3) + "ms " + (load * 100).toFixed(0) + "%";        
   }
});
body {user-select: none;}
<div id="forcedRateEl">Requested 60FPS</div>
<div id="loadEl">Load 0ms 0% frame time</div>
<div id="meanRateEl"></div>
<div id="FPSEl"></div>
<div id="droppedEl"></div>
<div id="options">
Frame rate</br>
<button data-rate="240">240FPS</button>
<button data-rate="120">120FPS</button>
<button data-rate="60">60FPS</button><br>
Frame load</br>
<button data-load="0.0">0%</button>
<button data-load="0.25">25%</button>
<button data-load="0.5">50%</button>
<button data-load="0.75">75%</button>
<button data-load="0.90">90%</button>
<button data-load="1.1">110%</button>
<button data-load="1.5">150%</button>
<button data-load="2">200%</button>
</div>

User experience

If you have a game that can frequently come under a heavy load causing the frame rate to drop (say 60 down to 30) users will notice, It is best to cap the rate to 30 so that the game maintains a consistent rate and the slow down is not noticed.

Also be aware that doubling the frame rate more than halves the amount of CPU time you have to render the scene. Allowing users to control the frames rate can result in negative perceptions of game quality if it causes the game to frequently jump between rates, which is much more likely at higher frame rates.

98% of users can not tell the difference between 60 and 30FPS unless they have a side by side comparison. Even further almost all users can be completely fooled by simply adding a bogus frame rate indicator to the game, as long as you have a consistent frame rate, and it is above human visual persistence.

Upvotes: 5

Related Questions