HTML canvas fps limiter

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;
  }
})();