From bebc478f857b68e45753052ac80f0f98daa48fe2 Mon Sep 17 00:00:00 2001 From: Ib Green Date: Fri, 12 Jun 2026 09:34:43 -0400 Subject: [PATCH] Add shared terrain tileset path --- .../geo-layers/shared-tile-2d-layer.md | 2 +- .../geo-layers/shared-tileset-2d.md | 5 +- .../api-reference/geo-layers/terrain-layer.md | 41 +- docs/whats-new.md | 2 +- modules/geo-layers/src/index.ts | 5 + .../shared-tileset-2d/shared-tileset-2d.ts | 24 +- .../src/terrain-layer/terrain-layer.ts | 180 +++++--- .../src/terrain-layer/terrain-source.ts | 391 ++++++++++++++++++ test/modules/geo-layers/index.ts | 2 + .../geo-layers/terrain-layer-loading.spec.ts | 116 +++++- 10 files changed, 706 insertions(+), 62 deletions(-) create mode 100644 modules/geo-layers/src/terrain-layer/terrain-source.ts diff --git a/docs/api-reference/geo-layers/shared-tile-2d-layer.md b/docs/api-reference/geo-layers/shared-tile-2d-layer.md index f3d5b71057c..f2c3951cb28 100644 --- a/docs/api-reference/geo-layers/shared-tile-2d-layer.md +++ b/docs/api-reference/geo-layers/shared-tile-2d-layer.md @@ -1,6 +1,6 @@ # SharedTile2DLayer (Experimental) -`_SharedTile2DLayer` is an experimental composite layer for rendering 2D tiled data when multiple layer instances or viewports should reuse one tile-content cache. It is a parallel API to [`TileLayer`](./tile-layer.md), not a replacement for `TileLayer`, `MVTLayer`, or `TerrainLayer`. +`_SharedTile2DLayer` is an experimental composite layer for rendering 2D tiled data when multiple layer instances or viewports should reuse one tile-content cache. It is a parallel API to [`TileLayer`](./tile-layer.md), not a replacement for `TileLayer` or `MVTLayer`. `TerrainLayer` can consume the same shared cache machinery through its experimental `_terrainTileset` prop. Use `_SharedTile2DLayer` when the same tiled payload should feed multiple views, such as a main map and minimap. The layer keeps selection and visibility state per viewport, while [`_SharedTileset2D`](./shared-tileset-2d.md) owns loading, request scheduling, cache eviction, stats, and TileSource metadata. diff --git a/docs/api-reference/geo-layers/shared-tileset-2d.md b/docs/api-reference/geo-layers/shared-tileset-2d.md index 5216be5dfef..b43dce60c3e 100644 --- a/docs/api-reference/geo-layers/shared-tileset-2d.md +++ b/docs/api-reference/geo-layers/shared-tileset-2d.md @@ -35,7 +35,7 @@ import type { } from '@deck.gl/geo-layers'; new SharedTileset2D(props: SharedTileset2DProps); -SharedTileset2D.fromTileSource(tileSource, props); +SharedTileset2D.fromTileSource(tileSource, props); ``` Provide either `getTileData` or `tileSource`. A shared tileset also needs an adapter before traversal is used. `_SharedTile2DLayer` installs `sharedTile2DDeckAdapter` automatically for deck.gl viewport traversal; applications constructing the tileset directly should usually pass that adapter themselves. @@ -50,10 +50,11 @@ An external `_SharedTileset2D` can be passed to one or more `_SharedTile2DLayer` ## Runtime API -- `tiles`, `selectedTiles`, `visibleTiles`, `loadingTiles`, and `cacheByteSize` expose current shared cache state. +- `tiles`, `selectedTiles`, `visibleTiles`, `loadingTiles`, `cacheByteSize`, and `tileSize` expose current shared cache state. - `stats` is a `@probe.gl/stats` `Stats` object with tile cache, visibility, loading, eviction, and consumer counters. - `setOptions()` updates effective tileset options. Pass `{replace: true}` as the second argument to replace prior caller options instead of merging them. - `reloadAll()` marks selected tiles stale and drops unselected cached tiles. +- `notifyTileContentChanged(tile)` recomputes cache bytes after a retained payload mutates in place, for example when `TerrainLayer` adds a shared projection mesh variant. - `subscribe()` listens for tile load, tile error, tile unload, metadata/config update, metadata error, and stats change events. - `finalize()` aborts in-flight requests and clears the shared cache. diff --git a/docs/api-reference/geo-layers/terrain-layer.md b/docs/api-reference/geo-layers/terrain-layer.md index 6af090ed051..b3a408914a2 100644 --- a/docs/api-reference/geo-layers/terrain-layer.md +++ b/docs/api-reference/geo-layers/terrain-layer.md @@ -151,14 +151,51 @@ When in Tiled Mode, inherits from all [TileLayer](./tile-layer.md) properties. F ### Data Options -#### `elevationData` (string | string[], required) {#elevationdata} +#### `elevationData` (string | string[], optional) {#elevationdata} Image URL that encodes height data. +Required unless `_terrainTileset` is supplied. + - If the value is a valid URL, this layer will render a single mesh. - If the value is a string, and contains substrings `{x}` and `{y}`, it is considered a URL template. This layer will render a `TileLayer` of meshes. `{x}` `{y}` and `{z}` will be replaced with a tile's actual index when it is requested. - If the value is an array: multiple URL templates. See `TileLayer`'s `data` prop documentation for use cases. +#### `_terrainTileset` (`_SharedTileset2D<_TerrainTileData>`, experimental) {#terraintileset} + +Caller-owned shared terrain tile cache for tiled mode. When supplied, `TerrainLayer` renders the shared payload through `_SharedTile2DLayer` instead of creating its own `TileLayer`. + +Use `_TerrainSource` to load projection-independent terrain payloads into `_SharedTileset2D`, then pass the same tileset to each `TerrainLayer` that should reuse those payloads: + +```ts +import { + TerrainLayer, + _SharedTileset2D as SharedTileset2D, + _TerrainSource as TerrainSource, + sharedTile2DDeckAdapter +} from '@deck.gl/geo-layers'; + +const terrainSource = new TerrainSource({ + elevationData: 'https://example.com/elevation/{z}/{x}/{y}.png', + texture: 'https://example.com/texture/{z}/{x}/{y}.png', + meshMaxError: 4 +}); +const terrainTileset = SharedTileset2D.fromTileSource(terrainSource, { + adapter: sharedTile2DDeckAdapter, + minZoom: 0, + maxZoom: 14 +}); + +const layers = [ + new TerrainLayer({id: 'terrain-a', _terrainTileset: terrainTileset}), + new TerrainLayer({id: 'terrain-b', _terrainTileset: terrainTileset, wireframe: true}) +]; +``` + +The shared source owns elevation, texture, decoder, mesh, loader, and load option props. The shared tileset owns tile traversal and cache options. Individual `TerrainLayer` instances keep render props such as `color`, `wireframe`, `material`, and `operation`. The owner should call `terrainTileset.finalize()` when the cache is no longer needed. + +The base tile payload is projection-independent. Each terrain layer asks the shared payload for the mesh variant needed by its active view; MapView and GlobeView variants are cached on the shared tile data and reused by later terrain layers using the same projection. + #### `texture` (string | null, optional) {#texture} @@ -260,7 +297,7 @@ Forwarded to `SimpleMeshLayer`'s `material` prop. The `TerrainLayer` renders the following sublayers: -* `tiles` - a [TileLayer](./tile-layer.md). Only rendered if `elevationData` is a URL template. +* `tiles` - a [TileLayer](./tile-layer.md) when `elevationData` is a URL template, or an experimental [_SharedTile2DLayer](./shared-tile-2d-layer.md) when `_terrainTileset` is supplied. * `mesh` - a [SimpleMeshLayer](../mesh-layers/simple-mesh-layer.md) rendering the terrain mesh. diff --git a/docs/whats-new.md b/docs/whats-new.md index 4342e6dafd9..3acfc173034 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -79,7 +79,7 @@ Class-specific improvements: - [TextLayer](./api-reference/layers/text-layer.md) now supports per-object clipping box; and making text "sticky" when its container is partially off-screen. See a demo with this [new example](https://deck.gl/examples/text-layer-clipping). - [TileLayer](./api-reference/geo-layers/tile-layer.md) adds new `visibleMinZoom` and `visibleMaxZoom` props to control the zoom range at which tiles are drawn, independent of the zoom range at which data is loaded. -- Experimental [`_SharedTile2DLayer`](./api-reference/geo-layers/shared-tile-2d-layer.md) and [`_SharedTileset2D`](./api-reference/geo-layers/shared-tileset-2d.md) let 2D tiled layers reuse one tile-content cache across multiple views. +- Experimental [`_SharedTile2DLayer`](./api-reference/geo-layers/shared-tile-2d-layer.md) and [`_SharedTileset2D`](./api-reference/geo-layers/shared-tileset-2d.md) let 2D tiled layers reuse one tile-content cache across multiple views. [`TerrainLayer`](./api-reference/geo-layers/terrain-layer.md) can now opt into that cache with `_TerrainSource` and `_terrainTileset`. - Improvements to [Tile3DLayer](./api-reference/geo-layers/tile-3d-layer.md) including better performance and tile tracking. - WebGPU now materializes constant layer attributes into full buffers through `AttributeManager`, improving compatibility for layers that rely on constant accessors. diff --git a/modules/geo-layers/src/index.ts b/modules/geo-layers/src/index.ts index acda8aa0360..f1cdff5a467 100644 --- a/modules/geo-layers/src/index.ts +++ b/modules/geo-layers/src/index.ts @@ -16,6 +16,7 @@ export {default as H3ClusterLayer} from './h3-layers/h3-cluster-layer'; export {default as H3HexagonLayer} from './h3-layers/h3-hexagon-layer'; export {default as Tile3DLayer} from './tile-3d-layer/tile-3d-layer'; export {default as TerrainLayer} from './terrain-layer/terrain-layer'; +export {TerrainSource as _TerrainSource} from './terrain-layer/terrain-source'; export {default as MVTLayer} from './mvt-layer/mvt-layer'; export {default as GeohashLayer} from './geohash-layer/geohash-layer'; @@ -36,6 +37,10 @@ export type { export type {TripsLayerProps} from './trips-layer/trips-layer'; export type {QuadkeyLayerProps} from './quadkey-layer/quadkey-layer'; export type {TerrainLayerProps} from './terrain-layer/terrain-layer'; +export type { + TerrainSourceProps as _TerrainSourceProps, + TerrainTileData as _TerrainTileData +} from './terrain-layer/terrain-source'; export type {Tile3DLayerProps} from './tile-3d-layer/tile-3d-layer'; export type {MVTLayerProps, MVTLayerPickingInfo} from './mvt-layer/mvt-layer'; export type {GeoCellLayerProps as _GeoCellLayerProps} from './geo-cell-layer/GeoCellLayer'; diff --git a/modules/geo-layers/src/shared-tileset-2d/shared-tileset-2d.ts b/modules/geo-layers/src/shared-tileset-2d/shared-tileset-2d.ts index 3a723094514..778cf1812c2 100644 --- a/modules/geo-layers/src/shared-tileset-2d/shared-tileset-2d.ts +++ b/modules/geo-layers/src/shared-tileset-2d/shared-tileset-2d.ts @@ -174,11 +174,11 @@ export class SharedTileset2D { } /** Convenience factory for wrapping a loaders.gl `TileSource`. */ - static fromTileSource( + static fromTileSource( tileSource: TileSource, - opts: Omit, 'tileSource' | 'getTileData'> = {} - ): SharedTileset2D { - return new SharedTileset2D({...opts, tileSource}); + opts: Omit, 'tileSource' | 'getTileData'> = {} + ): SharedTileset2D { + return new SharedTileset2D({...opts, tileSource}); } /** All tiles currently present in the shared cache. */ @@ -220,6 +220,11 @@ export class SharedTileset2D { return this._minZoom; } + /** Pixel dimension used by the shared tile index and tile payloads. */ + get tileSize(): number { + return this.opts.tileSize; + } + /** Active refinement strategy for placeholder handling. */ get refinementStrategy(): SharedRefinementStrategy { return this.opts.refinementStrategy || STRATEGY_DEFAULT; @@ -328,6 +333,17 @@ export class SharedTileset2D { this._updateStats(); } + /** Recomputes retained bytes after a cached tile payload mutates in place. */ + notifyTileContentChanged(tile: SharedTile2DHeader): void { + if (this._cache.get(tile.id) !== tile) { + return; + } + this._cacheByteSize = this._getCacheByteSize(); + this._resizeCache(); + this._notifyUpdate(); + this._updateStats(); + } + /** Updates the selected and visible tile sets for one consumer. */ updateConsumer( id: symbol, diff --git a/modules/geo-layers/src/terrain-layer/terrain-layer.ts b/modules/geo-layers/src/terrain-layer/terrain-layer.ts index a950fbb52cd..73ed7526533 100644 --- a/modules/geo-layers/src/terrain-layer/terrain-layer.ts +++ b/modules/geo-layers/src/terrain-layer/terrain-layer.ts @@ -8,11 +8,13 @@ import { CompositeLayerProps, DefaultProps, Layer, + LayerProps, LayersList, log, Material, TextureSource, UpdateParameters, + type Viewport, _GlobeViewport as GlobeViewport } from '@deck.gl/core'; import {SimpleMeshLayer} from '@deck.gl/mesh-layers'; @@ -28,17 +30,28 @@ import type { ZRange } from '../tileset-2d/index'; import {Tile2DHeader, urlType, getURLFromTemplate, URLTemplate} from '../tileset-2d/index'; +import SharedTile2DLayer from '../shared-tile-2d-layer/shared-tile-2d-layer'; +import {SharedTile2DHeader, type SharedTileset2D} from '../shared-tileset-2d/index'; +import { + getEffectiveMeshMaxError, + getTerrainRenderMesh, + isTerrainTileData, + type ElevationDecoder, + type TerrainMesh, + type TerrainTileData +} from './terrain-source'; const DUMMY_DATA = [1]; const TILE_OVERLAP_PIXELS = 1; -const MIN_TERRAIN_MESH_MAX_ERROR = 1; const MAX_LATITUDE = 90; const MAX_LONGITUDE = 180; const defaultProps: DefaultProps = { ...TileLayer.defaultProps, // Image url that encodes height data - elevationData: urlType, + elevationData: {...urlType, optional: true}, + // Caller-owned shared terrain payload cache for tiled mode. + _terrainTileset: {type: 'object', value: null, optional: true}, // Image url to use as texture texture: {...urlType, optional: true}, // Martini error tolerance in meters, smaller number -> more detailed mesh @@ -96,14 +109,6 @@ function getOverlappedBounds(bounds: Bounds, tileSize: number, clampLngLat: bool ]; } -function getEffectiveMeshMaxError(meshMaxError: number): number { - if (!Number.isFinite(meshMaxError) || meshMaxError <= 0) { - return MIN_TERRAIN_MESH_MAX_ERROR; - } - return Math.max(meshMaxError, MIN_TERRAIN_MESH_MAX_ERROR); -} - -type ElevationDecoder = {rScaler: number; gScaler: number; bScaler: number; offset: number}; type TerrainLoadProps = { bounds: Bounds; elevationData: string | null; @@ -112,9 +117,16 @@ type TerrainLoadProps = { signal?: AbortSignal; }; -type MeshAndTexture = [MeshAttributes | null, TextureSource | null]; +type TerrainMeshData = TerrainMesh | MeshAttributes; +type MeshAndTexture = [TerrainMeshData | null, TextureSource | null]; +type TerrainTileHeader = Tile2DHeader | SharedTile2DHeader; +type TerrainSubLayerProps = LayerProps & { + id: string; + data: MeshAndTexture | TerrainTileData; + tile: TerrainTileHeader; +}; type MeshBoundingBox = [min: number[], max: number[]]; -type MeshWithBoundingBox = MeshAttributes & { +type MeshWithBoundingBox = TerrainMeshData & { header?: { boundingBox?: MeshBoundingBox; }; @@ -128,7 +140,10 @@ export type TerrainLayerProps = _TerrainLayerProps & /** Props added by the TerrainLayer */ type _TerrainLayerProps = { /** Image url that encodes height data. **/ - elevationData: URLTemplate; + elevationData?: URLTemplate; + + /** Experimental caller-owned shared terrain tileset used by tiled mode. */ + _terrainTileset?: SharedTileset2D | null; /** Image url to use as texture. **/ texture?: URLTemplate; @@ -172,10 +187,10 @@ export default class TerrainLayer extends Composite updateState({props, oldProps}: UpdateParameters): void { const elevationDataChanged = props.elevationData !== oldProps.elevationData; - if (elevationDataChanged) { + const terrainTilesetChanged = props._terrainTileset !== oldProps._terrainTileset; + if (elevationDataChanged || terrainTilesetChanged) { const {elevationData} = props; - const isTiled = - elevationData && (Array.isArray(elevationData) || isTileSetURL(elevationData)); + const isTiled = isTiledTerrainData(elevationData, props._terrainTileset); this.setState({isTiled}); } @@ -205,7 +220,7 @@ export default class TerrainLayer extends Composite elevationDecoder, meshMaxError, signal - }: TerrainLoadProps): Promise | null { + }: TerrainLoadProps): Promise | null { if (!elevationData) { return null; } @@ -227,28 +242,11 @@ export default class TerrainLayer extends Composite getTiledTerrainData(tile: TileLoadProps): Promise { const {elevationData, fetch, texture, elevationDecoder, meshMaxError} = this.props; - const {viewport} = this.context; const dataUrl = getURLFromTemplate(elevationData, tile); const textureUrl = texture && getURLFromTemplate(texture, tile); const {signal} = tile; - let bottomLeft = [0, 0] as [number, number]; - let topRight = [0, 0] as [number, number]; - if (viewport.isGeospatial) { - const bbox = tile.bbox as GeoBoundingBox; - bottomLeft = viewport.projectFlat([bbox.west, bbox.south]); - topRight = viewport.projectFlat([bbox.east, bbox.north]); - } else { - const bbox = tile.bbox as Exclude; - bottomLeft = [bbox.left, bbox.bottom]; - topRight = [bbox.right, bbox.top]; - } - const bounds: Bounds = [bottomLeft[0], bottomLeft[1], topRight[0], topRight[1]]; - const overlappedBounds = getOverlappedBounds( - bounds, - this.props.tileSize, - viewport instanceof GlobeViewport - ); + const overlappedBounds = this.getTiledTerrainBounds(tile, this.props.tileSize); const terrain = this.loadTerrain({ elevationData: dataUrl, @@ -265,13 +263,7 @@ export default class TerrainLayer extends Composite return Promise.all([terrain, surface]); } - renderSubLayers( - props: TileLayerProps & { - id: string; - data: MeshAndTexture; - tile: Tile2DHeader; - } - ) { + renderSubLayers(props: TerrainSubLayerProps) { const SubLayerClass = this.getSubLayerClass('mesh', SimpleMeshLayer); const {color, wireframe, material} = this.props; @@ -281,7 +273,9 @@ export default class TerrainLayer extends Composite return null; } - const [mesh, texture] = data; + const [mesh, texture] = isTerrainTileData(data) + ? this.getSharedTerrainTileData(data, props.tile) + : data; const {viewport} = this.context; // Bounds are baked with projectFlat. In GlobeView projectFlat is identity, @@ -312,20 +306,23 @@ export default class TerrainLayer extends Composite } // Update zRange of viewport - onViewportLoad(tiles?: Tile2DHeader[]): void { + onViewportLoad(tiles?: TerrainTileHeader[]): void { if (!tiles) { return; } const {zRange} = this.state; const ranges = tiles - .map(tile => tile.content) + .map(tile => getTerrainContentMesh(tile.content)) .filter(Boolean) - .map(arr => { - // @ts-ignore - const bounds = arr[0].header.boundingBox; + .map(mesh => { + const bounds = (mesh as MeshWithBoundingBox).header?.boundingBox; + if (!bounds) { + return null; + } return bounds.map(bound => bound[2]); - }); + }) + .filter(Boolean) as number[][]; if (ranges.length === 0) { return; } @@ -356,10 +353,36 @@ export default class TerrainLayer extends Composite onTileError, maxCacheSize, maxCacheByteSize, - refinementStrategy + refinementStrategy, + _terrainTileset } = this.props; if (this.state.isTiled) { + if (_terrainTileset) { + return new SharedTile2DLayer( + this.getSubLayerProps({ + id: 'tiles' + }), + { + data: _terrainTileset, + renderSubLayers: this.renderSubLayers.bind(this) as any, + updateTriggers: { + renderSubLayers: { + color, + wireframe, + material, + projectionMode: this.context.viewport.projectionMode + } + }, + onViewportLoad: this.onViewportLoad.bind(this) as any, + zRange: this.state.zRange || null, + onTileLoad: onTileLoad as any, + onTileUnload: onTileUnload as any, + onTileError: onTileError as any + } + ); + } + return new TileLayer( this.getSubLayerProps({ id: 'tiles' @@ -414,7 +437,64 @@ export default class TerrainLayer extends Composite } ); } + + private getSharedTerrainTileData(data: TerrainTileData, tile: TerrainTileHeader): MeshAndTexture { + const {mesh, isNew} = getTerrainRenderMesh( + data, + getTerrainProjectionKey(this.context.viewport), + this.getTiledTerrainBounds(tile, this.props._terrainTileset?.tileSize ?? this.props.tileSize) + ); + + if (isNew && this.props._terrainTileset && tile instanceof SharedTile2DHeader) { + this.props._terrainTileset.notifyTileContentChanged(tile); + } + + return [mesh, data.texture]; + } + + private getTiledTerrainBounds(tile: TileLoadProps | TerrainTileHeader, tileSize: number): Bounds { + const {viewport} = this.context; + let bottomLeft = [0, 0] as [number, number]; + let topRight = [0, 0] as [number, number]; + if (viewport.isGeospatial) { + const bbox = tile.bbox as GeoBoundingBox; + bottomLeft = viewport.projectFlat([bbox.west, bbox.south]); + topRight = viewport.projectFlat([bbox.east, bbox.north]); + } else { + const bbox = tile.bbox as Exclude; + bottomLeft = [bbox.left, bbox.bottom]; + topRight = [bbox.right, bbox.top]; + } + const bounds: Bounds = [bottomLeft[0], bottomLeft[1], topRight[0], topRight[1]]; + return getOverlappedBounds(bounds, tileSize, viewport instanceof GlobeViewport); + } } const isTileSetURL = (url: string): boolean => url.includes('{x}') && (url.includes('{y}') || url.includes('{-y}')); + +function isTiledTerrainData( + elevationData: URLTemplate, + terrainTileset: SharedTileset2D | null +): boolean { + return Boolean( + terrainTileset || + (elevationData && (Array.isArray(elevationData) || isTileSetURL(elevationData))) + ); +} + +function getTerrainContentMesh( + content: MeshAndTexture | TerrainTileData | null +): TerrainMeshData | null { + if (!content) { + return null; + } + return isTerrainTileData(content) ? content.mesh : content[0]; +} + +function getTerrainProjectionKey(viewport: Viewport): string { + if (viewport instanceof GlobeViewport) { + return 'globe'; + } + return viewport.isGeospatial ? 'web-mercator' : 'cartesian'; +} diff --git a/modules/geo-layers/src/terrain-layer/terrain-source.ts b/modules/geo-layers/src/terrain-layer/terrain-source.ts new file mode 100644 index 00000000000..22cafe66510 --- /dev/null +++ b/modules/geo-layers/src/terrain-layer/terrain-source.ts @@ -0,0 +1,391 @@ +// deck.gl +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors + +import type {TextureSource} from '@deck.gl/core'; +import {load} from '@loaders.gl/core'; +import type {Loader, TileSource, TileSourceMetadata} from '@loaders.gl/loader-utils'; +import type {MeshAttribute, MeshAttributes} from '@loaders.gl/schema'; +import {TerrainWorkerLoader} from '@loaders.gl/terrain'; + +import { + getURLFromTemplate, + type Bounds, + type TileLoadProps, + type URLTemplate +} from '../tileset-2d/index'; + +const MIN_TERRAIN_MESH_MAX_ERROR = 1; +const DEFAULT_TERRAIN_MESH_MAX_ERROR = 4; +const NORMALIZED_TERRAIN_BOUNDS: Bounds = [0, 0, 1, 1]; + +/** Object used to decode color channels into elevation meters. */ +export type ElevationDecoder = { + rScaler: number; + gScaler: number; + bScaler: number; + offset: number; +}; + +/** Bounding box stored on loaders.gl terrain meshes. */ +export type TerrainMeshBoundingBox = [min: number[], max: number[]]; + +/** Terrain mesh shape returned by loaders.gl terrain loaders. */ +export type TerrainMesh = { + attributes: MeshAttributes; + indices?: MeshAttribute; + header?: { + vertexCount?: number; + boundingBox?: TerrainMeshBoundingBox; + }; + [key: string]: unknown; +}; + +/** Projection-independent terrain tile payload retained by a shared tileset. */ +export type TerrainTileData = { + /** Terrain mesh positioned in normalized tile-local x/y coordinates. */ + mesh: TerrainMesh | null; + /** Optional surface texture for the tile. */ + texture: TextureSource | null; + /** Derived render meshes keyed by projection family. */ + renderMeshes: Map; + /** Estimated bytes retained by the base payload and derived render meshes. */ + readonly byteLength: number; +}; + +/** Fetch hook used by {@link TerrainSource}. */ +export type TerrainSourceFetch = ( + url: string, + context: { + propName: 'elevationData' | 'texture'; + loaders?: Loader[]; + loadOptions?: any; + signal?: AbortSignal; + } +) => Promise | unknown; + +/** Props for the experimental terrain tile source. */ +export type TerrainSourceProps = { + /** Image URL template that encodes height data. */ + elevationData: URLTemplate; + /** Optional image URL template to use as the surface texture. */ + texture?: URLTemplate; + /** Martini error tolerance in meters. */ + meshMaxError?: number; + /** Object used to decode color channels into elevation meters. */ + elevationDecoder?: ElevationDecoder; + /** Loaders used for elevation requests. Defaults to `TerrainWorkerLoader`. */ + loaders?: Loader[]; + /** loaders.gl options used by elevation and texture requests. */ + loadOptions?: any; + /** Optional fetch hook for applications that need custom request handling. */ + fetch?: TerrainSourceFetch; + /** Minimum source zoom reported through TileSource metadata. */ + minZoom?: number; + /** Maximum source zoom reported through TileSource metadata. */ + maxZoom?: number; + /** Source bounds reported through TileSource metadata. */ + extent?: Bounds | null; +}; + +/** Result of retrieving or materializing one derived terrain render mesh. */ +export type TerrainRenderMeshResult = { + mesh: TerrainMesh | null; + isNew: boolean; +}; + +const DEFAULT_ELEVATION_DECODER: ElevationDecoder = { + rScaler: 1, + gScaler: 0, + bScaler: 0, + offset: 0 +}; + +/** Experimental loaders.gl-compatible tile source for shared TerrainLayer payloads. */ +export class TerrainSource implements TileSource { + readonly props: TerrainSourceProps; + + constructor(props: TerrainSourceProps) { + this.props = props; + } + + /** Returns source zoom and extent metadata understood by `_SharedTileset2D`. */ + getMetadata(): Promise { + const {minZoom, maxZoom, extent} = this.props; + const metadata: TileSourceMetadata = {}; + + if (Number.isFinite(minZoom)) { + metadata.minZoom = minZoom; + } + if (Number.isFinite(maxZoom)) { + metadata.maxZoom = maxZoom; + } + if (extent) { + metadata.boundingBox = [ + [extent[0], extent[1]], + [extent[2], extent[3]] + ]; + } + + return Promise.resolve(metadata); + } + + /** Loads one terrain tile by x/y/z coordinates. */ + getTile({x, y, z}: {x: number; y: number; z: number}): Promise { + return this.getTileData({ + index: {x, y, z}, + id: `${x}-${y}-${z}`, + bbox: {left: 0, top: 0, right: 0, bottom: 0}, + zoom: z + }); + } + + /** Loads one projection-independent terrain tile payload for deck.gl tile layers. */ + async getTileData(tile: TileLoadProps): Promise { + const {elevationData, texture} = this.props; + const dataUrl = getURLFromTemplate(elevationData, tile); + const textureUrl = texture && getURLFromTemplate(texture, tile); + + const terrain = dataUrl ? this._loadTerrain(dataUrl, tile.signal) : Promise.resolve(null); + const surface = textureUrl + ? this._load(textureUrl, { + propName: 'texture', + loaders: [], + loadOptions: withAbortSignal(this.props.loadOptions, tile.signal), + signal: tile.signal + }).catch(() => null) + : Promise.resolve(null); + + const [mesh, surfaceTexture] = await Promise.all([terrain, surface]); + return createTerrainTileData(mesh, surfaceTexture as TextureSource | null); + } + + private _loadTerrain(url: string, signal?: AbortSignal): Promise { + const { + elevationDecoder = DEFAULT_ELEVATION_DECODER, + loaders, + loadOptions, + meshMaxError + } = this.props; + const effectiveMeshMaxError = getEffectiveMeshMaxError( + meshMaxError ?? DEFAULT_TERRAIN_MESH_MAX_ERROR + ); + const terrainLoadOptions = withAbortSignal( + { + ...loadOptions, + terrain: { + skirtHeight: effectiveMeshMaxError * 2, + ...loadOptions?.terrain, + bounds: NORMALIZED_TERRAIN_BOUNDS, + meshMaxError: effectiveMeshMaxError, + elevationDecoder + } + }, + signal + ); + + return this._load(url, { + propName: 'elevationData', + loaders: loaders || [TerrainWorkerLoader], + loadOptions: terrainLoadOptions, + signal + }) as Promise; + } + + private _load(url: string, context: Parameters[1]): Promise { + const {fetch} = this.props; + if (fetch) { + return Promise.resolve(fetch(url, context)); + } + return context.loaders + ? load(url, context.loaders, context.loadOptions) + : load(url, context.loadOptions); + } +} + +/** Creates mutable shared tile data with a live byte length getter. */ +export function createTerrainTileData( + mesh: TerrainMesh | null, + texture: TextureSource | null +): TerrainTileData { + const tileData = { + mesh, + texture, + renderMeshes: new Map(), + get byteLength() { + return getTerrainTileByteLength(tileData); + } + } satisfies TerrainTileData; + + return tileData; +} + +/** Returns whether a tile payload uses the shared terrain payload shape. */ +export function isTerrainTileData(data: unknown): data is TerrainTileData { + return Boolean( + data && + typeof data === 'object' && + 'mesh' in data && + 'texture' in data && + 'renderMeshes' in data && + (data as TerrainTileData).renderMeshes instanceof Map + ); +} + +/** Returns a cached render mesh or materializes one for the requested bounds. */ +export function getTerrainRenderMesh( + tileData: TerrainTileData, + projectionKey: string, + bounds: Bounds +): TerrainRenderMeshResult { + if (tileData.renderMeshes.has(projectionKey)) { + return {mesh: tileData.renderMeshes.get(projectionKey) || null, isNew: false}; + } + + const mesh = materializeTerrainMesh(tileData.mesh, bounds); + tileData.renderMeshes.set(projectionKey, mesh); + return {mesh, isNew: true}; +} + +/** Clamps invalid terrain mesh tolerances to the loaders.gl supported range. */ +export function getEffectiveMeshMaxError(meshMaxError: number): number { + if (!Number.isFinite(meshMaxError) || meshMaxError <= 0) { + return MIN_TERRAIN_MESH_MAX_ERROR; + } + return Math.max(meshMaxError, MIN_TERRAIN_MESH_MAX_ERROR); +} + +function materializeTerrainMesh(mesh: TerrainMesh | null, bounds: Bounds): TerrainMesh | null { + if (!mesh) { + return null; + } + + const positionAttributeName = mesh.attributes.POSITION ? 'POSITION' : 'positions'; + const positionAttribute = mesh.attributes[positionAttributeName]; + if (!positionAttribute) { + return mesh; + } + + const sourcePositions = positionAttribute.value as Float32Array; + const positions = new Float32Array(sourcePositions.length); + const [minX, minY, maxX, maxY] = bounds; + const xScale = maxX - minX; + const yScale = maxY - minY; + + for (let index = 0; index < sourcePositions.length; index += 3) { + positions[index] = sourcePositions[index] * xScale + minX; + positions[index + 1] = sourcePositions[index + 1] * yScale + minY; + positions[index + 2] = sourcePositions[index + 2]; + } + + const attributes = { + ...mesh.attributes, + [positionAttributeName]: {...positionAttribute, value: positions} + }; + + return { + ...mesh, + attributes, + header: { + ...mesh.header, + boundingBox: getTerrainMeshBoundingBox(attributes) + } + }; +} + +function getTerrainMeshBoundingBox(attributes: MeshAttributes): TerrainMeshBoundingBox { + const positions = (attributes.POSITION || attributes.positions)?.value as Float32Array; + let minX = Infinity; + let minY = Infinity; + let minZ = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; + let maxZ = -Infinity; + + for (let index = 0; index < positions.length; index += 3) { + const x = positions[index]; + const y = positions[index + 1]; + const z = positions[index + 2]; + minX = Math.min(minX, x); + minY = Math.min(minY, y); + minZ = Math.min(minZ, z); + maxX = Math.max(maxX, x); + maxY = Math.max(maxY, y); + maxZ = Math.max(maxZ, z); + } + + return [ + [minX, minY, minZ], + [maxX, maxY, maxZ] + ]; +} + +function getTerrainTileByteLength(tileData: TerrainTileData): number { + const seenBuffers = new Set(); + let byteLength = getTerrainMeshByteLength(tileData.mesh, seenBuffers); + for (const mesh of tileData.renderMeshes.values()) { + byteLength += getTerrainMeshByteLength(mesh, seenBuffers); + } + byteLength += getTextureByteLength(tileData.texture, seenBuffers); + return byteLength; +} + +function getTerrainMeshByteLength( + mesh: TerrainMesh | null, + seenBuffers: Set +): number { + if (!mesh) { + return 0; + } + + let byteLength = 0; + for (const attribute of Object.values(mesh.attributes)) { + byteLength += getBufferViewByteLength(attribute?.value, seenBuffers); + } + byteLength += getBufferViewByteLength(mesh.indices?.value, seenBuffers); + return byteLength; +} + +function getTextureByteLength( + texture: TextureSource | null, + seenBuffers: Set +): number { + if (!texture || typeof texture !== 'object') { + return 0; + } + if ('data' in texture) { + return getBufferViewByteLength((texture as ImageData).data, seenBuffers); + } + if ('width' in texture && 'height' in texture) { + const {width, height} = texture; + return Number.isFinite(width) && Number.isFinite(height) ? width * height * 4 : 0; + } + return 0; +} + +function getBufferViewByteLength( + value: ArrayBufferView | undefined, + seenBuffers: Set +): number { + if (!value || seenBuffers.has(value.buffer)) { + return 0; + } + seenBuffers.add(value.buffer); + return value.byteLength; +} + +function withAbortSignal(loadOptions: any, signal?: AbortSignal): any { + if (!signal) { + return loadOptions; + } + return { + ...loadOptions, + core: { + ...loadOptions?.core, + fetch: { + ...loadOptions?.core?.fetch, + signal + } + } + }; +} diff --git a/test/modules/geo-layers/index.ts b/test/modules/geo-layers/index.ts index afdf93f53fc..31124682c9d 100644 --- a/test/modules/geo-layers/index.ts +++ b/test/modules/geo-layers/index.ts @@ -15,6 +15,7 @@ import { _SharedTile2DHeader as SharedTile2DHeader, _SharedTile2DLayer as SharedTile2DLayer, _SharedTileset2D as SharedTileset2D, + _TerrainSource as TerrainSource, TileLayer, TripsLayer, TerrainLayer, @@ -32,6 +33,7 @@ test('Top-level imports', () => { expect(SharedTile2DLayer, 'SharedTile2DLayer symbol imported').toBeTruthy(); expect(SharedTileset2D, 'SharedTileset2D symbol imported').toBeTruthy(); expect(SharedTile2DHeader, 'SharedTile2DHeader symbol imported').toBeTruthy(); + expect(TerrainSource, 'TerrainSource symbol imported').toBeTruthy(); expect(WMSLayer, 'WMSLayer symbol imported').toBeTruthy(); expect(TripsLayer, 'TripsLayer symbol imported').toBeTruthy(); expect(TerrainLayer, 'TerrainLayer symbol imported').toBeTruthy(); diff --git a/test/modules/geo-layers/terrain-layer-loading.spec.ts b/test/modules/geo-layers/terrain-layer-loading.spec.ts index dc307634b0e..456809745e1 100644 --- a/test/modules/geo-layers/terrain-layer-loading.spec.ts +++ b/test/modules/geo-layers/terrain-layer-loading.spec.ts @@ -3,8 +3,15 @@ // Copyright (c) vis.gl contributors import {test, expect} from 'vitest'; -import {COORDINATE_SYSTEM, MapView, _GlobeView as GlobeView} from '@deck.gl/core'; -import {TerrainLayer} from '@deck.gl/geo-layers'; +import {COORDINATE_SYSTEM, MapView, _GlobeView as GlobeView, type Viewport} from '@deck.gl/core'; +import { + TerrainLayer, + _SharedTile2DLayer as SharedTile2DLayer, + _SharedTileset2D as SharedTileset2D, + _TerrainSource as TerrainSource, + sharedTile2DDeckAdapter +} from '@deck.gl/geo-layers'; +import type {_TerrainTileData as TerrainTileData} from '@deck.gl/geo-layers'; import {testInitializeLayerAsync} from '@deck.gl/test-utils/vitest'; import {TruncatedConeGeometry} from '@luma.gl/engine'; @@ -55,6 +62,24 @@ function createTestTexture() { return new ImageData(1, 1); } +function createNormalizedTestTerrainMesh() { + return { + header: { + vertexCount: 3, + boundingBox: [ + [0, 0, 0], + [1, 1, 2] + ] + }, + mode: 4, + indices: {value: new Uint32Array([0, 1, 2]), size: 1}, + attributes: { + POSITION: {value: new Float32Array([0, 0, 0, 1, 0, 1, 0, 1, 2]), size: 3}, + TEXCOORD_0: {value: new Float32Array([0, 0, 1, 0, 0, 1]), size: 2} + } + }; +} + test('TerrainLayer#isLoaded waits for elevation and texture in single-terrain mode', async () => { const elevation = createDeferred(); const texture = createDeferred(); @@ -154,3 +179,90 @@ test('TerrainLayer renders tiled Martini meshes in lng/lat coordinates on GlobeV ); handle?.finalize(); }); + +test('TerrainLayer shares terrain source payloads and projection meshes through _SharedTileset2D', async () => { + const fetchCalls: string[] = []; + let sourceTerrainBounds: number[] | undefined; + const source = new TerrainSource({ + elevationData: 'https://example.com/elevation/{z}/{x}/{y}.png', + fetch: (_url, {loadOptions, propName}) => { + fetchCalls.push(propName); + sourceTerrainBounds = loadOptions?.terrain?.bounds; + return Promise.resolve(createNormalizedTestTerrainMesh()); + } + }); + const terrainTileset = SharedTileset2D.fromTileSource(source, { + adapter: sharedTile2DDeckAdapter, + minZoom: 0, + maxZoom: 0 + }); + const handles: Array<{finalize: () => void} | undefined> = []; + + try { + const mapLayer = new TerrainLayer({ + id: 'shared-terrain-map-a', + _terrainTileset: terrainTileset, + color: [255, 0, 0] + }); + handles.push( + await testInitializeLayerAsync({ + layer: mapLayer, + viewport: TEST_VIEWPORT, + finalize: false + }) + ); + + const mapTileLayer = mapLayer.getSubLayers()[0]; + expect(mapTileLayer).toBeInstanceOf(SharedTile2DLayer); + const mapMeshLayer = mapTileLayer.getSubLayers()[0]; + const tileData = terrainTileset.tiles[0].content!; + const mapMesh = mapMeshLayer.props.mesh; + + expect(fetchCalls).toEqual(['elevationData']); + expect(sourceTerrainBounds).toEqual([0, 0, 1, 1]); + expect(tileData.renderMeshes.size).toBe(1); + expect(terrainTileset.cacheByteSize).toBe(tileData.byteLength); + + const secondMapLayer = new TerrainLayer({ + id: 'shared-terrain-map-b', + _terrainTileset: terrainTileset, + color: [0, 255, 0] + }); + handles.push( + await testInitializeLayerAsync({ + layer: secondMapLayer, + viewport: TEST_VIEWPORT, + finalize: false + }) + ); + + const secondMapMeshLayer = secondMapLayer.getSubLayers()[0].getSubLayers()[0]; + expect(fetchCalls).toEqual(['elevationData']); + expect(secondMapMeshLayer.props.mesh).toBe(mapMesh); + expect(secondMapMeshLayer.props.getColor).toEqual([0, 255, 0]); + + const globeLayer = new TerrainLayer({ + id: 'shared-terrain-globe', + _terrainTileset: terrainTileset + }); + handles.push( + await testInitializeLayerAsync({ + layer: globeLayer, + viewport: TEST_GLOBE_VIEWPORT, + finalize: false + }) + ); + + const globeMeshLayer = globeLayer.getSubLayers()[0].getSubLayers()[0]; + expect(fetchCalls).toEqual(['elevationData']); + expect(globeMeshLayer.props.mesh).not.toBe(mapMesh); + expect(globeMeshLayer.props.coordinateSystem).toBe(COORDINATE_SYSTEM.LNGLAT); + expect(tileData.renderMeshes.size).toBe(2); + expect(terrainTileset.cacheByteSize).toBe(tileData.byteLength); + } finally { + for (const handle of handles) { + handle?.finalize(); + } + terrainTileset.finalize(); + } +});