Source: geometry/heightmap-geometry.js

import { Geometry } from './geometry.js';
import {
    createColorsFromSpec,
    createIndexArray,
    createWireframeIndicesFromSolidIndices
} from './geometry-utils.js';

/**
 * Default heightmap geometry width along the X axis.
 *
 * @type {number}
 */
const DEFAULT_HEIGHTMAP_WIDTH = 1.0;

/**
 * Default heightmap geometry depth along the Z axis.
 *
 * @type {number}
 */
const DEFAULT_HEIGHTMAP_DEPTH = 1.0;

/**
 * Default height scale multiplier (amplitude).
 *
 * @type {number}
 */
const DEFAULT_HEIGHT_SCALE = 1.0;

/**
 * Default height offset applied to all vertices.
 *
 * @type {number}
 */
const DEFAULT_HEIGHT_OFFSET = 0.0;

/**
 * Default segment count along the X axis.
 *
 * @type {number}
 */
const DEFAULT_SEGMENTS_X = 1;

/**
 * Default segment count along the Z axis.
 *
 * @type {number}
 */
const DEFAULT_SEGMENTS_Z = 1;

/**
 * Minimum segment count supported by heightmap geometry.
 *
 * @type {number}
 */
const MIN_SEGMENT_COUNT = 1;

/**
 * Lower bound for positive-only numeric options.
 *
 * @type {number}
 */
const MIN_POSITIVE_VALUE = 0;

/**
 * Default wireframe hint state.
 *
 * @type {boolean}
 */
const DEFAULT_WIREFRAME_STATE = false;

/**
 * Default vertical flip for heightmap sampling.
 *
 * @type {boolean}
 */
const DEFAULT_FLIP_Y = true;

/**
 * Heightmap sampling mode: `nearest`.
 *
 * @type {string}
 */
const SAMPLING_NEAREST = 'nearest';

/**
 * Heightmap sampling mode: `bilinear`.
 *
 * @type {string}
 */
const SAMPLING_BILINEAR = 'bilinear';

/**
 * Default heightmap sampling mode.
 *
 * @type {string}
 */
const DEFAULT_SAMPLING = SAMPLING_NEAREST;

/**
 * Default terrain color red component.
 *
 * @type {number}
 */
const DEFAULT_TERRAIN_COLOR_RED = 0.18;

/**
 * Default terrain color green component.
 *
 * @type {number}
 */
const DEFAULT_TERRAIN_COLOR_GREEN = 0.65;

/**
 * Default terrain color blue component.
 *
 * @type {number}
 */
const DEFAULT_TERRAIN_COLOR_BLUE = 0.28;

/**
 * Default terrain vertex color (green).
 *
 * @type {Float32Array}
 */
const DEFAULT_TERRAIN_COLOR = new Float32Array([
    DEFAULT_TERRAIN_COLOR_RED,
    DEFAULT_TERRAIN_COLOR_GREEN,
    DEFAULT_TERRAIN_COLOR_BLUE
]);

/**
 * Adds one vertex per grid intersection, so vertex count along an axis is `segments + 1`.
 *
 * @type {number}
 */
const VERTICES_PER_SEGMENT_INCREMENT = 1;

/**
 * Offset to move from a vertex to the next vertex in the same row.
 *
 * @type {number}
 */
const NEXT_VERTEX_OFFSET = 1;

/**
 * Used to center coordinates around the origin: `(t - 0.5) * size`.
 *
 * @type {number}
 */
const CENTER_T_OFFSET = 0.5;

/**
 * Number of float components per `vec3` (position/normal).
 *
 * @type {number}
 */
const VECTOR_COMPONENTS_3 = 3;

/**
 * Number of float components per `vec2` (uv).
 *
 * @type {number}
 */
const UV_COMPONENTS_2 = 2;

/**
 * X component index within `vec3`.
 *
 * @type {number}
 */
const X_INDEX = 0;

/**
 * Y component index within `vec3`.
 *
 * @type {number}
 */
const Y_INDEX = 1;

/**
 * Z component index within `vec3`.
 *
 * @type {number}
 */
const Z_INDEX = 2;

/**
 * U component index within `vec2`.
 *
 * @type {number}
 */
const U_INDEX = 0;

/**
 * V component index within `vec2`.
 *
 * @type {number}
 */
const V_INDEX = 1;

