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.
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.
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.
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.
heightmust 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.
Internal links
- 3D Tiles and the web delivery of 3D city models explained โ the heavyweight route
- How to export a 3D city model to glTF in Python โ producing the middle route
- A 3D viewer shows nothing or a black screen โ when it loads and shows nothing
- How to extrude building footprints into 3D in Python โ why you often should not
- How to prepare a GeoDataFrame for the web โ trimming the payload
- How to serve PMTiles with MapLibre โ tiled delivery without a server
- Vector tiles explained โ what tippecanoe is producing
- A web map layer is not appearing โ the 2D version of the same debugging
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.