Skip to content

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:

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

PropertyTypeDescription
anchorStringviewport (relative to camera) or map (fixed direction)
colorStringLight color hex string
intensitydoubleBrightness (0.0 - 1.0)
positionList[radial, azimuthal, polar] — distance, compass angle, elevation
fillExtrusionVerticalGradientboolDarker at base, lighter at top

Next Steps


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.