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 Building Visualization in Flutter

This tutorial shows how to display 3D extruded buildings on your MapMetrics Flutter map — great for urban planning, real estate, and city exploration apps.

Prerequisites

Before you begin, ensure you have:

Enable 3D Buildings

3D buildings are a FillExtrusionStyleLayer you add on top of a vector source that already has building footprints — most MapMetrics vector styles ship one. Check your style JSON for the source name (it's usually something like openmaptiles) and the building source-layer, then tilt the camera so the extrusion is visible:

dart
import 'package:flutter/material.dart';
import 'package:mapmetrics/mapmetrics.dart';

class Buildings3DScreen extends StatefulWidget {
  const Buildings3DScreen({super.key});

  @override
  State<Buildings3DScreen> createState() => _Buildings3DScreenState();
}

class _Buildings3DScreenState extends State<Buildings3DScreen> {
  MapController? mapController;

  static const _buildingsLayer = FillExtrusionStyleLayer(
    id: '3d-buildings',
    sourceId: 'openmaptiles', // must match a source already in your style
    paint: {
      // See the MapLibre Style Specification for details on data expressions.
      // https://maplibre.org/maplibre-style-spec/expressions/
      'fill-extrusion-color': '#aaaaaa',
      'fill-extrusion-height': ['get', 'render_height'],
      'fill-extrusion-base': ['get', 'render_min_height'],
      // Fade the layer in above zoom 14 instead of a hard minZoom cutoff --
      // FillExtrusionStyleLayer doesn't expose a minZoom parameter.
      'fill-extrusion-opacity': [
        'interpolate',
        ['linear'],
        ['zoom'],
        13,
        0,
        14,
        0.6,
      ],
    },
    layout: {'source-layer': 'building'},
  );

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('3D Buildings')),
      body: MapMetricsView(
        options: MapOptions(
          initStyle:
              'https://gateway.mapmetrics-atlas.net/styles/?fileName=YOUR_STYLE_ID/YOUR_STYLE.json&token=YOUR_API_KEY',
          initCenter: Position(2.2945, 48.8584), // Eiffel Tower area
          initZoom: 16,
          initPitch: 60, // Tilt to see buildings in 3D
          initBearing: 45, // Rotate for a better perspective
        ),
        onMapCreated: (controller) => mapController = controller,
        onStyleLoaded: (style) async {
          await style.addLayer(_buildingsLayer);
        },
      ),
      floatingActionButton: Column(
        mainAxisAlignment: MainAxisAlignment.end,
        children: [
          FloatingActionButton(
            heroTag: 'tilt_up',
            onPressed: () => _setPitch(60),
            tooltip: '3D View',
            child: const Icon(Icons.landscape),
          ),
          const SizedBox(height: 8),
          FloatingActionButton(
            heroTag: 'tilt_down',
            onPressed: () => _setPitch(0),
            tooltip: '2D 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,
      );
    }
  }
}

3D Buildings with Custom Colors

Color buildings by swapping the layer out — there's no per-property setter, so changing color means removeLayer followed by addLayer with the new paint values:

dart
import 'package:flutter/material.dart';
import 'package:mapmetrics/mapmetrics.dart';

class ColoredBuildings3DScreen extends StatefulWidget {
  const ColoredBuildings3DScreen({super.key});

  @override
  State<ColoredBuildings3DScreen> createState() =>
      _ColoredBuildings3DScreenState();
}

class _ColoredBuildings3DScreenState extends State<ColoredBuildings3DScreen> {
  MapController? mapController;
  StyleController? styleController;
  String colorScheme = 'height'; // 'height', 'blue', 'warm'

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Colored 3D Buildings')),
      body: Column(
        children: [
          // Color scheme selector
          Padding(
            padding: const EdgeInsets.all(8),
            child: Row(
              mainAxisAlignment: MainAxisAlignment.spaceEvenly,
              children: [
                _schemeChip('Height', 'height'),
                _schemeChip('Cool Blue', 'blue'),
                _schemeChip('Warm Sunset', 'warm'),
              ],
            ),
          ),
          Expanded(
            child: MapMetricsView(
              options: MapOptions(
                initStyle:
                    'https://gateway.mapmetrics-atlas.net/styles/?fileName=YOUR_STYLE_ID/YOUR_STYLE.json&token=YOUR_API_KEY',
                initCenter: Position(2.3376, 48.8606), // Louvre area
                initZoom: 16,
                initPitch: 55,
                initBearing: -20,
              ),
              onMapCreated: (controller) => mapController = controller,
              onStyleLoaded: (style) async {
                styleController = style;
                await _applyColorScheme();
              },
            ),
          ),
        ],
      ),
    );
  }

  Widget _schemeChip(String label, String scheme) {
    return ChoiceChip(
      label: Text(label),
      selected: colorScheme == scheme,
      onSelected: (selected) {
        if (selected) {
          setState(() => colorScheme = scheme);
          _applyColorScheme();
        }
      },
    );
  }

  Future<void> _applyColorScheme() async {
    final style = styleController;
    if (style == null) return;

    String color;
    switch (colorScheme) {
      case 'blue':
        color = '#4a90d9';
        break;
      case 'warm':
        color = '#e8775a';
        break;
      default: // height-based
        color = '#8a8a8a';
    }

    // Layers have no property setters -- remove and re-add with the new paint.
    await style.removeLayer('3d-buildings');
    await style.addLayer(
      FillExtrusionStyleLayer(
        id: '3d-buildings',
        sourceId: 'openmaptiles',
        paint: {
          'fill-extrusion-color': color,
          'fill-extrusion-opacity': 0.7,
          'fill-extrusion-height': ['get', 'render_height'],
          'fill-extrusion-base': ['get', 'render_min_height'],
        },
        layout: const {'source-layer': 'building'},
      ),
    );
  }
}

