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-tools and 3d-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.

python
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.

bash
# 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.

python
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', '')}")
Four stages, each independently shippable Unwrapping b3dm to glb, replacing inferred metadata types with a declared schema, converting deep subtrees to implicit tiling, and finally retiring the 1.0 output. Each stage produces a valid tileset that can be published and rolled back on its own, and each has its own trigger for being worth doing. 1 · b3dm → glbwrapper removedtypes inferred 2 · declared schematypes and enums fixedby hand, once 3 · implicit subtreesdeep levels onlyupper tree stays explicit 4 · retire 1.0once every clientreads 1.1 trigger: batch-table bloat trigger: a type surprise trigger: tileset.json size trigger: client fleet upgraded Each stage is a valid tileset on its own, so a stall at stage two is a stable state rather than a half-migration Doing all four in one change means one rollback unit and one very large diff
The stages are ordered by cost and by how reversible they are. Stage three is the only one that changes how a client addresses tiles.

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.

python
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.

python
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.

A hybrid tree: explicit above, implicit below The upper levels of the tree stay explicit, so hand-tuned bounding volumes and region-specific refinement survive. Below a chosen cut level, where subdivision is uniform, each node roots an implicit subtree and its descendants vanish from the JSON entirely. cut level — implicit below here root, explicit district, explicit district, explicit implicitTiling6 levels implicitTiling6 levels implicitTiling6 levels implicitTiling6 levels Seven explicit nodes instead of 22 million, with the hand-tuned upper tree untouched And each district's subtree regenerates independently when that district changes
The hybrid is usually the destination rather than a waypoint. The upper levels are where human judgement lives, and they are also where there are too few nodes for implicit addressing to save anything.

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.

bash
# 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.

python
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:

text
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.

What each stage actually saved Unwrapping b3dm removed about nine per cent of the content bytes. Replacing repeated strings with enums removed a further two. Converting the deep tree to implicit tiling reduced the tileset JSON from thirty-eight megabytes to six kilobytes, replaced by two megabytes of subtree files. 14.2 GB content, before 12.9 GB after stage 1 12.6 GB after stage 2 38.4 MB tileset.json, before 2.1 MB of subtrees after stage 3 Content shrinks by a tenth; the tree description shrinks by a factor of eighteen and stops blocking first paint
Two of the three stages are worth a few per cent. The third is worth an order of magnitude, and it is the only one that needs dual-publishing.

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.

Back to Tileset Metadata and 3D Tiles Next.