/**
 * Indices per triangle (3 vertices).
 *
 * @type {number}
 */
const TRIANGLE_INDEX_STRIDE = 3;

/**
 * Number of bytes per RGBA pixel.
 *
 * @type {number}
 */
const BYTES_PER_PIXEL = 4;

/**
 * Red channel offset inside RGBA pixel data.
 *
 * @type {number}
 */
const RED_CHANNEL_OFFSET = 0;

/**
 * Maximum channel value for 8-bit color channels.
 *
 * @type {number}
 */
const MAX_CHANNEL_VALUE = 255;

/**
 * String tag name used to create canvas elements.
 *
 * @type {string}
 */
const CANVAS_TAG_NAME = 'canvas';

/**
 * String identifier for 2D canvas context.
 *
 * @type {string}
 */
const CANVAS_CONTEXT_2D = '2d';

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

/**
 * Numeric zero value used for comparisons.
 *
 * @type {number}
 */
const ZERO_VALUE = 0;

/**
 * Numeric one value used for comparisons.
 *
 * @type {number}
 */
const ONE_VALUE = 1;

/**
 * Error message for invalid options object.
 *
 * @type {string}
 */
const ERROR_OPTIONS_PLAIN_OBJECT = '`HeightmapGeometry` expects options as a plain object.';

/**
 * Error message for invalid WebGL context.
 *
 * @type {string}
 */
const ERROR_WEBGL_CONTEXT = '`HeightmapGeometry` expects `webglContext` as a `WebGL2RenderingContext`.';

/**
 * Error message for invalid heightmap image data.
 *
 * @type {string}
 */
const ERROR_HEIGHTMAP_IMAGE_DATA = '`HeightmapGeometry` expects `heightmapImageData` as an `ImageData` instance or a `HeightmapSource` with `imageData`.';

/**
 * Field name for `HeightmapSource.imageData`.
 *
 * @type {string}
 */
const HEIGHTMAP_SOURCE_IMAGE_DATA_FIELD = 'imageData';

/**
 * Error message for invalid width/depth values.
 *
 * @type {string}
 */
const ERROR_SIZE_VALUES = '`HeightmapGeometry` expects `width` and `depth` as positive numbers.';

/**
 * Error message for invalid height scale value.
 *
 * @type {string}
 */
const ERROR_HEIGHT_SCALE_VALUE = '`HeightmapGeometry` expects `heightScale` as a positive number.';

/**
 * Error message for invalid height offset value.
 *
 * @type {string}
 */
const ERROR_HEIGHT_OFFSET_VALUE = '`HeightmapGeometry` expects `heightOffset` as a finite number.';

/**
 * Error message for invalid colors buffer.
 *
 * @type {string}
 */
const ERROR_COLORS_BUFFER = '`HeightmapGeometry` expects `colors` as a `Float32Array`.';

/**
 * Error message for invalid flipY option.
 *
 * @type {string}
 */
const ERROR_FLIP_Y_VALUE = '`HeightmapGeometry` expects `flipY` as a boolean.';

/**
 * Error message for invalid isWireframe option.
 *
 * @type {string}
 */
const ERROR_WIREFRAME_VALUE = '`HeightmapGeometry` expects `isWireframe` as a boolean.';

/**
 * Error message for invalid sampling option.
 *
 * @type {string}
 */
const ERROR_SAMPLING_VALUE = '`HeightmapGeometry` expects `sampling` to be a supported string value.';

/**
 * Error message for invalid segments option.
 *
 * @type {string}
 */
const ERROR_SEGMENT_VALUE = '`HeightmapGeometry` expects `{name}` to be a finite number.';

/**
 * Error message for segments below minimum.
 *
 * @type {string}
 */
const ERROR_SEGMENT_RANGE = '`HeightmapGeometry` expects `{name}` to be `>= {min}`.';

/**
 * Error message for invalid load URL.
 *
 * @type {string}
 */
const ERROR_LOAD_URL = '`HeightmapGeometry.loadFromUrl` expects url as a non-empty string.';

/**
 * Error message for invalid load options.
 *
 * @type {string}
 */
const ERROR_LOAD_OPTIONS = '`HeightmapGeometry.loadFromUrl` expects options as a plain object.';

/**
 * Error message for missing 2D canvas context.
 *
 * @type {string}
 */
