layer_BaseGeoImage.ts

import * as mercator from "../mercator";
import { doubleToTwoFloats2 } from "../math/coder";
import { Extent } from "../Extent";
import type { EventCallback, EventsHandler } from "../Events";
import { Layer } from "./Layer";
import type { LayerEventsList, ILayerParams } from "./Layer";
import { LonLat } from "../LonLat";
import { Material } from "./Material";
import type { NumberArray2 } from "../math/Vec2";
import type { NumberArray4 } from "../math/Vec4";
import { Planet } from "../scene/Planet";
import type { WebGLBufferExt, WebGLTextureExt } from "../webgl/Handler";
import { SRGB } from "../utils/colorSpace";

export interface IBaseGeoImageParams extends ILayerParams {
    fullExtent?: boolean;
    corners?: NumberArray2[];
}

type BaseGeoImageEventsList = ["loadend"];

const BASEGEOIMAGE_EVENTS: BaseGeoImageEventsList = [
    /**
     * Triggered when image data is loaded
     * @event EventsHandler<BaseGeoImageEventsList>#loadend
     */
    "loadend"
];

const ANIMATED_MIPMAP_UPDATE_INTERVAL = 4;
const LON_WRAP = 360.0;
const LON_HALF_WRAP = 180.0;
const FULL_WORLD_EDGE_EPS = 1e-6;

export type BaseGeoImageEventsType = EventsHandler<BaseGeoImageEventsList> & EventsHandler<LayerEventsList>;

/**
 * BaseGeoImage represents a square imagery layer displayed on the globe.
 * It can use a static image, animated video, or WebGL buffer as a source.
 * @class
 * @extends {Layer}
 */
class BaseGeoImage extends Layer {
    public override events: BaseGeoImageEventsType;

    protected _projType: number;

    protected _frameWidth: number;
    protected _frameHeight: number;

    protected _sourceReady: boolean;
    protected _sourceTexture: WebGLTextureExt | null;
    protected _materialTexture: WebGLTextureExt | null;

    protected _gridBufferLow: WebGLBufferExt | null;
    protected _gridBufferHigh: WebGLBufferExt | null;

    protected _extentWgs84ParamsHigh: Float32Array;
    protected _extentWgs84ParamsLow: Float32Array;

    protected _extentMercParamsHigh: Float32Array;
    protected _extentMercParamsLow: Float32Array;

    protected _refreshFrame: boolean;
    protected _frameCreated: boolean;
    protected _sourceCreated: boolean;

    public _animate: boolean;
    protected _ready: boolean;
    public _creationProceeding: boolean;
    public _isRendering: boolean;

    protected _extentWgs84: Extent;
    protected _cornersWgs84: LonLat[];
    protected _cornersMerc: LonLat[];

    protected _isFullExtent: number;
    protected _crossesAntimeridian: boolean;

    /**
     * rendering function pointer
     * @type {Function}
     */
    public rendering: Function;

    protected _onLoadend_: EventCallback | null;
    protected _materialMipmapUpdateCounter: number;

    constructor(name: string | null, options: IBaseGeoImageParams = {}) {
        super(name, options);

        // @ts-ignore
        this.events = this.events.registerNames(BASEGEOIMAGE_EVENTS);

        this._projType = 0;

        this._frameWidth = 256;
        this._frameHeight = 256;

        this._sourceReady = false;
        this._sourceTexture = null;
        this._materialTexture = null;

        this._gridBufferLow = null;
        this._gridBufferHigh = null;

        this._extentWgs84ParamsHigh = new Float32Array(4);
        this._extentWgs84ParamsLow = new Float32Array(4);

        this._extentMercParamsHigh = new Float32Array(4);
        this._extentMercParamsLow = new Float32Array(4);

        this._refreshFrame = true;
        this._frameCreated = false;
        this._sourceCreated = false;

        this._animate = false;
        this._ready = false;
        this._creationProceeding = false;
        this._isRendering = false;

        this._extentWgs84 = new Extent();
        this._cornersWgs84 = [];
        this._cornersMerc = [];

        this._isFullExtent = options.fullExtent ? 1 : 0;
        this._crossesAntimeridian = false;

        /**
         * rendering function pointer
         */
        this.rendering = this._renderingProjType0.bind(this);

        this._onLoadend_ = null;
        this._materialMipmapUpdateCounter = 0;

        options.corners && this.setCorners(options.corners);
    }

