How to publish a 3D building layer to a web map

Problem statement

There are three ways to get buildings into a browser in 3D, and the difference between them is two orders of magnitude of effort. Extruding footprints client-side is a fill-extrusion layer and a GeoJSON. A single glTF is a file and a model layer. 3D Tiles is a pipeline.

Picking the heaviest option for a neighbourhood is the common mistake, and it is expensive: you build a tiling pipeline for four thousand buildings that a browser would have extruded from a 300 KB GeoJSON without noticing.

This guide covers all three, what each costs, and the thresholds where you move up.

Quick answer

For anything up to a few tens of thousands of buildings, ship footprints with a height property and let the GPU extrude:

import geopandas as gpd

web = b[["id", "height_m", "ground_z", "geometry"]].to_crs(4326)
web["base_m"] = 0.0
web.to_file("buildings.geojson", driver="GeoJSON", COORDINATE_PRECISION=6)
map.addLayer({
  id: 'buildings-3d', type: 'fill-extrusion', source: 'buildings',
  paint: {
    'fill-extrusion-height': ['get', 'height_m'],
    'fill-extrusion-base': ['get', 'base_m'],
    'fill-extrusion-color': [
      'interpolate', ['linear'], ['get', 'height_m'],
      0, '#e8f4fd', 10, '#7dd3fc', 25, '#0ea5e9', 50, '#1a3a6b'
    ],
    'fill-extrusion-opacity': 0.9
  }
});

The payload stays 2D, the browser does the extrusion in a frame, and the data is still queryable โ€” a click returns a feature with its properties, which a mesh does not.

Three routes to 3D buildings in a browser: extruded vector data, a single glTF, and 3D Tiles.
Effort rises by an order of magnitude at each step; pick by building count.

Step-by-step solution

1. Choose the route by scale

  • Up to ~50,000 buildings: GeoJSON or vector tiles with fill-extrusion.
  • One block, with real roofs: a single glTF placed as a model layer.
  • A whole city, with LoD and streaming: 3D Tiles.

2. Prepare the vector data properly

Reproject to EPSG:4326, drop every column the map does not use, round coordinates, and simplify the footprints to something appropriate for the zoom levels you serve.

web = (b.to_crs(4326)
        [["id", "height_m", "use", "geometry"]]
        .assign(geometry=lambda d: d.geometry.simplify(0.000015)))

3. Move to vector tiles when the GeoJSON gets big

Above a few megabytes, a single GeoJSON stalls the browser on load. PMTiles gives you tiled delivery from static hosting with no server.

4. Set base as well as height

fill-extrusion-base lets a building start above zero, which is how you represent terrain-following buildings and how you stack floors. Without it, every building starts at the ellipsoid and buildings on a slope look wrong.

5. Decide about terrain

MapLibre and Mapbox can drape extrusions over a terrain source. With terrain enabled, fill-extrusion-base is relative to the terrain, which means a base of 0 is the right answer โ€” the opposite of the flat case.

6. Style by data, not by height alone

Colouring by use, age or energy rating is what makes a 3D map informative rather than decorative. Height is already encoded by the geometry.

7. Budget the payload

A browser will hold a few tens of megabytes of geometry comfortably. Count your features, estimate the vertices, and test on a mid-range phone rather than a workstation.

Bars of typical payload size for 5,000, 50,000 and 500,000 buildings by delivery route.
The thresholds are about payload, not about how modern the technology is.

Code examples

Example 1 โ€” the vector route, end to end

import geopandas as gpd, json, pathlib

def prepare_web_buildings(b, out="buildings.geojson", simplify_deg=0.000015,
                          keep=("id", "height_m", "use")):
    web = b.to_crs(4326).copy()
    web["geometry"] = web.geometry.simplify(simplify_deg, preserve_topology=True)
    web = web[[*keep, "geometry"]]
    web = web[web.geometry.notna() & ~web.geometry.is_empty]
    web.to_file(out, driver="GeoJSON", COORDINATE_PRECISION=6)

    size_kb = pathlib.Path(out).stat().st_size / 1024
    print(f"{len(web):,} buildings, {size_kb:,.0f} KB "
          f"({size_kb * 1024 / len(web):.0f} bytes each)")
    return out

prepare_web_buildings(b)

Bytes per building is the number to watch. Above about 400 bytes each, the footprints need more simplification or the columns need trimming.

Example 2 โ€” the same data as PMTiles

