MapMath

CHAPTER 24

Terrain and elevation data

Digital Elevation Models, Terrain RGB encoding, hillshading math, slope and aspect — the foundation of 3D maps.

4 min read

Without elevation, a 3D map is just a 2D map at an angle. Hillshading, terrain exaggeration, and depth cues all come from the same source: a raster grid where every pixel encodes a height value. This chapter covers how that data is stored, decoded, and put to work.

3D terrain mesh · drag to rotate

deep water
lowland
hills
mountain
snow

Digital Elevation Models (DEM)

A DEM is a raster where each pixel encodes the elevation of that geographic cell.

DatasetResolutionCoverageAccess
SRTM (NASA)30 m±60° latFree
ASTER30 m±83° latFree
Copernicus DEM30 mGlobalFree
Mapbox Terrain-DEMVariableGlobalAPI key
USGS 3DEP1 m (US)USAFree

Resolution here means the geographic footprint of one pixel. At 30 m resolution, a pixel covers a 30 m × 30 m cell on the ground — fine enough to see individual buildings, but not individual cars. The vertical accuracy is typically ±5–15 m for 30 m DEMs, meaning the stored elevation value can be 5–15 m off from the true elevation.

Terrain RGB encoding

Raw elevation floats don't fit in PNG pixels. Mapbox's Terrain-DEM tiles encode elevation into RGB channels:

elevation (m)=10,000+(R×6553.6+G×25.6+B×0.1)\text{elevation (m)} = -10{,}000 + (R \times 6553.6 + G \times 25.6 + B \times 0.1)
// Decode a pixel from a Terrain-DEM tile
function decodeElevation(r, g, b) {
  return -10_000 + (r * 6553.6 + g * 25.6 + b * 0.1);
}

// Fetch and decode a terrain tile
async function getElevationAt(lat, lon, zoom = 12) {
  const tx = lonToTileX(lon, zoom), ty = latToTileY(lat, zoom);
  const img = await fetchTileAsImageData(zoom, tx, ty);
  const [r, g, b] = pixelAt(img, lat, lon, zoom, tx, ty);
  return decodeElevation(r, g, b);
}

The encoding spreads a 24-bit value across three 8-bit channels. R is the most significant byte (scaled by 6553.6 = 256² / 10), G the middle byte, B the least significant (scaled by 0.1 = 1/10). The -10,000 offset allows encoding values below sea level down to the deepest ocean trenches (~−9,000 m).

The Terrarium format (Mapzen/AWS) uses a different encoding:

elevation (m)=(R×256+G+B/256)32,768\text{elevation (m)} = (R \times 256 + G + B / 256) - 32{,}768

Hillshading

Hillshading simulates sunlight on terrain, producing the shadow effect that makes mountains look 3D on a map. The math:

  1. Surface normal at each pixel: computed from the elevation gradient
  2. Light direction: typically northwest (azimuth 315°, altitude 45°)
  3. Intensity: dot product of normal and light direction
normal=normalize ⁣(zx,zy,1)\text{normal} = \text{normalize}\!\left(-\frac{\partial z}{\partial x},\, -\frac{\partial z}{\partial y},\, 1\right) intensity=max(0,  n^L^)\text{intensity} = \max(0,\; \hat{n} \cdot \hat{L})

The northwest light source is a cartographic convention — terrain lit from the northwest matches what humans expect from light coming from the upper-left of a printed page. Lighting from other directions can make valleys look like ridges (the "relief inversion" illusion), because the human visual system expects top-lit surfaces to be convex.

// Fragment shader for hillshading
float dzdx = (elevRight - elevLeft) / (2.0 * cellSize);
float dzdy = (elevBottom - elevTop) / (2.0 * cellSize);

vec3 normal = normalize(vec3(-dzdx, -dzdy, 1.0));
vec3 light  = normalize(vec3(cos(azimuth), sin(azimuth), sin(altitude)));

float intensity = max(0.0, dot(normal, light));
gl_FragColor = vec4(vec3(intensity), 1.0);
Chapter 24 · Paid content

Continue reading "Terrain and elevation data"

You've reached the end of the free preview. Unlock all 22 paid chapters, including distance math, bearings, polygons, spatial indexing, and 3D map rendering — plus a downloadable PDF and the companion code repo.

  • All 22 paid chapters with worked examples
  • Downloadable PDF for offline reading
  • Companion GitHub repo (JavaScript + Python)
  • Free updates for life

Multiple payment options including Wise, PayPal, and bank transfer.