Skip to content
Merged
Show file tree
Hide file tree
Changes from 21 commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
0a45963
feat(extensions): TerrainExtension GlobeView support
charlieforward9 Apr 19, 2026
59cd841
fix(extensions): lint — template literals + remove unnecessary assertion
charlieforward9 Apr 19, 2026
34d4813
fix(extensions): prettier formatting
charlieforward9 Apr 19, 2026
b3d4ecf
fix(extensions): address terrain globe review
charlieforward9 Apr 23, 2026
92f63bb
Tidy terrain_globe_to_mercator
felixpalmer May 12, 2026
444afe7
Reduce comments
felixpalmer May 12, 2026
5a6627e
Keep same behavior for non-globe case
felixpalmer May 12, 2026
20765c0
Tidy
felixpalmer May 12, 2026
408bc0a
Add back casts
felixpalmer May 12, 2026
abeed1a
chore(examples): move terrain view toggle to controls
charlieforward9 May 12, 2026
7dd49ce
Merge branch 'master' into cr/feat/terrain-extension-globe
charlieforward9 May 12, 2026
95b48f3
Merge branch 'master' into cr/feat/terrain-extension-globe
felixpalmer May 14, 2026
9352f89
Merge branch 'master' into cr/feat/terrain-extension-globe
charlieforward9 May 14, 2026
6f4e2b2
Merge remote-tracking branch 'upstream/master' into cr/feat/terrain-e…
charlieforward9 May 18, 2026
0d3cbe2
Merge branch 'master' into cr/feat/terrain-extension-globe
charlieforward9 May 20, 2026
5aafcb7
Merge branch 'master' into cr/feat/terrain-extension-globe
charlieforward9 Jun 6, 2026
c2a667e
Merge branch 'master' into cr/feat/terrain-extension-globe
charlieforward9 Jun 10, 2026
b4f4a44
fix(terrain): address globe review feedback
charlieforward9 Jun 10, 2026
03d9af3
chore(example): remove unnecessary icon terrain offset
charlieforward9 Jun 10, 2026
7e0522b
chore(example): remove terrain view toggle
charlieforward9 Jun 10, 2026
aca1243
chore(example): show terrain in default globe view
charlieforward9 Jun 10, 2026
600cb82
fix(terrain): correct height map unit conversion for globe offset mode
chrisgervang Jun 10, 2026
c520bde
Merge branch 'master' into cr/feat/terrain-extension-globe
chrisgervang Jun 11, 2026
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
24 changes: 19 additions & 5 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 @@ -67,6 +68,7 @@ type Stage = {
coordinates: [number, number];
type: 'start' | 'finish';
};
type ViewType = 'MapView' | 'GlobeView';

