3D Terrain in Flutter
Terrain tile endpoint not confirmed
The terrain/relief tile URLs below are placeholders. MapMetrics does not currently serve /terrain-rgb/ or /relief/ on gateway.mapmetrics-atlas.net — both return 404 (checked 2026-08-04). Substitute a raster-DEM source you actually have access to. The layer and source wiring shown here is correct regardless of where the tiles come from.
This tutorial covers rendering mountainous terrain on your MapMetrics Flutter map — hillshade relief plus a tilted camera for an immersive, dimensional feel.
Prerequisites
Before you begin, ensure you have:
- Completed the Flutter Setup Guide
- A MapMetrics API key and style URL from the MapMetrics Portal
No setTerrain / Elevation Displacement
MapMetrics Flutter doesn't currently expose a setTerrain/elevation-exaggeration API — there's no way to physically displace the map surface by elevation the way some other SDKs do. What the SDK does support is a real RasterDemSource + HillshadeStyleLayer pair, which reads the same terrain-RGB/Terrarium DEM tiles and renders MapLibre's native shaded-relief look. Combined with camera pitch, this gets you a convincing sense of mountains and valleys without true 3D displacement.
If you need actual elevation displacement, it isn't available in this SDK today — say so plainly to users rather than faking it with a "terrain exaggeration" slider that doesn't do anything under the hood.
Enable Hillshade Relief
import 'package:flutter/material.dart';
import 'package:mapmetrics/mapmetrics.dart';
class TerrainScreen extends StatefulWidget {
const TerrainScreen({super.key});
@override
State<TerrainScreen> createState() => _TerrainScreenState();
}
class _TerrainScreenState extends State<TerrainScreen> {
MapController? mapController;
static const _sourceId = 'terrain-dem';
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Terrain Relief')),
body: MapMetricsView(
options: MapOptions(
initStyle:
'https://gateway.mapmetrics-atlas.net/styles/?fileName=YOUR_STYLE_ID/YOUR_STYLE.json&token=YOUR_API_KEY',
initCenter: Position(8.2275, 46.8182), // Swiss Alps
initZoom: 10,
initPitch: 60,
initBearing: 30,
),
onMapCreated: (controller) => mapController = controller,
onStyleLoaded: (style) async {
await style.addSource(
const RasterDemSource(
id: _sourceId,
tiles: [
'https://gateway.mapmetrics-atlas.net/terrain-rgb/{z}/{x}/{y}.png',
],
tileSize: 256,
),
);
await style.addLayer(
const HillshadeStyleLayer(
id: 'hillshade',
sourceId: _sourceId,
paint: {
'hillshade-shadow-color': '#473B24',
'hillshade-exaggeration': 0.6,
},
),
);
},
),
floatingActionButton: Column(
mainAxisAlignment: MainAxisAlignment.end,
children: [
FloatingActionButton(
heroTag: 'tilt',
onPressed: () => _setPitch(60),
tooltip: 'Tilted View',
child: const Icon(Icons.terrain),
),
const SizedBox(height: 8),
FloatingActionButton(
heroTag: 'flat',
onPressed: () => _setPitch(0),
tooltip: 'Top-Down View',
child: const Icon(Icons.map),
),
],
),
);
}
void _setPitch(double pitch) {
final camera = mapController?.camera;
if (camera != null) {
mapController?.animateCamera(
center: camera.center,
zoom: camera.zoom,
bearing: camera.bearing,
pitch: pitch,
);
}
}
}RasterDemSource.encoding defaults to Mapbox Terrain-RGB (RasterDemMapboxEncoding); pass RasterDemTerrariumEncoding() if your tile server serves Mapzen Terrarium PNGs instead.
Mountain Explorer with Location Buttons
Jump between famous mountain locations by animating pitch, bearing, zoom, and center together:
import 'package:flutter/material.dart';
import 'package:mapmetrics/mapmetrics.dart';
class MountainExplorerScreen extends StatefulWidget {
const MountainExplorerScreen({super.key});
@override
State<MountainExplorerScreen> createState() =>
_MountainExplorerScreenState();
}
class _MountainExplorerScreenState extends State<MountainExplorerScreen> {
MapController? mapController;
final mountains = const [
(name: 'Swiss Alps', point: Position(8.2275, 46.8182), zoom: 10.0, bearing: 30.0),
(name: 'Mont Blanc', point: Position(6.8652, 45.8326), zoom: 12.0, bearing: 150.0),
(name: 'Matterhorn', point: Position(7.6586, 45.9763), zoom: 13.0, bearing: 220.0),
(name: 'Dolomites', point: Position(11.8440, 46.4102), zoom: 11.0, bearing: 90.0),
(name: 'Pyrenees', point: Position(0.0414, 42.6953), zoom: 10.0, bearing: 45.0),
];
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Mountain Explorer')),
body: Stack(
children: [
MapMetricsView(
options: MapOptions(
initStyle:
'https://gateway.mapmetrics-atlas.net/styles/?fileName=YOUR_STYLE_ID/YOUR_STYLE.json&token=YOUR_API_KEY',
initCenter: Position(8.2275, 46.8182),
initZoom: 10,
initPitch: 60,
initBearing: 30,
),
onMapCreated: (controller) => mapController = controller,
onStyleLoaded: (style) async {
await style.addSource(
const RasterDemSource(
id: 'terrain-dem',
tiles: [
'https://gateway.mapmetrics-atlas.net/terrain-rgb/{z}/{x}/{y}.png',
],
tileSize: 256,
),
);
await style.addLayer(
const HillshadeStyleLayer(
id: 'hillshade',
sourceId: 'terrain-dem',
paint: {'hillshade-shadow-color': '#473B24'},
),
);
},
),
// Mountain selector
Positioned(
bottom: 16,
left: 8,
right: 8,
child: SizedBox(
height: 44,
child: ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: mountains.length,
separatorBuilder: (_, __) => const SizedBox(width: 8),
itemBuilder: (context, i) {
final m = mountains[i];
return ElevatedButton.icon(
onPressed: () {
mapController?.animateCamera(
center: m.point,
zoom: m.zoom,
pitch: 60,
bearing: m.bearing,
);
},
icon: const Icon(Icons.terrain, size: 16),
label: Text(m.name, style: const TextStyle(fontSize: 12)),
);
},
),
),
),
],
),
);
}
}Hillshade Intensity Slider
Instead of a fake "terrain exaggeration" control, expose the real hillshade-exaggeration paint property — remembering that, like every other paint property here, changing it means removing and re-adding the layer:
import 'package:flutter/material.dart';
import 'package:mapmetrics/mapmetrics.dart';
class HillshadeSliderScreen extends StatefulWidget {
const HillshadeSliderScreen({super.key});
@override
State<HillshadeSliderScreen> createState() => _HillshadeSliderScreenState();
}
class _HillshadeSliderScreenState extends State<HillshadeSliderScreen> {
MapController? mapController;
StyleController? styleController;
double exaggeration = 0.6;
bool _layerAdded = false;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Hillshade Intensity')),
body: Stack(
children: [
MapMetricsView(
options: MapOptions(
initStyle:
'https://gateway.mapmetrics-atlas.net/styles/?fileName=YOUR_STYLE_ID/YOUR_STYLE.json&token=YOUR_API_KEY',
initCenter: Position(8.2275, 46.8182),
initZoom: 10,
initPitch: 60,
),
onMapCreated: (controller) => mapController = controller,
onStyleLoaded: (style) async {
styleController = style;
await style.addSource(
const RasterDemSource(
id: 'terrain-dem',
tiles: [
'https://gateway.mapmetrics-atlas.net/terrain-rgb/{z}/{x}/{y}.png',
],
tileSize: 256,
),
);
await _updateHillshade();
},
),
// Intensity slider
Positioned(
bottom: 24,
left: 16,
right: 16,
child: Card(
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'Hillshade exaggeration: ${exaggeration.toStringAsFixed(1)}',
style: const TextStyle(fontWeight: FontWeight.bold),
),
Slider(
value: exaggeration,
min: 0.0,
max: 1.0,
divisions: 10,
label: exaggeration.toStringAsFixed(1),
onChanged: (value) {
setState(() => exaggeration = value);
_updateHillshade();
},
),
const Text(
'0 = flat shading | 1 = strongest relief contrast',
style: TextStyle(color: Colors.grey, fontSize: 11),
),
],
),
),
),
),
],
),
);
}
Future<void> _updateHillshade() async {
final style = styleController;
if (style == null) return;
if (_layerAdded) {
await style.removeLayer('hillshade');
}
_layerAdded = true;
await style.addLayer(
HillshadeStyleLayer(
id: 'hillshade',
sourceId: 'terrain-dem',
paint: {
'hillshade-shadow-color': '#473B24',
'hillshade-exaggeration': exaggeration,
},
),
);
}
}Terrain Parameters
| Parameter | Range | Description |
|---|---|---|
hillshade-exaggeration | 0.0 - 1.0 | Contrast of the shaded relief effect (real MapLibre paint property) |
tileSize | 256 / 512 | Resolution of the DEM tiles |
initPitch | 0 - 60 | Camera tilt angle for a 3D-feeling view |
Next Steps
- 3D Buildings — Add extruded buildings
- Add a Color Relief Layer — Elevation-tinted terrain coloring
- 3D Buildings with Shadow — Depth-aware building shading
Tip: initPitch does more for the "3D" feeling here than anything else — combine a 55-65 degree pitch with hillshade rather than chasing a terrain-exaggeration effect the SDK doesn't provide.