3D Tiles and the web delivery of 3D city models explained
Problem statement
A city-scale 3D model is gigabytes. A browser has a few hundred megabytes of memory and a frame budget of sixteen milliseconds. Something has to decide which buildings to send, at what detail, in what order โ and that something is a tiling scheme, not a file format.
3D Tiles is the OGC standard for this: a spatial tree of tiles, each with a bounding volume and a geometric error, and a client that walks the tree until the error is below what the current view can resolve. Understanding it is mostly understanding that one number, because everything about the experience โ what loads, when, and how much โ follows from it.
This guide covers the tileset structure, how geometric error drives loading, the payload formats, and how a Python-produced city model gets into one.
Quick answer
A tileset is a JSON tree; each node has a bounding volume, a geometric error and optionally a content URI:
{
"asset": {"version": "1.1"},
"geometricError": 500,
"root": {
"boundingVolume": {"region": [0.0744, 0.9093, 0.0746, 0.9095, 2.4, 37.5]},
"geometricError": 100,
"refine": "REPLACE",
"content": {"uri": "l0/tile.glb"},
"children": [
{"boundingVolume": {"...": "..."}, "geometricError": 20,
"content": {"uri": "l1/0.glb"}}
]
}
}
The client computes the on-screen error of each tile from its geometricError and the camera distance, and descends only where that exceeds a threshold. A tileset whose geometric errors are wrong will either load everything at once or never load any detail.
Step-by-step solution
1. Understand geometric error
It is the error, in metres, introduced by rendering this tile instead of its children. A root tile that replaces a whole city has a large value; a leaf holding real geometry has zero or near zero. The client projects it to screen space and compares against a Screen Space Error budget, typically 16 pixels.
2. Choose REPLACE or ADD
REPLACE means a child's geometry replaces the parent's โ used for level-of-detail pyramids. ADD means children add to the parent โ used when the parent holds a coarse subset and children fill in. Most building tilesets use REPLACE.
3. Pick a bounding volume type
region is a geographic box in radians plus heights, box is an oriented box in the tileset's coordinate system, and sphere is a centre and radius. region is easiest to produce from geographic data; box gives tighter culling for a city block.
4. Know the payload
Tile content is glTF (.glb) in 3D Tiles 1.1, with the older .b3dm container still widely encountered. glTF is Y-up and uses a right-handed coordinate system, so a Z-up city model needs a rotation โ which is the commonest cause of a city lying on its side.
5. Put the attributes somewhere the client can read
3D Tiles 1.1 uses EXT_mesh_features and EXT_structural_metadata to attach per-feature properties, so a click on a building can show its id, height and use. Without them the tileset is scenery.
6. Georeference the tileset
A tileset has a transform placing its local coordinates on the globe. The usual pattern is model coordinates in a local ENU frame plus a 4ร4 transform to ECEF. Getting this wrong puts the city in the ocean or underground.
7. Generate rather than hand-write
py3dtiles produces tilesets from point clouds and meshes; conversion tools exist for CityJSON and CityGML. Hand-writing a tileset is reasonable for a single small tile and unreasonable beyond that.
8. Consider whether you need 3D Tiles at all
For a few thousand buildings in one neighbourhood, a single glTF or a GeoJSON extruded by the map library is simpler, smaller in total and far less work. 3D Tiles earns its complexity at city scale.
Code examples
Example 1 โ a minimal tileset around a glTF you produced
import json, numpy as np, pathlib
def tileset_for(glb_path, bbox_wgs84, min_h, max_h, geometric_error=50):
"""bbox_wgs84: (west, south, east, north) in degrees."""
w, s, e, n = [np.radians(v) for v in bbox_wgs84]
return {
"asset": {"version": "1.1"},
"geometricError": geometric_error * 4,
"root": {
"boundingVolume": {"region": [w, s, e, n, min_h, max_h]},
"geometricError": geometric_error,
"refine": "REPLACE",
"content": {"uri": pathlib.Path(glb_path).name},
},
}
pathlib.Path("tileset.json").write_text(json.dumps(
tileset_for("block.glb", (4.2665, 52.1010, 4.2787, 52.1078), 2.4, 37.5), indent=2))
The heights in a region bounding volume are metres above the WGS84 ellipsoid, not above a national datum โ a model in NAP needs about 43 m adding in the Netherlands.
Example 2 โ a two-level tileset from a height threshold
import geopandas as gpd, numpy as np, json
def two_level_tileset(buildings, tall_threshold=15):
"""Tall buildings in the root, everything else in children."""
tall = buildings[buildings.height_m >= tall_threshold]
rest = buildings[buildings.height_m < tall_threshold]
def region(gdf):
w, s, e, n = gdf.to_crs(4326).total_bounds
return [*np.radians([w, s, e, n]), 0.0, float(gdf.height_m.max())]
children = []
for i, (_, chunk) in enumerate(rest.groupby(np.arange(len(rest)) // 500)):
children.append({"boundingVolume": {"region": region(chunk)},
"geometricError": 0,
"content": {"uri": f"c{i}.glb"}})
return {"asset": {"version": "1.1"}, "geometricError": 200,
"root": {"boundingVolume": {"region": region(buildings)},
"geometricError": 20, "refine": "ADD",
"content": {"uri": "tall.glb"}, "children": children}}
ADD refinement here means the tall buildings stay visible as you zoom in and the smaller ones appear alongside them, which is usually what a city view wants.
Example 3 โ check the tileset before serving it
import json, math
def audit_tileset(path):
ts = json.loads(open(path).read())
problems = []
def walk(node, parent_error=math.inf, depth=0):
ge = node.get("geometricError")
if ge is None:
problems.append(f"depth {depth}: no geometricError")
elif ge >= parent_error:
problems.append(f"depth {depth}: geometricError {ge} not less than parent {parent_error}")
bv = node.get("boundingVolume", {})
if "region" in bv:
w, s, e, n, lo, hi = bv["region"]
if w >= e or s >= n:
problems.append(f"depth {depth}: region is not west/south/east/north")
if abs(w) > math.pi or abs(s) > math.pi / 2:
problems.append(f"depth {depth}: region looks like degrees, not radians")
if hi < lo:
problems.append(f"depth {depth}: heights reversed")
for child in node.get("children", []):
walk(child, ge if ge is not None else parent_error, depth + 1)
walk(ts["root"], ts.get("geometricError", math.inf))
return problems
Degrees in a region is the single most common tileset error: the numbers are valid, the tileset loads, and the city is rendered somewhere near the centre of the Earth.
Explanation
Why geometric error is the whole design
Everything a 3D Tiles client does follows from comparing a tile's geometric error, scaled by distance and field of view, against a pixel budget. A tileset whose errors decrease properly towards the leaves loads progressively and looks right at every zoom. One whose root error is too small loads the leaves immediately and stalls the browser; one whose leaf errors are too large never refines and looks permanently blurry.
Why glTF is Y-up and why that matters
glTF was designed for real-time graphics, where Y-up right-handed is the convention. Geospatial data is Z-up. The conversion is a โ90ยฐ rotation about X, and forgetting it produces a model that is not subtly wrong but lying on its side โ which at least makes it an easy bug to spot.
Why heights are ellipsoidal in a region volume
The region bounding volume is defined in the WGS84 geographic frame with heights above the ellipsoid, because that is what the globe renderer uses. National vertical datums are offset from the ellipsoid by tens of metres โ roughly 43 m in the Netherlands and 45โ55 m across much of western Europe โ so a model placed with orthometric heights sinks by that amount.
Why per-feature metadata is what makes it useful
A tileset without feature metadata is a picture of a city. With EXT_mesh_features and EXT_structural_metadata, each building carries an id and properties, so a click selects a feature, a style can colour by height or use, and the viewer becomes a map rather than scenery.
Edge cases or notes
regionis in radians. Degrees is the commonest mistake..b3dmis the legacy container. Still common;.glbis the current one.- Draco compression is worth it for large meshes and costs decode time.
- Refinement mode is per node, not per tileset.
- External tilesets nest. A
content.urimay point at anothertileset.json. - Screen space error is a client setting. Do not tune your errors around one client's default.
- Terrain and buildings are separate tilesets and must share a datum.
- Cesium and its derivatives are the main clients. Test in the one your users have.
Internal links
- How to publish a 3D building layer to a web map โ the practical route
- How to export a 3D city model to glTF in Python โ producing the payload
- A 3D viewer shows nothing or a black screen โ when the tileset loads and nothing appears
- CityJSON and CityGML explained โ the source model
- Vertical datums explained โ the 43 m offset
- Web map tiles explained โ the 2D equivalent of this idea
- Vector tiles explained โ where extruded GeoJSON fits
- How to serve PMTiles with MapLibre โ the simpler delivery route
FAQ
What is 3D Tiles?
An OGC standard for streaming large 3D geospatial datasets: a spatial tree of tiles, each with a bounding volume and a geometric error, whose payloads are glTF meshes or point clouds.
What does geometric error mean?
The error in metres introduced by rendering a tile instead of its children. The client projects it to screen space and refines only where it exceeds a pixel budget.
Do I need 3D Tiles for a few thousand buildings?
Probably not. A single glTF or a GeoJSON extruded by the map library is simpler and smaller in total. 3D Tiles pays off at city scale.
Why is my tileset lying on its side?
glTF is Y-up and geospatial data is Z-up. The payload needs a โ90ยฐ rotation about the X axis.
Why is my city underground?
The region bounding volume uses heights above the WGS84 ellipsoid. A model in a national datum needs the geoid separation added โ about 43 m in the Netherlands.
How do I make buildings clickable?
Attach per-feature metadata with EXT_mesh_features and EXT_structural_metadata. Without them the tileset has no features to select.