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
2 changes: 2 additions & 0 deletions docs/api-reference/extensions/terrain-extension.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ To use this extension, first define a terrain source with the prop `operation: '

For each layer that should be fitted to the terrain surface, add the `TerrainExtension` to its `extensions` prop.

The extension works on both `MapView` and `GlobeView`. Terrain cover and height-map FBOs are computed in absolute Mercator common space so the same draw target can be sampled from either projection without re-rendering when the user toggles between them. When pairing with a `TerrainLayer` source on `GlobeView`, set the source's `tesselator: 'grid'` so its mesh is valid on both projections.

<div style={{position:'relative',height:450}}></div>
<div style={{position:'absolute',transform:'translateY(-450px)',paddingLeft:'inherit',paddingRight:'inherit',left:0,right:0}}>
<iframe height="450" style={{width: '100%'}} scrolling="no" title="deck.gl TerrainExtension" src="https://codepen.io/vis-gl/embed/VwGLLeR?height=450&theme-id=light&default-tab=result" frameborder="no" loading="lazy" allowtransparency="true" allowfullscreen="true">
Expand Down
49 changes: 38 additions & 11 deletions examples/website/terrain-extension/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import React, {useState, useEffect, useMemo} from 'react';
import {createRoot} from 'react-dom/client';
import {DeckGL} from '@deck.gl/react';
import {_GlobeView as GlobeView, MapView} from '@deck.gl/core';
import {TerrainLayer} from '@deck.gl/geo-layers';
import {GeoJsonLayer, IconLayer, TextLayer} from '@deck.gl/layers';
import {_TerrainExtension as TerrainExtension} from '@deck.gl/extensions';
Expand All @@ -25,7 +26,7 @@ const INITIAL_VIEW_STATE: MapViewState = {
longitude: -0.6194,
zoom: 10,
pitch: 55,
maxZoom: 13.5,
maxZoom: 23.5,
bearing: 0,
maxPitch: 89
};
Expand Down Expand Up @@ -84,6 +85,8 @@ export default function App({
initialViewState?: MapViewState;
}) {
const [routes, setRoutes] = useState<FeatureCollection<LineString, RouteProperties>>();
const [useGlobe, setUseGlobe] = useState(false);
const [viewState, setViewState] = useState<MapViewState>(initialViewState);

useEffect(() => {
// eslint-disable-next-line @typescript-eslint/no-floating-promises
Expand All @@ -108,11 +111,11 @@ export default function App({
id: 'terrain',
minZoom: 0,
strategy: 'no-overlap',
// Grid tesselator so the same mesh renders on MapView and GlobeView.
tesselator: 'grid',
elevationDecoder: ELEVATION_DECODER,
elevationData: TERRAIN_IMAGE,
texture: SURFACE_IMAGE,
wireframe: false,
color: [255, 255, 255],
operation: 'terrain+draw'
}),
new GeoJsonLayer<RouteProperties>({
Expand All @@ -132,7 +135,8 @@ export default function App({
getPosition: d => d.coordinates,
getIcon: d => (d.type === 'start' ? 'green' : 'checker'),
getSize: 32,
extensions: [new TerrainExtension()]
extensions: [new TerrainExtension()],
terrainDrawMode: 'offset'
}),
new TextLayer<Stage>({
id: 'stage-label',
Expand All @@ -151,14 +155,37 @@ export default function App({
})
];

const view = useGlobe ? new GlobeView() : new MapView();

return (
<DeckGL
initialViewState={initialViewState}
controller={true}
layers={layers}
pickingRadius={5}
getTooltip={getTooltip}
/>
<>
<DeckGL
views={view}
viewState={viewState}
onViewStateChange={e => setViewState(e.viewState as MapViewState)}
controller={true}
layers={layers}
pickingRadius={5}
getTooltip={getTooltip}
/>
<button
onClick={() => setUseGlobe(v => !v)}
style={{
position: 'absolute',
top: 12,
right: 12,
zIndex: 10,
padding: '6px 10px',
font: '12px ui-monospace, monospace',
background: 'rgba(0,0,0,0.65)',
color: '#fff',
border: '1px solid #888',
cursor: 'pointer'
}}
>
{useGlobe ? 'MapView' : 'GlobeView'}
</button>
</>
);
}

Expand Down
47 changes: 39 additions & 8 deletions modules/extensions/src/terrain/height-map-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,14 @@
// Copyright (c) vis.gl contributors

import {Device, Framebuffer} from '@luma.gl/core';
import {joinLayerBounds, getRenderBounds, makeViewport, Bounds} from '../utils/projection-utils';
import {
joinLayerBounds,
getRenderBounds,
makeViewport,
getMercatorReferenceViewport,
lngLatToMercatorCommon,
Bounds
} from '../utils/projection-utils';
import {createRenderTarget} from './utils';

import type {Viewport, Layer} from '@deck.gl/core';
Expand Down Expand Up @@ -72,18 +79,31 @@ export class HeightMapBuilder {
);

if (layersChanged) {
// Recalculate cached bounds
// Recalculate cached bounds.
// Use a Mercator reference viewport so layer bounds live in ABSOLUTE
// Mercator common space — same rationale as terrain-cover.ts. On
// GlobeView, `viewport.projectPosition` would return 3D sphere cartesian
// coords that can't be compared against screen-space render bounds.
this.layers = layers;
this.layersBounds = layers.map(layer => layer.getBounds());
this.layersBoundsCommon = joinLayerBounds(layers, viewport);
this.layersBoundsCommon = joinLayerBounds(layers, getMercatorReferenceViewport(viewport));
}

const viewportChanged = !this.lastViewport || !viewport.equals(this.lastViewport);

if (!this.layersBoundsCommon) {
this.renderViewport = null;
} else if (layersChanged || viewportChanged) {
const bounds = getRenderBounds(this.layersBoundsCommon, viewport);
// getRenderBounds intersects layer bounds with viewport bounds. On globe,
// viewport bounds project to sphere cartesian and won't intersect the
// Mercator layer bounds meaningfully — use the full layer bounds instead.
const isGlobe = Boolean(
(viewport as {resolution?: number}).resolution &&
(viewport as {resolution?: number}).resolution! > 0
);
const bounds = isGlobe
? this.layersBoundsCommon
: getRenderBounds(this.layersBoundsCommon, viewport);
if (bounds[2] <= bounds[0] || bounds[3] <= bounds[1]) {
this.renderViewport = null;
return false;
Expand All @@ -96,6 +116,17 @@ export class HeightMapBuilder {
const pixelWidth = (bounds[2] - bounds[0]) * scale;
const pixelHeight = (bounds[3] - bounds[1]) * scale;

// Center for the render viewport must be expressed in Mercator common so
// makeViewport (which unprojects through a WebMercatorViewport for
// geospatial inputs) gets a valid lng/lat back. `viewport.center` on
// GlobeView is 3D sphere cartesian and would unproject bogusly.
const centerMerc = viewport.isGeospatial
? lngLatToMercatorCommon([
(viewport as {longitude?: number}).longitude ?? 0,
(viewport as {latitude?: number}).latitude ?? 0
])
: [viewport.center[0], viewport.center[1]];

this.renderViewport =
pixelWidth > 0 || pixelHeight > 0
? makeViewport({
Expand All @@ -104,10 +135,10 @@ export class HeightMapBuilder {
// However the viewport must have the same center and zoom as the screen viewport
// So that projection uniforms used for calculating z are the same
bounds: [
viewport.center[0] - 1,
viewport.center[1] - 1,
viewport.center[0] + 1,
viewport.center[1] + 1
centerMerc[0] - 1,
centerMerc[1] - 1,
centerMerc[0] + 1,
centerMerc[1] + 1
],
zoom: viewport.zoom,
width: Math.min(pixelWidth, MAP_MAX_SIZE),
Expand Down
62 changes: 42 additions & 20 deletions modules/extensions/src/terrain/shader-module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
/* eslint-disable camelcase */

import type {ShaderModule} from '@luma.gl/shadertools';
import {project, ProjectProps, ProjectUniforms} from '@deck.gl/core';
import {project, ProjectProps} from '@deck.gl/core';

import type {Texture} from '@luma.gl/core';
import type {Bounds} from '../utils/projection-utils';
Expand Down Expand Up @@ -67,10 +67,16 @@ uniform sampler2D terrain_map;
export const terrainModule = {
name: 'terrain',
dependencies: [project],
// eslint-disable-next-line prefer-template
vs: uniformBlock + /* glsl */ 'out vec3 commonPos;',
// eslint-disable-next-line prefer-template
fs: uniformBlock + /* glsl */ 'in vec3 commonPos;',
vs: `${uniformBlock}
out vec3 commonPos;
// Fragment position in ABSOLUTE Mercator common space, regardless of the live
// viewport's projection mode. Computed here (not in FS) because the project
// module's helpers (project_mercator_, PROJECTION_MODE_*) are only declared
// in the vertex shader. Mercator is log-nonlinear in lat, but terrain meshes
// are fine enough that varying-interpolation error is negligible.
out vec2 terrainMercPos;
`,
fs: `${uniformBlock}in vec3 commonPos;\nin vec2 terrainMercPos;`,
inject: {
'vs:#main-start': /* glsl */ `
if (terrain.mode == TERRAIN_MODE_SKIP) {
Expand All @@ -80,16 +86,33 @@ if (terrain.mode == TERRAIN_MODE_SKIP) {
`,
'vs:DECKGL_FILTER_GL_POSITION': /* glsl */ `
commonPos = geometry.position.xyz;
if (project.projectionMode == PROJECTION_MODE_GLOBE) {
// Unproject globe cartesian (see project_globe_) back to lng/lat, then
// forward-project through project_mercator_. Elevation scales the sphere
// radius uniformly, so angular components recover cleanly.
vec3 cp = commonPos;
float D = length(cp);
float lat = degrees(asin(clamp(cp.z / D, -1.0, 1.0)));
float lng = degrees(atan(cp.x, -cp.y));
terrainMercPos = project_mercator_(vec2(lng, lat));
} else {
// Web Mercator modes: commonPos.xy is mercator-common minus commonOrigin.
terrainMercPos = commonPos.xy + project.commonOrigin.xy;
}
if (terrain.mode == TERRAIN_MODE_WRITE_HEIGHT_MAP) {
vec2 texCoords = (commonPos.xy - terrain.bounds.xy) / terrain.bounds.zw;
// Height-map bounds are in ABSOLUTE Mercator common (so the FBO is reusable
// across MapView / GlobeView). Use the mercator xy computed above.
vec2 texCoords = (terrainMercPos - terrain.bounds.xy) / terrain.bounds.zw;
position = vec4(texCoords * 2.0 - 1.0, 0.0, 1.0);
commonPos.z += project.commonOrigin.z;
}
if (terrain.mode == TERRAIN_MODE_USE_HEIGHT_MAP) {
vec3 anchor = geometry.worldPosition;
anchor.z = 0.0;
vec3 anchorCommon = project_position(anchor);
vec2 texCoords = (anchorCommon.xy - terrain.bounds.xy) / terrain.bounds.zw;
// worldPosition.xy is lng/lat for geospatial instance-position layers
// (IconLayer, TextLayer, etc.) — project directly through mercator so the
// UV matches the absolute-mercator bounds used by WRITE_HEIGHT_MAP, on
// both MapView and GlobeView.
vec2 anchorMerc = project_mercator_(geometry.worldPosition.xy);
vec2 texCoords = (anchorMerc - terrain.bounds.xy) / terrain.bounds.zw;
if (texCoords.x >= 0.0 && texCoords.y >= 0.0 && texCoords.x <= 1.0 && texCoords.y <= 1.0) {
float terrainZ = texture(terrain_map, texCoords).r;
geometry.position.z += terrainZ;
Expand All @@ -105,7 +128,7 @@ if (terrain.mode == TERRAIN_MODE_WRITE_HEIGHT_MAP) {
`,
'fs:DECKGL_FILTER_COLOR': /* glsl */ `
if ((terrain.mode == TERRAIN_MODE_USE_COVER) || (terrain.mode == TERRAIN_MODE_USE_COVER_ONLY)) {
vec2 texCoords = (commonPos.xy - terrain.bounds.xy) / terrain.bounds.zw;
vec2 texCoords = (terrainMercPos - terrain.bounds.xy) / terrain.bounds.zw;
vec4 pixel = texture(terrain_map, texCoords);
if (terrain.mode == TERRAIN_MODE_USE_COVER_ONLY) {
color = pixel;
Expand All @@ -129,8 +152,9 @@ if ((terrain.mode == TERRAIN_MODE_USE_COVER) || (terrain.mode == TERRAIN_MODE_US
useTerrainHeightMap,
terrainSkipRender
} = opts;
const projectUniforms = project.getUniforms(opts.project) as ProjectUniforms;
const {commonOrigin} = projectUniforms;
// All modes now pack bounds in absolute Mercator common; shader samples
// against absolute xy computed per-fragment, so we no longer need the
// project module's commonOrigin here.

let mode: number = terrainSkipRender ? TERRAIN_MODE.SKIP : TERRAIN_MODE.NONE;
// height map if case USE_HEIGHT_MAP, terrain cover if USE_COVER, otherwise empty
Expand Down Expand Up @@ -165,18 +189,16 @@ if ((terrain.mode == TERRAIN_MODE_USE_COVER) || (terrain.mode == TERRAIN_MODE_US
}
}

// All bounds live in ABSOLUTE Mercator common space so the FBOs can be
// shared across MapView and GlobeView (see terrain-cover.ts and
// height-map-builder.ts). No commonOrigin subtract.
/* eslint-disable camelcase */
return {
mode,
terrain_map: sampler,
// Convert bounds to the common space, as [minX, minY, width, height]
// Pack bounds as [minX, minY, width, height]
bounds: bounds
? [
bounds[0] - commonOrigin[0],
bounds[1] - commonOrigin[1],
bounds[2] - bounds[0],
bounds[3] - bounds[1]
]
? [bounds[0], bounds[1], bounds[2] - bounds[0], bounds[3] - bounds[1]]
: [0, 0, 0, 0]
};
}
Expand Down
37 changes: 32 additions & 5 deletions modules/extensions/src/terrain/terrain-cover.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,14 @@ import {Framebuffer} from '@luma.gl/core';
import type {Layer, Viewport} from '@deck.gl/core';

import {createRenderTarget} from './utils';
import {joinLayerBounds, makeViewport, getRenderBounds, Bounds} from '../utils/projection-utils';
import {
getMercatorReferenceViewport,
joinLayerBounds,
lngLatToMercatorCommon,
makeViewport,
getRenderBounds,
Bounds
} from '../utils/projection-utils';

type TileHeader = {
boundingBox: [min: number[], max: number[]];
Expand Down Expand Up @@ -113,13 +120,18 @@ export class TerrainCover {
const targetLayer = this.targetLayer;
let shouldRedraw = false;

// Bounds are computed in ABSOLUTE Mercator common space — NOT the live
// viewport's common space. The terrain cover FBO is rendered via a
// WebMercatorViewport regardless of the screen viewport, so UVs must also
// live in Mercator. This is what lets the same cover texture be sampled
// from MapView and GlobeView.
if (this.tile && 'boundingBox' in this.tile) {
if (!this.targetBounds) {
shouldRedraw = true;
this.targetBounds = this.tile.boundingBox;

const bottomLeftCommon = viewport.projectPosition(this.targetBounds[0]);
const topRightCommon = viewport.projectPosition(this.targetBounds[1]);
const bottomLeftCommon = lngLatToMercatorCommon(this.targetBounds[0]);
const topRightCommon = lngLatToMercatorCommon(this.targetBounds[1]);
this.targetBoundsCommon = [
bottomLeftCommon[0],
bottomLeftCommon[1],
Expand All @@ -131,7 +143,14 @@ export class TerrainCover {
// console.log('bounds changed', this.bounds, '>>', newBounds);
shouldRedraw = true;
this.targetBounds = targetLayer.getBounds();
this.targetBoundsCommon = joinLayerBounds([targetLayer], viewport);
// Non-tile terrain layer: project layer bounds through the Mercator
// reference so the cover is projection-invariant. joinLayerBounds uses
// layer.projectPosition() internally, which honors the layer's
// coordinateSystem (LNGLAT / CARTESIAN / METER_OFFSETS).
this.targetBoundsCommon = joinLayerBounds(
[targetLayer],
getMercatorReferenceViewport(viewport)
);
}

if (!this.targetBoundsCommon) {
Expand All @@ -146,7 +165,15 @@ export class TerrainCover {
} else {
const oldZoom = this.renderViewport?.zoom;
shouldRedraw = shouldRedraw || newZoom !== oldZoom;
const newBounds = getRenderBounds(this.targetBoundsCommon, viewport);
// getRenderBounds intersects layer bounds (Mercator) with viewport bounds
// derived via viewport.projectPosition. On GlobeView that yields sphere
// cartesian coords, which would corrupt the intersection. Fall back to
// full layer bounds on non-Mercator geospatial viewports — resolution
// is reduced but output stays correct.
const isGlobe = Boolean(viewport.resolution && viewport.resolution > 0);
const newBounds = isGlobe
? this.targetBoundsCommon
: getRenderBounds(this.targetBoundsCommon, viewport);
const oldBounds = this.bounds;
shouldRedraw = shouldRedraw || !oldBounds || newBounds.some((x, i) => x !== oldBounds[i]);
this.bounds = newBounds;
Expand Down
Loading
Loading