Skip to content
This site is a preview of the main branch. The published documentation is at maplibre.org.

Show 3D buildings and relief

Height data helps users see the shape of a city or a landscape. You can draw buildings as 3D blocks, shade mountains from elevation tiles, and show the whole world as a globe.

MapLibre Compose has no API for 3D terrain, which raises the ground itself. Hillshade and color relief shade the flat map instead.

FillExtrusionLayer raises each polygon to a height in meters. OpenFreeMap serves vector tiles with a building source layer, where each building has a render_height and a render_min_height:

Gray buildings rise above streets in Lower Manhattan, with labels drawn over the buildings.
3D buildings in Lower Manhattan
App.kt
val mapState =
rememberMapState(
baseStyle = BaseStyle.Uri("https://tiles.openfreemap.org/styles/bright"),
initialCameraPosition =
CameraPosition(
center = Position(latitude = 40.7135, longitude = -74.0066),
zoom = 15.5,
pitch = 45.0,
bearing = -17.6,
),
) {
getBaseSource<VectorTileSource>(id = "openmaptiles")?.let { tiles ->
// Below the base style's labels
Anchor.Below({ it.type == "symbol" }) {
FillExtrusionLayer(
id = "3d-buildings",
source = tiles,
sourceLayer = "building",
minZoom = 15f,
color = const(Color.LightGray),
height = feature["render_height"].asNumber(const(0f)),
base = feature["render_min_height"].asNumber(const(0f)),
)
}
}
}
MaplibreMap(state = mapState)

The camera starts tilted with pitch, so the heights are visible. The layer goes below the base style’s labels, so buildings do not cover them. Some styles, such as OpenFreeMap Liberty, already include 3D buildings.

The style’s light shades the sides of extruded buildings. Set it with StyleLight.set when the style is ready:

App.kt
LaunchedEffect(mapState, mapState.style.loadState) {
if (mapState.style.loadState == StyleLoadState.Ready) {
mapState.style.light.set(
Light(
// Light from the west, whichever way the map is rotated
anchor = const(IlluminationAnchor.Map),
position = const(listOf(1.5f, 270f, 60f)),
color = const(Color(0xFFFFE0B2)),
intensity = const(0.6f),
)
)
}
}

position lists the light’s distance, its direction in degrees clockwise from north, and its angle in degrees from straight above. With IlluminationAnchor.Map, the light keeps its direction when the user rotates the map.

Loading another base style replaces the light with the new style’s light. The effect sets it again each time MapStyleState.loadState becomes StyleLoadState.Ready.

A raster DEM source loads elevation tiles: images that store a height in the color of each pixel. HillshadeLayer draws shadows and highlights from those heights, so mountains and valleys stand out:

Shadows and highlights show mountain ridges and valleys around Innsbruck on a flat map.
Hillshade around Innsbruck
App.kt
val mapState =
rememberMapState(
baseStyle = BaseStyle.Uri("https://tiles.openfreemap.org/styles/positron"),
initialCameraPosition =
CameraPosition(center = Position(latitude = 47.27, longitude = 11.39), zoom = 10.0),
) {
val dem =
rememberRasterDemTileSource(
uri = "https://demotiles.maplibre.org/terrain-tiles/tiles.json",
tileSize = 256,
)
Anchor.Below({ it.type == "symbol" }) {
HillshadeLayer(id = "hillshade", source = dem, exaggeration = const(0.7f))
}
}
MaplibreMap(state = mapState)

The sample tiles from demotiles.maplibre.org cover only a small area of the Alps around Innsbruck, Austria, so the camera starts there. For other areas, use a provider that serves elevation tiles. Pass its tile URLs and its RasterDemEncoding to rememberRasterDemTileSource.

ColorReliefLayer colors each pixel by its height. Build the colors with interpolate over elevation, in meters:

Elevation colors around Innsbruck change from green in the valleys to yellow and brown on higher mountains.
Color relief around Innsbruck
App.kt
val state = rememberMapState {
val dem =
rememberRasterDemTileSource(
uri = "https://demotiles.maplibre.org/terrain-tiles/tiles.json",
tileSize = 256,
)
ColorReliefLayer(
id = "color-relief",
source = dem,
color =
interpolate(
type = linear(),
input = elevation(),
500 to const(Color(0xFF4CAF50)),
1500 to const(Color(0xFFF0E68C)),
2500 to const(Color(0xFF8B5A2B)),
3000 to const(Color.White),
),
opacity = const(0.6f),
)
}
MaplibreMap(state = state)

Without color, the layer uses LayerDefaults.ColorReliefColors. With the sample tiles from the previous section, start the camera over Innsbruck as that example does; elsewhere the layer has no data to color.

Set the style’s projection to ProjectionType.Globe to show the world as a globe when the user zooms out:

App.kt
@OptIn(ExperimentalMaplibreComposeApi::class)
@Composable
fun GlobeMap() {
val mapState = rememberMapState(initialCameraPosition = CameraPosition(zoom = 1.5))
LaunchedEffect(mapState, mapState.style.loadState) {
if (mapState.style.loadState == StyleLoadState.Ready) {
mapState.style.projection.set(Projection(type = const(ProjectionType.Globe)))
}
}
MaplibreMap(state = mapState)
}

Below zoom 11, the map is a globe. Between zoom 11 and zoom 12, it turns into the flat Mercator map. ProjectionType.Globe is experimental, so the function opts in to ExperimentalMaplibreComposeApi. As with the light, a new base style replaces the projection, so the effect sets it again.

Only the browser draws a globe. MapLibre Native supports only the Mercator projection, so it logs a warning and keeps the flat map. On the browser, the globe has an atmosphere around it. StyleSky.set changes the atmosphere and the color of the sky above the horizon. MapLibre Native does not draw a sky.