Source: texture/texture2d.js

/**
 * Default value for the `flipY` option.
 * When true, uploaded images are flipped vertically during upload via `UNPACK_FLIP_Y_WEBGL`.
 *
 * @type {boolean}
 */
const DEFAULT_FLIP_Y = true;

/**
 * Mipmap policy, never generate the mipmaps.
 *
 * @type {number}
 */
const MIPMAP_POLICY_NONE = 0;

/**
 * Mipmap policy, always generate mipmaps after the successful upload.
 *
 * @type {number}
 */
const MIPMAP_POLICY_ALWAYS = 1;

/**
 * Mipmap policy, generate the mipmaps only, when the texture size is `power-of-two`.
 *
 * @type {number}
 */
const MIPMAP_POLICY_AUTO = 2;

/**
 * Default mipmap generation policy, used by `Texture2D`.
 *
 * @type {number}
 */
const DEFAULT_MIPMAP_POLICY = MIPMAP_POLICY_AUTO;

/**
 * Cross-origin mode, `anonymous`.
 *
 * @type {string}
 */
const CROSS_ORIGIN_ANONYMOUS = 'anonymous';

/**
 * Cross-origin mode, `use-credentials`.
 *
 * @type {string}
 */
const CROSS_ORIGIN_USE_CREDENTIALS = 'use-credentials';

/**
 * Error message: invalid WebGL2 rendering context argument.
 *
 * @type {string}
 */
const ERROR_EXPECTS_WEBGL2_CONTEXT = '`Texture2D` expects `WebGL2RenderingContext`.';

/**
 * Error message: invalid options argument type (must be an object).
 *
 * @type {string}
 */
const ERROR_EXPECTS_OPTIONS_OBJECT = '`Texture2D` expects options as an object.';

/**
 * Error message: invalid `flipY` option type.
 *
 * @type {string}
 */
const ERROR_EXPECTS_FLIPY_BOOLEAN = '`Texture2D` expects `options.flipY` as boolean.';

/**
 * Error message: WebGL failed to create a texture handle.
 *
 * @type {string}
 */
const ERROR_FAILED_CREATE_TEXTURE = 'Failed to create `WebGLTexture`.';

/**
 * Error message: invalid `wrapS` enum value.
 *
 * @type {string}
 */
const ERROR_EXPECTS_WRAP_S_ENUM = '`Texture2D` expects `options.wrapS` as the valid WebGL wrap mode.';

/**
 * Error message: invalid `wrapT` enum value.
 *
 * @type {string}
 */
const ERROR_EXPECTS_WRAP_T_ENUM = '`Texture2D` expects `options.wrapT` as the valid WebGL wrap mode.';

/**
 * Error message: invalid `minFilter` enum value.
 *
 * @type {string}
 */
const ERROR_EXPECTS_MIN_FILTER_ENUM = '`Texture2D` expects `options.minFilter` as the valid WebGL min filter.';

/**
 * Error message: invalid `magFilter` enum value.
 *
 * @type {string}
 */
const ERROR_EXPECTS_MAG_FILTER_ENUM = '`Texture2D` expects `options.magFilter` as the valid WebGL mag filter.';

/**
 * Error message: invalid `mipmapPolicy` enum value.
 *
 * @type {string}
 */
const ERROR_EXPECTS_MIPMAP_POLICY = '`Texture2D` expects `options.mipmapPolicy` as the valid mipmap policy.';

/**
 * Error message: mipmap-minification filter cannot be, used with `MIPMAP_POLICY_NONE`.
 *
 * @type {string}
 */
const ERROR_MIPMAP_POLICY_CONFLICT = '`Texture2D` cannot use the mipmap min filter, when mipmap policy is NONE.';

/**
 * Error message: mipmap min filter was explicitly requested, but mipmaps were not generated by the auto policy.
 *
 * @type {string}
 */
const ERROR_MIPMAP_AUTO_POT_REQUIRED_FOR_MIPMAP_FILTER = '`Texture2D` cannot apply a mipmap min filter with the auto policy for a `non power-of-two` texture. Use `MIPMAP_POLICY_ALWAYS` or the non-mipmap min filter.';

