Merging Shard Tilesets into a Root Tileset

This page combines independently built shard tilesets — one tileset.json per city block or quadkey cell — into a single root tileset that a viewer loads with one URL, using 3D Tiles external tileset references, bounding regions unioned in radians on the WGS84 ellipsoid (EPSG:4979), geometric errors that decrease strictly down the tree, and an intermediate grouping level so the root never has thousands of direct children.

Why you hit this

Batch tiling pipelines shard a city so that shards can be built in parallel and rebuilt independently — the pattern in 3D Tiles batch tiling pipelines. The viewer, though, wants one entry point. The naive merge writes a root with every shard as a direct child, and it works in a demo with twelve shards. With four thousand, the runtime evaluates four thousand bounding volumes every frame before it refines anything, the root tileset is several megabytes of JSON downloaded before the first tile appears, and one shard with a bad bounding volume or an oversized geometric error makes the whole city refine too early or never.

Prerequisites

  • Python 3.10+ with the standard library json and math modules, plus numpy>=1.24.
  • Shard tilesets on disk or object storage, each with a root boundingVolume as a region in radians. Shards that only have a box under a transform need a region computed for them first — see step 1.
  • Node.js 18+ for npx 3d-tiles-validator, used in verification.

Step-by-Step

1. Read every shard’s root bounds and geometric error

python
import json
import math
from pathlib import Path

SHARDS = Path("build/shards")

def shard_summary(path):
    ts = json.loads(path.read_text())
    root = ts["root"]
    bv = root["boundingVolume"]
    if "region" not in bv:
        raise ValueError(f"{path}: root bounding volume must be a region for merging, got {list(bv)}")
    return {
        "uri": path.relative_to(SHARDS.parent).as_posix(),
        "region": bv["region"],                     # [west, south, east, north, minH, maxH]
        "geometric_error": ts["geometricError"],
        "version": ts["asset"]["version"],
    }

shards = [shard_summary(p) for p in sorted(SHARDS.glob("*/tileset.json"))]
versions = {s["version"] for s in shards}
print(f"{len(shards)} shards, asset versions {versions}")
assert len(versions) == 1, "mixed 3D Tiles versions: migrate before merging"

The merge works in regions — longitude, latitude and height bounds on the ellipsoid — because a region means the same thing at every level of the tree regardless of any transform in a child. A shard whose root is a box under an ENU transform describes its bounds in a local frame; putting that box under a parent without the same transform places it at the centre of the Earth. Convert such shards once, by transforming the box’s eight corners to longitude and latitude and taking their extremes plus a small margin.

2. Group shards into an intermediate level

python
from collections import defaultdict

def quadkey_prefix(uri, length):
    return Path(uri).parent.name[:length]           # shard directories are named by quadkey

GROUP_PREFIX = 12                                   # shards are z16; group at z12 → up to 256 per group
groups = defaultdict(list)
for s in shards:
    groups[quadkey_prefix(s["uri"], GROUP_PREFIX)].append(s)

sizes = sorted(len(g) for g in groups.values())
print(f"{len(groups)} groups, children per group: min {sizes[0]}, median {sizes[len(sizes) // 2]}, max {sizes[-1]}")

Grouping by quadkey prefix is free when shard directories are already named by quadkey, and it produces spatially compact groups whose regions barely overlap. A few hundred children per group and a few dozen groups under the root keeps every node’s child list short enough that culling it costs nothing measurable.

Flat and grouped root tilesets On the left, a root tileset has four thousand shard tilesets as direct children, so every frame tests four thousand bounding volumes. On the right, the root has sixteen group nodes, each with up to two hundred and fifty-six shard references, so a typical view tests sixteen groups and then only the children of the two or three groups in view. root root 4,096 children tested per frame 16 groups, then only visible ones One extra level turns a linear scan into a two-step lookup.
The grouped hierarchy costs one additional small JSON per group and removes the per-frame cost of culling thousands of siblings at the root.

3. Union regions and choose geometric errors

python
def union_regions(regions):
    w = min(r[0] for r in regions); s = min(r[1] for r in regions)
    e = max(r[2] for r in regions); n = max(r[3] for r in regions)
    lo = min(r[4] for r in regions); hi = max(r[5] for r in regions)
    assert e - w < math.pi, "group crosses the antimeridian or spans half the globe"
    return [w, s, e, n, lo, hi]

def region_diagonal_m(region, radius=6378137.0):
    w, s, e, n, lo, hi = region
    dx = (e - w) * radius * math.cos((s + n) / 2)
    dy = (n - s) * radius
    return math.sqrt(dx * dx + dy * dy + (hi - lo) ** 2)

group_nodes = []
for prefix, members in sorted(groups.items()):
    region = union_regions([m["region"] for m in members])
    child_max_ge = max(m["geometric_error"] for m in members)
    group_ge = max(child_max_ge * 2.0, region_diagonal_m(region) / 20.0)
    group_nodes.append({
        "boundingVolume": {"region": region},
        "geometricError": group_ge,
        "refine": "ADD",
        "children": [
            {"boundingVolume": {"region": m["region"]},
             "geometricError": m["geometric_error"],
             "content": {"uri": m["uri"]}}
            for m in members
        ],
    })

Two rules decide the geometric errors. A child reference in the parent must carry the shard’s own root geometric error, because that is the value the runtime compares against when deciding whether to load the external tileset. And every node must have a geometric error strictly larger than any of its children, or the runtime can decide to refine a child before its parent — the familiar symptom being a group that stays blank until the camera is very close. The group has no content of its own, so its error only controls when its children are considered; a fraction of the group’s diagonal is a sensible scale. The derivation of geometric error from real geometry is in computing geometric error for 3D Tiles levels.

