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
jsonandmathmodules, plusnumpy>=1.24. - Shard tilesets on disk or object storage, each with a root
boundingVolumeas aregionin radians. Shards that only have aboxunder atransformneed 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
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
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.
3. Union regions and choose geometric errors
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
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.
Expected Output & Verification
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:
npx 3d-tiles-validator --tilesetFile build/tileset.json --reportFile build/validation.json
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.
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.
Related Guides
- Parallel b3dm Encoding with Process Pools — producing the shards being merged
- Incremental Retiling of Changed City Blocks — rebuilding shards without touching the root
- Writing 3D Tiles Validator Checks in CI — running the verification on every build
Back to 3D Tiles Batch Tiling Pipelines.