3D Buildings with Shadow in Flutter
This tutorial shows how to display 3D extruded buildings with a shaded, depth-aware look — adding realism to your map without a live shadow-casting light source.
Prerequisites
Before you begin, ensure you have:
- Completed the Flutter Setup Guide
- A MapMetrics API key and style URL from the MapMetrics Portal
No Runtime Light API
MapMetrics Flutter doesn't expose a setLight (or any other) runtime lighting call — the MapLibre Style Spec's root-level light object can only be set in the style JSON itself, not changed live from Dart. There's also no setPaintProperty: to change a layer's paint at runtime you removeLayer then addLayer again with new values.
That still leaves two real, supported ways to get a shadow-like look:
fill-extrusion-vertical-gradient— a real MapLibre paint property (darker at the base, lighter at the top) that you can set directly in aFillExtrusionStyleLayer'spaintmap.- Swap
fill-extrusion-colorby removing/re-adding the layer — simulate different light conditions (morning/noon/evening) by changing the building color, rather than moving an actual light source.
3D Buildings with a Vertical Gradient
import 'package:flutter/material.dart';
import 'package:mapmetrics/mapmetrics.dart';
class BuildingsWithShadowScreen extends StatefulWidget {
const BuildingsWithShadowScreen({super.key});
@override
State<BuildingsWithShadowScreen> createState() =>
_BuildingsWithShadowScreenState();
}
class _BuildingsWithShadowScreenState
extends State<BuildingsWithShadowScreen> {
MapController? mapController;
StyleController? styleController;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('3D Buildings with Shadow')),
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.3376, 48.8606), // Louvre area
initZoom: 16.5,
initPitch: 60,
initBearing: -30,
),
onMapCreated: (controller) => mapController = controller,
onStyleLoaded: (style) async {
styleController = style;
await _addBuildingsWithShadow('#b0b0b0');
},
),
floatingActionButton: Column(
mainAxisAlignment: MainAxisAlignment.end,
children: [
FloatingActionButton.small(
heroTag: 'morning',
onPressed: () => _addBuildingsWithShadow('#c0a060'),
tooltip: 'Morning Light',
child: const Icon(Icons.wb_twilight),
),
const SizedBox(height: 8),
FloatingActionButton.small(
heroTag: 'noon',
onPressed: () => _addBuildingsWithShadow('#b0b0b0'),
tooltip: 'Noon Light',
child: const Icon(Icons.wb_sunny),
),
const SizedBox(height: 8),
FloatingActionButton.small(
heroTag: 'evening',
onPressed: () => _addBuildingsWithShadow('#8b6914'),
tooltip: 'Evening Light',
child: const Icon(Icons.nights_stay),
),
],
),
);
}
Future<void> _addBuildingsWithShadow(String buildingColor) async {
final style = styleController;
if (style == null) return;
// removeLayer is a no-op error if the layer doesn't exist yet on first
// call -- guard it in production code with a try/catch or a "did I add
// this already" flag.
try {
await style.removeLayer('3d-buildings-shadow');
} catch (_) {
// First call: nothing to remove yet.
}
await style.addLayer(
FillExtrusionStyleLayer(
id: '3d-buildings-shadow',
sourceId: 'openmaptiles', // must match a source in your style
paint: {
'fill-extrusion-color': buildingColor,
'fill-extrusion-opacity': 0.85,
'fill-extrusion-height': ['get', 'render_height'],
'fill-extrusion-base': ['get', 'render_min_height'],
// Darker at the base, lighter toward the roofline -- a real
// MapLibre paint property, not a light simulation.
'fill-extrusion-vertical-gradient': true,
},
layout: const {'source-layer': 'building'},
),
);
}
}Time-of-Day Color Simulation
Since there's no live light source to move, "time of day" here means picking a building color and background feel per hour and re-adding the layer — the closest real equivalent of the original light-angle animation:
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:mapmetrics/mapmetrics.dart';
class ShadowTimeScreen extends StatefulWidget {
const ShadowTimeScreen({super.key});
@override
State<ShadowTimeScreen> createState() => _ShadowTimeScreenState();
}
class _ShadowTimeScreenState extends State<ShadowTimeScreen> {
MapController? mapController;
StyleController? styleController;
double timeOfDay = 12.0; // 0-24 hours
Timer? animTimer;
bool isAnimating = false;
bool _layerAdded = false;
String _colorForHour(double hour) {
// Building color: warmer tones at golden hour, neutral at midday.
if (hour < 7 || hour > 19) return '#8B6914'; // Dark warm
if (hour < 9 || hour > 17) return '#C0A060'; // Warm
return '#b0b0b0'; // Neutral grey
}
String _hourLabel(double hour) {
final h = hour.toInt();
final m = ((hour - h) * 60).toInt();
final period = h >= 12 ? 'PM' : 'AM';
final displayH = h > 12 ? h - 12 : (h == 0 ? 12 : h);
return '$displayH:${m.toString().padLeft(2, '0')} $period';
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Shadow Time Simulation')),
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.3376, 48.8606),
initZoom: 16.5,
initPitch: 55,
initBearing: -20,
),
onMapCreated: (controller) => mapController = controller,
onStyleLoaded: (style) async {
styleController = style;
await _updateBuildings();
},
),
// Time controls
Positioned(
bottom: 16,
left: 16,
right: 16,
child: Card(
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
_hourLabel(timeOfDay),
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
),
),
ElevatedButton.icon(
onPressed:
isAnimating ? _stopAnimation : _startAnimation,
icon: Icon(
isAnimating ? Icons.pause : Icons.play_arrow,
),
label: Text(isAnimating ? 'Pause' : 'Animate'),
),
],
),
Slider(
value: timeOfDay,
min: 5.0,
max: 21.0,
divisions: 64,
label: _hourLabel(timeOfDay),
onChanged: (val) {
setState(() => timeOfDay = val);
_updateBuildings();
},
),
],
),
),
),
),
],
),
);
}
Future<void> _updateBuildings() async {
final style = styleController;
if (style == null) return;
if (_layerAdded) {
await style.removeLayer('3d-buildings');
}
_layerAdded = true;
await style.addLayer(
FillExtrusionStyleLayer(
id: '3d-buildings',
sourceId: 'openmaptiles',
paint: {
'fill-extrusion-color': _colorForHour(timeOfDay),
'fill-extrusion-opacity': 0.85,
'fill-extrusion-height': ['get', 'render_height'],
'fill-extrusion-base': ['get', 'render_min_height'],
'fill-extrusion-vertical-gradient': true,
},
layout: const {'source-layer': 'building'},
),
);
}
void _startAnimation() {
setState(() {
isAnimating = true;
if (timeOfDay >= 21.0) timeOfDay = 5.0;
});
animTimer = Timer.periodic(const Duration(milliseconds: 300), (_) {
if (timeOfDay >= 21.0) {
_stopAnimation();
return;
}
setState(() => timeOfDay += 0.2);
_updateBuildings();
});
}
void _stopAnimation() {
animTimer?.cancel();
setState(() => isAnimating = false);
}
@override
void dispose() {
animTimer?.cancel();
super.dispose();
}
}Removing and re-adding a style layer every animation tick is noticeably heavier than a native paint-property setter would be, so the interval here is deliberately slower (300ms) than a 60fps camera animation — treat this as a "few keyframes over the day" simulation, not a smooth light sweep.
Fill-Extrusion Paint Properties Used Here
| Property | Type | Description |
|---|---|---|
fill-extrusion-color | String | Building wall/roof color |
fill-extrusion-opacity | double | Transparency (0.0 - 1.0) |
fill-extrusion-vertical-gradient | bool | Darker at base, lighter at top |
fill-extrusion-height / fill-extrusion-base | Expression | Building height/base from source properties |
Next Steps
- 3D Buildings — Basic 3D building setup
- Add a Popup — Show building details on tap
- Add Animated Icon — Animate markers instead of layers
Tip: Since there's no live light setter, reach for fill-extrusion-vertical-gradient: true first — it gives buildings a believable sense of depth with zero runtime cost, no layer swapping required.