/**
 * Error message: invalid sampler options argument type for `setSamplerParams`.
 *
 * @type {string}
 */
const ERROR_EXPECTS_SAMPLER_OPTIONS_OBJECT = '`Texture2D.setSamplerParams` expects options as an object.';

/**
 * Error message: invalid texture unit index passed to `bind`.
 *
 * @type {string}
 */
const ERROR_EXPECTS_TEXTURE_UNIT_INDEX = '`Texture2D.bind` expects `textureUnitIndex` as a non-negative integer.';

/**
 * Error message prefix: texture unit index is out of range for the current WebGL context.
 *
 * @type {string}
 */
const ERROR_TEXTURE_UNIT_INDEX_OUT_OF_RANGE_PREFIX = '`Texture2D.bind` texture unit index is out of range. Max allowed index is ';

/**
 * Error message: invalid URL argument passed to `loadFromUrl`.
 *
 * @type {string}
 */
const ERROR_EXPECTS_URL_STRING = '`Texture2D.loadFromUrl` expects url as a non-empty string.';

/**
 * Error message: the texture instance is already disposed.
 *
 * @type {string}
 */
const ERROR_INSTANCE_DISPOSED = '`Texture2D` instance is disposed.';


/**
 * Error message: invalid load options argument type for `loadFromUrl`.
 *
 * @type {string}
 */
const ERROR_EXPECTS_LOAD_OPTIONS_OBJECT = '`Texture2D.loadFromUrl` expects options as an object.';

/**
 * Error message: invalid `crossOrigin` value for `loadFromUrl`.
 *
 * @type {string}
 */
const ERROR_EXPECTS_CROSS_ORIGIN = '`Texture2D.loadFromUrl` expects `options.crossOrigin` as `anonymous`, `use-credentials` or null.';

/**
 * Error message prefix used, when the image fails to load.
 *
 * @type {string}
 */
const ERROR_FAILED_LOAD_IMAGE_PREFIX = 'Failed to load the texture image: ';

/**
 * Error message: failed to read `MAX_COMBINED_TEXTURE_IMAGE_UNITS` from WebGL context.
 *
 * @type {string}
 */
const ERROR_FAILED_READ_MAX_TEXTURE_UNITS = 'Failed to read WebGL `MAX_COMBINED_TEXTURE_IMAGE_UNITS`.';

/**
 * Default width of a placeholder texture (in pixels).
 *
 * @type {number}
 */
const PLACEHOLDER_TEXTURE_WIDTH = 1;

/**
 * Default height of a placeholder texture (in pixels).
 *
 * @type {number}
 */
const PLACEHOLDER_TEXTURE_HEIGHT = 1;

/**
 * WebGL expects the border parameter of `texImage2D` to be `0`.
 *
 * @type {number}
 */
const TEXTURE_BORDER_VALUE = 0;

/**
 * Default mipmap level, used by `texImage2D`.
 *
 * @type {number}
 */
const BASE_MIPMAP_LEVEL = 0;

/**
 * Placeholder pixel color (magenta).
 *
 * @type {Uint8Array}
 */
const PLACEHOLDER_PIXEL_RGBA = new Uint8Array([255, 0, 255, 255]);

/**
 * Integer value, used to represent boolean true in WebGL `pixelStorei` calls.
 *
 * @type {number}
 */
const WEBGL_TRUE_AS_INTEGER = 1;

/**
 * Integer value, used to represent boolean false in WebGL `pixelStorei` calls.
 *
 * @type {number}
 */
const WEBGL_FALSE_AS_INTEGER = 0;

/**
 * Minimum allowed texture unit index.
 *
 * @type {number}
 */
const MIN_TEXTURE_UNIT_INDEX = 0;

/**
 * Minimal allowed string length for required input url, used in `loadFromUrl()` method.
 *
 * @type {number}
 */
const MIN_REQUIRED_STRING_LENGTH = 1;