    public override get isIdle(): boolean {
        return super.isIdle && this._ready;
    }

    public override addTo(planet: Planet) {
        this._onLoadend_ = this._onLoadend.bind(this);
        this.events.on("loadend", this._onLoadend_, this);
        return super.addTo(planet);
    }

    protected _onLoadend() {
        if (this._planet) {
            this._planet.events.dispatch(this._planet.events.layerloadend, this);
        }
    }

    public override remove() {
        if (this._planet) {
            this._planet._geoImageCreator.remove(this);
        }
        this.events.off("loadend", this._onLoadend_);
        this._onLoadend_ = null;
        return super.remove();
    }

    public override get instanceName(): string {
        return "BaseGeoImage";
    }

    /**
     * Gets corners coordinates.
     * @public
     * @returns {Array.<LonLat>} - (exactly 4 entries)
     */
    public getCornersLonLat(): LonLat[] {
        let c = this._cornersWgs84;
        return [
            new LonLat(c[0].lon, c[0].lat),
            new LonLat(c[1].lon, c[1].lat),
            new LonLat(c[2].lon, c[2].lat),
            new LonLat(c[3].lon, c[3].lat)
        ];
    }

    /**
     * Gets corners coordinates.
     * @public
     * @returns {Array.<Array<number>>} - (exactly 4 entries)
     */
    public getCorners(): NumberArray2[] {
        let c = this._cornersWgs84;
        return [
            [c[0].lon, c[0].lat],
            [c[1].lon, c[1].lat],
            [c[2].lon, c[2].lat],
            [c[3].lon, c[3].lat]
        ];
    }

    /**
     * Sets geoImage geographical corners coordinates.
     * @public
     * @param {Array.<Array.<number>>} corners - GeoImage corner coordinates. Each coordinate has exactly 2 entries.
     * The first corner is top-left, the second is top-right, the third is bottom-right, and the fourth is bottom-left.
     */
    public setCorners(corners: NumberArray2[]) {
        this.setCornersLonLat(LonLat.join(corners));
    }

    /**
     * Sets geoImage geographical corners coordinates.
     * @public
     * @param {Array.<LonLat>} corners - GeoImage corner coordinates.
     * The first corner is top-left, the second is top-right, the third is bottom-right, and the fourth is bottom-left.
     * (exactly 4 entries)
     */
    public setCornersLonLat(corners: LonLat[]) {
        this._refreshFrame = true;
        const cornersUnwrapped = this._unwrapCornersLonLat(corners);
        this._cornersWgs84 = [
            cornersUnwrapped[0].clone(),
            cornersUnwrapped[1].clone(),
            cornersUnwrapped[2].clone(),
            cornersUnwrapped[3].clone()
        ];
        this._crossesAntimeridian = this._detectAntimeridianCrossing(this._cornersWgs84);

        for (let i = 0; i < this._cornersWgs84.length; i++) {
            if (this._cornersWgs84[i].lat >= 89.9) {
                this._cornersWgs84[i].lat = 89.9;
            }
            if (this._cornersWgs84[i].lat <= -89.9) {
                this._cornersWgs84[i].lat = -89.9;
            }
        }
        this._extent.setByCoordinates(this._cornersWgs84);

        let me = this._extent;
        if (me.southWest.lat > mercator.MAX_LAT || me.northEast.lat < mercator.MIN_LAT) {
            this._projType = 0;
            this.rendering = this._renderingProjType0;
        } else {
            this._projType = 1;
            this.rendering = this._renderingProjType1;
        }

        if (this._ready && !this._creationProceeding) {
            this._planet!._geoImageCreator.add(this);
        }
    }

