คุณต้องเข้าสู่ระบบหรือลงทะเบียนก่อนดำเนินการต่อ
Fps limiter for browser games or some 2D/3D animations
// ==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; } })();