/**
 * Minimal allowed value for `power-of-two` checks.
 *
 * @type {number}
 */
const MIN_POWER_OF_TWO_VALUE = 1;

/**
 * Bit mask helper value, used by the `power-of-two` check.
 *
 * @type {number}
 */
const BIT_MASK_ONE = 1;

/**
 * Bitwise `zero` value, used in bit-mask comparisons.
 *
 * @type {number}
 */
const BITWISE_ZERO = 0;

/**
 * Texture2D creation/update options.
 *
 * @typedef {Object} Texture2DOptions
 * @property {boolean} [flipY = true]     - Flip image data vertically on upload.
 * @property {number}  [wrapS]            - WebGL wrap mode for S (REPEAT/CLAMP_TO_EDGE/MIRRORED_REPEAT).
 * @property {number}  [wrapT]            - WebGL wrap mode for T (REPEAT/CLAMP_TO_EDGE/MIRRORED_REPEAT).
 * @property {(number|null)} [minFilter]  - WebGL min filter (including the mipmap variants). Use `null` to clear explicit override and return to auto behavior.
 * @property {number}  [magFilter]        - WebGL mag filter (NEAREST/LINEAR).
 * @property {number}  [mipmapPolicy = 2] - `MIPMAP_POLICY_*` constants usage.
 */

/**
 * Texture2D load options, used by `loadFromUrl`.
 *
 * @typedef {Object} Texture2DLoadOptions
 * @property {("anonymous"|"use-credentials"|null)} [crossOrigin] - Optional cross-origin mode.
 */

/**
 * `Texture2D` is a thin wrapper around a `WebGLTexture`.
 * It supports a placeholder pixel (1x1), and can asynchronously upload the image from URL.
 */
export class Texture2D {

    /**
     * WebGL2 rendering context, used to: create, upload and dispose the underlying WebGL texture.
     *
     * @type {WebGL2RenderingContext}
     * @private
     */
    #webglContext;

    /**
     * Underlying WebGL texture handle.
     *
     * @type {WebGLTexture}
     * @private
     */
    #texture;

    /**
     * When true, uploaded images are flipped vertically during upload. Applied via `UNPACK_FLIP_Y_WEBGL`.
     *
     * @type {boolean}
     * @private
     */
    #flipY;

    /**
     * Current wrap mode for S-coordinate.
     *
     * @type {number}
     * @private
     */
    #wrapS;

    /**
     * Current wrap mode for T-coordinate.
     *
     * @type {number}
     * @private
     */
    #wrapT;

    /**
     * Current minification filter.
     *
     * @type {number}
     * @private
     */
    #minFilter;

    /**
     * Current magnification filter.
     *
     * @type {number}
     * @private
     */
    #magFilter;

    /**
     * Current mipmap generation policy.
     *
     * @type {number}
     * @private
     */
    #mipmapPolicy;

    /**
     * Indicates whether the min filter was explicitly set by the user.
     *
     * @type {boolean}
     * @private
     */
    #hasExplicitMinFilter = false;

    /**
     * Current texture width in pixels. Initialized to placeholder size and updated after a successful upload.
     *
     * @type {number}
     * @private
     */
    #width = PLACEHOLDER_TEXTURE_WIDTH;

    /**
     * Current texture height in pixels. Initialized to placeholder size and updated after a successful upload.
     *
     * @type {number}
     * @private
     */
    #height = PLACEHOLDER_TEXTURE_HEIGHT;

    /**
     * Indicates whether the image has been successfully loaded and uploaded to GPU.
     *
     * @type {boolean}
     * @private
     */
    #isLoaded = false;

    /**
     * Indicates whether this texture instance has been disposed. Disposed textures must not be bound or updated.
     *
     * @type {boolean}
     * @private
     */
    #isDisposed = false;