    protected _isExplicitFullWorldLonEdge(lonA: number, lonB: number): boolean {
        return Math.abs(Math.abs(lonB - lonA) - LON_WRAP) <= FULL_WORLD_EDGE_EPS;
    }

    protected _unwrapCornersLonLat(corners: LonLat[]): LonLat[] {
        if (corners.length !== 4) {
            return [corners[0].clone(), corners[1].clone(), corners[2].clone(), corners[3].clone()];
        }

        const out = [corners[0].clone(), corners[1].clone(), corners[2].clone(), corners[3].clone()];

        for (let i = 1; i < out.length; i++) {
            const prevLon = out[i - 1].lon;
            const sourcePrevLon = corners[i - 1].lon;
            const sourceCurrLon = corners[i].lon;

            if (this._isExplicitFullWorldLonEdge(sourcePrevLon, sourceCurrLon)) {
                out[i].lon = prevLon + (sourceCurrLon - sourcePrevLon);
                continue;
            }

            let lon = sourceCurrLon;
            let delta = lon - prevLon;

            while (delta > LON_HALF_WRAP) {
                lon -= LON_WRAP;
                delta = lon - prevLon;
            }

            while (delta < -LON_HALF_WRAP) {
                lon += LON_WRAP;
                delta = lon - prevLon;
            }

            out[i].lon = lon;
        }

        return out;
    }

    protected _detectAntimeridianCrossing(corners: LonLat[]): boolean {
        let minLon = 180.0;
        let maxLon = -180.0;

        for (let i = 0; i < corners.length; i++) {
            let lon = corners[i].lon;
            if (lon < -180.0 || lon > 180.0) {
                lon = ((((lon + 180.0) % 360.0) + 360.0) % 360.0) - 180.0;
            }
            if (lon < minLon) minLon = lon;
            if (lon > maxLon) maxLon = lon;
        }

        return maxLon - minLon > 180.0;
    }

    /**
     * Creates geoImage frame.
     * @protected
     */
    protected _createFrame() {
        this._extentWgs84 = this._extent.clone();

        this._cornersMerc = [
            this._cornersWgs84[0].forwardMercatorEPS01(),
            this._cornersWgs84[1].forwardMercatorEPS01(),
            this._cornersWgs84[2].forwardMercatorEPS01(),
            this._cornersWgs84[3].forwardMercatorEPS01()
        ];

        this._extentMerc = new Extent(
            this._extentWgs84.southWest.forwardMercatorEPS01(),
            this._extentWgs84.northEast.forwardMercatorEPS01()
        );

        let tempArr = new Float32Array(2);

        if (this._projType === 0) {
            doubleToTwoFloats2(this._extentWgs84.southWest.lon, tempArr);
            this._extentWgs84ParamsHigh[0] = tempArr[0];
            this._extentWgs84ParamsLow[0] = tempArr[1];

            doubleToTwoFloats2(this._extentWgs84.southWest.lat, tempArr);
            this._extentWgs84ParamsHigh[1] = tempArr[0];
            this._extentWgs84ParamsLow[1] = tempArr[1];

            this._extentWgs84ParamsHigh[2] = 2.0 / this._extentWgs84.getWidth();
            this._extentWgs84ParamsHigh[3] = 2.0 / this._extentWgs84.getHeight();
        } else {
            doubleToTwoFloats2(this._extentMerc.southWest.lon, tempArr);
            this._extentMercParamsHigh[0] = tempArr[0];
            this._extentMercParamsLow[0] = tempArr[1];

            doubleToTwoFloats2(this._extentMerc.southWest.lat, tempArr);
            this._extentMercParamsHigh[1] = tempArr[0];
            this._extentMercParamsLow[1] = tempArr[1];

            this._extentMercParamsHigh[2] = 2.0 / this._extentMerc.getWidth();
            this._extentMercParamsHigh[3] = 2.0 / this._extentMerc.getHeight();
        }

        // creates material frame textures
        if (this._planet) {
            let p = this._planet,
                h = p.renderer!.handler,
                gl = h.gl!;

            gl.deleteTexture(this._materialTexture as WebGLTexture);
            this._materialTexture = h.createEmptyTexture_l(this._frameWidth, this._frameHeight);
            this._materialMipmapUpdateCounter = 0;

            let gridBufferArr = this._planet._geoImageCreator.createGridBuffer(
                this._cornersWgs84,
                this._projType === 1
            );

            this._gridBufferHigh = gridBufferArr[0];
            this._gridBufferLow = gridBufferArr[1];

            this._refreshFrame = false;
        }
    }

