diff --git a/docs/api-reference/geo-layers/terrain-layer.md b/docs/api-reference/geo-layers/terrain-layer.md index 6af090ed051..fce03858bf0 100644 --- a/docs/api-reference/geo-layers/terrain-layer.md +++ b/docs/api-reference/geo-layers/terrain-layer.md @@ -8,6 +8,8 @@ The `TerrainLayer` reconstructs mesh surfaces from height map images, e.g. [Mapz When `elevationData` is supplied with a URL template, i.e. a string containing `'{x}'` and `'{y}'` (or `'{-y}'` for TMS tiles), it loads terrain tiles on demand using a `TileLayer` and renders a mesh for each tile. If `elevationData` is an absolute URL, a single mesh is used, and the `bounds` prop is required to position it into the world space. +The layer supports both `MapView` and `GlobeView`. For `GlobeView`, use `tesselator: 'grid'` — meshes are emitted with lng/lat vertex positions so the same tile mesh renders correctly on both projections without re-tesselation when toggling between them. + import Tabs from '@theme/Tabs'; import TabItem from '@theme/TabItem'; @@ -169,10 +171,33 @@ Image URL to use as the surface texture. Same schema as `elevationData`. #### `meshMaxError` (number, optional) {#meshmaxerror} -Martini error tolerance in meters, smaller number results in more detailed mesh.. +Martini error tolerance in meters, smaller number results in more detailed mesh. Only applies when `tesselator` is `'auto'`. - Default: `4.0` +#### `tesselator` ('auto' | 'grid', optional) {#tesselator} + +Algorithm used to turn a terrain-RGB tile into a mesh. + +- `'auto'` — error-driven refinement (Martini for square pow-2 tiles, Delatin otherwise) via `@loaders.gl/terrain`. Runs in a web worker. Produces adaptive meshes tuned by `meshMaxError`, baked into the active viewport's projection frame. +- `'grid'` — fixed-resolution lng/lat grid. The mesh is emitted with lng/lat/elev vertex positions, so a single cached mesh renders correctly on both `MapView` and `GlobeView`; switching projections does not invalidate cached tiles. Rows are uniform in Mercator-y, matching the heightmap pixel layout, which eliminates polar sampling warp. Runs on the main thread (no worker). + +Use `'grid'` when rendering on `GlobeView`, when switching projections, or when you want to avoid worker loading. Use `'auto'` when you need adaptive detail and are rendering on `MapView` only. + +- Default: `'auto'` + +#### `gridSize` (number, optional) {#gridsize} + +Vertices per side when `tesselator` is `'grid'`. Each tile emits `gridSize × gridSize` vertices and `2 × (gridSize − 1)²` triangles. + +- `33` — ~2k tris/tile, faceted on 256px tiles. +- `65` — ~8k tris/tile, smooth on terrain-RGB 256px tiles (default). +- `129` — ~32k tris/tile, high fidelity at the cost of vertex memory. + +Ignored when `tesselator` is not `'grid'`. + +- Default: `65` + #### `elevationDecoder` (object, optional) {#elevationdecoder} Parameters used to convert a pixel to elevation in meters. @@ -220,7 +245,7 @@ Bounds of the image to fit x,y coordinates into. In `[left, bottom, right, top]` `left` and `right` refers to the world longitude/x at the corresponding side of the image. `top` and `bottom` refers to the world latitude/y at the corresponding side of the image. -Must be supplied when using non-tiled elevation data. +Must be supplied when using non-tiled elevation data. For tiled data, tile bounds are derived from the tile index. - Default: `null` diff --git a/modules/geo-layers/src/terrain-layer/grid-terrain-mesh.ts b/modules/geo-layers/src/terrain-layer/grid-terrain-mesh.ts new file mode 100644 index 00000000000..a98f029d7cf --- /dev/null +++ b/modules/geo-layers/src/terrain-layer/grid-terrain-mesh.ts @@ -0,0 +1,220 @@ +// deck.gl +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors + +// Fixed-resolution grid tesselator for terrain-RGB tiles. +// +// This is a local copy of @loaders.gl/terrain's `makeGridTerrainMesh`, kept +// here while that export ships in a loaders.gl release. Once available, swap +// to `import {makeGridTerrainMesh} from '@loaders.gl/terrain'` and delete this +// file. +// +// Why a grid: +// - Skips Martini's error-driven CPU refinement (~60× cheaper per tile). +// - Vertices are emitted in lng/lat/elev, so the same mesh renders on both +// MapView and GlobeView — switching projection does not invalidate the +// cache. +// - Grid rows are uniform in Mercator-y (matches the heightmap pixel layout +// at any latitude), which eliminates the polar sampling warp seen with +// lat-uniform tesselation. + +import type {MeshAttributes} from '@loaders.gl/schema'; + +export type ElevationDecoder = { + rScaler: number; + gScaler: number; + bScaler: number; + offset: number; +}; + +export type GridMeshOptions = { + // [west, south, east, north] in degrees (lng/lat). For OSM tiles fed through + // TerrainLayer on GlobeView, these are tile bounds in degrees. + bounds: [number, number, number, number]; + elevationDecoder: ElevationDecoder; + // Vertices per side. 33 → 1089 verts, 2048 tris per tile; a good default. + gridSize?: number; + // Meters to drop edge vertices to hide seams between adjacent tiles. + skirtHeight?: number; +}; + +export type TerrainImage = { + data: Uint8Array | Uint8ClampedArray; + width: number; + 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; + +function latToMercatorY(lat: number): number { + const clamped = Math.max(-MAX_LATITUDE, Math.min(MAX_LATITUDE, lat)); + const s = Math.sin(clamped * DEG2RAD); + return 0.5 * Math.log((1 + s) / (1 - s)); +} + +function mercatorYToLat(y: number): number { + return (2 * Math.atan(Math.exp(y)) - Math.PI / 2) * RAD2DEG; +} + +function sampleElevationBilinear( + image: TerrainImage, + u: number, + v: number, + decoder: ElevationDecoder +): number { + const {data, width, height} = image; + const fx = u * (width - 1); + const fy = v * (height - 1); + const x0 = Math.floor(fx); + const y0 = Math.floor(fy); + const x1 = Math.min(x0 + 1, width - 1); + const y1 = Math.min(y0 + 1, height - 1); + const dx = fx - x0; + const dy = fy - y0; + + const decode = (x: number, y: number): number => { + const i = (y * width + x) * 4; + return ( + decoder.rScaler * data[i] + + decoder.gScaler * data[i + 1] + + decoder.bScaler * data[i + 2] + + decoder.offset + ); + }; + + const z00 = decode(x0, y0); + const z10 = decode(x1, y0); + const z01 = decode(x0, y1); + const z11 = decode(x1, y1); + + const z0 = z00 * (1 - dx) + z10 * dx; + const z1 = z01 * (1 - dx) + z11 * dx; + return z0 * (1 - dy) + z1 * dy; +} + +// Output mesh shape mirrors @loaders.gl/terrain's parse-terrain output so the +// same TerrainLayer render path consumes either tesselator's result. +export function makeGridTerrainMesh( + image: TerrainImage, + options: GridMeshOptions +): { + loaderData: {header: Record}; + header: {vertexCount: number; boundingBox: BoundingBox}; + mode: number; + indices: {value: Uint32Array; size: 1}; + attributes: MeshAttributes; +} { + const {bounds, elevationDecoder, gridSize = 33, skirtHeight = 0} = options; + const [west, south, east, north] = bounds; + const N = gridSize; + + const mercYNorth = latToMercatorY(north); + const mercYSouth = latToMercatorY(south); + + // Skirt geometry: drop edge vertices vertically to hide minor elevation + // disagreement between adjacent tiles. The skirt angle is set by the skirt + // height divided by the in-tile grid step — a fixed meter-skirt (e.g. 8m) + // over a 0.6m grid step at z=21 is a near-vertical wall that catches + // specular light and reads as a visible cliff. + // + // Clamp skirt drop to 1% of the grid step so the seam slope is ≤~0.6° at + // any zoom — imperceptible under lighting while still providing a tiny + // downstep that hides neighbor-tile seams. + const metersPerDegree = 111_000; + const midLatRad = ((north + south) * 0.5) * DEG2RAD; + const tileMetersX = (east - west) * metersPerDegree * Math.cos(midLatRad); + const gridStepMeters = tileMetersX / (N - 1); + const effectiveSkirtHeight = Math.min(skirtHeight, gridStepMeters * 0.01); + + const vertexCount = N * N; + const positions = new Float32Array(vertexCount * 3); + const texCoords = new Float32Array(vertexCount * 2); + + let minElev = Infinity; + let maxElev = -Infinity; + + for (let j = 0; j < N; j++) { + const v = j / (N - 1); + // Uniform in Mercator-y so each grid row lines up with heightmap rows. + // v=0 is top (north), v=1 is bottom (south). + const mercY = mercYNorth + v * (mercYSouth - mercYNorth); + const lat = mercatorYToLat(mercY); + + for (let i = 0; i < N; i++) { + const u = i / (N - 1); + const lng = west + u * (east - west); + + let elev = sampleElevationBilinear(image, u, v, elevationDecoder); + + // Clamp to Earth's physical elevation range. Terrain-RGB encodes + // elevation into 24 bits across three 8-bit channels, so one corrupt + // pixel (PNG decode artifact, overzoom noise, partial load) can decode + // to millions of meters. One such pixel on a 33×33 grid becomes a + // vertical spike that glints as a specular star against the directional + // light. Clamping to [-500, 9000]m covers the full range of real + // terrain (Dead Sea shore to Everest with headroom) and suppresses + // these spikes without affecting valid samples. + if (elev < -500 || elev > 9000 || !Number.isFinite(elev)) { + elev = Math.max(-500, Math.min(9000, elev || 0)); + } + + const onEdge = i === 0 || j === 0 || i === N - 1 || j === N - 1; + if (onEdge) { + elev -= effectiveSkirtHeight; + } + + const vi = (j * N + i) * 3; + positions[vi] = lng; + positions[vi + 1] = lat; + positions[vi + 2] = elev; + + const ti = (j * N + i) * 2; + texCoords[ti] = u; + texCoords[ti + 1] = v; + + if (elev < minElev) minElev = elev; + if (elev > maxElev) maxElev = elev; + } + } + + // Two triangles per quad, (N-1)² quads. + const quadCount = (N - 1) * (N - 1); + const indices = new Uint32Array(quadCount * 6); + let idx = 0; + for (let j = 0; j < N - 1; j++) { + for (let i = 0; i < N - 1; i++) { + const a = j * N + i; + const b = j * N + (i + 1); + const c = (j + 1) * N + i; + const d = (j + 1) * N + (i + 1); + // Two CCW triangles — winding matches parse-terrain output so + // SimpleMeshLayer's default front-face culling behaves consistently. + indices[idx++] = a; + indices[idx++] = c; + indices[idx++] = b; + indices[idx++] = b; + indices[idx++] = c; + indices[idx++] = d; + } + } + + const boundingBox: BoundingBox = [ + [west, south, minElev], + [east, north, maxElev] + ]; + + return { + loaderData: {header: {}}, + header: {vertexCount, boundingBox}, + mode: 4, // TRIANGLES + indices: {value: indices, size: 1}, + attributes: { + POSITION: {value: positions, size: 3}, + TEXCOORD_0: {value: texCoords, size: 2} + } + }; +} diff --git a/modules/geo-layers/src/terrain-layer/terrain-layer.ts b/modules/geo-layers/src/terrain-layer/terrain-layer.ts index aead696d70d..c35a1a46377 100644 --- a/modules/geo-layers/src/terrain-layer/terrain-layer.ts +++ b/modules/geo-layers/src/terrain-layer/terrain-layer.ts @@ -18,6 +18,8 @@ import {SimpleMeshLayer} from '@deck.gl/mesh-layers'; import {COORDINATE_SYSTEM} from '@deck.gl/core'; import type {MeshAttributes} from '@loaders.gl/schema'; import {TerrainWorkerLoader} from '@loaders.gl/terrain'; +import {ImageLoader} from '@loaders.gl/images'; +import {makeGridTerrainMesh} from './grid-terrain-mesh'; import TileLayer, {TileLayerProps} from '../tile-layer/tile-layer'; import type { Bounds, @@ -38,11 +40,18 @@ const defaultProps: DefaultProps = { texture: {...urlType, optional: true}, // Martini error tolerance in meters, smaller number -> more detailed mesh meshMaxError: {type: 'number', value: 4.0}, + // Tesselator: 'auto' (Martini/Delatin via @loaders.gl/terrain worker) or + // 'grid' (fixed-resolution lng/lat grid, valid on MapView and GlobeView) + tesselator: 'auto', + // Vertices per side for the grid tesselator (ignored when tesselator !== 'grid') + gridSize: {type: 'number', value: 65}, // Bounding box of the terrain image, [minX, minY, maxX, maxY] in world coordinates bounds: {type: 'array', value: null, optional: true, compare: true}, // Color to use if texture is unavailable color: {type: 'color', value: [255, 255, 255]}, - // Object to decode height data, from (r, g, b) to height in meters + // Object to decode height data, from (r, g, b) to height in meters. + // compare:true so consumers that pass fresh decoder refs with identical + // values don't force a tile reload. elevationDecoder: { type: 'object', value: { @@ -50,15 +59,25 @@ const defaultProps: DefaultProps = { gScaler: 0, bScaler: 0, offset: 0 - } + }, + compare: true }, // Supply url to local terrain worker bundle. Only required if running offline and cannot access CDN. workerUrl: '', // Same as SimpleMeshLayer wireframe wireframe: false, - material: true, + // Matte terrain material: high ambient + modest diffuse, zero specular. The + // default `true` enables PBR with specular highlights, which on terrain + // meshes catches on skirt edges and elevation discontinuities as bright + // star-shaped glints. A matte surface reads as a fluid landscape instead. + material: { + ambient: 0.7, + diffuse: 0.4, + shininess: 1, + specularColor: [0, 0, 0] + }, - loaders: [TerrainWorkerLoader] + loaders: [TerrainWorkerLoader, ImageLoader] }; // Turns array of templates into a single string to work around shallow change @@ -69,6 +88,13 @@ function urlTemplateToUpdateTrigger(template: URLTemplate): string { return template || ''; } +// updateTriggers diff with shallow equality. Collapse the decoder to a +// value-identity string so callers passing a fresh object each render with +// identical values don't invalidate every tile. +function elevationDecoderToUpdateTrigger(decoder: ElevationDecoder): string { + return `${decoder.rScaler}|${decoder.gScaler}|${decoder.bScaler}|${decoder.offset}`; +} + type ElevationDecoder = {rScaler: number; gScaler: number; bScaler: number; offset: number}; type TerrainLoadProps = { bounds: Bounds; @@ -96,6 +122,19 @@ type _TerrainLayerProps = { /** Martini error tolerance in meters, smaller number -> more detailed mesh. **/ meshMaxError?: number; + /** + * Tesselator used to turn a terrain-RGB tile into a mesh. `'grid'` uses + * a fixed-resolution grid in lng/lat — cheaper on CPU and portable across + * MapView/GlobeView without re-tesselating on projection change. + */ + tesselator?: 'auto' | 'grid'; + + /** + * Vertices per side for the grid tesselator. Default 65 (≈8k tris per tile). + * Bump to 129 for higher fidelity; drop to 33 to halve vertex cost. + */ + gridSize?: number; + /** Bounding box of the terrain image, [minX, minY, maxX, maxY] in world coordinates. **/ bounds?: Bounds | null; @@ -185,12 +224,36 @@ export default class TerrainLayer extends Composite } getTiledTerrainData(tile: TileLoadProps): Promise { - const {elevationData, fetch, texture, elevationDecoder, meshMaxError} = this.props; + const {elevationData, fetch, texture, elevationDecoder, meshMaxError, tesselator, gridSize} = + this.props; const {viewport} = this.context; const dataUrl = getURLFromTemplate(elevationData, tile); const textureUrl = texture && getURLFromTemplate(texture, tile); const {signal} = tile; + + // Grid path keeps bounds in lng/lat degrees so the mesh renders via LNGLAT + // on both MapView and GlobeView — no projectFlat bake, no invalidation on + // projection toggle. + if (tesselator === 'grid' && viewport.isGeospatial) { + const bbox = tile.bbox as GeoBoundingBox; + const bounds: Bounds = [bbox.west, bbox.south, bbox.east, bbox.north]; + const terrain = this.loadGridTerrain({ + elevationData: dataUrl, + bounds, + elevationDecoder, + gridSize, + skirtHeight: meshMaxError * 2, + signal + }); + const surface = textureUrl + ? fetch(textureUrl, {propName: 'texture', layer: this, loaders: [], signal}).catch( + _ => null + ) + : Promise.resolve(null); + return Promise.all([terrain, surface]); + } + let bottomLeft = [0, 0] as [number, number]; let topRight = [0, 0] as [number, number]; if (viewport.isGeospatial) { @@ -219,6 +282,49 @@ export default class TerrainLayer extends Composite return Promise.all([terrain, surface]); } + // Fetch a terrain-RGB tile as raw pixels and run the grid tesselator + // in-process. The grid is cheap enough that worker handoff overhead + // dominates, so the main-thread path is faster than the worker loader. + loadGridTerrain({ + elevationData, + bounds, + elevationDecoder, + gridSize, + skirtHeight, + signal + }: { + elevationData: string | null; + bounds: Bounds; + elevationDecoder: ElevationDecoder; + gridSize: number; + skirtHeight: number; + signal?: AbortSignal; + }): Promise { + if (!elevationData) { + return Promise.resolve(null); + } + const {fetch} = this.props; + const loadOptions = { + ...this.getLoadOptions(), + image: {type: 'data' as const} + }; + return fetch(elevationData, { + propName: 'elevationData', + layer: this, + loaders: [ImageLoader], + loadOptions, + signal + }).then((image: {data: Uint8ClampedArray; width: number; height: number}) => { + if (!image) return null; + return makeGridTerrainMesh(image, { + bounds: bounds as [number, number, number, number], + elevationDecoder, + gridSize, + skirtHeight + }) as unknown as MeshAttributes; + }); + } + renderSubLayers( props: TileLayerProps & { id: string; @@ -237,12 +343,26 @@ export default class TerrainLayer extends Composite const [mesh, texture] = data; + // Grid tesselator emits lng/lat/elev vertices → LNGLAT on any projection. + // Legacy path bakes bounds into projectFlat space: CARTESIAN on MapView + // (Mercator world units) and LNGLAT on GlobeView (where projectFlat is + // identity and the mesh is already lng/lat). + const {viewport} = this.context; + const {tesselator} = this.props; + let coordinateSystem: string; + if (tesselator === 'grid') { + coordinateSystem = COORDINATE_SYSTEM.LNGLAT; + } else { + const isGlobe = Boolean(viewport.resolution && viewport.resolution > 0); + coordinateSystem = isGlobe ? COORDINATE_SYSTEM.LNGLAT : COORDINATE_SYSTEM.CARTESIAN; + } + return new SubLayerClass(props, { data: DUMMY_DATA, mesh, texture, _instanced: false, - coordinateSystem: COORDINATE_SYSTEM.CARTESIAN, + coordinateSystem, getPosition: d => [0, 0, 0], getColor: color, wireframe, @@ -285,6 +405,8 @@ export default class TerrainLayer extends Composite wireframe, meshMaxError, elevationDecoder, + tesselator, + gridSize, tileSize, maxZoom, minZoom, @@ -299,6 +421,12 @@ export default class TerrainLayer extends Composite } = this.props; if (this.state.isTiled) { + // Legacy (Martini/Delatin) meshes are baked into the active viewport's + // projectFlat frame, so toggling projections must rebuild every tile. + // Grid meshes are lng/lat/elev and stay valid across projections, so + // projectionMode is omitted from the update trigger there. + const isGridPath = tesselator === 'grid'; + const projectionMode = isGridPath ? undefined : this.context.viewport.projectionMode; return new TileLayer( this.getSubLayerProps({ id: 'tiles' @@ -311,7 +439,10 @@ export default class TerrainLayer extends Composite elevationData: urlTemplateToUpdateTrigger(elevationData), texture: urlTemplateToUpdateTrigger(texture), meshMaxError, - elevationDecoder + elevationDecoder: elevationDecoderToUpdateTrigger(elevationDecoder), + tesselator, + gridSize, + projectionMode } }, onViewportLoad: this.onViewportLoad.bind(this), diff --git a/modules/geo-layers/src/tileset-2d/tile-2d-traversal.ts b/modules/geo-layers/src/tileset-2d/tile-2d-traversal.ts index ab3dba4ef55..1974951382c 100644 --- a/modules/geo-layers/src/tileset-2d/tile-2d-traversal.ts +++ b/modules/geo-layers/src/tileset-2d/tile-2d-traversal.ts @@ -91,6 +91,14 @@ class OSMNode { return false; } + // Globe-only: reject tiles on the far hemisphere of the sphere. The frustum + // test above accepts any tile whose bounding volume intersects the view + // frustum, including back-side tiles occluded by the near hemisphere. Without + // this, low-zoom traversal fans out across the entire globe. + if (project && this.beyondHorizon(viewport.cameraPosition, project, elevationBounds[1])) { + return false; + } + // Avoid loading overlapping tiles - if a descendant is requested, do not request the ancester if (!this.childVisible) { let {z} = this; @@ -129,6 +137,47 @@ class OSMNode { return result; } + // A point P on the sphere is visible from camera C iff dot(P, C) > |P|². + // Rather than sampling discrete refPoints (which can all miss the visibility + // cone when the cone is small), pick the tile point closest to the camera's + // radial direction — the tile's clamp to camera lng/lat. If even that + // closest point is beyond the horizon, no part of the tile is visible. + beyondHorizon( + cameraPosition: number[], + project: (xyz: number[]) => number[], + elevation: number + ): boolean { + const cx = cameraPosition[0]; + const cy = cameraPosition[1]; + const cz = cameraPosition[2]; + const cMag = Math.sqrt(cx * cx + cy * cy + cz * cz); + + // Match GlobeViewport.unprojectPosition: lng = atan2(x, -y), lat = asin(z/D). + const camLng = (Math.atan2(cx, -cy) * 180) / Math.PI; + const camLat = (Math.asin(cz / cMag) * 180) / Math.PI; + + // OSM tile bounds in lng/lat. + const [west, north] = osmTile2lngLat(this.x, this.y, this.z); + const [east, south] = osmTile2lngLat(this.x + 1, this.y + 1, this.z); + + // Shift camLng into the tile's local frame so clamping uses ANGULAR + // distance on the sphere, not Euclidean distance in degrees. Without this, + // a camera near the antimeridian (e.g. lng=-175) clamping against a tile + // at [170, 175] picks 170 (far, ~345°) instead of 175 (near, ~10° via + // wrap) and wrongly culls a visible tile. + const tileCenterLng = (west + east) / 2; + const wrappedCamLng = + tileCenterLng + (((camLng - tileCenterLng + 540) % 360) - 180); + + const closestLng = Math.max(west, Math.min(wrappedCamLng, east)); + const closestLat = Math.max(south, Math.min(camLat, north)); + + const closest = project([closestLng, closestLat, elevation]); + const dot = closest[0] * cx + closest[1] * cy + closest[2] * cz; + const magSq = closest[0] * closest[0] + closest[1] * closest[1] + closest[2] * closest[2]; + return dot <= magSq; + } + insideBounds([minX, minY, maxX, maxY]: Bounds): boolean { const scale = Math.pow(2, this.z); const extent = TILE_SIZE / scale;