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 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:

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:

  1. fill-extrusion-vertical-gradient — a real MapLibre paint property (darker at the base, lighter at the top) that you can set directly in a FillExtrusionStyleLayer's paint map.
  2. Swap fill-extrusion-color by 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

dart
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:

dart
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

PropertyTypeDescription
fill-extrusion-colorStringBuilding wall/roof color
fill-extrusion-opacitydoubleTransparency (0.0 - 1.0)
fill-extrusion-vertical-gradientboolDarker at base, lighter at top
fill-extrusion-height / fill-extrusion-baseExpressionBuilding height/base from source properties

Next Steps


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.