HTML canvas fps limiter

Fps limiter for browser games or some 2D/3D animations

  1. // ==UserScript==
  2. // @name HTML canvas fps limiter
  3. // @description Fps limiter for browser games or some 2D/3D animations
  4. // @author Konf
  5. // @namespace https://greasyfork.org/users/424058
  6. // @icon https://img.icons8.com/external-neu-royyan-wijaya/32/external-animation-neu-solid-neu-royyan-wijaya.png
  7. // @icon64 https://img.icons8.com/external-neu-royyan-wijaya/64/external-animation-neu-solid-neu-royyan-wijaya.png
  8. // @version 2.0.0
  9. // @match *://*/*
  10. // @compatible Chrome
  11. // @compatible Opera
  12. // @run-at document-start
  13. // @grant unsafeWindow
  14. // @grant GM_getValue
  15. // @grant GM_setValue
  16. // @grant GM_registerMenuCommand
  17. // @grant GM_unregisterMenuCommand
  18. // ==/UserScript==
  19.  
  20. /*
  21. * msPrevMap is needed to provide individual rate limiting in cases
  22. * where requestAnimationFrame is used by more than one function loop.
  23. * Using a variable instead of a map in such cases makes limiter not working properly.
  24. * But if some loop is using anonymous functions, the map mode can't limit it,
  25. * so I've decided to make a switcher: the map mode or the single variable mode.
  26. * Default is the map mode (mode 1)
  27. */
  28.  
  29. /* jshint esversion: 8 */
  30.  
  31. (async function() {
  32. function DataStore(uuid, defaultStorage = {}) {
  33. if (typeof uuid !== 'string' && typeof uuid !== 'number') {
  34. throw new Error('Expected uuid when creating DataStore');
  35. }
  36.  
  37. let cachedStorage = defaultStorage;
  38.  
  39. try {
  40. cachedStorage = JSON.parse(GM_getValue(uuid));
  41. } catch (err) {
  42. GM_setValue(uuid, JSON.stringify(defaultStorage));
  43. }
  44.  
  45. const getter = (obj, prop) => cachedStorage[prop];
  46.  
  47. const setter = (obj, prop, val) => {
  48. cachedStorage[prop] = val;
  49.  
  50. GM_setValue(uuid, JSON.stringify(cachedStorage));
  51.  
  52. return val;
  53. }
  54.  
  55. return new Proxy({}, { get: getter, set: setter });
  56. }
  57.  
  58. class Measure {
  59. constructor(functionToMeasure, measurementsTargetAmount = 100) {
  60. this.isMeasureEnded = false;
  61. this.isMeasureStarted = false;
  62.  
  63. this.functionToMeasure = functionToMeasure;
  64. this.measurements = [];
  65. this.measurementsTargetAmount = measurementsTargetAmount;
  66.  
  67. this._completionPromise = {
  68. object: null,
  69. reject: null,
  70. resolve: null,
  71. };
  72.  
  73. this._completionPromise.object = new Promise((resolve, reject) => {
  74. this._completionPromise.reject = reject;
  75. this._completionPromise.resolve = resolve;
  76. });
  77.  
  78. this._handleVisibilityChange = this._handleVisibilityChange.bind(this);
  79. }
  80.  
  81. _performMeasure() {
  82. const start = performance.now();
  83.  
  84. this.functionToMeasure(() => {
  85. const end = performance.now();
  86. const elapsed = end - start;
  87.  
  88. if (this.isMeasureEnded) return;
  89.  
  90. this.measurements.push(elapsed);
  91.  
  92. if (this.measurements.length < this.measurementsTargetAmount) {
  93. this._performMeasure();
  94. } else {
  95. this.end();
  96. this._completionPromise.resolve(this._calculateMedian());
  97. }
  98. });
  99. }
  100.  
  101. _calculateMedian() {
  102. const sorted = this.measurements.slice().sort((a, b) => a - b);
  103. const middle = Math.floor(sorted.length / 2);
  104.  
  105. return sorted.length % 2 === 0 ? (sorted[middle - 1] + sorted[middle]) / 2 : sorted[middle];
  106. }
  107.  
  108. _handleVisibilityChange() {
  109. if (document.hidden) {
  110. // just reject to avoid messing with
  111. // some measurements pause/unpause system
  112. this.end();
  113. this._completionPromise.reject();
  114. } else {
  115. this._performMeasure();
  116. }
  117. }
  118.  
  119. end() {
  120. this.isMeasureEnded = true;
  121. document.removeEventListener('visibilitychange', this._handleVisibilityChange);
  122. }
  123.  
  124. async run() {
  125. this.isMeasureStarted = true;
  126.  
  127. document.addEventListener('visibilitychange', this._handleVisibilityChange);
  128.  
  129. if (!document.hidden) this._performMeasure();
  130.  
  131. return this._completionPromise.object;
  132. }
  133. }
  134.  
  135. const setZeroTimeout = ((operatingWindow = window) => {
  136. const messageName = 'ZERO_TIMEOUT_MESSAGE';
  137. const timeouts = [];
  138.  
  139. operatingWindow.addEventListener('message', (ev) => {
  140. if (ev.source === operatingWindow && ev.data === messageName) {
  141. ev.stopPropagation();
  142.  
  143. if (timeouts.length > 0) {
  144. try {
  145. timeouts.shift()();
  146. } catch (e) {
  147. console.error(e);
  148. }
  149. }
  150. }
  151. }, true);
  152.  
  153. return (fn) => {
  154. timeouts.push(fn);
  155. operatingWindow.postMessage(messageName);
  156. };
  157. })(unsafeWindow);
  158.  
  159. const MODE = {
  160. map: 1,
  161. variable: 2,
  162. };
  163.  
  164. const DEFAULT_FPS_CAP = 10;
  165. const DEFAULT_MODE = MODE.map;
  166. const MAX_FPS_CAP = 200;
  167.  
  168. const s = DataStore('storage', {
  169. fpsCap: DEFAULT_FPS_CAP,
  170. isFirstRun: true,
  171. mode: DEFAULT_MODE,
  172. });
  173.  
  174. const stallFnNames = {
  175. oldRequestAnimationFrame: 'oldRequestAnimationFrame',
  176. setTimeout: 'setTimeout',
  177. setZeroTimeout: 'setZeroTimeout',
  178. };
  179.  
  180. const fpsLimiterActivationConditions = {
  181. fpsCapIsSmallerThanHz: false,
  182. tabIsVisible: !document.hidden,
  183. };
  184.  
  185. const oldRequestAnimationFrame = unsafeWindow.requestAnimationFrame;
  186. const msPrevMap = new Map();
  187. const menuCommandsIds = [];
  188. let stallTimings, sortedStallTimings;
  189. let isLimiterActive = false;
  190. let userHz = 60;
  191. let msPerFrame = 1000 / s.fpsCap;
  192. let msPrev = 0;
  193.  
  194. unsafeWindow.requestAnimationFrame = function newRequestAnimationFrame(cb) {
  195. for (const key in fpsLimiterActivationConditions) {
  196. if (!fpsLimiterActivationConditions[key]) return oldRequestAnimationFrame(cb);
  197. }
  198.  
  199. let msPassed, now;
  200.  
  201. (function recursiveTimeout() {
  202. now = performance.now();
  203. msPassed = now - ((s.mode === MODE.map ? msPrevMap.get(cb) : msPrev) || 0);
  204.  
  205. const diff = msPerFrame - msPassed;
  206.  
  207. if (diff > 0) {
  208. let chosenStallFnName, chosenStallValue;
  209.  
  210. for (let i = 0; i < sortedStallTimings.length; i++) {
  211. const [stallFnName, stallValue] = sortedStallTimings[i];
  212.  
  213. chosenStallFnName = stallFnName;
  214. chosenStallValue = stallValue;
  215.  
  216. if (diff >= stallValue) break;
  217. }
  218.  
  219. if (chosenStallFnName === stallFnNames.oldRequestAnimationFrame) {
  220. return oldRequestAnimationFrame(recursiveTimeout);
  221. }
  222.  
  223. if (chosenStallFnName === stallFnNames.setTimeout) {
  224. return setTimeout(recursiveTimeout);
  225. }
  226.  
  227. if (chosenStallFnName === stallFnNames.setZeroTimeout) {
  228. return setZeroTimeout(recursiveTimeout);
  229. }
  230. }
  231.  
  232. if (s.mode === MODE.variable) {
  233. msPrev = now;
  234. } else {
  235. msPrevMap.set(cb, now);
  236. }
  237.  
  238. return cb(now);
  239. }());
  240. }
  241.  
  242. document.addEventListener('visibilitychange', () => {
  243. fpsLimiterActivationConditions.tabIsVisible = !document.hidden;
  244. });
  245.  
  246. stallTimings = await (async function makeMeasurements(attemptsNumber = 10) {
  247. attemptsNumber -= 1;
  248.  
  249. const t = {
  250. [stallFnNames.oldRequestAnimationFrame]: Infinity,
  251. [stallFnNames.setTimeout]: Infinity,
  252. [stallFnNames.setZeroTimeout]: Infinity,
  253. };
  254.  
  255. try {
  256. await Promise.all([
  257. (async () => {
  258. const measureFn = (cb) => setTimeout(cb);
  259.  
  260. t.setTimeout = await (new Measure(measureFn, 100)).run();
  261. })(),
  262.  
  263. (async () => {
  264. const measureFn = (cb) => oldRequestAnimationFrame(cb);
  265.  
  266. t.oldRequestAnimationFrame = await (new Measure(measureFn, 100)).run();
  267. })(),
  268. ]);
  269.  
  270. await (async () => {
  271. const measureFn = (cb) => setZeroTimeout(cb);
  272.  
  273. t.setZeroTimeout = await (new Measure(measureFn, 3000)).run();
  274. })();
  275. } catch (e) {
  276. if (attemptsNumber > 0) return await makeMeasurements();
  277.  
  278. throw new Error('Failed with unknown reason');
  279. }
  280.  
  281. return t;
  282. }());
  283.  
  284. userHz = Math.round(1000 / stallTimings[stallFnNames.oldRequestAnimationFrame]);
  285. sortedStallTimings = Object.entries(stallTimings).sort((a, b) => b[1] - a[1]);
  286. fpsLimiterActivationConditions.fpsCapIsSmallerThanHz = s.fpsCap < userHz;
  287.  
  288. // mode 1 garbage collector. 50 is random number
  289. setInterval(() => (msPrevMap.size > 50) && msPrevMap.clear(), 1000);
  290.  
  291. function changeFpsCapWithUser() {
  292. const userInput = prompt(
  293. `Current fps cap: ${s.fpsCap}. ` +
  294. 'What should be the new one? Leave empty or cancel to not to change'
  295. );
  296.  
  297. if (userInput !== null && userInput !== '') {
  298. let userInputNum = Number(userInput);
  299.  
  300. if (isNaN(userInputNum)) {
  301. messageUser('bad input', 'Seems like the input is not a number');
  302. } else if (userInputNum > MAX_FPS_CAP) {
  303. s.fpsCap = MAX_FPS_CAP;
  304. fpsLimiterActivationConditions.fpsCapIsSmallerThanHz = s.fpsCap < userHz;
  305.  
  306. messageUser(
  307. 'bad input',
  308. `Seems like the input number is way too big. Decreasing it to ${MAX_FPS_CAP}`,
  309. );
  310. } else if (userInputNum < 0) {
  311. messageUser(
  312. 'bad input',
  313. "The input number can't be negative",
  314. );
  315. } else {
  316. s.fpsCap = userInputNum;
  317. fpsLimiterActivationConditions.fpsCapIsSmallerThanHz = s.fpsCap < userHz;
  318. }
  319.  
  320. msPerFrame = 1000 / s.fpsCap;
  321.  
  322. // can't be applied in iframes
  323. messageUser(
  324. `the fps cap was set to ${s.fpsCap}`,
  325. "For some places the fps cap change can't be applied without a reload, " +
  326. "and if you can't tell worked it out or not, better to refresh the page",
  327. );
  328.  
  329. unregisterMenuCommands();
  330. registerMenuCommands();
  331. }
  332. }
  333.  
  334. function messageUser(title, text) {
  335. alert(`Fps limiter: ${title}.\n\n${text}`);
  336. }
  337.  
  338. function registerMenuCommands() {
  339. // skip if in an iframe
  340. if (window.self !== window.top) return;
  341.  
  342. menuCommandsIds.push(GM_registerMenuCommand(
  343. `Cap fps (${s.fpsCap} now)`, () => changeFpsCapWithUser(), 'c'
  344. ));
  345.  
  346. menuCommandsIds.push(GM_registerMenuCommand(
  347. `Switch mode to ${s.mode === MODE.map ? MODE.variable : MODE.map}`, () => {
  348. s.mode = s.mode === MODE.map ? MODE.variable : MODE.map;
  349.  
  350. // can't be applied in iframes
  351. messageUser(
  352. `the mode was set to ${s.mode}`,
  353. "For some places the mode change can't be applied without a reload, " +
  354. "and if you can't tell worked it out or not, better to refresh the page. " +
  355. "You can find description of the modes at the script download page",
  356. );
  357.  
  358. unregisterMenuCommands();
  359. registerMenuCommands();
  360. }, 'm'
  361. ));
  362. }
  363.  
  364. function unregisterMenuCommands() {
  365. // skip if in an iframe
  366. if (window.self !== window.top) return;
  367.  
  368. for (const id of menuCommandsIds) {
  369. GM_unregisterMenuCommand(id);
  370. }
  371.  
  372. menuCommandsIds.length = 0;
  373. }
  374.  
  375. registerMenuCommands();
  376.  
  377. if (s.isFirstRun) {
  378. messageUser(
  379. 'it seems like your first run of this script',
  380. 'You need to refresh the page on which this script should work. ' +
  381. `What fps cap do you prefer? Default is ${DEFAULT_FPS_CAP} as a demonstration. ` +
  382. 'You can always quickly change it from your script manager icon ↗'
  383. );
  384.  
  385. changeFpsCapWithUser();
  386. s.isFirstRun = false;
  387. }
  388. })();