diff --git a/modules/geo-layers/src/terrain-layer/terrain-layer.ts b/modules/geo-layers/src/terrain-layer/terrain-layer.ts index dfb78cc4411..478137bd540 100644 --- a/modules/geo-layers/src/terrain-layer/terrain-layer.ts +++ b/modules/geo-layers/src/terrain-layer/terrain-layer.ts @@ -35,6 +35,7 @@ const MAX_LATITUDE = 90; const MAX_LONGITUDE = 180; const DEGREES_TO_RADIANS = Math.PI / 180; const RADIANS_TO_DEGREES = 180 / Math.PI; +const MAX_STITCHED_TEXTURE_TILE_SCALE = 4; const defaultProps: DefaultProps = { ...TileLayer.defaultProps, @@ -63,6 +64,8 @@ const defaultProps: DefaultProps = { // Same as SimpleMeshLayer wireframe wireframe: false, material: true, + meshMaxZoom: null, + textureMaxZoom: null, loaders: [TerrainWorkerLoader] }; @@ -114,6 +117,7 @@ type TerrainLoadProps = { }; type MeshAndTexture = [Mesh | null, TextureSource | null]; +type DrawableTextureSource = HTMLImageElement | HTMLCanvasElement | HTMLVideoElement | ImageBitmap; type MeshBoundingBox = [min: number[], max: number[]]; type MeshWithBoundingBox = Mesh & { header?: { @@ -134,6 +138,18 @@ type _TerrainLayerProps = { /** Image url to use as texture. **/ texture?: URLTemplate; + /** + * Maximum zoom level of the elevation data used to create the terrain mesh. + * If unset, `maxZoom` is used. + */ + meshMaxZoom?: number | null; + + /** + * Maximum zoom level of the imagery used as the terrain texture. + * If unset, `maxZoom` is used. + */ + textureMaxZoom?: number | null; + /** Martini error tolerance in meters, smaller number -> more detailed mesh. **/ meshMaxError?: number; @@ -227,10 +243,9 @@ export default class TerrainLayer extends Composite } getTiledTerrainData(tile: TileLoadProps): Promise { - const {elevationData, fetch, texture, elevationDecoder, meshMaxError} = this.props; + const {elevationData, 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]; @@ -261,14 +276,80 @@ export default class TerrainLayer extends Composite })?.then(mesh => viewport.resolution && mesh ? remapMeshToWebMercatorTile(mesh, overlappedBounds) : mesh ) ?? Promise.resolve(null); - const surface = textureUrl - ? // If surface image fails to load, the tile should still be displayed - fetch(textureUrl, {propName: 'texture', layer: this, loaders: [], signal}).catch(_ => null) - : Promise.resolve(null); + const surface = this.loadTexture({tile, texture, signal}); return Promise.all([terrain, surface]); } + async loadTexture({ + tile, + texture, + signal + }: { + tile: TileLoadProps; + texture?: URLTemplate; + signal?: AbortSignal; + }): Promise { + if (!texture) { + return null; + } + + const textureZoom = this.getTextureZoom(tile.index.z); + if (textureZoom <= tile.index.z) { + return this.fetchTextureTile(texture, tile, signal); + } + + const childZoomLevels = textureZoom - tile.index.z; + const scale = 2 ** childZoomLevels; + const textureTiles: Promise[] = []; + + for (let y = 0; y < scale; y++) { + for (let x = 0; x < scale; x++) { + const textureTile = { + ...tile, + index: { + x: tile.index.x * scale + x, + y: tile.index.y * scale + y, + z: textureZoom + }, + id: `${tile.index.x * scale + x}-${tile.index.y * scale + y}-${textureZoom}`, + zoom: textureZoom + }; + textureTiles.push(this.fetchTextureTile(texture, textureTile, signal)); + } + } + + const textures = await Promise.all(textureTiles); + return stitchTextureTiles(textures, scale); + } + + private getTextureZoom(meshZoom: number): number { + const {textureMaxZoom, maxZoom} = this.props; + const textureZoom = textureMaxZoom ?? maxZoom; + if (!Number.isFinite(textureZoom)) { + return meshZoom; + } + + const viewportZoom = Math.floor(this.context.viewport.zoom); + const highestStitchableZoom = meshZoom + Math.log2(MAX_STITCHED_TEXTURE_TILE_SCALE); + return Math.max(meshZoom, Math.min(textureZoom as number, viewportZoom, highestStitchableZoom)); + } + + private fetchTextureTile( + texture: URLTemplate, + tile: TileLoadProps, + signal?: AbortSignal + ): Promise { + const textureUrl = getURLFromTemplate(texture, tile); + if (!textureUrl) { + return Promise.resolve(null); + } + // If surface image fails to load, the terrain tile should still be displayed. + return this.props + .fetch(textureUrl, {propName: 'texture', layer: this, loaders: [], signal}) + .catch(_ => null); + } + renderSubLayers( props: TileLayerProps & { id: string; @@ -351,6 +432,7 @@ export default class TerrainLayer extends Composite tileSize, maxZoom, minZoom, + meshMaxZoom, extent, maxRequests, onTileLoad, @@ -373,6 +455,8 @@ export default class TerrainLayer extends Composite getTileData: { elevationData: urlTemplateToUpdateTrigger(elevationData), texture: urlTemplateToUpdateTrigger(texture), + textureMaxZoom: this.props.textureMaxZoom, + maxZoom: this.props.maxZoom, meshMaxError, elevationDecoder, projectionMode: this.context.viewport.projectionMode @@ -381,7 +465,7 @@ export default class TerrainLayer extends Composite onViewportLoad: this.onViewportLoad.bind(this), zRange: this.state.zRange || null, tileSize, - maxZoom, + maxZoom: meshMaxZoom ?? maxZoom, minZoom, extent, maxRequests, @@ -421,6 +505,67 @@ export default class TerrainLayer extends Composite const isTileSetURL = (url: string): boolean => url.includes('{x}') && (url.includes('{y}') || url.includes('{-y}')); +function stitchTextureTiles( + textures: (TextureSource | null)[], + scale: number +): TextureSource | null { + const firstTexture = textures.find(isDrawableTextureSource); + if (!firstTexture || typeof document === 'undefined') { + return firstTexture || null; + } + + const width = getTextureWidth(firstTexture); + const height = getTextureHeight(firstTexture); + if (!width || !height) { + return firstTexture; + } + + const canvas = document.createElement('canvas'); + canvas.width = width * scale; + canvas.height = height * scale; + const context = canvas.getContext('2d'); + if (!context) { + return firstTexture; + } + + for (let i = 0; i < textures.length; i++) { + const texture = textures[i]; + if (isDrawableTextureSource(texture)) { + const x = i % scale; + const y = Math.floor(i / scale); + context.drawImage(texture, x * width, y * height, width, height); + } + } + return canvas; +} + +function isDrawableTextureSource(texture: TextureSource | null): texture is DrawableTextureSource { + if (!texture || typeof texture !== 'object') { + return false; + } + if (typeof HTMLImageElement !== 'undefined' && texture instanceof HTMLImageElement) { + return true; + } + if (typeof HTMLCanvasElement !== 'undefined' && texture instanceof HTMLCanvasElement) { + return true; + } + if (typeof HTMLVideoElement !== 'undefined' && texture instanceof HTMLVideoElement) { + return true; + } + if (typeof ImageBitmap !== 'undefined' && texture instanceof ImageBitmap) { + return true; + } + return false; +} + +function getTextureWidth(texture: DrawableTextureSource): number { + return (texture as HTMLImageElement).naturalWidth || texture.width || 0; +} + +function getTextureHeight(texture: DrawableTextureSource): number { + return (texture as HTMLImageElement).naturalHeight || texture.height || 0; +} + function remapMeshToWebMercatorTile(mesh: Mesh, bounds: Bounds): Mesh { const positionAttribute = mesh.attributes.POSITION; const texCoordAttribute = mesh.attributes.TEXCOORD_0; diff --git a/test/modules/geo-layers/terrain-layer.spec.ts b/test/modules/geo-layers/terrain-layer.spec.ts index afc250413c7..044532c8ae4 100644 --- a/test/modules/geo-layers/terrain-layer.spec.ts +++ b/test/modules/geo-layers/terrain-layer.spec.ts @@ -5,7 +5,7 @@ import {test, expect} from 'vitest'; import {generateLayerTests, testLayerAsync} from '@deck.gl/test-utils/vitest'; import {TerrainLayer, TileLayer} from '@deck.gl/geo-layers'; -import {_GlobeView as GlobeView} from '@deck.gl/core'; +import {WebMercatorViewport, _GlobeView as GlobeView} from '@deck.gl/core'; import {SimpleMeshLayer} from '@deck.gl/mesh-layers'; import {TerrainLoader} from '@loaders.gl/terrain'; @@ -49,6 +49,85 @@ test('TerrainLayer', async () => { }); }); +test('TerrainLayer#separate elevation and texture zooms', async () => { + const urls: string[] = []; + const layer = new TerrainLayer({ + id: 'terrain', + elevationData: 'terrain/{z}/{x}/{y}.png', + texture: 'texture/{z}/{x}/{y}.png', + maxZoom: 12, + meshMaxZoom: 11, + fetch: url => { + urls.push(url); + return Promise.resolve(null); + } + }); + layer.context = { + viewport: new WebMercatorViewport({ + width: 512, + height: 512, + longitude: 0, + latitude: 0, + zoom: 12 + }) + }; + + layer.state = {isTiled: true}; + const tileLayer = layer.renderLayers() as TileLayer; + expect(tileLayer.props.maxZoom, 'TileLayer maxZoom uses meshMaxZoom').toBe(11); + + await layer.getTiledTerrainData({ + index: {x: 1, y: 2, z: 11}, + id: '1-2-11', + bbox: {west: 0, south: 0, east: 1, north: 1}, + zoom: 11 + }); + + expect(urls, 'loads elevation at mesh zoom and texture at maxZoom by default').toEqual([ + 'terrain/11/1/2.png', + 'texture/12/2/4.png', + 'texture/12/3/4.png', + 'texture/12/2/5.png', + 'texture/12/3/5.png' + ]); +}); + +test('TerrainLayer#limits stitched texture tile fanout', async () => { + const urls: string[] = []; + const layer = new TerrainLayer({ + id: 'terrain', + elevationData: 'terrain/{z}/{x}/{y}.png', + texture: 'texture/{z}/{x}/{y}.png', + meshMaxZoom: 13, + textureMaxZoom: 21, + fetch: url => { + urls.push(url); + return Promise.resolve(null); + } + }); + layer.context = { + viewport: new WebMercatorViewport({ + width: 512, + height: 512, + longitude: 0, + latitude: 0, + zoom: 21 + }) + }; + layer.state = {isTiled: true}; + + await layer.getTiledTerrainData({ + index: {x: 1, y: 2, z: 13}, + id: '1-2-13', + bbox: {west: 0, south: 0, east: 1, north: 1}, + zoom: 13 + }); + + expect(urls[0], 'loads elevation at mesh zoom').toBe('terrain/13/1/2.png'); + expect(urls.slice(1), 'limits texture stitching to 4x4 children').toHaveLength(16); + expect(urls[1], 'texture child zoom is capped relative to mesh zoom').toBe('texture/15/4/8.png'); +}); + test('TerrainLayer#globe remaps WebMercator tile rows to lng/lat mesh positions', async () => { const sourceMesh = { attributes: {