From 43ce6e9dc57394805fa41a1abf5a04ce131353b1 Mon Sep 17 00:00:00 2001 From: charlieforward9 Date: Thu, 23 Apr 2026 09:05:55 -0400 Subject: [PATCH 1/3] add terrain grid tesselator --- docs/modules/terrain/README.md | 6 +- .../terrain/api-reference/terrain-loader.md | 66 +++++ modules/terrain/src/index.ts | 9 +- modules/terrain/src/lib/grid-terrain-mesh.ts | 256 ++++++++++++++++++ modules/terrain/src/lib/parse-terrain.ts | 46 +++- modules/terrain/src/terrain-loader.ts | 1 + modules/terrain/test/terrain-loader.spec.js | 130 ++++++++- 7 files changed, 503 insertions(+), 11 deletions(-) create mode 100644 modules/terrain/src/lib/grid-terrain-mesh.ts diff --git a/docs/modules/terrain/README.md b/docs/modules/terrain/README.md index e03d043780..3e4e43ce9a 100644 --- a/docs/modules/terrain/README.md +++ b/docs/modules/terrain/README.md @@ -30,5 +30,7 @@ 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`. diff --git a/docs/modules/terrain/api-reference/terrain-loader.md b/docs/modules/terrain/api-reference/terrain-loader.md index cffaaddbba..360c5b54a0 100644 --- a/docs/modules/terrain/api-reference/terrain-loader.md +++ b/docs/modules/terrain/api-reference/terrain-loader.md @@ -32,6 +32,63 @@ 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 + } + } +); +``` + ## Options | Option | Type | Default | Description | @@ -40,6 +97,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 +161,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.spec.js b/modules/terrain/test/terrain-loader.spec.js index df197ab61b..35dcb9f20f 100644 --- a/modules/terrain/test/terrain-loader.spec.js +++ b/modules/terrain/test/terrain-loader.spec.js @@ -6,7 +6,7 @@ import test from 'tape-promise/tape'; import {validateLoader, validateMeshCategoryData} from 'test/common/conformance'; -import {TerrainLoader, TerrainWorkerLoader} from '@loaders.gl/terrain'; +import {TerrainLoader, TerrainWorkerLoader, makeGridTerrainMesh} from '@loaders.gl/terrain'; import {setLoaderOptions, load, registerLoaders} from '@loaders.gl/core'; // Should be possible to remove this @@ -15,11 +15,43 @@ 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'); @@ -190,6 +222,102 @@ 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('TerrainWorkerLoader#parse terrarium martini', async t => { if (typeof Worker === 'undefined') { t.comment('Worker is not usable in non-browser environments'); From f41c5e5b9d9583f30c26c4688f45e4a45ae34d28 Mon Sep 17 00:00:00 2001 From: charlieforward9 Date: Thu, 23 Apr 2026 09:43:16 -0400 Subject: [PATCH 2/3] add terrain grid coverage tests --- modules/terrain/test/terrain-loader.spec.js | 74 ++++++++++++++++++++- 1 file changed, 73 insertions(+), 1 deletion(-) diff --git a/modules/terrain/test/terrain-loader.spec.js b/modules/terrain/test/terrain-loader.spec.js index 35dcb9f20f..dcfcd7b777 100644 --- a/modules/terrain/test/terrain-loader.spec.js +++ b/modules/terrain/test/terrain-loader.spec.js @@ -6,7 +6,12 @@ import test from 'tape-promise/tape'; import {validateLoader, validateMeshCategoryData} from 'test/common/conformance'; -import {TerrainLoader, TerrainWorkerLoader, makeGridTerrainMesh} from '@loaders.gl/terrain'; +import { + TerrainLoader, + TerrainWorkerLoader, + buildGridMeshAttributes, + makeGridTerrainMesh +} from '@loaders.gl/terrain'; import {setLoaderOptions, load, registerLoaders} from '@loaders.gl/core'; // Should be possible to remove this @@ -55,6 +60,7 @@ function testAlmostEqual(t, actual, expected, message) { 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(); }); @@ -318,6 +324,72 @@ test('TerrainLoader#makeGridTerrainMesh applies skirts without changing terrain 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'); From eb5a57ee43f8da5040646268cf86684b79fa5d93 Mon Sep 17 00:00:00 2001 From: charlieforward9 Date: Thu, 23 Apr 2026 17:22:56 -0400 Subject: [PATCH 3/3] add terrain benchmark and coverage tests --- docs/modules/terrain/README.md | 2 + .../terrain/api-reference/terrain-loader.md | 22 +++ modules/terrain/test/terrain-loader.bench.js | 96 ++++++++++++ modules/terrain/test/terrain-loader.spec.js | 148 +++++++++++++++++- test/bench/modules.js | 3 + 5 files changed, 270 insertions(+), 1 deletion(-) create mode 100644 modules/terrain/test/terrain-loader.bench.js diff --git a/docs/modules/terrain/README.md b/docs/modules/terrain/README.md index 3e4e43ce9a..7e1eb41c2d 100644 --- a/docs/modules/terrain/README.md +++ b/docs/modules/terrain/README.md @@ -34,3 +34,5 @@ The `TerrainLoader` uses [MARTINI](https://github.com/mapbox/martini), [Delatin] 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 360c5b54a0..8704c1b312 100644 --- a/docs/modules/terrain/api-reference/terrain-loader.md +++ b/docs/modules/terrain/api-reference/terrain-loader.md @@ -89,6 +89,28 @@ const terrainMesh = makeGridTerrainMesh( ); ``` +## 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 | 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 dcfcd7b777..8791d32648 100644 --- a/modules/terrain/test/terrain-loader.spec.js +++ b/modules/terrain/test/terrain-loader.spec.js @@ -10,7 +10,8 @@ import { TerrainLoader, TerrainWorkerLoader, buildGridMeshAttributes, - makeGridTerrainMesh + makeGridTerrainMesh, + makeTerrainMeshFromImage } from '@loaders.gl/terrain'; import {setLoaderOptions, load, registerLoaders} from '@loaders.gl/core'; @@ -324,6 +325,151 @@ test('TerrainLoader#makeGridTerrainMesh applies skirts without changing terrain 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( () => 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);