    /**
     * @public
     * @override
     * @param {Material} material - GeoImage material.
     */
    public override abortMaterialLoading(material: Material) {
        this._creationProceeding = false;
        material.isLoading = false;
        material.isReady = false;
    }

    /**
     * Clear layer material.
     * @public
     * @override
     */
    public override clear() {
        let p = this._planet;

        if (p) {
            let gl = p.renderer!.handler.gl;
            p._geoImageCreator.remove(this);
            p._clearLayerMaterial(this);

            if (gl) {
                gl.deleteBuffer(this._gridBufferHigh as WebGLBuffer);
                gl.deleteBuffer(this._gridBufferLow as WebGLBuffer);
                gl.deleteTexture(this._sourceTexture as WebGLTexture);
                this._materialTexture && !this._materialTexture.default && gl.deleteTexture(this._materialTexture);
            }
        }

        this._sourceTexture = null;
        this._materialTexture = null;

        this._gridBufferHigh = null;
        this._gridBufferLow = null;

        this._refreshFrame = true;
        this._sourceCreated = false;

        this._ready = false;
        this._creationProceeding = false;
    }

    /**
     * Sets layer visibility.
     * @public
     * @override
     * @param {boolean} visibility - GeoImage visibility.
     */
    public override setVisibility(visibility: boolean) {
        if (visibility !== this._visibility) {
            super.setVisibility(visibility);

            // remove from creator
            if (this._planet && this._sourceReady) {
                if (visibility) {
                    this._planet._geoImageCreator.add(this);
                } else {
                    this._planet._geoImageCreator.remove(this);
                }
            }
        }
    }

    /**
     * @public
     * @param {Material} material - GeoImage material.
     */
    public override clearMaterial(material: Material) {
        material.texture = null;
        material.isLoading = false;
        material.isReady = false;
    }

    protected _getCyclicLonShift(sourceExtent: Extent, targetExtent: Extent, worldWidth: number): number {
        const sourceCenter = (sourceExtent.southWest.lon + sourceExtent.northEast.lon) * 0.5;
        const targetCenter = (targetExtent.southWest.lon + targetExtent.northEast.lon) * 0.5;
        const sourceWidth = sourceExtent.northEast.lon - sourceExtent.southWest.lon;

        if (worldWidth <= 0.0 || sourceWidth >= worldWidth) {
            return 0.0;
        }

        const k0 = Math.round((targetCenter - sourceCenter) / worldWidth);
        let bestShift = k0 * worldWidth;
        let bestScore = Number.POSITIVE_INFINITY;

        for (let dk = -1; dk <= 1; dk++) {
            const shift = (k0 + dk) * worldWidth;
            const shiftedSw = sourceExtent.southWest.lon + shift;
            const shiftedNe = sourceExtent.northEast.lon + shift;
            const shiftedCenter = (shiftedSw + shiftedNe) * 0.5;
            const overlapsX = targetExtent.southWest.lon <= shiftedNe && targetExtent.northEast.lon >= shiftedSw;
            const score = Math.abs(shiftedCenter - targetCenter) + (overlapsX ? 0.0 : worldWidth);

            if (score < bestScore) {
                bestScore = score;
                bestShift = shift;
            }
        }

        return bestShift;
    }

