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
159 changes: 152 additions & 7 deletions modules/geo-layers/src/terrain-layer/terrain-layer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<TerrainLayerProps> = {
...TileLayer.defaultProps,
Expand Down Expand Up @@ -63,6 +64,8 @@ const defaultProps: DefaultProps<TerrainLayerProps> = {
// Same as SimpleMeshLayer wireframe
wireframe: false,
material: true,
meshMaxZoom: null,
textureMaxZoom: null,

loaders: [TerrainWorkerLoader]
};
Expand Down Expand Up @@ -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?: {
Expand All @@ -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;

Expand Down Expand Up @@ -227,10 +243,9 @@ export default class TerrainLayer<ExtraPropsT extends {} = {}> extends Composite
}

getTiledTerrainData(tile: TileLoadProps): Promise<MeshAndTexture> {
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];
Expand Down Expand Up @@ -261,14 +276,80 @@ export default class TerrainLayer<ExtraPropsT extends {} = {}> 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<TextureSource | null> {
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<TextureSource | null>[] = [];

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<TextureSource | null> {
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<MeshAndTexture> & {
id: string;
Expand Down Expand Up @@ -351,6 +432,7 @@ export default class TerrainLayer<ExtraPropsT extends {} = {}> extends Composite
tileSize,
maxZoom,
minZoom,
meshMaxZoom,
extent,
maxRequests,
onTileLoad,
Expand All @@ -373,6 +455,8 @@ export default class TerrainLayer<ExtraPropsT extends {} = {}> extends Composite
getTileData: {
elevationData: urlTemplateToUpdateTrigger(elevationData),
texture: urlTemplateToUpdateTrigger(texture),
textureMaxZoom: this.props.textureMaxZoom,
maxZoom: this.props.maxZoom,
meshMaxError,
elevationDecoder,
projectionMode: this.context.viewport.projectionMode
Expand All @@ -381,7 +465,7 @@ export default class TerrainLayer<ExtraPropsT extends {} = {}> extends Composite
onViewportLoad: this.onViewportLoad.bind(this),
zRange: this.state.zRange || null,
tileSize,
maxZoom,
maxZoom: meshMaxZoom ?? maxZoom,
minZoom,
extent,
maxRequests,
Expand Down Expand Up @@ -421,6 +505,67 @@ export default class TerrainLayer<ExtraPropsT extends {} = {}> 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;
Expand Down
81 changes: 80 additions & 1 deletion test/modules/geo-layers/terrain-layer.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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: {
Expand Down
Loading