    /**
     * @param {WebGL2RenderingContext} webglContext - WebGL2 rendering context, used to create and manage the GPU resources.
     * @param {Texture2DOptions} [options]          - Optional texture creation options.
     * @throws {TypeError} When provided arguments do not match expected types or supported enums.
     */
    constructor(webglContext, options = {}) {
        if (!(webglContext instanceof WebGL2RenderingContext)) {
            throw new TypeError(ERROR_EXPECTS_WEBGL2_CONTEXT);
        }

        if (options === null || typeof options !== 'object' || Array.isArray(options)) {
            throw new TypeError(ERROR_EXPECTS_OPTIONS_OBJECT);
        }

        const {
            flipY = DEFAULT_FLIP_Y,
            wrapS,
            wrapT,
            minFilter,
            magFilter,
            mipmapPolicy = DEFAULT_MIPMAP_POLICY
        } = options;

        if (typeof flipY !== 'boolean') {
            throw new TypeError(ERROR_EXPECTS_FLIPY_BOOLEAN);
        }

        this.#webglContext = webglContext;

        if (!this.#isValidMipmapPolicy(mipmapPolicy)) {
            throw new TypeError(ERROR_EXPECTS_MIPMAP_POLICY);
        }

        const hasMinFilterProperty = Object.prototype.hasOwnProperty.call(options, 'minFilter');
        const isResetMinFilter     = hasMinFilterProperty && minFilter === null;
        const hasExplicitMinFilter = hasMinFilterProperty && !isResetMinFilter;
        const hasExplicitWrapS     = Object.prototype.hasOwnProperty.call(options, 'wrapS');
        const hasExplicitWrapT     = Object.prototype.hasOwnProperty.call(options, 'wrapT');
        const hasExplicitMagFilter = Object.prototype.hasOwnProperty.call(options, 'magFilter');
        const resolvedWrapS        = hasExplicitWrapS ? wrapS : webglContext.REPEAT;
        const resolvedWrapT        = hasExplicitWrapT ? wrapT : webglContext.REPEAT;
        const resolvedMinFilter    = hasExplicitMinFilter ? minFilter : webglContext.LINEAR;
        const resolvedMagFilter    = hasExplicitMagFilter ? magFilter : webglContext.LINEAR;

        if (hasExplicitWrapS && !this.#isValidWrapMode(resolvedWrapS)) {
            throw new TypeError(ERROR_EXPECTS_WRAP_S_ENUM);
        }

        if (hasExplicitWrapT && !this.#isValidWrapMode(resolvedWrapT)) {
            throw new TypeError(ERROR_EXPECTS_WRAP_T_ENUM);
        }

        if (hasExplicitMinFilter && !this.#isValidMinFilter(resolvedMinFilter)) {
            throw new TypeError(ERROR_EXPECTS_MIN_FILTER_ENUM);
        }

        if (hasExplicitMagFilter && !this.#isValidMagFilter(resolvedMagFilter)) {
            throw new TypeError(ERROR_EXPECTS_MAG_FILTER_ENUM);
        }

        if (mipmapPolicy === MIPMAP_POLICY_NONE
            && hasExplicitMinFilter
            && this.#isMipmapMinFilter(resolvedMinFilter)) {
            throw new TypeError(ERROR_MIPMAP_POLICY_CONFLICT);
        }

        this.#flipY                = flipY;
        this.#wrapS                = resolvedWrapS;
        this.#wrapT                = resolvedWrapT;
        this.#minFilter            = resolvedMinFilter;
        this.#magFilter            = resolvedMagFilter;
        this.#mipmapPolicy         = mipmapPolicy;
        this.#hasExplicitMinFilter = hasExplicitMinFilter;
        const texture              = webglContext.createTexture();

        if (!texture) {
            throw new Error(ERROR_FAILED_CREATE_TEXTURE);
        }

        this.#texture = texture;
        this.#bindTexture();

        webglContext.texImage2D(
            webglContext.TEXTURE_2D,
            BASE_MIPMAP_LEVEL,
            webglContext.RGBA,
            PLACEHOLDER_TEXTURE_WIDTH,
            PLACEHOLDER_TEXTURE_HEIGHT,
            TEXTURE_BORDER_VALUE,
            webglContext.RGBA,
            webglContext.UNSIGNED_BYTE,
            PLACEHOLDER_PIXEL_RGBA
        );

        this.#applySamplerParams();
        this.#unbindTexture();
    }

