// ==UserScript==
// @name HTML canvas fps limiter
// @description Fps limiter for browser games or some 2D/3D animations
// @author Konf
// @namespace https://greasyfork.org/users/424058
// @icon https://img.icons8.com/external-neu-royyan-wijaya/32/external-animation-neu-solid-neu-royyan-wijaya.png
// @icon64 https://img.icons8.com/external-neu-royyan-wijaya/64/external-animation-neu-solid-neu-royyan-wijaya.png
// @version 2.0.0
// @match *://*/*
// @compatible Chrome
// @compatible Opera
// @run-at document-start
// @grant unsafeWindow
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_registerMenuCommand
// @grant GM_unregisterMenuCommand
// ==/UserScript==
/*
* msPrevMap is needed to provide individual rate limiting in cases
* where requestAnimationFrame is used by more than one function loop.
* Using a variable instead of a map in such cases makes limiter not working properly.
* But if some loop is using anonymous functions, the map mode can't limit it,
* so I've decided to make a switcher: the map mode or the single variable mode.
* Default is the map mode (mode 1)
*/
/* jshint esversion: 8 */
(async function() {
function DataStore(uuid, defaultStorage = {}) {
if (typeof uuid !== 'string' && typeof uuid !== 'number') {
throw new Error('Expected uuid when creating DataStore');
}
let cachedStorage = defaultStorage;
try {
cachedStorage = JSON.parse(GM_getValue(uuid));
} catch (err) {
GM_setValue(uuid, JSON.stringify(defaultStorage));
}
const getter = (obj, prop) => cachedStorage[prop];
const setter = (obj, prop, val) => {
cachedStorage[prop] = val;
GM_setValue(uuid, JSON.stringify(cachedStorage));
return val;
}
return new Proxy({}, { get: getter, set: setter });
}
class Measure {
constructor(functionToMeasure, measurementsTargetAmount = 100) {
this.isMeasureEnded = false;
this.isMeasureStarted = false;
this.functionToMeasure = functionToMeasure;
this.measurements = [];
this.measurementsTargetAmount = measurementsTargetAmount;
this._completionPromise = {
object: null,
reject: null,
resolve: null,
};
this._completionPromise.object = new Promise((resolve, reject) => {
this._completionPromise.reject = reject;
this._completionPromise.resolve = resolve;
});
this._handleVisibilityChange = this._handleVisibilityChange.bind(this);
}
_performMeasure() {
const start = performance.now();
this.functionToMeasure(() => {
const end = performance.now();
const elapsed = end - start;
if (this.isMeasureEnded) return;
this.measurements.push(elapsed);
if (this.measurements.length < this.measurementsTargetAmount) {
this._performMeasure();
} else {
this.end();
this._completionPromise.resolve(this._calculateMedian());
}
});
}
_calculateMedian() {
const sorted = this.measurements.slice().sort((a, b) => a - b);
const middle = Math.floor(sorted.length / 2);
return sorted.length % 2 === 0 ? (sorted[middle - 1] + sorted[middle]) / 2 : sorted[middle];
}
_handleVisibilityChange() {
if (document.hidden) {
// just reject to avoid messing with
// some measurements pause/unpause system
this.end();
this._completionPromise.reject();
} else {
this._performMeasure();
}
}
end() {
this.isMeasureEnded = true;
document.removeEventListener('visibilitychange', this._handleVisibilityChange);
}
async run() {
this.isMeasureStarted = true;
document.addEventListener('visibilitychange', this._handleVisibilityChange);
if (!document.hidden) this._performMeasure();
return this._completionPromise.object;
}
}
const setZeroTimeout = ((operatingWindow = window) => {
const messageName = 'ZERO_TIMEOUT_MESSAGE';
const timeouts = [];
operatingWindow.addEventListener('message', (ev) => {
if (ev.source === operatingWindow && ev.data === messageName) {
ev.stopPropagation();
if (timeouts.length > 0) {
try {
timeouts.shift()();
} catch (e) {
console.error(e);
}
}
}
}, true);
return (fn) => {
timeouts.push(fn);
operatingWindow.postMessage(messageName);
};
})(unsafeWindow);
const MODE = {
map: 1,
variable: 2,
};
const DEFAULT_FPS_CAP = 10;
const DEFAULT_MODE = MODE.map;
const MAX_FPS_CAP = 200;
const s = DataStore('storage', {
fpsCap: DEFAULT_FPS_CAP,
isFirstRun: true,
mode: DEFAULT_MODE,
});
const stallFnNames = {
oldRequestAnimationFrame: 'oldRequestAnimationFrame',
setTimeout: 'setTimeout',
setZeroTimeout: 'setZeroTimeout',
};
const fpsLimiterActivationConditions = {
fpsCapIsSmallerThanHz: false,
tabIsVisible: !document.hidden,
};
const oldRequestAnimationFrame = unsafeWindow.requestAnimationFrame;
const msPrevMap = new Map();
const menuCommandsIds = [];
let stallTimings, sortedStallTimings;
let isLimiterActive = false;
let userHz = 60;
let msPerFrame = 1000 / s.fpsCap;
let msPrev = 0;
unsafeWindow.requestAnimationFrame = function newRequestAnimationFrame(cb) {
for (const key in fpsLimiterActivationConditions) {
if (!fpsLimiterActivationConditions[key]) return oldRequestAnimationFrame(cb);
}
let msPassed, now;
(function recursiveTimeout() {
now = performance.now();
msPassed = now - ((s.mode === MODE.map ? msPrevMap.get(cb) : msPrev) || 0);
const diff = msPerFrame - msPassed;
if (diff > 0) {
let chosenStallFnName, chosenStallValue;
for (let i = 0; i < sortedStallTimings.length; i++) {
const [stallFnName, stallValue] = sortedStallTimings[i];
chosenStallFnName = stallFnName;
chosenStallValue = stallValue;
if (diff >= stallValue) break;
}
if (chosenStallFnName === stallFnNames.oldRequestAnimationFrame) {
return oldRequestAnimationFrame(recursiveTimeout);
}
if (chosenStallFnName === stallFnNames.setTimeout) {
return setTimeout(recursiveTimeout);
}
if (chosenStallFnName === stallFnNames.setZeroTimeout) {
return setZeroTimeout(recursiveTimeout);
}
}
if (s.mode === MODE.variable) {
msPrev = now;
} else {
msPrevMap.set(cb, now);
}
return cb(now);
}());
}
document.addEventListener('visibilitychange', () => {
fpsLimiterActivationConditions.tabIsVisible = !document.hidden;
});
stallTimings = await (async function makeMeasurements(attemptsNumber = 10) {
attemptsNumber -= 1;
const t = {
[stallFnNames.oldRequestAnimationFrame]: Infinity,
[stallFnNames.setTimeout]: Infinity,
[stallFnNames.setZeroTimeout]: Infinity,
};
try {
await Promise.all([
(async () => {
const measureFn = (cb) => setTimeout(cb);
t.setTimeout = await (new Measure(measureFn, 100)).run();
})(),
(async () => {
const measureFn = (cb) => oldRequestAnimationFrame(cb);
t.oldRequestAnimationFrame = await (new Measure(measureFn, 100)).run();
})(),
]);
await (async () => {
const measureFn = (cb) => setZeroTimeout(cb);
t.setZeroTimeout = await (new Measure(measureFn, 3000)).run();
})();
} catch (e) {
if (attemptsNumber > 0) return await makeMeasurements();
throw new Error('Failed with unknown reason');
}
return t;
}());
userHz = Math.round(1000 / stallTimings[stallFnNames.oldRequestAnimationFrame]);
sortedStallTimings = Object.entries(stallTimings).sort((a, b) => b[1] - a[1]);
fpsLimiterActivationConditions.fpsCapIsSmallerThanHz = s.fpsCap < userHz;
// mode 1 garbage collector. 50 is random number
setInterval(() => (msPrevMap.size > 50) && msPrevMap.clear(), 1000);
function changeFpsCapWithUser() {
const userInput = prompt(
`Current fps cap: ${s.fpsCap}. ` +
'What should be the new one? Leave empty or cancel to not to change'
);
if (userInput !== null && userInput !== '') {
let userInputNum = Number(userInput);
if (isNaN(userInputNum)) {
messageUser('bad input', 'Seems like the input is not a number');
} else if (userInputNum > MAX_FPS_CAP) {
s.fpsCap = MAX_FPS_CAP;
fpsLimiterActivationConditions.fpsCapIsSmallerThanHz = s.fpsCap < userHz;
messageUser(
'bad input',
`Seems like the input number is way too big. Decreasing it to ${MAX_FPS_CAP}`,
);
} else if (userInputNum < 0) {
messageUser(
'bad input',
"The input number can't be negative",
);
} else {
s.fpsCap = userInputNum;
fpsLimiterActivationConditions.fpsCapIsSmallerThanHz = s.fpsCap < userHz;
}
msPerFrame = 1000 / s.fpsCap;
// can't be applied in iframes
messageUser(
`the fps cap was set to ${s.fpsCap}`,
"For some places the fps cap change can't be applied without a reload, " +
"and if you can't tell worked it out or not, better to refresh the page",
);
unregisterMenuCommands();
registerMenuCommands();
}
}
function messageUser(title, text) {
alert(`Fps limiter: ${title}.\n\n${text}`);
}
function registerMenuCommands() {
// skip if in an iframe
if (window.self !== window.top) return;
menuCommandsIds.push(GM_registerMenuCommand(
`Cap fps (${s.fpsCap} now)`, () => changeFpsCapWithUser(), 'c'
));
menuCommandsIds.push(GM_registerMenuCommand(
`Switch mode to ${s.mode === MODE.map ? MODE.variable : MODE.map}`, () => {
s.mode = s.mode === MODE.map ? MODE.variable : MODE.map;
// can't be applied in iframes
messageUser(
`the mode was set to ${s.mode}`,
"For some places the mode change can't be applied without a reload, " +
"and if you can't tell worked it out or not, better to refresh the page. " +
"You can find description of the modes at the script download page",
);
unregisterMenuCommands();
registerMenuCommands();
}, 'm'
));
}
function unregisterMenuCommands() {
// skip if in an iframe
if (window.self !== window.top) return;
for (const id of menuCommandsIds) {
GM_unregisterMenuCommand(id);
}
menuCommandsIds.length = 0;
}
registerMenuCommands();
if (s.isFirstRun) {
messageUser(
'it seems like your first run of this script',
'You need to refresh the page on which this script should work. ' +
`What fps cap do you prefer? Default is ${DEFAULT_FPS_CAP} as a demonstration. ` +
'You can always quickly change it from your script manager icon ↗'
);
changeFpsCapWithUser();
s.isFirstRun = false;
}
})();