    /**
     * @public
     * @override
     * @returns {NumberArray4}
     */
    public override applyMaterial(material: Material): NumberArray4 {
        let segment = material.segment;

        if (this._ready) {
            material.applyTexture(this._materialTexture);
        } else {
            material.texture = this._planet!.transparentTexture;
            !this._creationProceeding && this.loadMaterial(material);
        }

        let v0s: Extent, v0t: Extent, worldWidth: number;
        if (this._projType === 0) {
            v0s = this._extentWgs84;
            v0t = segment._extent;
            worldWidth = 360.0;
        } else {
            v0s = this._extentMerc;
            v0t = segment.getExtentMerc();
            worldWidth = mercator.POLE2;
        }

        const lonShift = this._getCyclicLonShift(v0s, v0t, worldWidth);
        const sourceSwLon = v0s.southWest.lon + lonShift;
        const sourceNeLon = v0s.northEast.lon + lonShift;

        let sSize_x = sourceNeLon - sourceSwLon;
        let sSize_y = v0s.northEast.lat - v0s.southWest.lat;
        let dV0s_x = (v0t.southWest.lon - sourceSwLon) / sSize_x;
        let dV0s_y = (v0s.northEast.lat - v0t.northEast.lat) / sSize_y;
        let dSize_x = (v0t.northEast.lon - v0t.southWest.lon) / sSize_x;
        let dSize_y = (v0t.northEast.lat - v0t.southWest.lat) / sSize_y;

        return [dV0s_x, dV0s_y, dSize_x, dSize_y];
    }

    /**
     * Gets frame width size in pixels.
     * @public
     * @returns {number} Frame width.
     */
    public get getFrameWidth(): number {
        return this._frameWidth;
    }

    /**
     * Gets frame height size in pixels.
     * @public
     * @returns {number} Frame height.
     */
    public get getFrameHeight(): number {
        return this._frameHeight;
    }

    /**
     * Method depends on GeoImage instance
     * @protected
     */
    protected _createSourceTexture() {
        //empty
    }