    /**
     * Returns the underlying `WebGLTexture` object.
     *
     * @returns {WebGLTexture}
     */
    get texture() {
        this.#assertNotDisposed();
        return this.#texture;
    }

    /**
     * Returns the width of the uploaded image (or the placeholder width until loaded).
     *
     * @returns {number}
     */
    get width() {
        this.#assertNotDisposed();
        return this.#width;
    }

    /**
     * Returns the height of the uploaded image (or placeholder height until loaded).
     *
     * @returns {number}
     */
    get height() {
        this.#assertNotDisposed();
        return this.#height;
    }

    /**
     * Indicates whether the image has been uploaded.
     *
     * @returns {boolean}
     */
    get isLoaded() {
        this.#assertNotDisposed();
        return this.#isLoaded;
    }

    /**
     * Indicates whether this instance has been disposed.
     *
     * @returns {boolean}
     */
    get isDisposed() {
        return this.#isDisposed;
    }

    /**
     * Binds this texture to a texture unit.
     *
     * @param {number} textureUnitIndex - Index of the texture unit.
     */
    bind(textureUnitIndex) {
        this.#assertNotDisposed();

        if (!Number.isInteger(textureUnitIndex) || textureUnitIndex < MIN_TEXTURE_UNIT_INDEX) {
            throw new TypeError(ERROR_EXPECTS_TEXTURE_UNIT_INDEX);
        }

        const webglContext = this.#webglContext;
        const maxUnits     = webglContext.getParameter(webglContext.MAX_COMBINED_TEXTURE_IMAGE_UNITS);

        if (!Number.isInteger(maxUnits) || maxUnits <= MIN_TEXTURE_UNIT_INDEX) {
            throw new Error(ERROR_FAILED_READ_MAX_TEXTURE_UNITS);
        }

        if (textureUnitIndex >= maxUnits) {
            throw new RangeError(`${ERROR_TEXTURE_UNIT_INDEX_OUT_OF_RANGE_PREFIX}${maxUnits - 1}.`);
        }

        webglContext.activeTexture(webglContext.TEXTURE0 + textureUnitIndex);
        webglContext.bindTexture(webglContext.TEXTURE_2D, this.#texture);
    }

    /**
     * Updates sampler parameters for this texture.
     *
     * @param {Texture2DOptions} [options] - Sampler options to update.
     * @throws {TypeError} When provided arguments do not match expected types or supported enums.
     */
    setSamplerParams(options = {}) {
        this.#assertNotDisposed();

        if (options === null || typeof options !== 'object' || Array.isArray(options)) {
            throw new TypeError(ERROR_EXPECTS_SAMPLER_OPTIONS_OBJECT);
        }

        const hasExplicitWrapS         = Object.prototype.hasOwnProperty.call(options, 'wrapS');
        const hasExplicitWrapT         = Object.prototype.hasOwnProperty.call(options, 'wrapT');
        const hasMinFilterProperty     = Object.prototype.hasOwnProperty.call(options, 'minFilter');
        const isResetMinFilter         = hasMinFilterProperty && options.minFilter === null;
        const hasExplicitMinFilter     = hasMinFilterProperty && !isResetMinFilter;
        const hasExplicitMagFilter     = Object.prototype.hasOwnProperty.call(options, 'magFilter');
        const hasExplicitMipmapPolicy  = Object.prototype.hasOwnProperty.call(options, 'mipmapPolicy');
        const nextWrapS                = hasExplicitWrapS ? options.wrapS : this.#wrapS;
        const nextWrapT                = hasExplicitWrapT ? options.wrapT : this.#wrapT;
        const nextMinFilter            = isResetMinFilter ? this.#webglContext.LINEAR : (hasExplicitMinFilter ? options.minFilter : this.#minFilter);
        const nextMagFilter            = hasExplicitMagFilter ? options.magFilter : this.#magFilter;
        const nextMipmapPolicy         = hasExplicitMipmapPolicy ? options.mipmapPolicy : this.#mipmapPolicy;
        const nextHasExplicitMinFilter = isResetMinFilter ? false : (hasExplicitMinFilter ? true : this.#hasExplicitMinFilter);

        if (hasExplicitWrapS && !this.#isValidWrapMode(nextWrapS)) {
            throw new TypeError(ERROR_EXPECTS_WRAP_S_ENUM);
        }

        if (hasExplicitWrapT && !this.#isValidWrapMode(nextWrapT)) {
            throw new TypeError(ERROR_EXPECTS_WRAP_T_ENUM);
        }

        if (hasExplicitMinFilter && !this.#isValidMinFilter(nextMinFilter)) {
            throw new TypeError(ERROR_EXPECTS_MIN_FILTER_ENUM);
        }

        if (hasExplicitMagFilter && !this.#isValidMagFilter(nextMagFilter)) {
            throw new TypeError(ERROR_EXPECTS_MAG_FILTER_ENUM);
        }

        if (hasExplicitMipmapPolicy && !this.#isValidMipmapPolicy(nextMipmapPolicy)) {
            throw new TypeError(ERROR_EXPECTS_MIPMAP_POLICY);
        }

        if (nextMipmapPolicy === MIPMAP_POLICY_NONE
            && nextHasExplicitMinFilter
            && this.#isMipmapMinFilter(nextMinFilter)) {
            throw new TypeError(ERROR_MIPMAP_POLICY_CONFLICT);
        }

        this.#wrapS                = nextWrapS;
        this.#wrapT                = nextWrapT;
        this.#minFilter            = nextMinFilter;
        this.#magFilter            = nextMagFilter;
        this.#mipmapPolicy         = nextMipmapPolicy;
        this.#hasExplicitMinFilter = nextHasExplicitMinFilter;
        this.#bindTexture();

        try {
            if (this.#isLoaded) {
                const mipmapsGenerated = this.#maybeGenerateMipmaps();
                this.#syncMinFilterWithMipmaps(mipmapsGenerated);
            } else if (!this.#hasExplicitMinFilter && this.#isMipmapMinFilter(this.#minFilter)) {
                this.#minFilter = this.#webglContext.LINEAR;
            }

            this.#applySamplerParams();
        } finally {
            this.#unbindTexture();
        }
    }

    /**
     * Loads an image from the given URL and uploads it into this WebGL texture.
     *
     * @param {string} url                     - Image URL (relative or absolute).
     * @param {Texture2DLoadOptions} [options] - Optional load options.
     * @returns {Promise<void>}                - Promise, that resolves after successful GPU upload, or rejects on `load/decode/upload` error.
     * @throws {TypeError} When arguments are invalid.
     */
    async loadFromUrl(url, options = {}) {
        this.#assertNotDisposed();

        if (typeof url !== 'string' || url.length < MIN_REQUIRED_STRING_LENGTH) {
            throw new TypeError(ERROR_EXPECTS_URL_STRING);
        }

        if (options === null || typeof options !== 'object' || Array.isArray(options)) {
            throw new TypeError(ERROR_EXPECTS_LOAD_OPTIONS_OBJECT);
        }

        const hasCrossOrigin = Object.prototype.hasOwnProperty.call(options, 'crossOrigin');
        const crossOrigin    = hasCrossOrigin ? options.crossOrigin : null;

        if (hasCrossOrigin
            && crossOrigin !== null
            && crossOrigin !== CROSS_ORIGIN_ANONYMOUS
            && crossOrigin !== CROSS_ORIGIN_USE_CREDENTIALS) {
            throw new TypeError(ERROR_EXPECTS_CROSS_ORIGIN);
        }

        const image = await this.#loadImage(url, crossOrigin);
        this.#assertNotDisposed();
        this.#uploadImage(image);
    }

    /**
     * Releases the WebGL texture.
     */
    dispose() {
        if (this.#isDisposed) {
            return;
        }

        this.#webglContext.deleteTexture(this.#texture);
        this.#isDisposed = true;
    }

    /**
     * Loads an `HTMLImageElement` from a URL.
     *
     * @param {string} url                         - Image URL.
     * @param {(string|null)} [crossOrigin = null] - Optional CORS mode: `anonymous/use-credentials`.
     * @returns {Promise<HTMLImageElement>}        - Promise, that resolves with a decoded image on `load`, or rejects on `error`.
     * @private
     */
    #loadImage(url, crossOrigin = null) {
        return new Promise((resolve, reject) => {
            const image = new Image();

            if (typeof crossOrigin === 'string') {
                image.crossOrigin = crossOrigin;
            }

            image.onload  = () => resolve(image);
            image.onerror = () => reject(new Error(`${ERROR_FAILED_LOAD_IMAGE_PREFIX}${url}`));
            image.src = url;
        });
    }

    /**
     * Uploads the given image into the GPU texture.
     *
     * @param {HTMLImageElement} image - Loaded image element.
     * @private
     */
    #uploadImage(image) {
        const webglContext  = this.#webglContext;
        const previousFlipY = webglContext.getParameter(webglContext.UNPACK_FLIP_Y_WEBGL);
        this.#bindTexture();

        try {
            webglContext.pixelStorei(
                webglContext.UNPACK_FLIP_Y_WEBGL,
                this.#flipY ? WEBGL_TRUE_AS_INTEGER : WEBGL_FALSE_AS_INTEGER
            );

            webglContext.texImage2D(
                webglContext.TEXTURE_2D,
                BASE_MIPMAP_LEVEL,
                webglContext.RGBA,
                webglContext.RGBA,
                webglContext.UNSIGNED_BYTE,
                image
            );

            this.#width    = image.width;
            this.#height   = image.height;
            this.#isLoaded = true;

            const mipmapsGenerated = this.#maybeGenerateMipmaps();
            this.#syncMinFilterWithMipmaps(mipmapsGenerated);
            this.#applySamplerParams();
        } finally {
            webglContext.pixelStorei(
                webglContext.UNPACK_FLIP_Y_WEBGL,
                previousFlipY ? WEBGL_TRUE_AS_INTEGER : WEBGL_FALSE_AS_INTEGER
            );

            this.#unbindTexture();
        }
    }

