Source: debug/fps-counter.js

/**
 * Default label used by the counter header.
 *
 * @type {string}
 */
const DEFAULT_LABEL = 'performance';

/**
 * Default DOM update interval in milliseconds.
 *
 * @type {number}
 */
const DEFAULT_UPDATE_INTERVAL_MS = 250;

/**
 * Default exponential smoothing factor.
 *
 * @type {number}
 */
const DEFAULT_SMOOTHING_FACTOR = 0.15;

/**
 * Minimum allowed update interval.
 *
 * @type {number}
 */
const MIN_UPDATE_INTERVAL_MS = 16;

/**
 * Inclusive lower bound for a [0..1] range value.
 *
 * @type {number}
 */
const MIN_NORMALIZED = 0;

/**
 * Inclusive upper bound for a [0..1] range value.
 *
 * @type {number}
 */
const MAX_NORMALIZED = 1;

/**
 * Default FPS threshold considered `good`.
 *
 * @type {number}
 */
const DEFAULT_GOOD_FPS_THRESHOLD = 55;

/**
 * Default FPS threshold considered `ok`.
 *
 * @type {number}
 */
const DEFAULT_OK_FPS_THRESHOLD = 30;

/**
 * DOM element tag name used by the counter.
 *
 * @type {string}
 */
const DIV_TAG_NAME = 'div';

/**
 * Placeholder text shown before the first measurement.
 *
 * @type {string}
 */
const PLACEHOLDER_TEXT = '--';

/**
 * FPS row label.
 *
 * @type {string}
 */
const FPS_ROW_LABEL = 'FPS';

/**
 * Frame time row label (milliseconds).
 *
 * @type {string}
 */
const FRAME_TIME_ROW_LABEL = 'MS';

/**
 * Amount of milliseconds in one second.
 *
 * @type {number}
 */
const MILLISECONDS_PER_SECOND = 1000;

/**
 * A guard value used by divisions to avoid a zero denominator.
 *
 * @type {number}
 */
const MIN_DENOMINATOR = 1;

/**
 * A numeric constant used for clamping values to a non-negative range.
 *
 * @type {number}
 */
const NON_NEGATIVE_MIN = 0;

/**
 * Initial frame counter value.
 *
 * @type {number}
 */
const INITIAL_FRAMES_SINCE_LAST_UPDATE = 0;

/**
 * Initial accumulated time value.
 *
 * @type {number}
 */
const INITIAL_ACCUMULATED_FRAME_TIME_MS = 0;

/**
 * Frame count increment used for each recorded frame.
 *
 * @type {number}
 */
const FRAMES_INCREMENT = 1;

/**
 * Fallback FPS returned by `FpsCounter#end`, when a value is not yet available.
 *
 * @type {number}
 */
const FPS_FALLBACK_VALUE = 0;

/**
 * Number of digits used by `Number#toFixed` to format frame time values.
 *
 * @type {number}
 */
const FRAME_TIME_DECIMAL_PLACES = 1;

/**
 * Role attribute value for the root element.
 *
 * @type {string}
 */
const ARIA_ROLE_STATUS = 'status';

/**
 * Attribute value used to announce updates politely.
 *
 * @type {string}
 */
const ARIA_LIVE_POLITE = 'polite';

/**
 * Root element CSS class.
 *
 * @type {string}
 */
const ROOT_CLASS = 'gwFpsCounter';

/**
 * Root element state class used, when FPS is in the `good` range.
 *
 * @type {string}
 */
const STATE_CLASS_GOOD = 'gwFpsCounter-good';

/**
 * Root element state class used, when FPS is in the `ok` range.
 *
 * @type {string}
 */
const STATE_CLASS_OK = 'gwFpsCounter-ok';

/**
 * Root element state class used, when FPS is in the `bad` range.
 *
 * @type {string}
 */
const STATE_CLASS_BAD = 'gwFpsCounter-bad';

/**
 * Header CSS class.
 *
 * @type {string}
 */
const HEADER_CLASS = 'gwFpsCounterHeader';

/**
 * Row CSS class.
 *
 * @type {string}
 */
const ROW_CLASS = 'gwFpsCounterRow';

/**
 * Label CSS class.
 *
 * @type {string}
 */
const ROW_LABEL_CLASS = 'gwFpsCounterRowLabel';

/**
 * Value CSS class.
 *
 * @type {string}
 */
const ROW_VALUE_CLASS = 'gwFpsCounterRowValue';

/**
 * Indicates that the counter has not yet computed a value.
 *
 * @type {number}
 */
