Short answer

To put 3D terrain on a web map you encode a DEM as Terrain-RGB raster tiles, register them as a raster-dem source, and tell the renderer to displace the surface by sampling those tiles. In MapLibre GL JS that is map.setTerrain({ source: 'terrain', exaggeration: 1.5 }); for globe-scale, fully tiltable views you instead serve a quantized-mesh terrain pyramid to CesiumJS. Add a hillshade or hemispheric light so slope is legible, pick an exaggeration between roughly 1.3 and 2.0, and keep a 2D mode available for measurement and comparison.

3D is justified when vertical relationships carry the meaning — drainage, fault scarps, pit walls, line-of-sight, flood depth over relief. It is a poor default when the user's real task is comparing two layers or reading a value, because perspective foreshortening makes distances and areas ambiguous.

How web terrain rendering actually works

Browsers do not render floating-point DEMs directly. Elevation has to be packed into something a GPU shader can sample cheaply — almost always an 8-bit RGB PNG/WebP tile. Two encodings dominate:

  • Mapbox/MapLibre Terrain-RGB: height = -10000 + ((R*256*256 + G*256 + B) * 0.1) metres. The 0.1 m base resolution and 24-bit range cover any terrestrial elevation with ample precision.
  • Terrarium (Mapzen/AWS Terrain Tiles): height = (R*256 + G + B/256) - 32768 metres. MapLibre supports both via the source encoding property ("mapbox" or "terrarium").

At runtime the renderer reads these tiles, builds a triangulated surface for each map tile, displaces the vertices by the decoded height times the exaggeration, and drapes the normal 2D style (or imagery) over it. Hillshade is computed in a separate shader pass from the same DEM source.

Generating Terrain-RGB tiles from a DEM

Start from a clean, projected DEM. Web tiles are served in EPSG:3857 (Web Mercator), so reproject first:

gdalwarp -t_srs EPSG:3857 -r bilinear -dstnodata 0 srtm.tif dem_3857.tif

Encode and tile. The rio-rgbify tool (rasterio plugin) writes a Terrain-RGB MBTiles pyramid:

rio rgbify -b -10000 -i 0.1 --min-z 5 --max-z 13 \
  --format png dem_3857.tif terrain.mbtiles

The -b -10000 -i 0.1 flags must match the decode formula your client uses. Serve the MBTiles with a tile server (tileserver-gl, martin) or explode to an XYZ directory. Watch the maxzoom: Terrain-RGB has no overzoom magic, so set the source maxzoom to the highest level you actually generated, and let the client overscale beyond it.

Wiring it into MapLibre GL JS

map.addSource('terrain', {
  type: 'raster-dem',
  tiles: ['https://tiles.example.com/terrain/{z}/{x}/{y}.png'],
  encoding: 'mapbox',
  tileSize: 256,
  maxzoom: 13
});

map.setTerrain({ source: 'terrain', exaggeration: 1.5 });

map.addLayer({
  id: 'hillshade',
  type: 'hillshade',
  source: 'terrain',
  paint: { 'hillshade-exaggeration': 0.6 }
});

map.addLayer({ id: 'sky', type: 'sky' }); // atmospheric backdrop

The camera now tilts (drag with right mouse / two fingers) and pitches up to ~85°. Set map.setMaxPitch(85). Keep a UI toggle that calls map.setTerrain(null) to drop back to a true plan view for measuring.

When to reach for Cesium and quantized-mesh

MapLibre's raster-dem terrain is really 2.5D: a tilted heightfield, no globe curvature, limited at very small scales. For continent-to-globe context, draping orthoimagery over a precise mesh, true horizon, or sub-surface/atmospheric scenes, use CesiumJS with the quantized-mesh terrain format (OGC-adjacent, irregular TIN tiles with vertex-level precision). You can build quantized-mesh tiles from a DEM with cesium-terrain-builder (ctb-tile) and serve them as a terrain pyramid. Cesium also reads 3D Tiles for point clouds and photogrammetry meshes, which is the path for drone-derived terrain or LiDAR.

Rule of thumb: regional study area, light footprint, you already use MapLibre → raster-dem. Global extent, heavy 3D, mixed mesh/point-cloud assets → Cesium.

deck.gl for data over terrain

When the terrain is a backdrop for analytical layers — flow lines, grids, time-animated points — deck.gl's TerrainLayer decodes the same Terrain-RGB tiles and other deck layers render in the same WebGL2/WebGPU context, so a PathLayer or ColumnLayer sits correctly on the surface. This is the better choice when the data, not the basemap, is the star.

Choosing vertical exaggeration honestly

Exaggeration is an interpretive choice, not decoration. Subtle terrain (coastal plains, gentle alluvial fans) often needs 2–3× to read at all; alpine relief at 1.0 already looks dramatic and higher factors mislead. Because exaggeration changes apparent slope, state the factor in the legend and never let users infer gradient from the 3D view — give them a slope layer or profile tool for that. Also remember Web Mercator stretches the vertical scale by 1/cos(latitude) relative to ground distance, so apparent relief grows toward the poles even at exaggeration 1.

Common pitfalls and why they happen

  • Spiky or stepped terrain. The DEM was reprojected with nearest-neighbour resampling, quantising elevation into terraces. Use -r bilinear (or cubic) in gdalwarp.
  • A wall of cliffs at tile edges. NoData was encoded as a valid height (often 0 or -10000), so voids render as vertical drops. Set and mask NoData before encoding, or fill voids first.
  • Terrain looks flat though it loaded. Encoding mismatch — client set to mapbox while tiles are Terrarium (or vice versa), so decoded heights are near-constant.
  • Blocky relief when zoomed in. Source maxzoom exceeds the levels you generated, or the DEM resolution is coarser than the zoom implies; do not advertise detail you do not have.
  • Distances feel wrong. Users are measuring off the tilted view. Provide a 2D toggle and an explicit profile/measure tool.

QA / validation

Spot-check decoded heights against the source DEM at known points — load a Terrain-RGB tile, decode three or four pixels with the formula, and compare to gdallocationinfo dem_3857.tif <px> <py>; they should agree within the 0.1 m quantisation. Confirm NoData renders as flat or transparent, not as cliffs. Test on a low-end device with realistic tile counts: terrain triples geometry load, and the failure mode is dropped frames on phones, not on your workstation.

Bathyl perspective

We use 3D terrain to make geomorphology and sub-surface relationships obvious, then guard against its distortions with an explicit exaggeration label, a 2D fallback, and a measurement tool that ignores perspective. The 3D view should invite inspection, not replace the quantitative layer behind it.

Related reading

Sources