3D Buildings with Shadow in Flutter
This tutorial shows how to display 3D extruded buildings with realistic shadow effects based on a simulated light source — adding depth and realism to your map.
Prerequisites
Before you begin, ensure you have:
- Completed the Flutter Setup Guide
- A MapMetrics API key and style URL from the MapMetrics Portal
3D Buildings with Light and Shadow
Add extruded buildings with shadow color and adjustable light position:
dart
import 'package:flutter/material.dart';
import 'package:mapmetrics/mapmetrics.dart';
class BuildingsWithShadowScreen extends StatefulWidget {
@override
_BuildingsWithShadowScreenState createState() =>
_BuildingsWithShadowScreenState();
}
class _BuildingsWithShadowScreenState
extends State<BuildingsWithShadowScreen> {
MapMetricsController? mapController;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('3D Buildings with Shadow')),
body: MapMetrics(
styleUrl:
'https://gateway.mapmetrics.org/styles/YOUR_STYLE_ID?token=YOUR_API_KEY',
onMapCreated: (MapMetricsController controller) {
mapController = controller;
},
initialCameraPosition: CameraPosition(
target: LatLng(48.8606, 2.3376), // Louvre area
zoom: 16.5,
tilt: 60.0,
bearing: -30.0,
),
onStyleLoaded: () {
_addBuildingsWithShadow();
},
),
floatingActionButton: Column(
mainAxisAlignment: MainAxisAlignment.end,
children: [
FloatingActionButton.small(
heroTag: 'morning',
onPressed: () => _setLightAngle(90.0, 30.0),
child: Icon(Icons.wb_twilight),
tooltip: 'Morning Light',
),
SizedBox(height: 8),
FloatingActionButton.small(
heroTag: 'noon',
onPressed: () => _setLightAngle(180.0, 80.0),
child: Icon(Icons.wb_sunny),
tooltip: 'Noon Light',
),
SizedBox(height: 8),
FloatingActionButton.small(
heroTag: 'evening',
onPressed: () => _setLightAngle(270.0, 20.0),
child: Icon(Icons.nights_stay),
tooltip: 'Evening Light',
),
],
),
);
}
void _addBuildingsWithShadow() {
// Set the global light source for shadow casting
mapController?.setLight(
anchor: 'viewport',
color: '#ffffff',
intensity: 0.4,
position: [1.5, 180.0, 40.0], // [radial, azimuthal, polar]
);
// Add 3D buildings with shadow-aware colors
mapController?.addFillExtrusionLayer(
'3d-buildings-shadow',
'composite',
sourceLayer: 'building',
fillExtrusionColor: '#b0b0b0',
fillExtrusionOpacity: 0.85,
fillExtrusionHeight: ['get', 'height'],
fillExtrusionBase: ['get', 'min_height'],
fillExtrusionVerticalGradient: true, // Darker at base, lighter at top
minZoom: 14.0,
);
}
void _setLightAngle(double azimuthal, double polar) {
mapController?.setLight(
anchor: 'viewport',
color: '#ffffff',
intensity: 0.4,
position: [1.5, azimuthal, polar],
);
}
}Time-of-Day Shadow Simulation
Simulate how building shadows change throughout the day:
dart
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:mapmetrics/mapmetrics.dart';
class ShadowTimeScreen extends StatefulWidget {
@override
_ShadowTimeScreenState createState() => _ShadowTimeScreenState();
}
class _ShadowTimeScreenState extends State<ShadowTimeScreen> {
MapMetricsController? mapController;
double timeOfDay = 12.0; // 0-24 hours
Timer? animTimer;
bool isAnimating = false;
// Map hour to light settings
Map<String, dynamic> _lightForHour(double hour) {
// Azimuthal: sun moves east (90°) → south (180°) → west (270°)
final azimuthal = 90.0 + (hour - 6) * 15.0; // 6AM=90°, noon=180°, 6PM=270°
// Polar: low at sunrise/sunset, high at noon
final noonDist = (hour - 12).abs();
final polar = 80.0 - noonDist * 8.0; // 80° at noon, ~32° at 6AM/6PM
// Intensity: brighter midday, dimmer morning/evening
final intensity = 0.2 + (1.0 - noonDist / 6.0).clamp(0.0, 1.0) * 0.3;
// Light color: warm at sunrise/sunset, white at noon
String color;
if (hour < 7 || hour > 19) {
color = '#FF8C00'; // Deep orange
} else if (hour < 9 || hour > 17) {
color = '#FFB74D'; // Warm orange
} else {
color = '#FFFFFF'; // White
}
// Building color: warmer tones at golden hour
String buildingColor;
if (hour < 7 || hour > 19) {
buildingColor = '#8B6914'; // Dark warm
} else if (hour < 9 || hour > 17) {
buildingColor = '#C0A060'; // Warm
} else {
buildingColor = '#b0b0b0'; // Neutral grey
}
return {
'azimuthal': azimuthal.clamp(45.0, 315.0),
'polar': polar.clamp(10.0, 80.0),
'intensity': intensity,
'color': color,
'buildingColor': buildingColor,
};
}
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: Text('Shadow Time Simulation')),
body: Stack(
children: [
MapMetrics(
styleUrl:
'https://gateway.mapmetrics.org/styles/YOUR_STYLE_ID?token=YOUR_API_KEY',
onMapCreated: (MapMetricsController controller) {
mapController = controller;
},
initialCameraPosition: CameraPosition(
target: LatLng(48.8606, 2.3376),
zoom: 16.5,
tilt: 55.0,
bearing: -20.0,
),
onStyleLoaded: () {
_setupBuildings();
_updateLight();
},
),
// Time controls
Positioned(
bottom: 16,
left: 16,
right: 16,
child: Card(
child: Padding(
padding: EdgeInsets.all(12),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
_hourLabel(timeOfDay),
style: 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);
_updateLight();
},
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('5 AM', style: TextStyle(fontSize: 11, color: Colors.grey)),
Text('Noon', style: TextStyle(fontSize: 11, color: Colors.grey)),
Text('9 PM', style: TextStyle(fontSize: 11, color: Colors.grey)),
],
),
],
),
),
),
),
],
),
);
}
void _setupBuildings() {
final settings = _lightForHour(timeOfDay);
mapController?.setLight(
anchor: 'viewport',
color: settings['color'],
intensity: settings['intensity'],
position: [1.5, settings['azimuthal'], settings['polar']],
);
mapController?.addFillExtrusionLayer(
'3d-buildings',
'composite',
sourceLayer: 'building',
fillExtrusionColor: settings['buildingColor'],
fillExtrusionOpacity: 0.85,
fillExtrusionHeight: ['get', 'height'],
fillExtrusionBase: ['get', 'min_height'],
fillExtrusionVerticalGradient: true,
minZoom: 14.0,
);
}
void _updateLight() {
final settings = _lightForHour(timeOfDay);
mapController?.setLight(
anchor: 'viewport',
color: settings['color'],
intensity: settings['intensity'],
position: [1.5, settings['azimuthal'], settings['polar']],
);
mapController?.setPaintProperty(
'3d-buildings',
'fill-extrusion-color',
settings['buildingColor'],
);
}
void _startAnimation() {
setState(() {
isAnimating = true;
if (timeOfDay >= 21.0) timeOfDay = 5.0;
});
animTimer = Timer.periodic(Duration(milliseconds: 80), (_) {
if (timeOfDay >= 21.0) {
_stopAnimation();
return;
}
setState(() {
timeOfDay += 0.05;
});
_updateLight();
});
}
void _stopAnimation() {
animTimer?.cancel();
setState(() => isAnimating = false);
}
@override
void dispose() {
animTimer?.cancel();
super.dispose();
}
}Light Properties
| Property | Type | Description |
|---|---|---|
anchor | String | viewport (relative to camera) or map (fixed direction) |
color | String | Light color hex string |
intensity | double | Brightness (0.0 - 1.0) |
position | List | [radial, azimuthal, polar] — distance, compass angle, elevation |
fillExtrusionVerticalGradient | bool | Darker at base, lighter at top |
Next Steps
- 3D Buildings — Basic 3D building setup
- Building Color by Zoom — Zoom-dependent colors
- Sky, Fog & Terrain — Atmospheric effects
Tip: Use anchor: 'viewport' so shadows rotate with the camera (feels natural), or anchor: 'map' so shadows stay fixed to compass direction (geographically accurate). The time-of-day animation makes a great demo for real estate or urban planning apps.