Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions docs/modules/terrain/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
88 changes: 88 additions & 0 deletions docs/modules/terrain/api-reference/terrain-loader.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -40,6 +119,7 @@ const data = await load(url, TerrainLoader, options);
| `terrain.bounds` | `array<number>` | `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
Expand Down Expand Up @@ -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.
9 changes: 8 additions & 1 deletion modules/terrain/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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

Expand Down
256 changes: 256 additions & 0 deletions modules/terrain/src/lib/grid-terrain-mesh.ts
Original file line number Diff line number Diff line change
@@ -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
};
}
Loading
Loading