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:
- Completed the Flutter Setup Guide
- A MapMetrics API key and style URL from the MapMetrics Portal
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:
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:
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:
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
| Property | Description |
|---|---|
fill-extrusion-color | Building wall/roof color |
fill-extrusion-opacity | Transparency (0.0 - 1.0) |
fill-extrusion-height | Building height in meters (a ['get', ...] expression reading the source property) |
fill-extrusion-base | Base 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
- 3D Buildings with Shadow — Add light-based shading
- Add a Cluster — Group markers at low zoom
- Add a Popup — Show details when a building is tapped
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.