Migrating a 1.0 Tileset to 3D Tiles 1.1
This page migrates a working 3D Tiles 1.0 tileset to 1.1 incrementally — unwrapping b3dm into plain glb, lifting batch tables into a declared schema, converting the deep parts of the tree to implicit tiling, and dual-publishing while clients catch up. The migration is worth doing when the tileset.json size or the batch-table duplication is costing you something measurable, and it is worth doing in stages because each stage is independently useful and independently reversible.
Why you hit this
A 1.0 tileset that works does not need replacing, so the migration is always triggered by a specific cost: a tileset.json measured in tens of megabytes that every client parses before drawing anything, a batch table repeated across thousands of tiles, or a viewer team asking for typed metadata the batch table cannot express. Each of those has its own remedy in 1.1, and doing all three at once turns a tractable change into a rewrite.
The target format is described in tileset metadata and 3D Tiles Next; this page is about getting there from something that already ships.
Prerequisites
- Node 18+ with
3d-tiles-toolsand3d-tiles-validator(npm i -g 3d-tiles-tools 3d-tiles-validator). - The existing tileset, its build pipeline, and a way to regenerate it — migration by conversion is a stopgap, not the destination.
- A record of which clients consume the tileset and their versions. CesiumJS below 1.107 reads 1.0 only, and that constraint decides the sequencing.
- An atomic publish mechanism, as in automated 3D Tiles deployment to CDN, so a stage can be rolled back with one alias write.
Step-by-Step
1. Establish the baseline you are migrating from
Measure before changing anything, so each stage can be justified by what it saved.
import glob
import json
import os
ts = json.load(open("tileset/tileset.json"))
def walk(node):
yield node
for c in node.get("children", []):
yield from walk(c)
nodes = list(walk(ts["root"]))
b3dm = glob.glob("tileset/**/*.b3dm", recursive=True)
total_content = sum(os.path.getsize(f) for f in b3dm)
print(f"asset version: {ts['asset']['version']}")
print(f"tileset.json: {os.path.getsize('tileset/tileset.json') / 1e6:.2f} MB")
print(f"explicit nodes: {len(nodes):,}")
print(f"content files: {len(b3dm):,} ({total_content / 1e6:.1f} MB)")
Record those four numbers. They are what you will point at when the migration takes longer than expected, and the tileset.json figure in particular is what decides whether implicit tiling is worth the work at all — under a megabyte, it is not.
2. Stage one: unwrap b3dm into glb
3D Tiles 1.1 takes glTF directly as tile content. This stage alone removes the b3dm header, feature table and batch table wrapper from every tile, and it is reversible.
# Converts b3dm content to glb and rewrites the content URIs in tileset.json.
npx 3d-tiles-tools upgrade \
--input tileset/tileset.json \
--output tileset_v11/tileset.json \
--targetVersion 1.1
npx 3d-tiles-validator --tilesetFile tileset_v11/tileset.json
The upgrade lifts each tile’s batch table into EXT_structural_metadata automatically, inferring types from the values. That inference is the part to check rather than trust: a column that happened to hold only integers in the sample tile becomes an integer type, and the first tile containing a decimal then fails.
from pygltflib import GLTF2
g = GLTF2().load("tileset_v11/content/0/0/0.glb")
ext = g.extensions.get("EXT_structural_metadata", {})
cls = list(ext.get("schema", {}).get("classes", {}).values())[0]
for name, prop in cls["properties"].items():
print(f"{name:<14} {prop.get('type')} {prop.get('componentType', '')}")
3. Stage two: replace inferred types with a declared schema
The automatic upgrade gives you a schema per tile, inferred. Replace it with one schema for the tileset, written deliberately.
import json
import glob
from pygltflib import GLTF2
# Survey what the inference produced across every tile, and find the disagreements.
seen = {}
for path in glob.glob("tileset_v11/content/**/*.glb", recursive=True):
g = GLTF2().load(path)
ext = g.extensions.get("EXT_structural_metadata")
if not ext:
continue
for cls in ext["schema"]["classes"].values():
for name, prop in cls["properties"].items():
key = (prop.get("type"), prop.get("componentType"))
seen.setdefault(name, set()).add(key)
for name, kinds in sorted(seen.items()):
flag = " <-- inconsistent" if len(kinds) > 1 else ""
print(f"{name:<14} {sorted(kinds)}{flag}")
Every inconsistent row is a property whose type varied by tile — legal under a batch table, and exactly what a declared schema exists to prevent. Pick the widest correct type, write it into a single schema file, and rebuild the property tables against it rather than patching the inferred ones.
4. Stage three: make the deep subtrees implicit
Convert only the levels where the node count actually hurts. The upper tree stays explicit, which keeps hand-authored bounding volumes and per-region refinement intact.
import json
def uniform_below(node, depth, scheme="QUADTREE"):
"""True when every node at or below `depth` has the full child count."""
expect = 4 if scheme == "QUADTREE" else 8
def walk(n, d):
kids = n.get("children", [])
if not kids:
return True
if d >= depth and len(kids) != expect:
return False
return all(walk(k, d + 1) for k in kids)
return walk(node, 0)
ts = json.load(open("tileset_v11/tileset.json"))
for cut in range(2, 8):
print(f"implicit from level {cut}: {uniform_below(ts['root'], cut)}")
Choose the shallowest level at which the tree becomes uniform, and root the implicit subtrees there. Every node above it stays as it is; every node below it disappears from the JSON and is replaced by subtree bitstreams, as described in implicit tiling with subtree files.
5. Dual-publish under two aliases until the clients move
Publish both formats side by side and let each client resolve the one it can read.
# Both builds land under their own immutable prefixes.
aws s3 sync tileset/ s3://twin/builds/${GIT_SHA}-v10/ --cache-control "public,max-age=31536000,immutable"
aws s3 sync tileset_v11/ s3://twin/builds/${GIT_SHA}-v11/ --cache-control "public,max-age=31536000,immutable"
# Two aliases, swapped independently.
aws s3 cp s3://twin/builds/${GIT_SHA}-v10/tileset.json s3://twin/live-v10/tileset.json --cache-control "no-cache"
aws s3 cp s3://twin/builds/${GIT_SHA}-v11/tileset.json s3://twin/live-v11/tileset.json --cache-control "no-cache"
Generate both from the same intermediate rather than converting one into the other on each build. A conversion step is a second pipeline that has to be kept correct, and the two outputs drift the first time somebody fixes one of them.
6. Retire 1.0 when the client fleet has moved
The trigger is measurable, so measure it rather than guessing.
import collections
import re
# CDN access log: count requests per alias and per client version.
counts = collections.Counter()
for line in open("cdn_access.log"):
if "/live-v10/" in line:
m = re.search(r"CesiumJS/([\d.]+)", line)
counts[m.group(1) if m else "unknown"] += 1
for version, n in counts.most_common(10):
print(f"CesiumJS {version:<10} {n:>8,} requests against the 1.0 alias")
Expected Output & Verification
A representative migration of a mid-sized city tileset:
asset version: 1.0
tileset.json: 38.42 MB
explicit nodes: 221,184
content files: 221,184 (14.2 GB)
after stage 1: content 12.9 GB (b3dm wrapper removed)
after stage 2: content 12.6 GB (enums replace repeated strings)
after stage 3: tileset.json 0.006 MB, 7 explicit nodes, 48 subtree files (2.1 MB)
Verify at each stage rather than at the end. 3d-tiles-validator must exit clean; the content-file count must be unchanged by stages one and two; and after stage three the number of content-available bits across all subtrees must equal the number of content files on disk. That last check is the one that catches a migration which validates and 404s.
Common Errors
A property that validated in the sample tile fails elsewhere. The automatic type inference saw only integers in the tiles it looked at. Survey every tile as in stage two before fixing the schema, and choose the widest correct type.
The 1.1 tileset renders nothing in a client that reads 1.0. Expected — implicitTiling is ignored and there are no explicit children to fall back to. This is why stage three is the one that needs dual-publishing, and stages one and two do not.
Content 404s only in some regions after stage three. contentAvailability was derived from tileAvailability rather than from the files that exist. Interior nodes without geometry then advertise a payload.
The upgraded tileset is larger than the original. The batch tables were lifted into per-tile schemas, so the schema is now repeated in every tile. Stage two fixes this by hoisting one schema to the tileset; skipping it leaves the migration worse than where it started.
Frequently Asked Questions
Can I skip straight to implicit tiling?
Only if the tree is already uniform. Most 1.0 trees produced by a density-driven tiler are not, and forcing uniformity is a rebuild rather than a migration — at which point regenerating from source is cheaper than converting.
How long should dual-publishing run?
Until the access log shows the 1.0 alias below whatever residual traffic you are willing to break. Measuring it takes one query and removes the argument entirely.
Does 1.1 change how geometric error works?
No. The refinement model is unchanged, so a geometric error inversion behaves exactly as it did in 1.0 — and is harder to spot, because the offending node is no longer written out anywhere.
Related Guides
- Tileset Metadata and 3D Tiles Next — what you are migrating to
- Implicit Tiling with Subtree Files — stage three in detail
- Attaching EXT_structural_metadata to Building Tiles — stage two in detail
- Automated 3D Tiles Deployment to CDN — the alias mechanism dual-publishing relies on
Back to Tileset Metadata and 3D Tiles Next.