Skip to main content

3D Terrain

Render the map draped over real elevation, with correct depth occlusion (near ridges hide far slopes), fill draping and fast zooming.

3D terrain over the Chartreuse massif, Grenoble

3D terrain over the Chartreuse massif above Grenoble — hillshade relief, contour lines and a route line draped onto the surface. Captured from the scripts/android-dev demo.
Panning across the tilted 3D terrain (demo capture).

How it works

Terrain consumes RGB-encoded elevation tiles (MapBox or Terrarium encoding) from any TileDataSource. The renderer builds a per-tile surface mesh, displaces it by the decoded elevation, and draws the map on top of it.

  • Fill draping — polygon fills and the style background are baked flat into a per-tile offscreen texture (MapLibre-style render-to-texture) and used as the terrain surface's texture. Fills therefore follow the terrain exactly: zero holes, zero see-through, no depth slack. The bake is cached per tile, so steady-state panning does no offscreen work.
  • Depth occlusion — the draped surface writes true depth and is the occluder, so a near ridge correctly blocks the far slope's raster, contours and route lines (shared depth buffer, painter-order model).
  • Sharp geometry (contour and tile lines) is displaced and lattice-clamped to the surface, drawn GL_LEQUAL with zero depth bias so it hugs the terrain without leaking through ridges.

Quick start

import com.massifmaps.components.TerrainOptions
import com.massifmaps.datasources.HTTPTileDataSource
import com.massifmaps.datasources.MemoryCacheTileDataSource

// 1. An RGB-elevation source (Terrarium or MapBox encoding).
val demSource = MemoryCacheTileDataSource(
HTTPTileDataSource(0, 12, "https://your.tiles/dem/{z}/{x}/{y}.png").apply {
// the "dem_encoding" meta data selects the decoder: "terrarium" or "mapbox"
setMetaDataElement("dem_encoding", Variant("terrarium"))
}
)

// 2. Build terrain options. Each tile resolves its decoder from its own "dem_encoding".
val terrain = TerrainOptions(demSource).apply {
isEnabled = true
isDrapeFillsEnabled = true // render-to-texture fill draping (default on)
meshResolution = 64 // grid cells per tile edge (2..256)
exaggeration = 1.0f // 1.0 = true-to-scale
}

// 3. Attach to the map.
mapView.options.terrainOptions = terrain
let dem = MSFMemoryCacheTileDataSource(dataSource: httpDem)
let terrain = MSFTerrainOptions(dataSource: dem)
terrain?.setEnabled(true)
terrain?.setDrapeFillsEnabled(true)
terrain?.setMeshResolution(64)
mapView.getOptions()?.setTerrainOptions(terrain)

With the surface API

The same three steps are one spec and one property path — and the knobs stay reachable as terrain.* on the map afterwards:

map.terrain(Spec.of("terrain")
.set("source", Spec.of("memory-cache")
.set("source", Spec.of("http")
.set("url", "https://your.tiles/dem/{z}/{x}/{y}.png")
.set("maxZoom", 12)
.set("metaData", Spec.object().set("dem_encoding", "terrarium"))))
.set("enabled", true)
.set("meshResolution", 64)
.set("exaggeration", 1.0));

map.set("terrain.drapeLinesEnabled", true);
map.set("terrain.autoFlattenParallax", 1.5);

Every terrain property: terrain in the options reference. Runnable, on three platforms: the 3D terrain example and 2D ↔ 3D switching.

Share the DEM with hillshade

TerrainOptions can share its elevation TileDataSource with a HillshadeRasterTileLayer. Wrap the source in a MemoryCacheTileDataSource so both features hit the same tiles instead of downloading twice.

Mixing two DEM sources of different encodings

dem_encoding is resolved per tile, not per source, so an OrderedTileDataSource may combine a MapBox-encoded DEM with a Terrarium one. Every tile carries the meta data of the source that actually answered for it, and the terrain, the hillshade and the contours each decode it with that tile's own coefficients.

val mapboxDem = HTTPTileDataSource(0, 15, "https://a.tiles/dem/{z}/{x}/{y}.png").apply {
setMetaDataElement("dem_encoding", Variant("mapbox"))
}
val terrariumDem = HTTPTileDataSource(0, 12, "https://b.tiles/dem/{z}/{x}/{y}.png").apply {
setMetaDataElement("dem_encoding", Variant("terrarium"))
}
// mapboxDem answers first; terrariumDem fills what it does not cover.
val demSource = OrderedTileDataSource(mapboxDem, terrariumDem)