Interactive 3D Building Explorer

Tap landmark buttons to fly the camera between famous spots, and toggle a slow auto-rotation:

dart
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:mapmetrics/mapmetrics.dart';

class BuildingExplorerScreen extends StatefulWidget {
  const BuildingExplorerScreen({super.key});

  @override
  State<BuildingExplorerScreen> createState() =>
      _BuildingExplorerScreenState();
}

class _BuildingExplorerScreenState extends State<BuildingExplorerScreen> {
  MapController? mapController;
  Timer? rotationTimer;
  double currentBearing = 0;
  bool isRotating = false;

  final landmarks = const [
    (name: 'Eiffel Tower Area', point: Position(2.2945, 48.8584), zoom: 17.0, pitch: 60.0),
    (name: 'Louvre Area', point: Position(2.3376, 48.8606), zoom: 16.5, pitch: 55.0),
    (name: 'Notre-Dame Area', point: Position(2.3499, 48.8530), zoom: 17.0, pitch: 65.0),
    (name: 'Opera Area', point: Position(2.3316, 48.8720), zoom: 16.5, pitch: 55.0),
  ];

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('3D 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(2.2945, 48.8584),
              initZoom: 17,
              initPitch: 60,
            ),
            onMapCreated: (controller) => mapController = controller,
            onStyleLoaded: (style) async {
              await style.addLayer(
                const FillExtrusionStyleLayer(
                  id: '3d-buildings',
                  sourceId: 'openmaptiles',
                  paint: {
                    'fill-extrusion-color': '#667799',
                    'fill-extrusion-opacity': 0.7,
                    'fill-extrusion-height': ['get', 'render_height'],
                    'fill-extrusion-base': ['get', 'render_min_height'],
                  },
                  layout: {'source-layer': 'building'},
                ),
              );
            },
          ),
          // Landmark buttons
          Positioned(
            bottom: 24,
            left: 8,
            right: 8,
            child: SizedBox(
              height: 44,
              child: ListView.separated(
                scrollDirection: Axis.horizontal,
                itemCount: landmarks.length,
                separatorBuilder: (_, __) => const SizedBox(width: 8),
                itemBuilder: (context, i) {
                  final lm = landmarks[i];
                  return ElevatedButton(
                    onPressed: () {
                      mapController?.animateCamera(
                        center: lm.point,
                        zoom: lm.zoom,
                        pitch: lm.pitch,
                        bearing: currentBearing,
                      );
                    },
                    child: Text(lm.name, style: const TextStyle(fontSize: 12)),
                  );
                },
              ),
            ),
          ),
          // Rotate toggle
          Positioned(
            top: 16,
            right: 16,
            child: FloatingActionButton.small(
              onPressed: _toggleRotation,
              tooltip: 'Auto Rotate',
              child: Icon(isRotating ? Icons.pause : Icons.rotate_right),
            ),
          ),
        ],
      ),
    );
  }

  void _toggleRotation() {
    if (isRotating) {
      rotationTimer?.cancel();
      setState(() => isRotating = false);
    } else {
      setState(() => isRotating = true);
      rotationTimer = Timer.periodic(const Duration(milliseconds: 50), (_) {
        currentBearing = (currentBearing + 0.5) % 360;
        final camera = mapController?.camera;
        if (camera != null) {
          // moveCameraSync avoids the microtask gap that shows up as
          // visible stutter in a tight per-frame rotation loop.
          mapController?.moveCameraSync(
            center: camera.center,
            zoom: camera.zoom,
            pitch: camera.pitch,
            bearing: currentBearing,
          );
        }
      });
    }
  }

  @override
  void dispose() {
    rotationTimer?.cancel();
    super.dispose();
  }
}

3D Building Paint Properties

PropertyDescription
fill-extrusion-colorBuilding wall/roof color
fill-extrusion-opacityTransparency (0.0 - 1.0)
fill-extrusion-heightBuilding height in meters (a ['get', ...] expression reading the source property)
fill-extrusion-baseBase height (for elevated structures)

FillExtrusionStyleLayer doesn't accept a minZoom constructor argument the way some other layer types do — use a 'zoom' interpolation on fill-extrusion-opacity (as in the first example) to fade buildings in instead of hiding them below a hard cutoff.

Next Steps


Tip: 3D buildings look best at zoom levels 15-18 with a pitch of 45-65 degrees. Combine with a bearing rotation for dramatic fly-through effects.