const UNINITIALIZED_NUMBER = -1;

/**
 * Default flag that enables frame time (ms) display.
 *
 * @type {boolean}
 */
const DEFAULT_SHOW_FRAME_TIME = true;

/**
 * Options used by `FpsCounter`.
 *
 * @typedef {Object} FpsCounterOptions
 * @property {string}  [label = 'Performance']  - Short title shown at the top of the panel.
 * @property {number}  [updateIntervalMs = 250] - DOM refresh interval (ms). Values are computed every frame.
 * @property {number}  [smoothingFactor = 0.15] - Exponential smoothing factor in [0..1].
 * @property {boolean} [showFrameTime = true]   - When true, also shows the average frame time in milliseconds.
 * @property {number}  [goodFpsThreshold = 55]  - FPS value that should be considered `good`.
 * @property {number}  [okFpsThreshold   = 30]  - FPS value that should be considered `ok`.
 */

/**
 * Small DOM-based FPS counter intended for demos and debugging.
 */
export class FpsCounter {

    /**
     * Root DOM element.
     *
     * @type {HTMLElement}
     * @private
     */
    #domElement;

    /**
     * DOM element, that shows the FPS value.
     *
     * @type {HTMLElement}
     * @private
     */
    #fpsValueElement;

    /**
     * DOM element, that shows the frame time value (ms).
     *
     * @type {HTMLElement | null}
     * @private
     */
    #frameTimeValueElement;

    /**
     * DOM refresh interval in milliseconds.
     *
     * @type {number}
     * @private
     */
    #updateIntervalMs;

    /**
     * Exponential smoothing factor in [0..1].
     *
     * @type {number}
     * @private
     */
    #smoothingFactor;

    /**
     * When true, frame time (ms) is displayed.
     *
     * @type {boolean}
     * @private
     */
    #showFrameTime;

    /**
     * FPS value considered `good`.
     *
     * @type {number}
     * @private
     */
    #goodFpsThreshold;

    /**
     * FPS value considered `ok`.
     *
     * @type {number}
     * @private
     */
    #okFpsThreshold;

    /**
     * Frame start timestamp (ms).
     *
     * @type {number}
     * @private
     */
    #frameStartTimeMs = UNINITIALIZED_NUMBER;

    /**
     * Timestamp (ms) of the last DOM refresh.
     *
     * @type {number}
     * @private
     */
    #lastUpdateTimeMs = UNINITIALIZED_NUMBER;

    /**
     * Number of frames since the last DOM refresh.
     *
     * @type {number}
     * @private
     */
    #framesSinceLastUpdate = INITIAL_FRAMES_SINCE_LAST_UPDATE;

    /**
     * Accumulated frame time in milliseconds since the last DOM refresh.
     *
     * @type {number}
     * @private
     */
    #accumulatedFrameTimeMs = INITIAL_ACCUMULATED_FRAME_TIME_MS;

    /**
     * Smoothed FPS value.
     *
     * @type {number}
     * @private
     */
    #smoothedFps = UNINITIALIZED_NUMBER;

    /**
     * Smoothed frame time (ms).
     *
     * @type {number}
     * @private
     */
    #smoothedFrameTimeMs = UNINITIALIZED_NUMBER;