const ERROR_CANVAS_CONTEXT = '`HeightmapGeometry.loadFromUrl` failed to acquire a 2D canvas context.';

/**
 * Error message prefix for image load failures.
 *
 * @type {string}
 */
const ERROR_LOAD_IMAGE_PREFIX = 'Failed to load the heightmap image: ';

/**
 * Option name for `segmentsX` in error messages.
 *
 * @type {string}
 */
const SEGMENTS_X_OPTION_NAME = 'segmentsX';

/**
 * Option name for `segmentsZ` in error messages.
 *
 * @type {string}
 */
const SEGMENTS_Z_OPTION_NAME = 'segmentsZ';

/**
 * Cross-origin mode for images used in canvas extraction.
 *
 * Prevents the `tainted canvas` issues, when loading images from other origins
 * (requires server to send the proper CORS headers).
 *
 * @type {string}
 */
const IMAGE_CROSS_ORIGIN_ANON = 'anonymous';

/**
 * Heightmap source data.
 *
 * @typedef {Object} HeightmapSource
 * @property {ImageData} imageData - Heightmap image data.
 */

/**
 * Heightmap geometry options.
 *
 * `colors` supports:
 * - uniform RGB    (`length === 3`)
 * - per-vertex RGB (`length === vertexCount * 3`)
 *
 * Segment parameters must be `integers >= 1`.
 *
 * @typedef {Object} HeightmapGeometryOptions
 * @property {number} [width = 1.0]          - Terrain width along the X axis.
 * @property {number} [depth = 1.0]          - Terrain depth along the Z axis.
 * @property {number} [heightScale = 1.0]    - Heightmap scale multiplier.
 * @property {number} [heightOffset = 0.0]   - Heightmap offset along the Y axis.
 * @property {number} [segmentsX = 1]        - Segment count along the X axis.
 * @property {number} [segmentsZ = 1]        - Segment count along the Z axis.
 * @property {boolean} [isWireframe=false]   - Wireframe hint for consumers.
 * @property {Float32Array} [colors]         - Color specification buffer.
 * @property {boolean} [flipY = true]        - When true, flips heightmap sampling along the Y axis.
 * @property {string} [sampling = 'nearest'] - Sampling mode: `nearest` or `bilinear`.
 */

/**
 * Internal geometry buffers produced by `HeightmapGeometry`.
 *
 * @typedef {Object} HeightmapGeometryData
 * @property {Float32Array} positions                     - Vertex positions (xyz).
 * @property {Float32Array} normals                       - Vertex normals (xyz).
 * @property {Float32Array} uvs                           - Vertex UVs (uv).
 * @property {Float32Array} colors                        - Vertex colors (rgb).
 * @property {Uint16Array | Uint32Array} indicesSolid     - Triangle index buffer.
 * @property {Uint16Array | Uint32Array} indicesWireframe - Line index buffer for wireframe.
 */

/**
 * Heightmap geometry on the XZ plane with Y up.
 */
export class HeightmapGeometry extends Geometry {

    /**
     * Wireframe hint for consumers.
     *
     * @type {boolean}
     * @private
     */
    #isWireframe;

    /**
     * @param {WebGL2RenderingContext} webglContext          - WebGL2 rendering context.
     * @param {ImageData|HeightmapSource} heightmapImageData - Heightmap image data (grayscale) or a wrapped source.
     * @param {HeightmapGeometryOptions} [options={}]        - Geometry options.
     */
    constructor(webglContext, heightmapImageData, options = {}) {
        if (!(webglContext instanceof WebGL2RenderingContext)) {
            throw new TypeError(ERROR_WEBGL_CONTEXT);
        }

        const imageData  = HeightmapGeometry.#normalizeHeightmapImageData(heightmapImageData);
        const normalized = HeightmapGeometry.#normalizeOptions(options);
        const data       = HeightmapGeometry.#createGeometryData(imageData, normalized);

        super(
            webglContext,
            data.positions,
            data.colors,
            data.indicesSolid,
            data.indicesWireframe,
            data.uvs,
            data.normals
        );

        this.#isWireframe = normalized.isWireframe;
    }

    /**
     * Returns the wireframe hint value from construction options.
     *
     * @returns {boolean}
     */
    get isWireframe() {
        return this.#isWireframe;
    }