    /**
     * Applies current sampler parameters to the bound texture.
     *
     * @private
     */
    #applySamplerParams() {
        const webglContext = this.#webglContext;
        webglContext.texParameteri(webglContext.TEXTURE_2D, webglContext.TEXTURE_WRAP_S, this.#wrapS);
        webglContext.texParameteri(webglContext.TEXTURE_2D, webglContext.TEXTURE_WRAP_T, this.#wrapT);
        webglContext.texParameteri(webglContext.TEXTURE_2D, webglContext.TEXTURE_MIN_FILTER, this.#minFilter);
        webglContext.texParameteri(webglContext.TEXTURE_2D, webglContext.TEXTURE_MAG_FILTER, this.#magFilter);
    }

    /**
     * Generates mipmaps, when the policy allows it.
     *
     * @returns {boolean} True, when mipmaps were generated.
     * @private
     */
    #maybeGenerateMipmaps() {
        if (this.#mipmapPolicy === MIPMAP_POLICY_NONE) {
            return false;
        }

        if (this.#mipmapPolicy === MIPMAP_POLICY_AUTO
            && !(this.#isPowerOfTwo(this.#width)
            && this.#isPowerOfTwo(this.#height))) {

            if (this.#hasExplicitMinFilter && this.#isMipmapMinFilter(this.#minFilter)) {
                throw new TypeError(ERROR_MIPMAP_AUTO_POT_REQUIRED_FOR_MIPMAP_FILTER);
            }

            return false;
        }

        this.#webglContext.generateMipmap(this.#webglContext.TEXTURE_2D);
        return true;
    }

    /**
     * Updates minification filter, based on mipmap availability and explicit overrides.
     *
     * @param {boolean} mipmapsGenerated - True, when mipmaps were generated.
     * @private
     */
    #syncMinFilterWithMipmaps(mipmapsGenerated) {
        if (mipmapsGenerated) {
            if (!this.#hasExplicitMinFilter) {
                this.#minFilter = this.#webglContext.LINEAR_MIPMAP_LINEAR;
            }

            return;
        }

        if (!this.#hasExplicitMinFilter && this.#isMipmapMinFilter(this.#minFilter)) {
            this.#minFilter = this.#webglContext.LINEAR;
        }
    }

    /**
     * Checks whether a value is a valid wrap mode.
     *
     * @param {number} value - Wrap mode value to validate.
     * @returns {boolean} True, when value is a supported wrap mode.
     * @private
     */
    #isValidWrapMode(value) {
        const webglContext = this.#webglContext;
        return value === webglContext.REPEAT
            || value === webglContext.CLAMP_TO_EDGE
            || value === webglContext.MIRRORED_REPEAT;
    }

    /**
     * Checks whether a value is a valid minification filter.
     *
     * @param {number} value - Filter value to validate.
     * @returns {boolean} True, when value is a supported min filter.
     * @private
     */
    #isValidMinFilter(value) {
        const webglContext = this.#webglContext;
        return value === webglContext.NEAREST
            || value === webglContext.LINEAR
            || value === webglContext.NEAREST_MIPMAP_NEAREST
            || value === webglContext.LINEAR_MIPMAP_NEAREST
            || value === webglContext.NEAREST_MIPMAP_LINEAR
            || value === webglContext.LINEAR_MIPMAP_LINEAR;
    }