    /**
     * @param {FpsCounterOptions} [options] - Counter options.
     */
    constructor(options = {}) {
        if (options === null || typeof options !== 'object' || Array.isArray(options)) {
            throw new TypeError('`FpsCounter` expects an options object (plain object).');
        }

        const {
            label            = DEFAULT_LABEL,
            updateIntervalMs = DEFAULT_UPDATE_INTERVAL_MS,
            smoothingFactor  = DEFAULT_SMOOTHING_FACTOR,
            showFrameTime    = DEFAULT_SHOW_FRAME_TIME,
            goodFpsThreshold = DEFAULT_GOOD_FPS_THRESHOLD,
            okFpsThreshold   = DEFAULT_OK_FPS_THRESHOLD
        } = options;

        if (typeof label !== 'string') {
            throw new TypeError('`FpsCounter` option `label` must be a string.');
        }

        if (typeof updateIntervalMs !== 'number' || updateIntervalMs < MIN_UPDATE_INTERVAL_MS) {
            throw new RangeError(`\`FpsCounter\` option \`updateIntervalMs\` must be a number >= ${MIN_UPDATE_INTERVAL_MS}.`);
        }

        if (typeof smoothingFactor !== 'number' || smoothingFactor < MIN_NORMALIZED || smoothingFactor > MAX_NORMALIZED) {
            throw new RangeError('`FpsCounter` option `smoothingFactor` must be a number in [0..1].');
        }

        if (typeof showFrameTime !== 'boolean') {
            throw new TypeError('`FpsCounter` option `showFrameTime` must be a boolean.');
        }

        if (typeof goodFpsThreshold !== 'number' || goodFpsThreshold <= NON_NEGATIVE_MIN) {
            throw new RangeError('`FpsCounter` option `goodFpsThreshold` must be a positive number.');
        }

        if (typeof okFpsThreshold !== 'number' || okFpsThreshold <= NON_NEGATIVE_MIN) {
            throw new RangeError('`FpsCounter` option `okFpsThreshold` must be a positive number.');
        }

        if (okFpsThreshold > goodFpsThreshold) {
            throw new RangeError('`FpsCounter` options must satisfy: `okFpsThreshold <= goodFpsThreshold`.');
        }

        if (typeof document === 'undefined') {
            throw new Error('`FpsCounter` requires a browser environment with `document` available.');
        }

        this.#updateIntervalMs = updateIntervalMs;
        this.#smoothingFactor  = smoothingFactor;
        this.#showFrameTime    = showFrameTime;
        this.#goodFpsThreshold = goodFpsThreshold;
        this.#okFpsThreshold   = okFpsThreshold;

        this.#domElement           = document.createElement(DIV_TAG_NAME);
        this.#domElement.className = `${ROOT_CLASS} ${STATE_CLASS_GOOD}`;
        this.#domElement.setAttribute('role'      , ARIA_ROLE_STATUS);
        this.#domElement.setAttribute('aria-live' , ARIA_LIVE_POLITE);

        const headerElement       = document.createElement(DIV_TAG_NAME);
        headerElement.className   = HEADER_CLASS;
        headerElement.textContent = label;
        this.#domElement.appendChild(headerElement);

        const fpsRow          = FpsCounter.#createRow(FPS_ROW_LABEL, PLACEHOLDER_TEXT);
        this.#fpsValueElement = fpsRow.valueElement;
        this.#domElement.appendChild(fpsRow.rowElement);

        if (this.#showFrameTime) {
            const frameTimeRow = FpsCounter.#createRow(FRAME_TIME_ROW_LABEL, PLACEHOLDER_TEXT);
            this.#frameTimeValueElement = frameTimeRow.valueElement;
            this.#domElement.appendChild(frameTimeRow.rowElement);
        } else {
            this.#frameTimeValueElement = null;
        }
    }

    /** @returns {HTMLElement} */
    get domElement() {
        return this.#domElement;
    }

    /**
     * Marks the beginning of a frame.
     */
    begin() {
        this.#frameStartTimeMs = performance.now();
    }