function getTooltip({object}: PickingInfo<Route>) {
if (!object) return null;
Expand All @@ -79,11 +81,14 @@ function getTooltip({object}: PickingInfo<Route>) {
}

export default function App({
initialViewState = INITIAL_VIEW_STATE
initialViewState = INITIAL_VIEW_STATE,
view = 'GlobeView'
}: {
initialViewState?: MapViewState;
view?: ViewType;
}) {
const [routes, setRoutes] = useState<FeatureCollection<LineString, RouteProperties>>();
const [viewState, setViewState] = useState<MapViewState>(initialViewState);

useEffect(() => {
// eslint-disable-next-line @typescript-eslint/no-floating-promises
Expand Down Expand Up @@ -111,8 +116,8 @@ export default function App({
elevationDecoder: ELEVATION_DECODER,
elevationData: TERRAIN_IMAGE,
texture: SURFACE_IMAGE,
wireframe: false,
color: [255, 255, 255],
material: false,
maxRequests: 12,
operation: 'terrain+draw'
}),
new GeoJsonLayer<RouteProperties>({
Expand All @@ -129,6 +134,10 @@ export default function App({
data: stages,
iconAtlas: `${DATA_URL_BASE}/flag-icons.png`,
iconMapping: `${DATA_URL_BASE}/flag-icons.json`,
parameters: {
cullMode: 'none',

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.

General commentary: It's not obvious that billboarded layers need cullMode: 'none' to work on GlobeView.

I think it'd be good practice to add a remark on layer docs that need this until deck offers an automatic solution.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Kinda related to #10262 @ibgreen

depthCompare: 'always'
},
getPosition: d => d.coordinates,
getIcon: d => (d.type === 'start' ? 'green' : 'checker'),
getSize: 32,
Expand All @@ -140,6 +149,7 @@ export default function App({
characterSet: 'auto',
parameters: {
// should not be occluded by terrain
cullMode: 'none',
depthCompare: 'always'
},
getPosition: d => d.coordinates,
Expand All @@ -151,9 +161,13 @@ export default function App({
})
];

const deckView = useMemo(() => (view === 'GlobeView' ? new GlobeView() : new MapView()), [view]);

return (
<DeckGL
initialViewState={initialViewState}
views={deckView}
viewState={viewState}
onViewStateChange={e => setViewState(e.viewState as MapViewState)}
controller={true}
layers={layers}
pickingRadius={5}
Expand Down
35 changes: 25 additions & 10 deletions modules/extensions/src/terrain/height-map-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,17 @@
// 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 {_GlobeViewport as GlobeViewport} from '@deck.gl/core';
import type {Viewport, Layer} from '@deck.gl/core';

const MAP_MAX_SIZE = 2048;
Expand Down Expand Up @@ -72,18 +80,22 @@ export class HeightMapBuilder {
);

if (layersChanged) {
// Recalculate cached bounds
// Recalculate cached bounds in absolute Mercator common space
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);
// On GlobeView, viewport bounds are sphere cartesian — skip intersection
const bounds =
viewport instanceof GlobeViewport
? this.layersBoundsCommon
: getRenderBounds(this.layersBoundsCommon, viewport);
if (bounds[2] <= bounds[0] || bounds[3] <= bounds[1]) {
this.renderViewport = null;
return false;
Expand All @@ -96,19 +108,22 @@ export class HeightMapBuilder {
const pixelWidth = (bounds[2] - bounds[0]) * scale;
const pixelHeight = (bounds[3] - bounds[1]) * scale;

// Use Mercator center — viewport.center on GlobeView is sphere cartesian
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
46 changes: 35 additions & 11 deletions modules/extensions/src/terrain/shader-module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,10 +67,21 @@ 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;
// In globe mode, absolute Mercator position for terrain FBO UV lookups
out vec2 terrainMercPos;
out float terrainHeight;

vec2 terrain_globe_to_mercator(vec3 globePosition) {
float D = length(globePosition);
float sinLat = clamp(globePosition.z / D, -0.999998, 0.999998);
float x = atan(globePosition.x, -globePosition.y);
float y = atanh(sinLat);
return (vec2(x, y) + PI) * WORLD_SCALE;
}
`,
fs: `${uniformBlock}in vec2 terrainMercPos;\nin float terrainHeight;`,
inject: {
'vs:#main-start': /* glsl */ `
if (terrain.mode == TERRAIN_MODE_SKIP) {
Expand All @@ -80,32 +91,45 @@ if (terrain.mode == TERRAIN_MODE_SKIP) {
`,
'vs:DECKGL_FILTER_GL_POSITION': /* glsl */ `
commonPos = geometry.position.xyz;
terrainHeight = commonPos.z + project.commonOrigin.z;
if (project.projectionMode == PROJECTION_MODE_GLOBE) {
terrainMercPos = terrain_globe_to_mercator(commonPos);
terrainHeight = length(commonPos) - GLOBE_RADIUS;
} else {
terrainMercPos = commonPos.xy;
}
if (terrain.mode == TERRAIN_MODE_WRITE_HEIGHT_MAP) {
vec2 texCoords = (commonPos.xy - terrain.bounds.xy) / terrain.bounds.zw;
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;
vec2 anchorMercPos = project.projectionMode == PROJECTION_MODE_GLOBE
? terrain_globe_to_mercator(anchorCommon)
: anchorCommon.xy;
vec2 texCoords = (anchorMercPos - 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;
if (project.projectionMode == PROJECTION_MODE_GLOBE) {
geometry.position.xyz += normalize(geometry.position.xyz) * terrainZ;

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.

Suggested change
geometry.position.xyz += normalize(geometry.position.xyz) * terrainZ;
// Height map is written in Mercator common space (units = TILE_SIZE / EARTH_CIRCUMFERENCE / cos(lat))
// Convert to globe radial units (units = GLOBE_RADIUS / EARTH_RADIUS)
terrainZ *= cos(radians(geometry.worldPosition.y)) * PI;
geometry.position.xyz += normalize(geometry.position.xyz) * terrainZ;

Result

Image

} else {
geometry.position.z += terrainZ;
}
position = project_common_position_to_clipspace(geometry.position);
}
}
`,
'fs:#main-start': /* glsl */ `
if (terrain.mode == TERRAIN_MODE_WRITE_HEIGHT_MAP) {
fragColor = vec4(commonPos.z, 0.0, 0.0, 1.0);
fragColor = vec4(terrainHeight, 0.0, 0.0, 1.0);
return;
}
`,
'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 Down Expand Up @@ -173,7 +197,7 @@ if ((terrain.mode == TERRAIN_MODE_USE_COVER) || (terrain.mode == TERRAIN_MODE_US
return {
mode,
terrain_map: sampler,
// Convert bounds to the common space, as [minX, minY, width, height]
// Convert bounds to common space, 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],
Expand Down
26 changes: 21 additions & 5 deletions modules/extensions/src/terrain/terrain-cover.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,18 @@

import {Framebuffer} from '@luma.gl/core';

import {_GlobeViewport as GlobeViewport} from '@deck.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 +121,14 @@ export class TerrainCover {
const targetLayer = this.targetLayer;
let shouldRedraw = false;

// Bounds in absolute Mercator common space (matches terrain cover FBO viewport)
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 +140,10 @@ export class TerrainCover {
// console.log('bounds changed', this.bounds, '>>', newBounds);
shouldRedraw = true;
this.targetBounds = targetLayer.getBounds();
this.targetBoundsCommon = joinLayerBounds([targetLayer], viewport);
this.targetBoundsCommon = joinLayerBounds(
[targetLayer],
getMercatorReferenceViewport(viewport)
);
}

if (!this.targetBoundsCommon) {
Expand All @@ -146,7 +158,11 @@ export class TerrainCover {
} else {
const oldZoom = this.renderViewport?.zoom;
shouldRedraw = shouldRedraw || newZoom !== oldZoom;
const newBounds = getRenderBounds(this.targetBoundsCommon, viewport);
// On GlobeView, viewport bounds are sphere cartesian — skip intersection
const newBounds =
viewport instanceof GlobeViewport
? 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
33 changes: 28 additions & 5 deletions modules/extensions/src/utils/projection-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,26 @@ import type {Layer, Viewport} from '@deck.gl/core';
/** Bounds in CARTESIAN coordinates */
export type Bounds = [minX: number, minY: number, maxX: number, maxY: number];

/** Fixed Mercator viewport for projection-independent coordinate conversion */
const MERCATOR_REFERENCE_VIEWPORT = new WebMercatorViewport({
width: 1,
height: 1,
longitude: 0,
latitude: 0,
zoom: 0
});

/** Project lng/lat to absolute Mercator common space. */
export function lngLatToMercatorCommon(lngLat: number[]): [number, number] {
const [x, y] = MERCATOR_REFERENCE_VIEWPORT.projectPosition(lngLat);
return [x, y];
}

/** Returns a Mercator viewport for bounds computation, bypassing GlobeView. */
export function getMercatorReferenceViewport(viewport: Viewport): Viewport {
return viewport.isGeospatial ? MERCATOR_REFERENCE_VIEWPORT : viewport;
}

/*
* Compute the union of bounds from multiple layers
* Returns bounds in CARTESIAN coordinates
Expand Down Expand Up @@ -63,11 +83,14 @@ export function makeViewport(opts: {
return null;
}

const centerWorld = viewport.unprojectPosition([
(bounds[0] + bounds[2]) / 2,
(bounds[1] + bounds[3]) / 2,
0
]);
// Unproject through Mercator reference (GlobeView would give sphere coords)
const centerWorld = isGeospatial
? MERCATOR_REFERENCE_VIEWPORT.unprojectPosition([
(bounds[0] + bounds[2]) / 2,
(bounds[1] + bounds[3]) / 2,
0
])
: viewport.unprojectPosition([(bounds[0] + bounds[2]) / 2, (bounds[1] + bounds[3]) / 2, 0]);

let {width, height, zoom} = opts;
if (zoom === undefined) {
Expand Down
7 changes: 4 additions & 3 deletions modules/geo-layers/src/terrain-layer/terrain-layer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@ import {
log,
Material,
TextureSource,
UpdateParameters
UpdateParameters,
_GlobeViewport as GlobeViewport
} from '@deck.gl/core';
import {SimpleMeshLayer} from '@deck.gl/mesh-layers';
import {COORDINATE_SYSTEM} from '@deck.gl/core';
Expand Down Expand Up @@ -246,7 +247,7 @@ export default class TerrainLayer<ExtraPropsT extends {} = {}> extends Composite
const overlappedBounds = getOverlappedBounds(
bounds,
this.props.tileSize,
Boolean(viewport.resolution && viewport.resolution > 0)
viewport instanceof GlobeViewport
);

const terrain = this.loadTerrain({
Expand Down Expand Up @@ -286,7 +287,7 @@ export default class TerrainLayer<ExtraPropsT extends {} = {}> extends Composite
// Bounds are baked with projectFlat. In GlobeView projectFlat is identity,
// so tiled terrain meshes are in lng/lat degrees instead of common-space
// web-mercator units.
const isGlobe = Boolean(viewport.resolution && viewport.resolution > 0);
const isGlobe = viewport instanceof GlobeViewport;
const boundingBox = (mesh as MeshWithBoundingBox | null)?.header?.boundingBox;
const hasLngLatBounds =
boundingBox &&
Expand Down
Loading
Loading