Skip to content
Open
Show file tree
Hide file tree
Changes from 3 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
Comment thread
charlieforward9 marked this conversation as resolved.
Outdated
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
46 changes: 36 additions & 10 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
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we add mercator common space as a supported coordinateSystem?
Something like first class support for EPSG: 3857?
Or that is not helpful?

// 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,19 +116,25 @@ 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({
// It's not important whether the geometry is visible in this viewport, because
// vertices will not use the standard project_to_clipspace in the DRAW_TO_HEIGHT_MAP shader
// 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
],
bounds: [centerMerc[0] - 1, centerMerc[1] - 1, centerMerc[0] + 1, centerMerc[1] + 1],
zoom: viewport.zoom,
width: Math.min(pixelWidth, MAP_MAX_SIZE),
height: Math.min(pixelHeight, 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));
Comment thread
charlieforward9 marked this conversation as resolved.
Outdated
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);
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OK, so if I understand correctly you're basically forcing project_position() to internally take the following branch:

if (project.projectionMode == PROJECTION_MODE_WEB_MERCATOR) {

A natural question that comes to mind is that if we are going to hardcode a projection, then why Mercator? Sure it works well for MapView but it doesn't fit the other views.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perhaps related, I have been feeling that if we extend layers to take a "deformation" when generating geometry, such as a deformation UV grid for BitmapLayer than we could generalize that to support any projection.

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
? [
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I worry this will introduce precision issues

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
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we try to use a cartesian space? Seems like a closer match to the spherical coordinates

// 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