HTML canvas fps limiter

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

Stan na 13-12-2023. Zobacz najnowsza wersja.

Aby zainstalować ten skrypt, wymagana jest instalacje jednego z następujących rozszerzeń: Tampermonkey, Greasemonkey lub Violentmonkey.

Aby zainstalować ten skrypt, wymagana jest instalacje jednego z następujących rozszerzeń: Tampermonkey, Violentmonkey.

Aby zainstalować ten skrypt, wymagana jest instalacje jednego z następujących rozszerzeń: Tampermonkey, Violentmonkey.

Aby zainstalować ten skrypt, wymagana będzie instalacja rozszerzenia Tampermonkey lub Userscripts.

You will need to install an extension such as Tampermonkey to install this script.

Aby zainstalować ten skrypt, musisz zainstalować rozszerzenie menedżera skryptów użytkownika.

(Mam już menedżera skryptów użytkownika, pozwól mi to zainstalować!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

Będziesz musiał zainstalować rozszerzenie menedżera stylów użytkownika, aby zainstalować ten styl.

Będziesz musiał zainstalować rozszerzenie menedżera stylów użytkownika, aby zainstalować ten styl.

Musisz zainstalować rozszerzenie menedżera stylów użytkownika, aby zainstalować ten styl.

(Mam już menedżera stylów użytkownika, pozwól mi to zainstalować!)

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