diff --git a/docs/modules/terrain/README.md b/docs/modules/terrain/README.md index e03d043780..7e1eb41c2d 100644 --- a/docs/modules/terrain/README.md +++ b/docs/modules/terrain/README.md @@ -30,5 +30,9 @@ The `QuantizedMeshLoader` is a fork of [`quantized-mesh-decoder`](https://github.com/heremaps/quantized-mesh-decoder) from HERE under the MIT license to decode quantized mesh. -The `TerrainLoader` uses [MARTINI](https://github.com/mapbox/martini) or [Delatin](https://github.com/mapbox/delatin) for mesh -reconstruction which are both under the ISC License. +The `TerrainLoader` uses [MARTINI](https://github.com/mapbox/martini), [Delatin](https://github.com/mapbox/delatin), or a fixed grid tesselator for mesh +reconstruction. Martini and Delatin are both under the ISC License. + +The fixed grid path is designed for predictable terrain tile density and emits longitude, latitude, and elevation positions directly, which is useful for renderers such as deck.gl `TerrainLayer`. + +On the shared `mapbox.png` terrain benchmark, the fixed `gridSize=33` path generated about `33.2K tiles/s`, compared to `76.7 tiles/s` for Martini and `10.0 tiles/s` for Delatin on the same decoded tile. See the [TerrainLoader API reference](/docs/modules/terrain/api-reference/terrain-loader) for the benchmark setup and tradeoffs. diff --git a/docs/modules/terrain/api-reference/terrain-loader.md b/docs/modules/terrain/api-reference/terrain-loader.md index cffaaddbba..8704c1b312 100644 --- a/docs/modules/terrain/api-reference/terrain-loader.md +++ b/docs/modules/terrain/api-reference/terrain-loader.md @@ -32,6 +32,85 @@ const data = await load(url, TerrainLoader, options); `TerrainLoader` internally decodes heightmap images with [`ImageBitmapLoader`](/docs/modules/images/api-reference/image-bitmap-loader) and then converts them with `getImageData(image)`. +### Fixed grid loader example + +Use the fixed grid tesselator when you want deterministic mesh density and longitude/latitude output positions for a terrain tile: + +```typescript +import {load} from '@loaders.gl/core'; +import {TerrainLoader} from '@loaders.gl/terrain'; + +const terrainMesh = await load('https://example.com/terrain-rgb.png', TerrainLoader, { + terrain: { + tesselator: 'grid', + gridSize: 33, + bounds: [-122.523, 37.649, -122.356, 37.815], // [west, south, east, north] + skirtHeight: 20, + elevationDecoder: { + rScaler: 65536 * 0.1, + gScaler: 256 * 0.1, + bScaler: 0.1, + offset: -10000 + } + } +}); +``` + +With `terrain.tesselator = 'grid'`, the returned mesh contains: + +- `POSITION` attributes as `[longitude, latitude, elevation]` +- `TEXCOORD_0` attributes aligned with the source image +- indexed triangle-list geometry with a stable vertex count of `gridSize * gridSize` + +### Direct mesh API example + +If you already have decoded height-map bytes, you can bypass image loading and build the mesh directly: + +```typescript +import {makeGridTerrainMesh} from '@loaders.gl/terrain'; + +const terrainMesh = makeGridTerrainMesh( + { + width: imageData.width, + height: imageData.height, + data: new Uint8Array(imageData.data.buffer, imageData.data.byteOffset, imageData.data.byteLength) + }, + { + bounds: [-122.523, 37.649, -122.356, 37.815], + gridSize: 33, + skirtHeight: 20, + elevationDecoder: { + rScaler: 65536 * 0.1, + gScaler: 256 * 0.1, + bScaler: 0.1, + offset: -10000 + } + } +); +``` + +## Benchmark comparison + +The fixed grid path was added to reduce terrain mesh generation cost for renderers such as deck.gl `TerrainLayer`. The benchmark in [`modules/terrain/test/terrain-loader.bench.js`](https://github.com/visgl/loaders.gl/blob/master/modules/terrain/test/terrain-loader.bench.js) compares the three tesselators on the same already-decoded `mapbox.png` tile, so the numbers reflect mesh generation only and not image decode or network time. + +Run it with: + +```bash +node ./scripts/test.mjs bench +``` + +Representative Node benchmark output for that fixture: + +| Strategy | Vertex count | Triangle count | Throughput | +| ----------------------- | ------------ | -------------- | ---------- | +| `auto` / `martini` | `52,302` | `103,770` | `76.7 tiles/s` | +| `delatin` | `45,298` | `90,245` | `10.0 tiles/s` | +| `grid` with `gridSize=33` | `1,089` | `2,048` | `33.2K tiles/s` | + +On this fixture, `gridSize=33` was about `430x` faster than Martini and about `3,300x` faster than Delatin. The tradeoff is mesh density: `grid` emits a fixed-resolution mesh, while Martini and Delatin spend substantially more CPU time adapting triangles to the height field. + +Use `grid` when you want predictable per-tile cost, stable longitude/latitude output, and smooth streaming updates in `TerrainLayer`. Use Martini or Delatin when preserving more terrain detail per tile is more important than meshing speed. + ## Options | Option | Type | Default | Description | @@ -40,6 +119,7 @@ const data = await load(url, TerrainLoader, options); | `terrain.bounds` | `array` | `null` | Bounds of the image to fit x,y coordinates into. In `[minX, minY, maxX, maxY]`. If not supplied, x and y are in pixels relative to the image. | | `terrain.elevationDecoder` | `object` | See below | See below | | `terrain.tesselator` | `string` | `auto` | See below | +| `terrain.gridSize` | `number` | `33` | Vertices per side when `terrain.tesselator` is `grid`. | | `terrain.skirtHeight` | `number` | `null` | If set, create the skirt for the tile with particular height in meters | ### elevationDecoder @@ -103,3 +183,11 @@ The choices for tesselator are as follows: - Works on arbitrary raster grids. - Generates a single mesh for a particular detail. - Optimized for quality (as little triangles as possible for a given error). + +`grid`: + +- Builds a fixed-resolution indexed triangle grid directly from the height map. +- Uses `terrain.gridSize` to control vertices per side. The default `33` produces 1089 vertices and 2048 triangles per tile. `terrain.meshMaxError` is ignored by this fixed-resolution path. +- Requires `terrain.bounds` as longitude and latitude degrees ordered `[west, south, east, north]`. +- Emits `POSITION` attributes as `[longitude, latitude, elevation]`, which lets deck.gl `TerrainLayer` render the same mesh in `COORDINATE_SYSTEM.LNGLAT` across map and globe projections. +- Samples rows uniformly in Mercator y so high-latitude terrain tiles avoid the stretching produced by latitude-uniform sampling. diff --git a/modules/terrain/src/index.ts b/modules/terrain/src/index.ts index 6e62509e57..d2ee087a4c 100644 --- a/modules/terrain/src/index.ts +++ b/modules/terrain/src/index.ts @@ -8,7 +8,8 @@ import {parseFromContext} from '@loaders.gl/loader-utils'; import {ImageBitmapLoader, getImageData} from '@loaders.gl/images'; import {convertMeshToTable} from '@loaders.gl/schema-utils'; import {parseQuantizedMesh} from './lib/parse-quantized-mesh'; -import {TerrainOptions, makeTerrainMeshFromImage} from './lib/parse-terrain'; +import type {TerrainOptions} from './lib/parse-terrain'; +import {makeTerrainMeshFromImage} from './lib/parse-terrain'; import {TerrainLoader as TerrainWorkerLoader, TerrainLoaderOptions} from './terrain-loader'; import { @@ -17,6 +18,12 @@ import { } from './quantized-mesh-loader'; export type {QuantizedMeshWriterOptions} from './quantized-mesh-writer'; export {QuantizedMeshWriter} from './quantized-mesh-writer'; +export type {GridTerrainOptions, TerrainBounds, TerrainOptions} from './lib/parse-terrain'; +export { + buildGridMeshAttributes, + makeGridTerrainMesh, + makeTerrainMeshFromImage +} from './lib/parse-terrain'; // TerrainLoader diff --git a/modules/terrain/src/lib/grid-terrain-mesh.ts b/modules/terrain/src/lib/grid-terrain-mesh.ts new file mode 100644 index 0000000000..272bc51116 --- /dev/null +++ b/modules/terrain/src/lib/grid-terrain-mesh.ts @@ -0,0 +1,256 @@ +// loaders.gl +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors + +import type {Mesh, MeshAttributes} from '@loaders.gl/schema'; +import {deduceMeshSchema} from '@loaders.gl/schema-utils'; + +/** Bounds in longitude and latitude degrees, ordered as west, south, east, north. */ +export type TerrainBounds = [number, number, number, number]; + +type ElevationDecoder = { + /** Red channel elevation scale. */ + rScaler: number; + /** Green channel elevation scale. */ + gScaler: number; + /** Blue channel elevation scale. */ + bScaler: number; + /** Elevation offset added after channel scaling. */ + offset: number; +}; + +/** Options for fixed grid terrain mesh generation. */ +export type GridTerrainOptions = { + /** Terrain image bounds in longitude and latitude degrees. */ + bounds: TerrainBounds; + /** Decoder used to convert terrain image channels to elevation values. */ + elevationDecoder: ElevationDecoder; + /** Vertices per side. 33 produces 1089 vertices and 2048 triangles per tile. */ + gridSize?: number; + /** Meters to lower edge vertices to hide gaps between adjacent tiles. */ + skirtHeight?: number; +}; + +type TerrainImage = { + /** Terrain image pixel data. */ + data: Uint8Array | Uint8ClampedArray; + /** Terrain image width in pixels. */ + width: number; + /** Terrain image height in pixels. */ + height: number; +}; + +type BoundingBox = [[number, number, number], [number, number, number]]; + +const MAX_LATITUDE = 85.051129; +const DEG2RAD = Math.PI / 180; +const RAD2DEG = 180 / Math.PI; + +/** Convert latitude in degrees to normalized Mercator y. */ +function getMercatorYFromLatitude(latitude: number): number { + const clampedLatitude = Math.max(-MAX_LATITUDE, Math.min(MAX_LATITUDE, latitude)); + const sine = Math.sin(clampedLatitude * DEG2RAD); + return 0.5 * Math.log((1 + sine) / (1 - sine)); +} + +/** Convert normalized Mercator y to latitude in degrees. */ +function getLatitudeFromMercatorY(mercatorY: number): number { + return (2 * Math.atan(Math.exp(mercatorY)) - Math.PI / 2) * RAD2DEG; +} + +/** Sample a height-map image using bilinear interpolation. */ +function sampleElevationBilinear( + image: TerrainImage, + horizontalRatio: number, + verticalRatio: number, + decoder: ElevationDecoder +): number { + const {data, width, height} = image; + const horizontalPixel = horizontalRatio * (width - 1); + const verticalPixel = verticalRatio * (height - 1); + const westPixel = Math.floor(horizontalPixel); + const northPixel = Math.floor(verticalPixel); + const eastPixel = Math.min(westPixel + 1, width - 1); + const southPixel = Math.min(northPixel + 1, height - 1); + const horizontalWeight = horizontalPixel - westPixel; + const verticalWeight = verticalPixel - northPixel; + + const decode = (columnIndex: number, rowIndex: number): number => { + const pixelIndex = (rowIndex * width + columnIndex) * 4; + return ( + decoder.rScaler * data[pixelIndex] + + decoder.gScaler * data[pixelIndex + 1] + + decoder.bScaler * data[pixelIndex + 2] + + decoder.offset + ); + }; + + const northwestElevation = decode(westPixel, northPixel); + const northeastElevation = decode(eastPixel, northPixel); + const southwestElevation = decode(westPixel, southPixel); + const southeastElevation = decode(eastPixel, southPixel); + + const northElevation = + northwestElevation * (1 - horizontalWeight) + northeastElevation * horizontalWeight; + const southElevation = + southwestElevation * (1 - horizontalWeight) + southeastElevation * horizontalWeight; + return northElevation * (1 - verticalWeight) + southElevation * verticalWeight; +} + +/** Validate options before allocating fixed grid mesh buffers. */ +function validateGridTerrainOptions(options: GridTerrainOptions): number { + const {bounds, gridSize = 33} = options; + + if (!bounds || bounds.length !== 4 || bounds.some(value => !Number.isFinite(value))) { + throw new Error( + 'TerrainLoader: grid tesselator requires bounds as [west, south, east, north] in degrees' + ); + } + + if (!Number.isInteger(gridSize) || gridSize < 2) { + throw new Error('TerrainLoader: gridSize must be an integer greater than or equal to 2'); + } + + return gridSize; +} + +/** Validate height-map image dimensions before sampling. */ +function validateGridTerrainImage(image: TerrainImage): void { + const {data, width, height} = image; + + if (!data || !Number.isInteger(width) || !Number.isInteger(height) || width < 1 || height < 1) { + throw new Error('TerrainLoader: grid tesselator requires a valid terrain image'); + } +} + +/** + * Build raw fixed grid terrain mesh attributes from a height-map image. + * + * The grid emits longitude, latitude, and elevation positions directly. Rows + * are spaced uniformly in Mercator y so image rows map consistently at high + * latitude and the same mesh can be reused by globe-aware renderers. + */ +export function buildGridMeshAttributes( + image: TerrainImage, + options: GridTerrainOptions +): {attributes: MeshAttributes; indices: Uint32Array; boundingBox: BoundingBox} { + validateGridTerrainImage(image); + const verticesPerSide = validateGridTerrainOptions(options); + const {bounds, elevationDecoder, skirtHeight = 0} = options; + const [west, south, east, north] = bounds; + + const northMercatorY = getMercatorYFromLatitude(north); + const southMercatorY = getMercatorYFromLatitude(south); + + const vertexCount = verticesPerSide * verticesPerSide; + const positions = new Float32Array(vertexCount * 3); + const texCoords = new Float32Array(vertexCount * 2); + + let minimumElevation = Infinity; + let maximumElevation = -Infinity; + + for (let rowIndex = 0; rowIndex < verticesPerSide; rowIndex++) { + const verticalRatio = rowIndex / (verticesPerSide - 1); + const mercatorY = northMercatorY + verticalRatio * (southMercatorY - northMercatorY); + const latitude = getLatitudeFromMercatorY(mercatorY); + + for (let columnIndex = 0; columnIndex < verticesPerSide; columnIndex++) { + const horizontalRatio = columnIndex / (verticesPerSide - 1); + const longitude = west + horizontalRatio * (east - west); + + const sampledElevation = sampleElevationBilinear( + image, + horizontalRatio, + verticalRatio, + elevationDecoder + ); + let elevation = sampledElevation; + + minimumElevation = Math.min(minimumElevation, sampledElevation); + maximumElevation = Math.max(maximumElevation, sampledElevation); + + const onEdge = + columnIndex === 0 || + rowIndex === 0 || + columnIndex === verticesPerSide - 1 || + rowIndex === verticesPerSide - 1; + if (onEdge && skirtHeight) { + elevation -= skirtHeight; + } + + const positionIndex = (rowIndex * verticesPerSide + columnIndex) * 3; + positions[positionIndex] = longitude; + positions[positionIndex + 1] = latitude; + positions[positionIndex + 2] = elevation; + + const texCoordIndex = (rowIndex * verticesPerSide + columnIndex) * 2; + texCoords[texCoordIndex] = horizontalRatio; + texCoords[texCoordIndex + 1] = verticalRatio; + } + } + + const quadCount = (verticesPerSide - 1) * (verticesPerSide - 1); + const indices = new Uint32Array(quadCount * 6); + let indicesIndex = 0; + for (let rowIndex = 0; rowIndex < verticesPerSide - 1; rowIndex++) { + for (let columnIndex = 0; columnIndex < verticesPerSide - 1; columnIndex++) { + const northwestIndex = rowIndex * verticesPerSide + columnIndex; + const northeastIndex = rowIndex * verticesPerSide + (columnIndex + 1); + const southwestIndex = (rowIndex + 1) * verticesPerSide + columnIndex; + const southeastIndex = (rowIndex + 1) * verticesPerSide + (columnIndex + 1); + + indices[indicesIndex++] = northwestIndex; + indices[indicesIndex++] = southwestIndex; + indices[indicesIndex++] = northeastIndex; + indices[indicesIndex++] = northeastIndex; + indices[indicesIndex++] = southwestIndex; + indices[indicesIndex++] = southeastIndex; + } + } + + const boundingBox: BoundingBox = [ + [west, south, minimumElevation], + [east, north, maximumElevation] + ]; + + return { + attributes: { + POSITION: {value: positions, size: 3}, + TEXCOORD_0: {value: texCoords, size: 2} + }, + indices, + boundingBox + }; +} + +/** + * Build a fixed-resolution grid Mesh from a terrain-RGB heightmap. + * + * The returned mesh has triangle-list topology, Uint32 indices, POSITION + * attributes in longitude, latitude, and elevation, and TEXCOORD_0 attributes + * aligned to the source height-map image. + */ +export function makeGridTerrainMesh(image: TerrainImage, options: GridTerrainOptions): Mesh { + const {attributes, indices, boundingBox} = buildGridMeshAttributes(image, options); + + const topology = 'triangle-list'; + const mode = 4; // TRIANGLES + const schema = deduceMeshSchema(attributes, { + topology, + mode: String(mode), + boundingBox: JSON.stringify(boundingBox) + }); + + return { + loaderData: {header: {}}, + header: { + vertexCount: indices.length, + boundingBox + }, + schema, + topology, + mode, + indices: {value: indices, size: 1}, + attributes + }; +} diff --git a/modules/terrain/src/lib/parse-terrain.ts b/modules/terrain/src/lib/parse-terrain.ts index 3e3d03447e..4ddc762324 100644 --- a/modules/terrain/src/lib/parse-terrain.ts +++ b/modules/terrain/src/lib/parse-terrain.ts @@ -7,16 +7,39 @@ import {deduceMeshSchema, getMeshBoundingBox} from '@loaders.gl/schema-utils'; import Martini from '@mapbox/martini'; import Delatin from './delatin/index'; import {addSkirt} from './helpers/skirt'; +import {makeGridTerrainMesh} from './grid-terrain-mesh'; +import type {TerrainBounds} from './grid-terrain-mesh'; + +export type {GridTerrainOptions, TerrainBounds} from './grid-terrain-mesh'; +export {buildGridMeshAttributes, makeGridTerrainMesh} from './grid-terrain-mesh'; export type TerrainOptions = { /** Maximum terrain mesh error in meters. */ meshMaxError: number; - /** Bounds used to map terrain image coordinates to x/y positions. */ - bounds: number[]; + /** + * Bounds used to map terrain image coordinates to x/y positions. + * + * For `martini`, `delatin`, and `auto`, bounds are in the caller's flat + * projection units, commonly Web Mercator world units. + * + * For `grid`, bounds are longitude and latitude degrees ordered as west, + * south, east, north. The grid mesh emits POSITION attributes directly as + * longitude, latitude, and elevation. + */ + bounds: number[] | TerrainBounds; /** Decoder used to convert terrain image channels to elevation values. */ elevationDecoder: ElevationDecoder; - /** Tesselation algorithm used to reconstruct the terrain mesh. */ - tesselator: 'martini' | 'delatin' | 'auto'; + /** + * Tesselation algorithm. + * + * `martini` and `delatin` use error-driven refinement. `grid` uses a fixed + * longitude/latitude grid with Mercator-y row sampling, which is faster and + * avoids high-latitude texture warping when used by deck.gl TerrainLayer. + * `auto` uses Martini for square power-of-two tiles and Delatin otherwise. + */ + tesselator: 'martini' | 'delatin' | 'grid' | 'auto'; + /** Vertices per side when `tesselator` is `grid`. */ + gridSize?: number; /** Optional skirt height in meters. */ skirtHeight?: number; }; @@ -32,11 +55,11 @@ type TerrainImage = { type ElevationDecoder = { /** Red channel elevation scale. */ - rScaler: any; + rScaler: number; /** Blue channel elevation scale. */ - bScaler: any; + bScaler: number; /** Green channel elevation scale. */ - gScaler: any; + gScaler: number; /** Elevation offset added after channel scaling. */ offset: number; }; @@ -56,6 +79,15 @@ export function makeTerrainMeshFromImage( const {data, width, height} = terrainImage; + if (terrainOptions.tesselator === 'grid') { + return makeGridTerrainMesh(terrainImage, { + bounds: bounds as [number, number, number, number], + elevationDecoder, + gridSize: terrainOptions.gridSize, + skirtHeight: terrainOptions.skirtHeight + }); + } + let terrain; let mesh; switch (terrainOptions.tesselator) { diff --git a/modules/terrain/src/terrain-loader.ts b/modules/terrain/src/terrain-loader.ts index a7dec5d631..8030f0b237 100644 --- a/modules/terrain/src/terrain-loader.ts +++ b/modules/terrain/src/terrain-loader.ts @@ -37,6 +37,7 @@ export const TerrainLoader = { tesselator: 'auto', bounds: undefined!, meshMaxError: 10, + gridSize: 33, elevationDecoder: { rScaler: 1, gScaler: 0, diff --git a/modules/terrain/test/terrain-loader.bench.js b/modules/terrain/test/terrain-loader.bench.js new file mode 100644 index 0000000000..0ac59f93b4 --- /dev/null +++ b/modules/terrain/test/terrain-loader.bench.js @@ -0,0 +1,96 @@ +// loaders.gl +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors + +import {fetchFile, parse} from '@loaders.gl/core'; +import {ImageBitmapLoader, getImageData} from '@loaders.gl/images'; +import {makeTerrainMeshFromImage} from '@loaders.gl/terrain'; + +const MAPBOX_TERRAIN_PNG_URL = '@loaders.gl/terrain/test/data/mapbox.png'; +const PROJECTED_BOUNDS = [83, 329.5, 83.125, 329.625]; +const LNGLAT_BOUNDS = [-122.523, 37.649, -122.356, 37.815]; +const MAPBOX_ELEVATION_DECODER = { + rScaler: 65536 * 0.1, + gScaler: 256 * 0.1, + bScaler: 0.1, + offset: -10000 +}; + +/** + * Adds terrain mesh generation benchmarks that compare the new fixed grid path + * with the existing adaptive strategies on the same decoded terrain tile. + * + * @param {import('@probe.gl/bench').Bench} suite Benchmark suite. + * @returns {Promise} Resolves after benchmarks are added. + */ +export default async function terrainLoaderBench(suite) { + const response = await fetchFile(MAPBOX_TERRAIN_PNG_URL); + const arrayBuffer = await response.arrayBuffer(); + const image = await parse(arrayBuffer.slice(0), ImageBitmapLoader); + const imageData = getImageData(image); + const terrainImage = { + width: imageData.width, + height: imageData.height, + data: + imageData.data instanceof Uint8ClampedArray + ? new Uint8Array( + imageData.data.buffer, + imageData.data.byteOffset, + imageData.data.byteLength + ) + : imageData.data + }; + + const options = {unit: 'tiles'}; + + // Warm up the tesselators outside the timed benchmark loop. + makeTerrainMeshFromImage(terrainImage, { + elevationDecoder: MAPBOX_ELEVATION_DECODER, + meshMaxError: 5, + bounds: PROJECTED_BOUNDS, + tesselator: 'auto' + }); + makeTerrainMeshFromImage(terrainImage, { + elevationDecoder: MAPBOX_ELEVATION_DECODER, + meshMaxError: 5, + bounds: PROJECTED_BOUNDS, + tesselator: 'delatin' + }); + makeTerrainMeshFromImage(terrainImage, { + elevationDecoder: MAPBOX_ELEVATION_DECODER, + meshMaxError: 5, + bounds: LNGLAT_BOUNDS, + tesselator: 'grid', + gridSize: 33 + }); + + suite.group('TerrainLoader: terrain tile mesh generation'); + + suite.add('makeTerrainMeshFromImage(auto/martini)', options, () => { + makeTerrainMeshFromImage(terrainImage, { + elevationDecoder: MAPBOX_ELEVATION_DECODER, + meshMaxError: 5, + bounds: PROJECTED_BOUNDS, + tesselator: 'auto' + }); + }); + + suite.add('makeTerrainMeshFromImage(delatin)', options, () => { + makeTerrainMeshFromImage(terrainImage, { + elevationDecoder: MAPBOX_ELEVATION_DECODER, + meshMaxError: 5, + bounds: PROJECTED_BOUNDS, + tesselator: 'delatin' + }); + }); + + suite.add('makeTerrainMeshFromImage(grid-33)', options, () => { + makeTerrainMeshFromImage(terrainImage, { + elevationDecoder: MAPBOX_ELEVATION_DECODER, + meshMaxError: 5, + bounds: LNGLAT_BOUNDS, + tesselator: 'grid', + gridSize: 33 + }); + }); +} diff --git a/modules/terrain/test/terrain-loader.spec.js b/modules/terrain/test/terrain-loader.spec.js index df197ab61b..8791d32648 100644 --- a/modules/terrain/test/terrain-loader.spec.js +++ b/modules/terrain/test/terrain-loader.spec.js @@ -6,7 +6,13 @@ import test from 'tape-promise/tape'; import {validateLoader, validateMeshCategoryData} from 'test/common/conformance'; -import {TerrainLoader, TerrainWorkerLoader} from '@loaders.gl/terrain'; +import { + TerrainLoader, + TerrainWorkerLoader, + buildGridMeshAttributes, + makeGridTerrainMesh, + makeTerrainMeshFromImage +} from '@loaders.gl/terrain'; import {setLoaderOptions, load, registerLoaders} from '@loaders.gl/core'; // Should be possible to remove this @@ -15,14 +21,47 @@ registerLoaders([ImageBitmapLoader]); const MAPBOX_TERRAIN_PNG_URL = '@loaders.gl/terrain/test/data/mapbox.png'; const TERRARIUM_TERRAIN_PNG_URL = '@loaders.gl/terrain/test/data/terrarium.png'; +const GRID_TERRAIN_BOUNDS = [-123, 45, -122, 47]; +const GRID_ELEVATION_DECODER = { + rScaler: 1, + gScaler: 0, + bScaler: 0, + offset: 0 +}; +const MAX_LATITUDE = 85.051129; +const DEG2RAD = Math.PI / 180; +const RAD2DEG = 180 / Math.PI; setLoaderOptions({ _workerType: 'test' }); +function getMercatorYFromLatitude(latitude) { + const clampedLatitude = Math.max(-MAX_LATITUDE, Math.min(MAX_LATITUDE, latitude)); + const sine = Math.sin(clampedLatitude * DEG2RAD); + return 0.5 * Math.log((1 + sine) / (1 - sine)); +} + +function getLatitudeFromMercatorY(mercatorY) { + return (2 * Math.atan(Math.exp(mercatorY)) - Math.PI / 2) * RAD2DEG; +} + +function getExpectedMercatorLatitude(south, north, verticalRatio) { + const northMercatorY = getMercatorYFromLatitude(north); + const southMercatorY = getMercatorYFromLatitude(south); + return getLatitudeFromMercatorY( + northMercatorY + verticalRatio * (southMercatorY - northMercatorY) + ); +} + +function testAlmostEqual(t, actual, expected, message) { + t.ok(Math.abs(actual - expected) < 1e-5, `${message}: ${actual} ~= ${expected}`); +} + test('TerrainLoader#loader objects', async t => { validateLoader(t, TerrainLoader, 'TerrainLoader'); validateLoader(t, TerrainWorkerLoader, 'TerrainWorkerLoader'); + t.equal(TerrainLoader.options.terrain.gridSize, 33, 'grid size default was found'); t.end(); }); @@ -190,6 +229,313 @@ test('TerrainLoader#parse terrarium delatin', async t => { t.end(); }); +test('TerrainLoader#parse mapbox grid', async t => { + const data = await load(MAPBOX_TERRAIN_PNG_URL, TerrainLoader, { + terrain: { + elevationDecoder: { + rScaler: 65536 * 0.1, + gScaler: 256 * 0.1, + bScaler: 0.1, + offset: -10000 + }, + meshMaxError: 5.0, + bounds: GRID_TERRAIN_BOUNDS, + tesselator: 'grid', + gridSize: 3 + }, + core: {worker: false} + }); + validateMeshCategoryData(t, data); + + t.equal(data.mode, 4, 'mode is TRIANGLES (4)'); + t.equal(data.indices.value.length, 24, 'indices were generated for a 3 by 3 grid'); + t.equal(data.indices.size, 1, 'indices size was found'); + + t.equal(data.attributes.TEXCOORD_0.value.length, 18, 'TEXCOORD_0 attribute was found'); + t.equal(data.attributes.TEXCOORD_0.size, 2, 'TEXCOORD_0 attribute size was found'); + + t.equal(data.attributes.POSITION.value.length, 27, 'POSITION attribute was found'); + t.equal(data.attributes.POSITION.size, 3, 'POSITION attribute size was found'); + + const positions = data.attributes.POSITION.value; + const centerPositionIndex = 4 * 3; + const expectedCenterLatitude = getExpectedMercatorLatitude( + GRID_TERRAIN_BOUNDS[1], + GRID_TERRAIN_BOUNDS[3], + 0.5 + ); + + testAlmostEqual(t, positions[0], GRID_TERRAIN_BOUNDS[0], 'west edge longitude matches bounds'); + testAlmostEqual(t, positions[1], GRID_TERRAIN_BOUNDS[3], 'north edge latitude matches bounds'); + testAlmostEqual(t, positions[centerPositionIndex], -122.5, 'center longitude is the midpoint'); + testAlmostEqual( + t, + positions[centerPositionIndex + 1], + expectedCenterLatitude, + 'center latitude is sampled at Mercator-y midpoint' + ); + + const texCoords = data.attributes.TEXCOORD_0.value; + t.equal(texCoords[0], 0, 'northwest texture coordinate u starts at 0'); + t.equal(texCoords[1], 0, 'northwest texture coordinate v starts at 0'); + t.equal(texCoords[8], 0.5, 'center texture coordinate u is at midpoint'); + t.equal(texCoords[9], 0.5, 'center texture coordinate v is at midpoint'); + + t.end(); +}); + +test('TerrainLoader#makeGridTerrainMesh applies skirts without changing terrain bounds', t => { + const terrainImage = { + width: 2, + height: 2, + data: new Uint8Array([10, 0, 0, 255, 20, 0, 0, 255, 30, 0, 0, 255, 40, 0, 0, 255]) + }; + const gridOptions = { + bounds: GRID_TERRAIN_BOUNDS, + elevationDecoder: GRID_ELEVATION_DECODER, + gridSize: 3 + }; + const terrainMesh = makeGridTerrainMesh(terrainImage, gridOptions); + const skirtedTerrainMesh = makeGridTerrainMesh(terrainImage, { + ...gridOptions, + skirtHeight: 50 + }); + + const positions = terrainMesh.attributes.POSITION.value; + const skirtedPositions = skirtedTerrainMesh.attributes.POSITION.value; + const northwestPositionIndex = 0; + const centerPositionIndex = 4 * 3; + + t.equal( + skirtedPositions[northwestPositionIndex + 2], + positions[northwestPositionIndex + 2] - 50, + 'edge vertex was lowered by skirt height' + ); + t.equal( + skirtedPositions[centerPositionIndex + 2], + positions[centerPositionIndex + 2], + 'interior vertex elevation was not changed by skirt height' + ); + t.deepEqual( + skirtedTerrainMesh.header.boundingBox, + terrainMesh.header.boundingBox, + 'skirt does not change terrain bounding box' + ); + + t.end(); +}); + +test('TerrainLoader#buildGridMeshAttributes uses default grid size and samples Uint8ClampedArray input', t => { + const terrainImage = { + width: 2, + height: 2, + data: new Uint8ClampedArray([10, 0, 0, 255, 20, 0, 0, 255, 30, 0, 0, 255, 40, 0, 0, 255]) + }; + const {attributes, indices, boundingBox} = buildGridMeshAttributes(terrainImage, { + bounds: [-10, -90, 10, 90], + elevationDecoder: GRID_ELEVATION_DECODER + }); + + t.equal( + attributes.POSITION.value.length, + 33 * 33 * 3, + 'default grid size generated 33x33 positions' + ); + t.equal( + attributes.TEXCOORD_0.value.length, + 33 * 33 * 2, + 'default grid size generated 33x33 texCoords' + ); + t.equal(indices.length, 32 * 32 * 6, 'default grid size generated the expected triangle indices'); + + const positions = attributes.POSITION.value; + const topLeftLatitude = positions[1]; + const centerPositionIndex = (33 * 16 + 16) * 3; + const centerElevation = positions[centerPositionIndex + 2]; + const bottomLeftPositionIndex = 33 * 32 * 3; + const bottomLeftLatitude = positions[bottomLeftPositionIndex + 1]; + + testAlmostEqual( + t, + topLeftLatitude, + MAX_LATITUDE, + 'north edge latitude is clamped to Mercator max' + ); + testAlmostEqual( + t, + bottomLeftLatitude, + -MAX_LATITUDE, + 'south edge latitude is clamped to Mercator min' + ); + testAlmostEqual(t, centerElevation, 25, 'center elevation is bilinearly interpolated'); + t.deepEqual( + boundingBox, + [ + [-10, -90, 10], + [10, 90, 40] + ], + 'bounding box keeps input bounds and sampled elevations' + ); + + t.end(); +}); + +test('TerrainLoader#makeTerrainMeshFromImage grid path returns mesh metadata', t => { + const terrainImage = { + width: 2, + height: 2, + data: new Uint8Array([10, 0, 0, 255, 20, 0, 0, 255, 30, 0, 0, 255, 40, 0, 0, 255]) + }; + const terrainMesh = makeTerrainMeshFromImage(terrainImage, { + meshMaxError: 999, + bounds: GRID_TERRAIN_BOUNDS, + elevationDecoder: GRID_ELEVATION_DECODER, + tesselator: 'grid', + gridSize: 2 + }); + + t.equal(terrainMesh.topology, 'triangle-list', 'grid path returns triangle-list topology'); + t.equal(terrainMesh.mode, 4, 'grid path returns TRIANGLES mode'); + t.equal(terrainMesh.indices.value.length, 6, 'grid path generated one quad as two triangles'); + t.equal(terrainMesh.header.vertexCount, 6, 'header vertexCount matches generated indices'); + t.deepEqual( + terrainMesh.header.boundingBox, + [GRID_TERRAIN_BOUNDS.slice(0, 2).concat(10), GRID_TERRAIN_BOUNDS.slice(2, 4).concat(40)], + 'grid path header bounding box tracks source bounds and elevations' + ); + t.ok(terrainMesh.schema, 'grid path generated schema metadata'); + + t.end(); +}); + +test('TerrainLoader#makeTerrainMeshFromImage auto chooses martini for square power-of-two tiles', t => { + const terrainImage = { + width: 2, + height: 2, + data: new Uint8Array([10, 0, 0, 255, 20, 0, 0, 255, 30, 0, 0, 255, 40, 0, 0, 255]) + }; + const terrainOptions = { + meshMaxError: 0, + bounds: [0, 0, 2, 2], + elevationDecoder: GRID_ELEVATION_DECODER + }; + const martiniMesh = makeTerrainMeshFromImage(terrainImage, { + ...terrainOptions, + tesselator: 'martini' + }); + const autoMesh = makeTerrainMeshFromImage(terrainImage, { + ...terrainOptions, + tesselator: 'auto' + }); + + t.deepEqual(autoMesh.indices.value, martiniMesh.indices.value, 'auto matched martini indices'); + t.deepEqual( + autoMesh.attributes.POSITION.value, + martiniMesh.attributes.POSITION.value, + 'auto matched martini positions' + ); + + t.end(); +}); + +test('TerrainLoader#makeTerrainMeshFromImage auto chooses delatin for non-square tiles', t => { + const terrainImage = { + width: 3, + height: 2, + data: new Uint8Array([ + 10, 0, 0, 255, 20, 0, 0, 255, 30, 0, 0, 255, 40, 0, 0, 255, 50, 0, 0, 255, 60, 0, 0, 255 + ]) + }; + const terrainOptions = { + meshMaxError: 0, + bounds: [0, 0, 3, 2], + elevationDecoder: GRID_ELEVATION_DECODER + }; + const delatinMesh = makeTerrainMeshFromImage(terrainImage, { + ...terrainOptions, + tesselator: 'delatin' + }); + const autoMesh = makeTerrainMeshFromImage(terrainImage, { + ...terrainOptions, + tesselator: 'auto' + }); + + t.deepEqual(autoMesh.indices.value, delatinMesh.indices.value, 'auto matched delatin indices'); + t.deepEqual( + autoMesh.attributes.POSITION.value, + delatinMesh.attributes.POSITION.value, + 'auto matched delatin positions' + ); + + t.end(); +}); + +test('TerrainLoader#buildGridMeshAttributes rejects invalid bounds', t => { + t.throws( + () => + buildGridMeshAttributes( + { + width: 2, + height: 2, + data: new Uint8Array(16) + }, + { + bounds: [NaN, 0, 1, 1], + elevationDecoder: GRID_ELEVATION_DECODER, + gridSize: 3 + } + ), + /requires bounds as \[west, south, east, north\] in degrees/, + 'invalid bounds are rejected' + ); + + t.end(); +}); + +test('TerrainLoader#buildGridMeshAttributes rejects invalid grid size', t => { + t.throws( + () => + buildGridMeshAttributes( + { + width: 2, + height: 2, + data: new Uint8Array(16) + }, + { + bounds: GRID_TERRAIN_BOUNDS, + elevationDecoder: GRID_ELEVATION_DECODER, + gridSize: 1 + } + ), + /gridSize must be an integer greater than or equal to 2/, + 'invalid grid size is rejected' + ); + + t.end(); +}); + +test('TerrainLoader#buildGridMeshAttributes rejects invalid terrain image', t => { + t.throws( + () => + buildGridMeshAttributes( + { + width: 0, + height: 2, + data: new Uint8Array() + }, + { + bounds: GRID_TERRAIN_BOUNDS, + elevationDecoder: GRID_ELEVATION_DECODER, + gridSize: 3 + } + ), + /requires a valid terrain image/, + 'invalid terrain image is rejected' + ); + + t.end(); +}); + test('TerrainWorkerLoader#parse terrarium martini', async t => { if (typeof Worker === 'undefined') { t.comment('Worker is not usable in non-browser environments'); diff --git a/test/bench/modules.js b/test/bench/modules.js index 781e04e598..969d0fc421 100644 --- a/test/bench/modules.js +++ b/test/bench/modules.js @@ -14,6 +14,7 @@ import dracoBench from '@loaders.gl/draco/test/draco.bench'; import excelBench from '@loaders.gl/excel/test/excel.bench'; import imageBench from '@loaders.gl/images/test/images.bench'; import jsonBench from '@loaders.gl/json/test/json-loader.bench'; +import terrainBench from '@loaders.gl/terrain/test/terrain-loader.bench'; // import mvtBench from '@loaders.gl/mvt/test/mvt-loader.bench'; import {parquetBench} from '@loaders.gl/parquet/test/parquet.bench'; // import shapefileBench from '@loaders.gl/shapefile/test/shapefile.bench'; @@ -37,6 +38,8 @@ export async function addModuleBenchmarksToSuite(suite) { await jsonBench(suite); + await terrainBench(suite); + // await shapefileBench(suite); // await mvtBench(suite);