Source: webgl-context.js

/**
 * String identifier passed to canvas.getContext to request a WebGL2 context.
 * @type {string}
 */
const WEBGL2_CONTEXT_TYPE = 'webgl2';

/**
 * Fallback device pixel ratio used when window.devicePixelRatio is not set.
 * @type {number}
 */
const DEFAULT_DEVICE_PIXEL_RATIO = 1;

/**
 * X coordinate of the viewport origin.
 * @type {number}
 */
const VIEWPORT_ORIGIN_X = 0;

/**
 * Y coordinate of the viewport origin.
 * @type {number}
 */
const VIEWPORT_ORIGIN_Y = 0;

/**
 * Minimum drawing buffer size (in pixels) for `canvas.width/canvas.height`.
 * Used as a safeguard, when the canvas CSS size is 0.
 *
 * @type {number}
 */
const MIN_DRAWING_BUFFER_DIMENSION = 1;

/**
 * Minimum allowed value for an RGBA color component.
 * Used to validate clear color inputs in the range [0 -> 1].
 *
 * @type {number}
 */
const MIN_COLOR_COMPONENT = 0.0;

/**
 * Maximum allowed value for an RGBA color component.
 * Used to validate clear color inputs in the range [0 -> 1].
 *
 * @type {number}
 */
const MAX_COLOR_COMPONENT = 1.0;

/**
 * RGBA color represented as [red, green, blue, alpha],
 * each component in the range [0 -> 1].
 * @typedef {number[]} RGBAColor
 */

/**
 * Options used by resizeToDisplaySize.
 *
 * @typedef {Object} ResizeToDisplaySizeOptions
 * @property {boolean} [fitToWindow=false] - When true, uses `window.innerWidth/innerHeight`.
 * When false, uses the canvas CSS size (client size) and updates the drawing buffer accordingly.
 */

/**
 * Wrapper around a WebGL2 rendering context and its canvas.
 *
 * Responsible for:
 * - creating and validating the WebGL2 context
 * - managing the canvas size and viewport
 * - clearing the color and depth buffers
 */
export class WebGLContext {
    /** @type {HTMLCanvasElement} */
    #canvas;

    /** @type {WebGL2RenderingContext} */
    #webglContext;

    /** @type {RGBAColor} */
    static #DEFAULT_CLEAR_COLOR = [0.0, 0.0, 0.0, 1.0];

    /** @type {boolean} */
    static #ENABLE_DEPTH_TEST = true;

    /**
     * Creates a new WebGLContext bound to the provided canvas element.
     *
     * @param {HTMLCanvasElement} canvas - Target canvas element used for WebGL rendering.
     * @throws {TypeError} If the provided value is not an HTMLCanvasElement.
     * @throws {Error} If WebGL2 is not supported by the browser.
     */
    constructor(canvas) {
        if (!(canvas instanceof HTMLCanvasElement)) {
            throw new TypeError('WebGLContext constructor expects an HTMLCanvasElement.');
        }

        this.#canvas = canvas;
        const webglContext = this.#canvas.getContext(WEBGL2_CONTEXT_TYPE);

        if (!webglContext) {
            throw new Error('WebGL2 is not supported in this browser.');
        }

        this.#webglContext = webglContext;
        this.#initializeDefaults();
    }

    /**
     * Initializes default WebGL state for this context instance (depth testing and clear color).
     *
     * @private
     */
    #initializeDefaults() {
        const webglContext = this.#webglContext;

        if (WebGLContext.#ENABLE_DEPTH_TEST) {
            webglContext.enable(webglContext.DEPTH_TEST);
            webglContext.depthFunc(webglContext.LEQUAL);
        }

