Skip to content

Both copy text to your clipboard — Build with AI copies a setup prompt to paste into Claude Code, Cursor, Codex or Copilot; Copy page as Markdown copies this page to paste into a chat. How it works

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:

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

dart
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:

dart
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:

dart
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

ParameterRangeDescription
hillshade-exaggeration0.0 - 1.0Contrast of the shaded relief effect (real MapLibre paint property)
tileSize256 / 512Resolution of the DEM tiles
initPitch0 - 60Camera tilt angle for a 3D-feeling view

Next Steps


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.