    /**
     * Marks the end of a frame.
     *
     * @returns {number} - Latest smoothed FPS value.
     */
    end() {
        const nowMs            = performance.now();
        const frameStartTimeMs = (this.#frameStartTimeMs === UNINITIALIZED_NUMBER) ? nowMs : this.#frameStartTimeMs;
        const frameTimeMs      = Math.max(NON_NEGATIVE_MIN, nowMs - frameStartTimeMs);

        this.#recordFrame(frameTimeMs, nowMs);
        this.#frameStartTimeMs = nowMs;

        return (this.#smoothedFps === UNINITIALIZED_NUMBER) ? FPS_FALLBACK_VALUE : this.#smoothedFps;
    }

    /**
     * Updates the counter using a known delta time.
     *
     * @param {number} deltaTimeSeconds - Time since previous frame in seconds.
     */
    update(deltaTimeSeconds) {
        if (typeof deltaTimeSeconds !== 'number' || !Number.isFinite(deltaTimeSeconds) || deltaTimeSeconds < NON_NEGATIVE_MIN) {
            throw new TypeError('`FpsCounter.update` expects a non-negative finite number (seconds).');
        }

        const deltaTimeMs = deltaTimeSeconds * MILLISECONDS_PER_SECOND;
        this.#recordFrame(deltaTimeMs, performance.now());
    }

    /**
     * Resets internal counters and UI.
     */
    reset() {
        this.#frameStartTimeMs            = UNINITIALIZED_NUMBER;
        this.#lastUpdateTimeMs            = UNINITIALIZED_NUMBER;
        this.#framesSinceLastUpdate       = INITIAL_FRAMES_SINCE_LAST_UPDATE;
        this.#accumulatedFrameTimeMs      = INITIAL_ACCUMULATED_FRAME_TIME_MS;
        this.#smoothedFps                 = UNINITIALIZED_NUMBER;
        this.#smoothedFrameTimeMs         = UNINITIALIZED_NUMBER;
        this.#fpsValueElement.textContent = PLACEHOLDER_TEXT;

        if (this.#frameTimeValueElement) {
            this.#frameTimeValueElement.textContent = PLACEHOLDER_TEXT;
        }

        this.#applyStateClass(STATE_CLASS_GOOD);
    }

    /**
     * Records a single frame measurement.
     *
     * @param {number} frameTimeMs - Frame time in milliseconds.
     * @param {number} nowMs       - Current timestamp in milliseconds.
     * @private
     */
    #recordFrame(frameTimeMs, nowMs) {
        if (this.#lastUpdateTimeMs === UNINITIALIZED_NUMBER) {
            this.#lastUpdateTimeMs = nowMs;
        }

        this.#framesSinceLastUpdate  += FRAMES_INCREMENT;
        this.#accumulatedFrameTimeMs += frameTimeMs;

        const elapsedMs = nowMs - this.#lastUpdateTimeMs;

        if (elapsedMs < this.#updateIntervalMs) {
            return;
        }

        const fps            = (this.#framesSinceLastUpdate * MILLISECONDS_PER_SECOND) / Math.max(MIN_DENOMINATOR, elapsedMs);
        const avgFrameTimeMs = this.#accumulatedFrameTimeMs / Math.max(MIN_DENOMINATOR, this.#framesSinceLastUpdate);

        this.#smoothedFps            = this.#smoothValue(this.#smoothedFps, fps);
        this.#smoothedFrameTimeMs    = this.#smoothValue(this.#smoothedFrameTimeMs, avgFrameTimeMs);
        this.#updateDom();
        this.#lastUpdateTimeMs       = nowMs;
        this.#framesSinceLastUpdate  = INITIAL_FRAMES_SINCE_LAST_UPDATE;
        this.#accumulatedFrameTimeMs = INITIAL_ACCUMULATED_FRAME_TIME_MS;
    }

    /**
     * @param {number} currentValue - Current smoothed value or `UNINITIALIZED_NUMBER`.
     * @param {number} nextValue    - New measurement.
     * @returns {number}            - Smoothed value computed using exponential moving average.
     * @private
     */
    #smoothValue(currentValue, nextValue) {
        if (currentValue === UNINITIALIZED_NUMBER) {
            return nextValue;
        }

        return currentValue + (nextValue - currentValue) * this.#smoothingFactor;
    }

    /**
     * Updates the UI using current smoothed values.
     *
     * @private
     */
    #updateDom() {
        const fps = this.#smoothedFps;
        const frameTimeMs = this.#smoothedFrameTimeMs;
        this.#fpsValueElement.textContent = String(Math.round(fps));

        if (this.#frameTimeValueElement) {
            this.#frameTimeValueElement.textContent = frameTimeMs.toFixed(FRAME_TIME_DECIMAL_PLACES);
        }

        if (fps >= this.#goodFpsThreshold) {
            this.#applyStateClass(STATE_CLASS_GOOD);
        } else if (fps >= this.#okFpsThreshold) {
            this.#applyStateClass(STATE_CLASS_OK);
        } else {
            this.#applyStateClass(STATE_CLASS_BAD);
        }
    }

    /**
     * Applies one of the state classes to the root element.
     *
     * @param {string} nextStateClass - One of: `STATE_CLASS_GOOD/OK/BAD`.
     * @private
     */
    #applyStateClass(nextStateClass) {
        this.#domElement.classList.remove(STATE_CLASS_GOOD, STATE_CLASS_OK, STATE_CLASS_BAD);
        this.#domElement.classList.add(ROOT_CLASS, nextStateClass);
    }

    /**
     * @param {string} labelText        - Left label string.
     * @param {string} initialValueText - Initial value text.
     * @returns {{ rowElement: HTMLElement, valueElement: HTMLElement }}
     * @private
     */
    static #createRow(labelText, initialValueText) {
        const rowElement     = document.createElement(DIV_TAG_NAME);
        rowElement.className = ROW_CLASS;

        const labelElement       = document.createElement(DIV_TAG_NAME);
        labelElement.className   = ROW_LABEL_CLASS;
        labelElement.textContent = labelText;

        const valueElement       = document.createElement(DIV_TAG_NAME);
        valueElement.className   = ROW_VALUE_CLASS;
        valueElement.textContent = initialValueText;
        rowElement.appendChild(labelElement);
        rowElement.appendChild(valueElement);
        return { rowElement, valueElement };
    }
}