Two rules make it work:

  • Tag the leaves, not the wrapper. A wrapper source has no encoding of its own; it forwards a child's only as a fallback for consumers that ask before any tile has loaded.
  • Cache each leaf, or cache above them — either is fine. A PersistentCacheTileDataSource stores the tile's meta data alongside the blob, so a cache hit resolves the same decoder a fresh fetch would. A cache database written by an older SDK has no such column and is rebuilt on first open.

Where the two coverages meet, the terrain's border backfill notices the encodings differ and resamples through metres instead of copying texels, so the seam is continuous.

TerrainOptions reference

PropertyDefaultNotes
EnabledtrueWhen off, the map renders flat but the DEM stays attached.
Exaggeration1.0Height multiplier. Changing it re-tesselates loaded tiles (costly).
MeshResolution32Grid cells per tile edge, clamped 2..256. Limited by DEM resolution.
DrapeFillsEnabledtrueRender-to-texture fill/background draping.
DrapeLinesEnabledtrueDrape tile lines too (softer, zero-cost hug).
Bridges3DEnabledfalseLift span features onto the chord between their portals and draw span decks as extrusions. Off, they drape like the ground and none of the span machinery runs.
DrapeResolution0Drape texture size; 0 = derived from the tile size.
NoDrapeLayerFilterLayer-name pattern kept out of the drape (sharp geometry).
SeamlessTileEdgesEnabledtrueBackfill the 1-texel DEM border from the neighbour level — removes the ridge at tile borders.
ElevationPrefetchEnabledtrueAlso request the neighbours of every visible terrain tile.
TileEdgeStitchingEnabledtrueStitch the mesh across tiles of different levels.
BackgroundColortransparentFill color drawn before tiles (works even with zero tile layers).
BackgroundBitmapEnabledfalseDrape Options.getBackgroundBitmap() over the terrain (world-anchored, repeats).
ViewDistanceFactor / ViewDistance1.0 / 0Where the ground ends; ViewDistance is a minimum in metres and only extends the factor rule. Pair it with fog (FogOptions).
AutoFlattenParallax / AutoFlattenTilt2 / 88Render flat once 3D stops earning its cost — see below. 0 disables each half.
AutoFlattenDuration / Flattened0.3 / —Length of the flattening animation, and a read-only "is it flat right now".
MaxTileZoomCoarsening3How much coarser far tiles may get.
BillboardOcclusionEnabled / …Tolerancetrue / 0.02Hide markers and popups behind a ridge.
SurfaceShaderSourceReplace the terrain surface shader (post-processing).
MinZoom / DepthBias5 / 0.0002LOD floor and depth slack for draped geometry.

Recommended configuration:

terrainOptions.setMeshResolution(64); // the defaults already drape fills and lines

Rendering flat when 3D buys nothing

Zoomed far out, the displacement is smaller than a pixel; straight down, it shows nothing. Both still pay for the drape pass, the terrain draw and the elevation fetches, so the map handles it itself — on by default, at 2 px and 88°. Change or disable either half:

terrainOptions.setAutoFlattenParallax(4); // flatten sooner
terrainOptions.setAutoFlattenTilt(0); // never flatten on tilt alone

The threshold is screen parallax, not a zoom level: how far the highest ground in view moves because it is displaced. It therefore adapts to how mountainous the data is and to your Exaggeration — over the Alps, 2 px lands around zoom 8, and flat country flattens earlier. The transition is animated (AutoFlattenDuration), isFlattened() tells you what the map is doing, and isEnabled() keeps returning whatever you set, so your own terrain toggle still works.

Querying elevation

TerrainOptions (and the shared ElevationManager) can return heights for map positions:

val metres: Double = terrain.getElevation(mapPos) // single
val many: DoubleVector = terrain.getElevations(mapPosVector) // batched

Performance notes

  • Draped content skips terrain subdivision (it is baked flat), so vertex buffers upload at source density instead of ~meshResolution² per tile. This, plus an LRU elevation-texture cache (no full flush), removes most of the fast-zoom render-thread stall.
  • Prefer meshResolution = 64 as a good quality/cost balance; go higher only if you see terraced slopes at close range.
  • The shared regular grid and the painter-order depth model are always on where the GPU supports vertex texture fetch; there is no per-tile CPU tesselation to pay for there.

Known limitation

At low zoom, VectorLayer element lines (e.g. long routes) can still leak through a ridge: they are CPU-baked to a fine bilinear surface while the low-zoom occluder is coarse, and no depth bias wins that case. The fix (render-to-texture element draping across layers) is on the roadmap.

Styling differently in 3D

A style reads [render::3d] to branch on whether it is drawn on terrain — flat labels in 2D, billboard ones in 3D, for instance. See Live Style Parameters.

See also