    /**
     * Loads heightmap image data from a URL and returns a new `HeightmapGeometry`.
     *
     * @param {WebGL2RenderingContext} webglContext   - WebGL2 rendering context.
     * @param {string} url                            - Image URL (relative or absolute).
     * @param {HeightmapGeometryOptions} [options={}] - Geometry options.
     * @returns {Promise<HeightmapGeometry>}          - Promise, that resolves with created geometry.
     */
    static async loadFromUrl(webglContext, url, options = {}) {
        if (!(webglContext instanceof WebGL2RenderingContext)) {
            throw new TypeError(ERROR_WEBGL_CONTEXT);
        }

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

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

        const image     = await HeightmapGeometry.#loadImage(url);
        const imageData = HeightmapGeometry.#createImageData(image);
        return new HeightmapGeometry(webglContext, imageData, options);
    }

    /**
     * Normalizes constructor input to a `HeightmapGeometryOptions` object.
     *
     * @param {HeightmapGeometryOptions} options     - Options object.
     * @returns {Required<HeightmapGeometryOptions>} - Normalized options.
     * @private
     */
    static #normalizeOptions(options) {
        if (options === null || typeof options !== 'object' || Array.isArray(options)) {
            throw new TypeError(ERROR_OPTIONS_PLAIN_OBJECT);
        }

        const {
            width        = DEFAULT_HEIGHTMAP_WIDTH,
            depth        = DEFAULT_HEIGHTMAP_DEPTH,
            heightScale  = DEFAULT_HEIGHT_SCALE,
            heightOffset = DEFAULT_HEIGHT_OFFSET,
            segmentsX    = DEFAULT_SEGMENTS_X,
            segmentsZ    = DEFAULT_SEGMENTS_Z,
            isWireframe  = DEFAULT_WIREFRAME_STATE,
            colors       = DEFAULT_TERRAIN_COLOR,
            flipY        = DEFAULT_FLIP_Y,
            sampling     = DEFAULT_SAMPLING
        } = options;

        if (typeof width !== 'number' || typeof depth !== 'number'
            || !Number.isFinite(width) || !Number.isFinite(depth)
            || width <= MIN_POSITIVE_VALUE || depth <= MIN_POSITIVE_VALUE) {
            throw new RangeError(ERROR_SIZE_VALUES);
        }

        if (typeof heightScale !== 'number' || !Number.isFinite(heightScale) || heightScale <= MIN_POSITIVE_VALUE) {
            throw new RangeError(ERROR_HEIGHT_SCALE_VALUE);
        }

        if (typeof heightOffset !== 'number' || !Number.isFinite(heightOffset)) {
            throw new RangeError(ERROR_HEIGHT_OFFSET_VALUE);
        }

        if (!(colors instanceof Float32Array)) {
            throw new TypeError(ERROR_COLORS_BUFFER);
        }

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

        if (typeof isWireframe !== 'boolean') {
            throw new TypeError(ERROR_WIREFRAME_VALUE);
        }

        const normalizedSampling = HeightmapGeometry.#normalizeSampling(sampling);