import subprocess, geopandas as gpd

b.to_crs(4326).to_file("buildings.fgb", driver="FlatGeobuf")
subprocess.run([
    "tippecanoe", "-o", "buildings.pmtiles", "-l", "buildings",
    "-Z", "13", "-z", "16",
    "--drop-densest-as-needed", "--extend-zooms-if-still-dropping",
    "--force", "buildings.fgb",
], check=True)

-Z 13 is deliberate: below zoom 13 individual buildings are sub-pixel, so serving them wastes bandwidth. Extrusions are only meaningful when the map is pitched and zoomed in anyway.

Example 3 โ€” a single glTF for one block, placed on the map

import json, pathlib

# export the mesh (see the glTF guide), then record where it goes
placement = {
    "model": "block.glb",
    "origin_crs": "EPSG:7415",
    "origin": [78_642.0, 457_940.0, 0.0],
    "origin_wgs84": [4.2726, 52.1044, 0.0],
    "axis_convention": "Y-up, rotated -90ยฐ about X from Z-up",
    "vertical_datum": "NAP; add the geoid separation for ellipsoidal placement",
}
pathlib.Path("block.placement.json").write_text(json.dumps(placement, indent=2))

The vertical datum note is the one that saves an afternoon. A model in a national height datum placed with those numbers as ellipsoidal heights sinks by the geoid separation โ€” about 43 m in the Netherlands.

Explanation

Why client-side extrusion is usually right

The GPU extrudes a polygon into a prism essentially for free; the work is uploading the polygon. Sending the prism instead means sending roughly ten times the vertices for the same information, plus losing the ability to restyle, filter or query without regenerating the data. The exception is when the geometry is not a prism โ€” real roofs โ€” and then you are sending a mesh because you need one.

Why zoom limits matter more than simplification

An extruded building is only visible on a pitched, zoomed-in map. Serving building tiles at zoom 10 costs bandwidth for geometry that occupies a fraction of a pixel and cannot be seen at any pitch. Setting a minimum zoom is a bigger saving than any amount of coordinate rounding.

Why fill-extrusion-base behaves differently with terrain

Without a terrain source, the base is metres above the map's zero plane. With terrain enabled, the renderer places extrusions on the terrain surface, so a base of 0 sits on the ground and a non-zero base floats above it. Code written for one case is wrong in the other, and the symptom is buildings buried in a hill or hovering over it.

Why 3D Tiles is a different kind of commitment

3D Tiles is a streaming format with a tiling pipeline, a level-of-detail tree, a georeferencing transform and a client that must support it. It is the right answer for a city-scale model with real roofs and per-feature metadata, and it is a lot of machinery for a neighbourhood. The honest test is whether the payload fits in a browser; if it does, do not tile it.

Triage of four fill-extrusion problems โ€” a string height, missing heights, a base copied from the flat case and serving below zoom 13 โ€” with the fix for each.
A string height renders as zero, silently, in every renderer.

Edge cases or notes

  • Opacity is expensive. Semi-transparent extrusions force depth sorting.
  • Extrusions do not cast shadows in MapLibre; the light model is ambient plus directional.
  • height must be a number. A string property renders nothing, silently.
  • Missing heights render as zero. Filter them out or substitute a default.
  • Pitch limits vary by renderer. Test the pitch your users will actually use.
  • Mobile GPUs choke sooner. Test on a phone.
  • Keep the id. A click has to resolve back to the data.
  • Colour by data, not by height. The geometry already shows height.

FAQ

What is the simplest way to show 3D buildings on a web map?

A GeoJSON of footprints with a height property and a MapLibre fill-extrusion layer. The browser extrudes on the GPU and the payload stays 2D.

When should I move to vector tiles?

When the GeoJSON exceeds a few megabytes. PMTiles gives tiled delivery from static hosting with no server.

When do I need 3D Tiles?

At city scale with real roof geometry and per-feature metadata. For a neighbourhood it is a great deal of machinery for no benefit.

Why are my buildings buried in the terrain?

fill-extrusion-base is relative to the terrain when a terrain source is enabled and to the map plane when it is not. A base copied from the flat case sinks buildings into a hill.

Why is nothing extruded?

Usually because the height property is a string rather than a number, or is missing. Both render as zero with no error.

What zoom levels should I serve buildings at?

Thirteen and above. Below that an individual building is sub-pixel and cannot be seen at any pitch.