        const [red, green, blue, alpha] = WebGLContext.#DEFAULT_CLEAR_COLOR;
        webglContext.clearColor(red, green, blue, alpha);
    }

    /**
     * Returns the underlying `WebGL2RenderingContext` for direct low-level access.
     *
     * @returns {WebGL2RenderingContext}
     */
    get context() {
        return this.#webglContext;
    }

    /**
     * Resizes the underlying canvas drawing buffer to match its display size and updates the viewport.
     *
     * @param {ResizeToDisplaySizeOptions} [options] - Optional resize options.
     * @returns {boolean}                            - True if the canvas was resized, false otherwise.
     */
    resizeToDisplaySize(options) {
        if (options !== undefined && (options === null || typeof options !== 'object' || Array.isArray(options))) {
            throw new TypeError('WebGLContext.resizeToDisplaySize expects an options object or undefined.');
        }

        const fitToWindow = options !== undefined && options.fitToWindow === true;

        if (options !== undefined && 'fitToWindow' in options && typeof options.fitToWindow !== 'boolean') {
            throw new TypeError('WebGLContext.resizeToDisplaySize option `fitToWindow` must be a boolean.');
        }

        const pixelRatio   = window.devicePixelRatio || DEFAULT_DEVICE_PIXEL_RATIO;
        const cssWidth     = fitToWindow ? window.innerWidth  : this.#canvas.clientWidth;
        const cssHeight    = fitToWindow ? window.innerHeight : this.#canvas.clientHeight;
        const targetWidth  = Math.max(MIN_DRAWING_BUFFER_DIMENSION, Math.floor(cssWidth  * pixelRatio));
        const targetHeight = Math.max(MIN_DRAWING_BUFFER_DIMENSION, Math.floor(cssHeight * pixelRatio));
        const isResized    = (this.#canvas.width !== targetWidth) || (this.#canvas.height !== targetHeight);

        if (isResized === true) {
            this.#canvas.width  = targetWidth;
            this.#canvas.height = targetHeight;
            this.#webglContext.viewport(
                VIEWPORT_ORIGIN_X,
                VIEWPORT_ORIGIN_Y,
                this.#canvas.width,
                this.#canvas.height
            );
        }

        return isResized;
    }

    /**
     * Clears both the color and depth buffers using the current clear color.
     */
    clear() {
        this.#webglContext.clear(
            this.#webglContext.COLOR_BUFFER_BIT |
            this.#webglContext.DEPTH_BUFFER_BIT
        );
    }

    /**
     * Sets the default clear color used when initializing new WebGLContext instances.
     *
     * @param {number} red   - Red component   , from 0 to 1.
     * @param {number} green - Green component , from 0 to 1.
     * @param {number} blue  - Blue component  , from 0 to 1.
     * @param {number} alpha - Alpha component , from 0 to 1.
     * @throws {TypeError}  If any component is not a number.
     * @throws {RangeError} If any component is outside the [0, 1] range.
     */
    static setDefaultClearColor(red, green, blue, alpha) {
        WebGLContext.#validateColorComponent('red', red);
        WebGLContext.#validateColorComponent('green', green);
        WebGLContext.#validateColorComponent('blue', blue);
        WebGLContext.#validateColorComponent('alpha', alpha);
        WebGLContext.#DEFAULT_CLEAR_COLOR = [red, green, blue, alpha];
    }

    /**
     * Enables or disables depth testing for all future WebGLContext instances.
     *
     * @param {boolean} enabled - Whether depth testing should be enabled.
     * @throws {TypeError} If the provided value is not a boolean.
     */
    static setDepthTestEnabled(enabled) {
        if (typeof enabled !== 'boolean') {
            throw new TypeError('setDepthTestEnabled expects a boolean value.');
        }

        WebGLContext.#ENABLE_DEPTH_TEST = enabled;
    }

    /**
     * Validates that a color component is a number in the [0, 1] range.
     *
     * @param {string} componentName - Name of the component (for error messages).
     * @param {number} value         - Component value to validate.
     * @throws {TypeError}  If value is not a number.
     * @throws {RangeError} If value is outside the [0, 1] range.
     * @private
     */
    static #validateColorComponent(componentName, value) {
        if (typeof value !== 'number' || Number.isNaN(value)) {
            throw new TypeError(`Color component "${componentName}" must be a valid number.`);
        }

        if (value < MIN_COLOR_COMPONENT || value > MAX_COLOR_COMPONENT) {
            throw new RangeError(`Color component "${componentName}" must be in the range [0, 1].`);
        }
    }
}