HTML canvas fps limiter

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

Tendrás que instalar una extensión para tu navegador como Tampermonkey, Greasemonkey o Violentmonkey si quieres utilizar este script.

Necesitarás instalar una extensión como Tampermonkey o Violentmonkey para instalar este script.

Necesitarás instalar una extensión como Tampermonkey o Violentmonkey para instalar este script.

Necesitarás instalar una extensión como Tampermonkey o Userscripts para instalar este script.

Necesitará instalar una extensión como Tampermonkey para instalar este script.

Necesitarás instalar una extensión para administrar scripts de usuario si quieres instalar este script.

(Ya tengo un administrador de scripts de usuario, déjame instalarlo)

Necesitará instalar una extensión como Stylus para instalar este estilo.

Necesitará instalar una extensión como Stylus para instalar este estilo.

Necesitará instalar una extensión como Stylus para instalar este estilo.

Necesitará instalar una extensión del gestor de estilos de usuario para instalar este estilo.

Necesitará instalar una extensión del gestor de estilos de usuario para instalar este estilo.

Necesitará instalar una extensión del gestor de estilos de usuario para instalar este estilo.

(Ya tengo un administrador de estilos de usuario, déjame instalarlo)

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