4. Write group tilesets and the root

python
OUT = Path("build")

def write_json(path, obj):
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_text(json.dumps(obj, separators=(",", ":")))

root_children = []
for prefix, node in zip(sorted(groups), group_nodes):
    group_path = OUT / "groups" / f"{prefix}.json"
    for child in node["children"]:
        child["content"]["uri"] = "../" + child["content"]["uri"]
    write_json(group_path, {
        "asset": {"version": "1.1"},
        "geometricError": node["geometricError"],
        "root": node,
    })
    root_children.append({
        "boundingVolume": node["boundingVolume"],
        "geometricError": node["geometricError"],
        "content": {"uri": f"groups/{prefix}.json"},
    })

city_region = union_regions([c["boundingVolume"]["region"] for c in root_children])
root_ge = max(c["geometricError"] for c in root_children) * 2.0
write_json(OUT / "tileset.json", {
    "asset": {"version": "1.1", "tilesetVersion": "city-2026-09-17"},
    "geometricError": root_ge,
    "root": {"boundingVolume": {"region": city_region}, "geometricError": root_ge,
             "refine": "ADD", "children": root_children},
})
print(f"root: {len(root_children)} groups, geometricError {root_ge:.1f} m")

Content URIs in an external tileset resolve relative to that tileset’s location, not to the root’s. The ../ prefix is there because group files live one directory below the shards’ parent. Getting this wrong produces a tileset that validates structurally and 404s every shard at runtime. refine: "ADD" on the empty grouping nodes is deliberate: with no content of their own, replacement has nothing to replace, and ADD avoids a runtime waiting for a parent that will never render.

Geometric error must fall strictly down the tree A ladder of four levels: root at 1,600 metres, group at 800 metres, shard reference and shard root at 120 metres, and shard leaves at 2 metres. Each level's geometric error is larger than every child's. A red example shows a group at 60 metres above a shard at 120, which the runtime refines in the wrong order. root · 1,600 m group · 800 m shard root · 120 m leaves · 2 m group · 60 m shard root · 120 m child larger than parent: refinement order breaks The merge only chooses the upper two rows; the lower ones come from the shard builds.
The merge step owns the geometric errors above the shards, and the only hard constraint on them is that they stay above whatever the shards report.

Expected Output & Verification

text
4096 shards, asset versions {'1.1'}
16 groups, children per group: min 188, median 256, max 256
root: 16 groups, geometricError 1612.4 m

Validate the structure with the official validator, then check the relationships it does not know about:

bash
npx 3d-tiles-validator --tilesetFile build/tileset.json --reportFile build/validation.json
python
def check_node(node, parent_ge, parent_region, base):
    ge = node["geometricError"]
    assert parent_ge is None or ge < parent_ge, f"geometric error {ge} not below parent {parent_ge}"
    r = node["boundingVolume"]["region"]
    if parent_region:
        eps = 1e-9
        assert (r[0] >= parent_region[0] - eps and r[1] >= parent_region[1] - eps and
                r[2] <= parent_region[2] + eps and r[3] <= parent_region[3] + eps), "child outside parent"
    uri = node.get("content", {}).get("uri")
    if uri:
        target = (base / uri).resolve()
        assert target.exists(), f"missing content {target}"
        if target.suffix == ".json":
            ext = json.loads(target.read_text())
            check_node(ext["root"], ge + 1e-6, r, target.parent)
    for child in node.get("children", []):
        check_node(child, ge, r, base)

root = json.loads((OUT / "tileset.json").read_text())
check_node(root["root"], None, None, OUT)
print("hierarchy, containment and every external reference verified")

The recursive check follows external references into each group and each shard, which the validator only does when asked to, and it enforces containment of child regions in parent regions — a property that lets a runtime cull a whole group without ever looking inside it.

How content URIs resolve through external tilesets The root tileset in build references groups/0231.json. That group file lives in build/groups, so its reference to a shard must be written as ../shards/023103220112/tileset.json. The shard's own content URIs, such as leaf glb files, resolve relative to the shard directory. build/ tileset.json build/groups/ 0231.json build/shards/ …/tileset.json groups/0231.json ../shards/ …/tileset.json Each URI is relative to the file that contains it, never to the root.
Relative resolution is per file. A group written one directory deeper than the shards needs a parent-directory prefix on every reference.

Common Errors

The city loads, then only some districts ever appear. Those groups’ geometric errors are below their shards’ root errors, so the runtime never considers the external tileset worth loading. The recursive check above catches it; the fix is to derive group errors from the maximum child error, as step 3 does.

404 for every shard in the browser network panel. Content URIs were written relative to the root rather than to the group file. Resolve each URI against the containing file’s directory and compare with what the server actually serves.

Assertion group crosses the antimeridian. A naive min/max union of longitudes across ±180° produces a region spanning the whole globe. Groups near the antimeridian need their longitudes unwrapped before the union and split into two groups when written, because a 3D Tiles region’s west must be less than its east.

Frequently Asked Questions

Should the merged root use implicit tiling instead?

For a regular quadtree of shards, implicit tiling is more compact and is the direction 3D Tiles 1.1 encourages — see implicit tiling with subtree files. Explicit external references remain the simpler choice when shards are irregular, were built by different tools, or are rebuilt and versioned independently.

How often does the root need rewriting?

Only when a shard’s bounds or root geometric error change, or a shard is added or removed. An incremental rebuild that leaves those untouched can leave the root and group files byte-identical, which keeps their CDN cache entries valid.

Is there a limit on children per node?

Not in the specification. The practical limit is runtime cost and JSON size; a few hundred children per node is comfortable, a few thousand is measurable in frame time on mobile devices.

Back to 3D Tiles Batch Tiling Pipelines.