    protected _updateMaterialTextureMipmap() {
        const p = this._planet;
        if (!p || !this._materialTexture) return;

        const gl = p.renderer!.handler.gl!;
        const shouldUpdateMipmaps = !this._animate || this._materialMipmapUpdateCounter <= 0;

        gl.bindTexture(gl.TEXTURE_2D, this._materialTexture as WebGLTexture);

        if (shouldUpdateMipmaps) {
            gl.generateMipmap(gl.TEXTURE_2D);
            this._materialMipmapUpdateCounter = this._animate ? ANIMATED_MIPMAP_UPDATE_INTERVAL : 0;
        } else {
            this._materialMipmapUpdateCounter--;
        }

        gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
        gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR_MIPMAP_LINEAR);
        gl.bindTexture(gl.TEXTURE_2D, null);
    }

    public _renderingProjType1() {
        let p = this._planet!,
            h = p.renderer!.handler,
            gl = h.gl!,
            creator = p._geoImageCreator;

        this._refreshFrame && this._createFrame();
        this._createSourceTexture();

        let f = creator._framebuffer!;
        f.setSize(this._frameWidth, this._frameHeight);
        f.activate();

        h.programs.geoImageTransform.activate();
        let sh = h.programs.geoImageTransform;
        let sha = sh.attributes,
            shu = sh.uniforms;

        gl.disable(gl.CULL_FACE);

        f.bindOutputTexture(this._materialTexture as WebGLTexture);
        gl.clearColor(0.0, 0.0, 0.0, 0.0);
        gl.clear(gl.COLOR_BUFFER_BIT);

        // Keep edge-discard disabled for antimeridian-crossing images to avoid a seam on +/-180.
        gl.uniform1i(shu.isFullExtent, this._isFullExtent || this._crossesAntimeridian ? 1 : 0);
        gl.uniform1i(shu.decodeSourceSRGB, this._colorSpace === SRGB ? 1 : 0);

        gl.bindBuffer(gl.ARRAY_BUFFER, creator._texCoordsBuffer as WebGLBuffer);

        gl.vertexAttribPointer(sha.texCoords, 2, gl.UNSIGNED_SHORT, true, 0, 0);

        gl.bindBuffer(gl.ARRAY_BUFFER, this._gridBufferHigh as WebGLBuffer);
        gl.vertexAttribPointer(sha.cornersHigh, this._gridBufferHigh!.itemSize, gl.FLOAT, false, 0, 0);

        gl.bindBuffer(gl.ARRAY_BUFFER, this._gridBufferLow as WebGLBuffer);
        gl.vertexAttribPointer(sha.cornersLow, this._gridBufferLow!.itemSize, gl.FLOAT, false, 0, 0);

        gl.uniform4fv(shu.extentParamsHigh, this._extentMercParamsHigh);
        gl.uniform4fv(shu.extentParamsLow, this._extentMercParamsLow);

        gl.activeTexture(gl.TEXTURE0);
        gl.bindTexture(gl.TEXTURE_2D, this._sourceTexture as WebGLTexture);
        gl.uniform1i(shu.sourceTexture, 0);
        gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, creator._indexBuffer as WebGLBuffer);
        gl.drawElements(gl.TRIANGLE_STRIP, creator._indexBuffer!.numItems, gl.UNSIGNED_INT, 0);
        f.deactivate();
        this._updateMaterialTextureMipmap();

        gl.enable(gl.CULL_FACE);

        this._ready = true;

        this._creationProceeding = false;
    }

    protected _renderingProjType0() {
        let p = this._planet!,
            h = p.renderer!.handler,
            gl = h.gl!,
            creator = p._geoImageCreator;

        this._refreshFrame && this._createFrame();
        this._createSourceTexture();

        let f = creator._framebuffer!;
        f.setSize(this._frameWidth, this._frameHeight);
        f.activate();

        h.programs.geoImageTransform.activate();
        let sh = h.programs.geoImageTransform;
        let sha = sh.attributes,
            shu = sh.uniforms;

        gl.disable(gl.CULL_FACE);

        f.bindOutputTexture(this._materialTexture as WebGLTexture);
        gl.clearColor(0.0, 0.0, 0.0, 0.0);
        gl.clear(gl.COLOR_BUFFER_BIT);
        gl.bindBuffer(gl.ARRAY_BUFFER, creator._texCoordsBuffer as WebGLBuffer);
        gl.uniform1i(shu.isFullExtent, this._isFullExtent || this._crossesAntimeridian ? 1 : 0);
        gl.uniform1i(shu.decodeSourceSRGB, this._colorSpace === SRGB ? 1 : 0);

        gl.vertexAttribPointer(sha.texCoords, 2, gl.UNSIGNED_SHORT, true, 0, 0);

        gl.bindBuffer(gl.ARRAY_BUFFER, this._gridBufferHigh as WebGLBuffer);
        gl.vertexAttribPointer(sha.cornersHigh, this._gridBufferHigh!.itemSize, gl.FLOAT, false, 0, 0);

        gl.bindBuffer(gl.ARRAY_BUFFER, this._gridBufferLow as WebGLBuffer);
        gl.vertexAttribPointer(sha.cornersLow, this._gridBufferLow!.itemSize, gl.FLOAT, false, 0, 0);

        gl.uniform4fv(shu.extentParamsHigh, this._extentWgs84ParamsHigh);
        gl.uniform4fv(shu.extentParamsLow, this._extentWgs84ParamsLow);

        gl.activeTexture(gl.TEXTURE0);
        gl.bindTexture(gl.TEXTURE_2D, this._sourceTexture as WebGLTexture);
        gl.uniform1i(shu.sourceTexture, 0);

        gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, creator._indexBuffer as WebGLBuffer);
        gl.drawElements(gl.TRIANGLE_STRIP, creator._indexBuffer!.numItems, gl.UNSIGNED_INT, 0);
        f.deactivate();
        this._updateMaterialTextureMipmap();

        gl.enable(gl.CULL_FACE);

        this._ready = true;

        this._creationProceeding = false;
    }
}

export { BaseGeoImage };