terrain/RgbTerrain.tstypescript
import { Extent } from "../Extent";
import { getTileExtent } from "../mercator";
import type { IGlobusTerrainParams } from "./GlobusTerrain";
import { GlobusTerrain } from "./GlobusTerrain";
import { isPowerOfTwo } from "../math";
import { Layer } from "../layer/Layer";
import { LonLat } from "../LonLat";
import { Segment } from "../segment/Segment";
import type { TypedArray } from "../utils/shared";
import { binarySearchFast } from "../utils/shared";
import type { IResponse } from "../utils/Loader";
import type { DecodeHeightsRowFunc, IRgbDecodeParams, IRgbHeightsParams } from "./rgbTerrainGrid";
import {
createHeightsLevels,
decodeRgbHeightsRow,
getGridCells,
getParentHeight,
RGB_ENCODING_CUSTOM,
RGB_ENCODING_RGB,
RGB_ENCODING_TERRARIUM
} from "./rgbTerrainGrid";
import { RgbTerrainWorker } from "../utils/RgbTerrainWorker";
export type RgbTerrainEncoding = "rgb" | "terrarium";
function normalizeRgbTerrainEncoding(encoding?: string | null): RgbTerrainEncoding {
return encoding === "terrarium" ? "terrarium" : "rgb";
}
export type RgbToHeightFunc = (r: number, g: number, b: number, minHeight: number, resolution: number) => number;
export function rgbToHeightRgb(r: number, g: number, b: number, minHeight: number, resolution: number): number {
if (r === 255) {
return minHeight;
}
return minHeight + resolution * (r * 256 * 256 + g * 256 + b);
}
export function rgbToHeightTerrarium(r: number, g: number, b: number, _minHeight: number, _resolution: number): number {
// minHeight = - 32768, resolution = 1 /256
return r * 256 + g + b / 256 - 32768;
}
export const rgbToHeightByEncoding: Record<RgbTerrainEncoding, RgbToHeightFunc> = {
rgb: rgbToHeightRgb,
terrarium: rgbToHeightTerrarium
};
export function resolveRgbToHeightFunc(
options: Pick<IRgbTerrainParams, "encoding" | "rgbToHeightFunc">
): RgbToHeightFunc {
if (options.rgbToHeightFunc) {
return options.rgbToHeightFunc;
}
const encoding = normalizeRgbTerrainEncoding(options.encoding);
return rgbToHeightByEncoding[encoding];
}
let _rgbTerrainWorker: RgbTerrainWorker | null = null;
function getRgbTerrainWorker(): RgbTerrainWorker {
if (!_rgbTerrainWorker) {
_rgbTerrainWorker = new RgbTerrainWorker(2);
}
return _rgbTerrainWorker;
}
export interface IRgbTerrainParams extends IGlobusTerrainParams {
equalizeNormals?: boolean;
key?: string;
imageSize?: number;
minHeight?: number;
resolution?: number;
encoding?: RgbTerrainEncoding;
rgbToHeightFunc?: RgbToHeightFunc;
}
/**
* @class
* @extends {GlobusTerrain}
* @param {string} [name="RgbTerrain"] - Terrain provider name.
* @param {IRgbTerrainParams} [options] - Provider options:
* @param {boolean} [options.equalizeNormals=true] - Enables normal equalization on tile edges.
* @param {string} [options.key=""] - API key.
* @param {number} [options.imageSize=256] - Source image size.
* @param {number} [options.minHeight=-10000] - Minimal height for RGB-to-height conversion.
* @param {number} [options.resolution=0.1] - RGB-to-height conversion resolution.
* @param {RgbTerrainEncoding} [options.encoding="rgb"] - RGB elevation encoding: `"rgb"` (default) or `"terrarium"`. Ignored when `options.rgbToHeightFunc` is set.
* @param {RgbToHeightFunc} [options.rgbToHeightFunc] - Custom RGB-to-height decoder with signature `(r, g, b, minHeight, resolution) => number`. Defaults to `rgbToHeightByEncoding[options.encoding]`.
*/
class RgbTerrain extends GlobusTerrain {
protected _imageSize: number;
protected _ctx: CanvasRenderingContext2D;
protected _imageDataCache: Record<string, Uint8ClampedArray>;
protected _minHeight: number;
protected _resolution: number;
protected _rgbToHeight: RgbToHeightFunc;
protected _encoding: number;
constructor(name: string | null, options: IRgbTerrainParams = {}) {
super(name || "RgbTerrain", {
equalizeVertices: options.equalizeVertices != undefined ? options.equalizeVertices : true,
maxZoom: options.maxZoom || 17,
noDataValues: options.noDataValues || [options.minHeight != undefined ? options.minHeight : -10000],
plainGridSize: options.plainGridSize || 128,
url:
options.url != undefined
? options.url
: `//api.mapbox.com/v4/mapbox.terrain-rgb/{z}/{x}/{y}.pngraw?access_token=${options.key || "<key>"}`,
gridSizeByZoom: options.gridSizeByZoom || [
64, 32, 16, 8, 8, 8, 16, 16, 16, 32, 32, 32, 32, 32, 32, 64, 64, 64, 32, 32, 16, 8
],
...options
});
this.equalizeNormals = options.equalizeNormals != undefined ? options.equalizeNormals : true;
this._dataType = "imageBitmap";
this._imageSize = options.imageSize || 256;
this._ctx = this._createTemporalCanvas(this._imageSize);
this._imageDataCache = {};
this._minHeight = options.minHeight || -10000.0;
this._resolution = options.resolution || 0.1;
this._rgbToHeight = resolveRgbToHeightFunc(options);
this._encoding = options.rgbToHeightFunc
? RGB_ENCODING_CUSTOM
: normalizeRgbTerrainEncoding(options.encoding) === "terrarium"
? RGB_ENCODING_TERRARIUM
: RGB_ENCODING_RGB;
}
static override checkNoDataValue(noDataValues: number[] | TypedArray, value: number): boolean {
if (value > 50000) {
return true;
}
return binarySearchFast(noDataValues, value) !== -1;
}
public rgb2Height(r: number, g: number, b: number): number {
return this._rgbToHeight(r, g, b, this._minHeight, this._resolution);
}
public override isBlur(segment: Segment): boolean {
return segment.tileZoom >= 16;
}
protected _createTemporalCanvas(size: number): CanvasRenderingContext2D {
let canvas = document.createElement("canvas");
canvas.width = size;
canvas.height = size;
return canvas.getContext("2d", {
willReadFrequently: true
})!;
}
/**
* Returns true when "rgb" or "terrarium" heights are decoded without rgb2Height calls.
* @protected
* @returns {boolean} -
*/
protected _isBuiltInDecoding(): boolean {
return this._encoding !== RGB_ENCODING_CUSTOM && this.rgb2Height === RgbTerrain.prototype.rgb2Height;
}
/**
* Returns true when the loaded image heights are created in the worker.
* @protected
* @param {HTMLImageElement | ImageBitmap} data - Loaded image.
* @returns {boolean} -
*/
protected _isWorkerDecoding(data: HTMLImageElement | ImageBitmap): boolean {
return (
typeof OffscreenCanvas !== "undefined" &&
data instanceof ImageBitmap &&
data.width === this._imageSize &&
isPowerOfTwo(getGridCells(this._imageSize)) &&
this._isBuiltInDecoding()
);
}
protected _getHeightsParams(
segment: Segment | null,
tileZoom: number,
preventChildren: boolean
): IRgbHeightsParams {
const size = this._imageSize,
gridSize = this.plainGridSize;
let decode: IRgbDecodeParams = {
encoding: this._encoding,
minHeight: this._minHeight,
resolution: this._resolution,
heightFactor: this._heightFactor,
noDataValues: this.noDataValues,
parentData: null,
parentOffsetX: 0,
parentOffsetY: 0,
parentZoomDiff: 0,
skipPositiveHeights: false
};
//
// Getting parent segment terrain data, for zero and nodata values for the current segment
//
if (segment && segment.tileZoom > this.maxNativeZoom) {
let pn = segment.node;
while (pn && !pn.segment.terrainExists) {
pn = pn.parentNode!;
}
if (pn && pn.segment.elevationData) {
let ps = pn.segment;
decode.parentData = ps.elevationData;
[decode.parentOffsetX, decode.parentOffsetY, decode.parentZoomDiff] = getTileOffset(
segment.tileX,
segment.tileY,
segment.tileZoom,
ps.tileX,
ps.tileY,
ps.tileZoom
);
// in this case maxNativeZoom means sea level
decode.skipPositiveHeights = ps.tileZoom <= 8;
}
}
let levels = 0;
if (!preventChildren) {
for (let c = getGridCells(size) >> 1; c >= gridSize && tileZoom + levels + 1 <= this.maxZoom; c >>= 1) {
levels++;
}
}
return {
decode: decode,
size: size,
gridSize: gridSize,
levels: levels,
// Vertices with no data around them get the first no data value, the terrain worker recognizes it
noDataHeight: this.noDataValues.length ? this.noDataValues[0] : 0
};
}
protected _createDecodeRow(rgbaData: number[] | TypedArray, p: IRgbHeightsParams): DecodeHeightsRowFunc {
const parentScale = p.gridSize / getGridCells(p.size),
d = p.decode;
if (this._isBuiltInDecoding()) {
return (row: number, outHeights: Float32Array) =>
decodeRgbHeightsRow(rgbaData, row, p.size, parentScale, d, outHeights, NaN);
}
return (row: number, outHeights: Float32Array) =>
this.extractHeightsRgbRow(
rgbaData,
row,
d.heightFactor,
d.noDataValues,
d.parentData,
d.parentOffsetX,
d.parentOffsetY,
d.parentZoomDiff,
d.skipPositiveHeights,
p.size,
parentScale,
outHeights,
NaN
);
}
/**
* Saves the tile and its subdivision levels heights to cache.
* @protected
* @returns {Float32Array} - Tile heights.
*/
protected _cacheHeightsLevels(
heights: Float32Array,
p: IRgbHeightsParams,
tileGroup: number,
tileX: number,
tileY: number,
tileZoom: number,
extent: Extent
): Float32Array {
let tileLength = (p.gridSize + 1) * (p.gridSize + 1);
let outCurrenElevations = heights.subarray(0, tileLength);
for (let k = 1, offset = tileLength; k <= p.levels; k++) {
let d = 1 << k;
for (let i = 0; i < d; i++) {
for (let j = 0; j < d; j++, offset += tileLength) {
let x = tileX * d + j,
y = tileY * d + i,
z = tileZoom + k;
let tileIndex = Layer.getTileIndex(x, y, z, tileGroup);
let cache = this.getElevationCache(tileIndex);
// Don't replace data the child tile got from its own image
if (!cache || !cache.heights) {
this.setElevationCache(tileIndex, {
heights: heights.subarray(offset, offset + tileLength),
//
// @todo: must work for any grids
//
extent: getTileExtent(x, y, z)
});
}
}
}
}
let tileIndex = Layer.getTileIndex(tileX, tileY, tileZoom, tileGroup);
// Save current data to cache
this.setElevationCache(tileIndex, {
heights: outCurrenElevations,
extent: extent
});
return outCurrenElevations;
}
protected override _onTerrainLoaded(segment: Segment, data: HTMLImageElement | ImageBitmap) {
if (!this._isWorkerDecoding(data)) {
super._onTerrainLoaded(segment, data);
return;
}
let p = this._getHeightsParams(segment, segment.tileZoom, segment.tileZoom === this.maxZoom);
getRgbTerrainWorker().make({
segment: segment,
bitmap: data as ImageBitmap,
params: p,
callback: (heights: Float32Array | null) => {
// Terrain has been changed
if (!this._planet) {
return;
}
let elevations = heights
? this._cacheHeightsLevels(
heights,
p,
segment._tileGroup,
segment.tileX,
segment.tileY,
segment.tileZoom,
segment.getExtent()
)
: null;
if (segment.plainReady && segment.terrainIsLoading) {
this._applyElevationsData(segment, elevations);
}
}
});
}
protected override _createHeights(
data: HTMLImageElement | ImageBitmap,
segment: Segment | null,
tileGroup: number,
tileX: number,
tileY: number,
tileZoom: number,
extent: Extent,
preventChildren: boolean
): TypedArray | number[] {
this._ctx.clearRect(0, 0, this._imageSize, this._imageSize);
this._ctx.drawImage(data, 0, 0);
let rgbaData = this._ctx.getImageData(0, 0, this._imageSize, this._imageSize).data;
const SIZE = data.width;
//
// Neither 2^n nor (2^n + 1) images
//
if (!isPowerOfTwo(getGridCells(this._imageSize)) && SIZE === this._imageSize) {
let outCurrenElevations = new Float32Array(SIZE * SIZE);
this.extractElevationTilesRgbNonPowerOfTwo(rgbaData, outCurrenElevations, this._heightFactor);
return outCurrenElevations;
}
let p = this._getHeightsParams(segment, tileZoom, preventChildren);
let heights = createHeightsLevels(
p.size,
p.gridSize,
p.levels,
this._createDecodeRow(rgbaData, p),
p.noDataHeight
);
return this._cacheHeightsLevels(heights, p, tileGroup, tileX, tileY, tileZoom, extent);
}
public override getHeightAsync(lonLat: LonLat, callback: (h: number) => void, zoom?: number): boolean {
zoom = zoom != undefined ? zoom : this.maxZoom;
if (zoom === 0) {
callback(0);
return true;
}
const qts = this._planet!.quadTreeStrategy;
const [x, y, z, tileGroup] = qts.getTileXY(lonLat, zoom);
const [i, j] = qts.getLonLatTileOffset(lonLat, x, y, z, this._imageSize);
const index = (i * this._imageSize + j) * 4;
const tileIndex = Layer.getTileIndex(x, y, z, tileGroup);
if (this._imageDataCache[tileIndex]) {
let data = this._imageDataCache[tileIndex];
let height = this._heightFactor * this.rgb2Height(data[index], data[index + 1], data[index + 2]);
let isNoData = RgbTerrain.checkNoDataValue(this.noDataValues, height);
if (isNoData) {
return this.getHeightAsync(lonLat, callback, zoom - 1);
} else {
callback(this._heightFactor * this.rgb2Height(data[index], data[index + 1], data[index + 2]));
return true;
}
}
let def = this._fetchCache[tileIndex];
if (!def) {
def = this._loader.fetch({
src:
(this._urlRewriteCallback && this._urlRewriteCallback(x, y, z, tileGroup)) ||
this.buildURL(x, y, z, tileGroup),
type: this._dataType,
options: {
cache: this._cache
}
});
this._fetchCache[tileIndex] = def;
}
def!.then((response: IResponse) => {
if (response.status === "ready") {
this._ctx.clearRect(0, 0, this._imageSize, this._imageSize);
this._ctx.drawImage(response.data, 0, 0);
let data = this._ctx.getImageData(0, 0, this._imageSize, this._imageSize).data;
this._imageDataCache[tileIndex] = data;
let height = this._heightFactor * this.rgb2Height(data[index], data[index + 1], data[index + 2]);
let isNoData = RgbTerrain.checkNoDataValue(this.noDataValues, height);
if (isNoData) {
return this.getHeightAsync(lonLat, callback, zoom! - 1);
} else {
callback(this._heightFactor * this.rgb2Height(data[index], data[index + 1], data[index + 2]));
}
} else if (response.status === "error") {
return this.getHeightAsync(lonLat, callback, zoom! - 1);
} else {
//@ts-ignore
this._fetchCache[tileIndex] = null;
delete this._fetchCache[tileIndex];
}
});
return false;
}
public extractHeightsRgbRow(
rgbaData: number[] | TypedArray,
row: number,
heightFactor: number,
noDataValues: number[] | TypedArray,
availableParentData: TypedArray | number[] | null,
availableParentOffsetX: number,
availableParentOffsetY: number,
availableZoomDiff: number,
skipPositiveHeights: boolean,
size: number,
parentScale: number,
outHeights: Float32Array,
noDataHeight: number
) {
let pi = Math.floor(row * parentScale);
for (let j = 0, k4 = row * size * 4; j < size; j++, k4 += 4) {
let height = heightFactor * this.rgb2Height(rgbaData[k4], rgbaData[k4 + 1], rgbaData[k4 + 2]);
let isNoData = RgbTerrain.checkNoDataValue(noDataValues, height);
if ((isNoData || height === 0) && availableParentData) {
height = getParentHeight(
availableZoomDiff,
availableParentOffsetX,
availableParentOffsetY,
availableParentData,
pi,
Math.floor(j * parentScale),
skipPositiveHeights
);
isNoData = RgbTerrain.checkNoDataValue(noDataValues, height);
}
outHeights[j] = isNoData ? noDataHeight : height;
}
}
public extractElevationTilesRgbNonPowerOfTwo(
rgbaData: number[] | TypedArray,
outCurrenElevations: number[] | TypedArray,
heightFactor: number = 1
) {
for (let i = 0, len = outCurrenElevations.length; i < len; i++) {
let i4 = i * 4;
outCurrenElevations[i] = heightFactor * this.rgb2Height(rgbaData[i4], rgbaData[i4 + 1], rgbaData[i4 + 2]);
}
}
}
function getTileOffset(
currentTileX: number,
currentTileY: number,
currentTileZoom: number,
parentTileX: number,
parentTileY: number,
parentTileZoom: number
): [number, number, number] {
let dz2 = 2 << (currentTileZoom - parentTileZoom - 1);
return [currentTileX - dz2 * parentTileX, currentTileY - dz2 * parentTileY, 1.0 / dz2];
}
export { RgbTerrain };