    /**
     * Checks whether a value is a valid magnification filter.
     *
     * @param {number} value - Filter value to validate.
     * @returns {boolean} True, when value is a supported mag filter.
     * @private
     */
    #isValidMagFilter(value) {
        const webglContext = this.#webglContext;
        return value === webglContext.NEAREST
            || value === webglContext.LINEAR;
    }

    /**
     * Checks whether a value is a valid mipmap policy.
     *
     * @param {number} value - Policy value to validate.
     * @returns {boolean} True, when value is a supported policy.
     * @private
     */
    #isValidMipmapPolicy(value) {
        return value === MIPMAP_POLICY_NONE
            || value === MIPMAP_POLICY_ALWAYS
            || value === MIPMAP_POLICY_AUTO;
    }

    /**
     * Checks whether a min filter value uses mipmaps.
     *
     * @param {number} value - Filter value to validate.
     * @returns {boolean} True, when the filter expects mipmaps.
     * @private
     */
    #isMipmapMinFilter(value) {
        const webglContext = this.#webglContext;
        return value === webglContext.NEAREST_MIPMAP_NEAREST
            || value === webglContext.LINEAR_MIPMAP_NEAREST
            || value === webglContext.NEAREST_MIPMAP_LINEAR
            || value === webglContext.LINEAR_MIPMAP_LINEAR;
    }

    /**
     * Checks whether an integer value is a `power-of-two`.
     *
     * @param {number} value - Value to check.
     * @returns {boolean}    - True if value is a `power-of-two` (e.g.: 2, 4, 8, ...), otherwise false.
     * @private
     */
    #isPowerOfTwo(value) {
        return Number.isInteger(value)
        && value >= MIN_POWER_OF_TWO_VALUE
        && (value & (value - BIT_MASK_ONE)) === BITWISE_ZERO;
    }

    /**
     * @private
     */
    #bindTexture() {
        this.#webglContext.bindTexture(this.#webglContext.TEXTURE_2D, this.#texture);
    }

    /**
     * @private
     */
    #unbindTexture() {
        this.#webglContext.bindTexture(this.#webglContext.TEXTURE_2D, null);
    }

    /**
     * @private
     */
    #assertNotDisposed() {
        if (this.#isDisposed) {
            throw new Error(ERROR_INSTANCE_DISPOSED);
        }
    }
}