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.

Tree of 3D Tiles nodes with bounding volumes and decreasing geometric error towards the leaves.
The tree is spatial; the geometric error is what decides how deep the client goes.

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.

Comparison grid of a single glTF, extruded GeoJSON and 3D Tiles across scale, effort and interactivity.
Three delivery routes; the right one is decided by how many buildings, not by how modern it looks.

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.

Triage of four 3D Tiles mistakes โ€” degrees in a region volume, a zero root geometric error, a Z-up payload and orthometric heights โ€” with the symptom of each.
A tileset that loads and shows nothing is almost always one of these four.

Edge cases or notes

  • region is in radians. Degrees is the commonest mistake.
  • .b3dm is the legacy container. Still common; .glb is 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.uri may point at another tileset.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.

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.