        return {
            width,
            depth,
            heightScale,
            heightOffset,
            segmentsX : HeightmapGeometry.#normalizeSegmentCount(segmentsX, SEGMENTS_X_OPTION_NAME),
            segmentsZ : HeightmapGeometry.#normalizeSegmentCount(segmentsZ, SEGMENTS_Z_OPTION_NAME),
            isWireframe,
            colors,
            flipY,
            sampling : normalizedSampling
        };
    }

    /**
     * Normalizes and validates a segment count parameter.
     *
     * @param {number} value      - Segment count value.
     * @param {string} optionName - Name of the option for error messages.
     * @returns {number}          - Normalized integer `>= 1`.
     * @private
     */
    static #normalizeSegmentCount(value, optionName) {
        if (typeof value !== 'number' || !Number.isFinite(value)) {
            throw new TypeError(ERROR_SEGMENT_VALUE.replace('{name}', optionName));
        }

        const intValue = Math.floor(value);

        if (intValue < MIN_SEGMENT_COUNT) {
            /* eslint-disable indent */
            throw new RangeError(
                ERROR_SEGMENT_RANGE
                .replace('{name}', optionName)
                .replace('{min}', String(MIN_SEGMENT_COUNT))
            );
            /* eslint-enable indent */
        }

        return intValue;
    }

    /**
     * Normalizes sampling mode.
     *
     * @param {string} sampling - Sampling mode input.
     * @returns {string}        - Normalized sampling mode.
     * @private
     */
    static #normalizeSampling(sampling) {
        if (typeof sampling !== 'string') {
            throw new TypeError(ERROR_SAMPLING_VALUE);
        }

        if (sampling === SAMPLING_NEAREST || sampling === SAMPLING_BILINEAR) {
            return sampling;
        }

        throw new RangeError(ERROR_SAMPLING_VALUE);
    }

    /**
     * Creates full geometry data for a heightmap terrain.
     *
     * @param {ImageData} heightmapImageData               - Heightmap source image data.
     * @param {Required<HeightmapGeometryOptions>} options - Normalized options.
     * @returns {HeightmapGeometryData}                    - Geometry buffers.
     * @private
     */
    static #createGeometryData(heightmapImageData, options) {
        const widthSegments    = options.segmentsX;
        const depthSegments    = options.segmentsZ;
        const widthVertexCount = widthSegments + VERTICES_PER_SEGMENT_INCREMENT;
        const depthVertexCount = depthSegments + VERTICES_PER_SEGMENT_INCREMENT;
        const vertexCount      = widthVertexCount * depthVertexCount;
        const positions        = new Float32Array(vertexCount * VECTOR_COMPONENTS_3);
        const uvs              = new Float32Array(vertexCount * UV_COMPONENTS_2);
        let vertexIndex        = ZERO_VALUE;

        for (let zIndex = ZERO_VALUE; zIndex < depthVertexCount; zIndex += ONE_VALUE) {
            const vNormalized = zIndex / depthSegments;
            const positionZ   = (vNormalized - CENTER_T_OFFSET) * options.depth;

            for (let xIndex = ZERO_VALUE; xIndex < widthVertexCount; xIndex += ONE_VALUE) {
                const uNormalized = xIndex / widthSegments;
                const positionX   = (uNormalized - CENTER_T_OFFSET) * options.width;
                const height      = HeightmapGeometry.#sampleHeight(
                    heightmapImageData,
                    uNormalized,
                    vNormalized,
                    options
                );

                const positionY          = (height * options.heightScale) + options.heightOffset;
                const positionBaseOffset = vertexIndex * VECTOR_COMPONENTS_3;
                positions[positionBaseOffset + X_INDEX] = positionX;
                positions[positionBaseOffset + Y_INDEX] = positionY;
                positions[positionBaseOffset + Z_INDEX] = positionZ;

                const uvBaseOffset = vertexIndex * UV_COMPONENTS_2;
                uvs[uvBaseOffset + U_INDEX] = uNormalized;
                uvs[uvBaseOffset + V_INDEX] = vNormalized;
                vertexIndex += ONE_VALUE;
            }
        }

        const solidTriangleIndices = [];

        for (let zIndex = ZERO_VALUE; zIndex < depthSegments; zIndex += ONE_VALUE) {
            for (let xIndex = ZERO_VALUE; xIndex < widthSegments; xIndex += ONE_VALUE) {
                const topLeftVertexIndex     = (zIndex * widthVertexCount) + xIndex;
                const topRightVertexIndex    = topLeftVertexIndex    + NEXT_VERTEX_OFFSET;
                const bottomLeftVertexIndex  = topLeftVertexIndex    + widthVertexCount;
                const bottomRightVertexIndex = bottomLeftVertexIndex + NEXT_VERTEX_OFFSET;
                solidTriangleIndices.push(topLeftVertexIndex , bottomLeftVertexIndex, topRightVertexIndex);
                solidTriangleIndices.push(topRightVertexIndex, bottomLeftVertexIndex, bottomRightVertexIndex);
            }
        }

        const indicesSolid     = createIndexArray(vertexCount, solidTriangleIndices);
        const indicesWireframe = createWireframeIndicesFromSolidIndices(vertexCount, indicesSolid);
        const normals          = HeightmapGeometry.#computeVertexNormals(positions, indicesSolid, vertexCount);
        const colors           = createColorsFromSpec(vertexCount, options.colors);

        return {
            positions,
            normals,
            uvs,
            colors,
            indicesSolid,
            indicesWireframe
        };
    }

    /**
     * Samples the heightmap at the given normalized UV coordinate.
     *
     * @param {ImageData} heightmapImageData               - Heightmap image data.
     * @param {number} uNormalized                         - Normalized U coordinate [0..1].
     * @param {number} vNormalized                         - Normalized V coordinate [0..1].
     * @param {Required<HeightmapGeometryOptions>} options - Normalized options.
     * @returns {number}                                   - Height value in [0..1].
     * @private
     */
    static #sampleHeight(heightmapImageData, uNormalized, vNormalized, options) {
        const heightmapWidth  = heightmapImageData.width;
        const heightmapHeight = heightmapImageData.height;
        const vSample         = options.flipY ? (ONE_VALUE - vNormalized) : vNormalized;

        if (options.sampling === SAMPLING_BILINEAR) {
            // Convert normalized UV to continuous pixel coordinates in `[0..width - 1] / [0..height - 1]`:
            const xFloat = uNormalized * (heightmapWidth - ONE_VALUE);
            const yFloat = vSample * (heightmapHeight - ONE_VALUE);

            // Find the 2x2 pixel neighborhood around the sample point (clamped to image bounds):
            const x0 = Math.floor(xFloat);
            const y0 = Math.floor(yFloat);
            const x1 = Math.min(x0 + ONE_VALUE, heightmapWidth  - ONE_VALUE);
            const y1 = Math.min(y0 + ONE_VALUE, heightmapHeight - ONE_VALUE);

            // Compute interpolation weights inside the cell:
            const tx = xFloat - x0;
            const ty = yFloat - y0;

            // Fetch heights at the 2x2 neighborhood corners and bilinearly interpolate:
            const h00 = HeightmapGeometry.#getHeightAt(heightmapImageData, x0, y0);
            const h10 = HeightmapGeometry.#getHeightAt(heightmapImageData, x1, y0);
            const h01 = HeightmapGeometry.#getHeightAt(heightmapImageData, x0, y1);
            const h11 = HeightmapGeometry.#getHeightAt(heightmapImageData, x1, y1);
            const h0  = h00 + ((h10 - h00) * tx);
            const h1  = h01 + ((h11 - h01) * tx);
            return h0 + ((h1 - h0) * ty);
        }

        const xIndex = Math.round(uNormalized * (heightmapWidth - ONE_VALUE));
        const yIndex = Math.round(vSample * (heightmapHeight - ONE_VALUE));
        return HeightmapGeometry.#getHeightAt(heightmapImageData, xIndex, yIndex);
    }

    /**
     * Reads normalized height from image data at a pixel coordinate.
     *
     * @param {ImageData} heightmapImageData - Heightmap image data.
     * @param {number} xIndex                - Pixel X coordinate.
     * @param {number} yIndex                - Pixel Y coordinate.
     * @returns {number}                     - Height value in [0..1].
     * @private
     */
    static #getHeightAt(heightmapImageData, xIndex, yIndex) {
        const width      = heightmapImageData.width;
        const data       = heightmapImageData.data;
        const pixelIndex = ((yIndex * width) + xIndex) * BYTES_PER_PIXEL;
        const redValue   = data[pixelIndex + RED_CHANNEL_OFFSET];
        return redValue / MAX_CHANNEL_VALUE;
    }

    /**
     * Computes per-vertex normals from positions and indices.
     *
     * @param {Float32Array} positions            - Vertex positions.
     * @param {Uint16Array | Uint32Array} indices - Triangle indices.
     * @param {number} vertexCount                - Total vertex count.
     * @returns {Float32Array}                    - Vertex normals.
     * @private
     */
    static #computeVertexNormals(positions, indices, vertexCount) {
        const normals = new Float32Array(vertexCount * VECTOR_COMPONENTS_3);

        for (let i = ZERO_VALUE; i < indices.length; i += TRIANGLE_INDEX_STRIDE) {
            // Convert vertex indices (A, B, C) to the base offsets in the flat `vec3` buffer (vertexIndex * 3):
            const indexA = indices[i + X_INDEX] * VECTOR_COMPONENTS_3;
            const indexB = indices[i + Y_INDEX] * VECTOR_COMPONENTS_3;
            const indexC = indices[i + Z_INDEX] * VECTOR_COMPONENTS_3;

            // Fetch triangle vertex positions from the flat positions buffer:
            const ax = positions[indexA + X_INDEX];
            const ay = positions[indexA + Y_INDEX];
            const az = positions[indexA + Z_INDEX];
            const bx = positions[indexB + X_INDEX];
            const by = positions[indexB + Y_INDEX];
            const bz = positions[indexB + Z_INDEX];
            const cx = positions[indexC + X_INDEX];
            const cy = positions[indexC + Y_INDEX];
            const cz = positions[indexC + Z_INDEX];

            // Build edges `AB and AC`:
            const abx = bx - ax;
            const aby = by - ay;
            const abz = bz - az;
            const acx = cx - ax;
            const acy = cy - ay;
            const acz = cz - az;

            // `Face normal = cross(AB, AC)`, accumulated into per-vertex normals:
            const crossX = (aby * acz) - (abz * acy);
            const crossY = (abz * acx) - (abx * acz);
            const crossZ = (abx * acy) - (aby * acx);

            // Accumulate the face normal into each of the triangle's vertex normals (A, B, C):
            normals[indexA + X_INDEX] += crossX;
            normals[indexA + Y_INDEX] += crossY;
            normals[indexA + Z_INDEX] += crossZ;
            normals[indexB + X_INDEX] += crossX;
            normals[indexB + Y_INDEX] += crossY;
            normals[indexB + Z_INDEX] += crossZ;
            normals[indexC + X_INDEX] += crossX;
            normals[indexC + Y_INDEX] += crossY;
            normals[indexC + Z_INDEX] += crossZ;
        }

        for (let vertexIndex = ZERO_VALUE; vertexIndex < vertexCount; vertexIndex += ONE_VALUE) {
            const baseIndex = vertexIndex * VECTOR_COMPONENTS_3;
            const nx        = normals[baseIndex + X_INDEX];
            const ny        = normals[baseIndex + Y_INDEX];
            const nz        = normals[baseIndex + Z_INDEX];
            const length    = Math.sqrt((nx * nx) + (ny * ny) + (nz * nz));

            if (length > ZERO_VALUE) {
                const invLength = ONE_VALUE / length;
                normals[baseIndex + X_INDEX] = nx * invLength;
                normals[baseIndex + Y_INDEX] = ny * invLength;
                normals[baseIndex + Z_INDEX] = nz * invLength;
            }
        }

        return normals;
    }

    /**
     * Extracts `ImageData` from supported heightmap source formats.
     *
     * @param {ImageData|HeightmapSource} source - Heightmap source.
     * @returns {ImageData}                      - Extracted image data.
     * @private
     */
    static #normalizeHeightmapImageData(source) {
        if (source instanceof ImageData) {
            return source;
        }

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

        const imageData = source[HEIGHTMAP_SOURCE_IMAGE_DATA_FIELD];

        if (!(imageData instanceof ImageData)) {
            throw new TypeError(ERROR_HEIGHTMAP_IMAGE_DATA);
        }

        return imageData;
    }

    /**
     * Loads an `HTMLImageElement` from a URL.
     *
     * @param {string} url                  - Image URL.
     * @returns {Promise<HTMLImageElement>} - Promise, that resolves with a decoded image on `load`, or rejects on `error`.
     * @private
     */
    static #loadImage(url) {
        return new Promise((resolve, reject) => {
            const image       = new Image();
            image.crossOrigin = IMAGE_CROSS_ORIGIN_ANON;
            image.onload      = () => resolve(image);
            image.onerror     = () => reject(new Error(ERROR_LOAD_IMAGE_PREFIX + url));
            image.src         = url;
        });
    }

    /**
     * Creates ImageData from a loaded image.
     *
     * @param {HTMLImageElement} image - Loaded image element.
     * @returns {ImageData}            - Extracted image data.
     * @private
     */
    static #createImageData(image) {
        const canvas  = document.createElement(CANVAS_TAG_NAME);
        const context = canvas.getContext(CANVAS_CONTEXT_2D);

        if (!context) {
            throw new Error(ERROR_CANVAS_CONTEXT);
        }

        canvas.width  = image.width;
        canvas.height = image.height;
        context.drawImage(image, ZERO_VALUE, ZERO_VALUE);
        return context.getImageData(ZERO_VALUE, ZERO_VALUE, image.width, image.height);
    }

    /**
     * Heightmap sampling modes.
     *
     * @returns {{ NEAREST: string, BILINEAR: string }}
     */
    static get Sampling() {
        return Object.freeze({
            NEAREST  : SAMPLING_NEAREST,
            BILINEAR : SAMPLING_BILINEAR
        });
    }
}