Fixing Tilesets That Never Refine Past the Root

This page diagnoses a 3D Tiles tileset that loads, renders its coarsest level and then never shows more detail however close the camera gets — computing the screen-space error the runtime sees for each tile, checking child bounding volumes in the same Earth-centred frame (EPSG:4978) the runtime uses after composing every transform, and finding child content that fails to load while a REPLACE parent stays on screen.

Why you hit this

“The tileset only shows the blocky version” is one of the most common reports about a newly generated tileset, and the viewer gives no error because, from its point of view, nothing is wrong: it evaluated the tree and concluded the root was good enough, or that the children were not visible, or it is still waiting for children that will never arrive. There are only four causes that account for nearly every case, and each leaves a distinct signature in the tileset JSON. The general theory of when refinement happens is in computing geometric error for 3D Tiles levels.

Prerequisites

  • Python 3.10+ with numpy>=1.24 and requests>=2.31.
  • The tileset URL or a local copy of tileset.json and any external tilesets it references.
  • A viewer where Cesium’s 3D Tiles inspector can be enabled, for confirming what the script finds.

Step-by-Step

1. Compute the screen-space error the root produces

A runtime refines a tile when its screen-space error exceeds maximumScreenSpaceError (16 pixels by default in CesiumJS). The error is the tile’s geometric error projected to the screen at the camera’s distance.

python
import json
import math
from pathlib import Path

def sse(geometric_error, distance_m, screen_height_px=1080, fov_y_deg=60.0):
    return geometric_error * screen_height_px / (2 * distance_m * math.tan(math.radians(fov_y_deg) / 2))

ts = json.loads(Path("tiles/district/tileset.json").read_text())
root = ts["root"]
ge = root["geometricError"]
for d in (5000, 1000, 200, 50):
    print(f"camera {d:>5} m from root → SSE {sse(ge, d):8.2f} px "
          f"{'refines' if sse(ge, d) > 16 else 'stays'}")

A root whose geometric error is 0 produces an SSE of 0 at every distance and never refines — the runtime reads 0 as “this tile is perfect”. The same happens, less obviously, with a tiny value such as 0.5 on a tile covering several kilometres: it only refines when the camera is a few tens of metres away, which in practice is under the terrain. Geometric error is in metres; a tiler that wrote it in degrees, or copied the leaf value to every level, produces exactly this.

Screen-space error of the root against camera distance Curves of screen-space error against camera distance for three root geometric errors. A root error of 400 metres crosses the 16 pixel threshold at about 23 kilometres, so it refines as soon as the city is in view. A root error of 2 metres crosses only at about 115 metres. A root error of zero lies on the horizontal axis and never crosses. maximumScreenSpaceError = 16 px root GE 400 m: refines at ≈ 23 km root GE 2 m: only below ≈ 115 m root GE 0: never nearfar camera distance to the tile (log scale), 1080 px viewport, 60° field of view
Refinement happens where the curve rises above the threshold. A geometric error of zero keeps the curve flat on the axis at every distance.

2. Walk the tree and flag geometric-error signatures

python
def walk(node, parent_ge=None, path="root", depth=0):
    yield path, node, parent_ge, depth
    for i, c in enumerate(node.get("children", [])):
        yield from walk(c, node["geometricError"], f"{path}/{i}", depth + 1)

issues = []
for path, node, parent_ge, depth in walk(root):
    ge = node["geometricError"]
    kids = node.get("children", [])
    if kids and ge == 0:
        issues.append((path, "geometricError 0 on a tile with children: children are never loaded"))
    if parent_ge is not None and ge > parent_ge:
        issues.append((path, f"child error {ge} exceeds parent {parent_ge}"))
    if kids and all(abs(c["geometricError"] - ge) < 1e-9 for c in kids):
        issues.append((path, f"all children share the parent's error {ge}: no reason to refine"))
if ts.get("geometricError", 0) < ge:
    issues.append(("tileset", "top-level geometricError below the root's"))
print(len(issues), "issues", issues[:6])

The equal-error signature is common in hand-built or merged tilesets: every level carries the same value, so refinement from a parent to its children changes nothing the runtime can measure, and it may stop at whichever level first satisfies the threshold — usually the root.

3. Check that children are where the runtime will look for them

The runtime culls a child whose bounding volume is outside the view. A volume written in the wrong frame is outside every view.

python
import numpy as np

def mat(t):
    return np.array(t, dtype=float).reshape(4, 4).T if t else np.eye(4)

def box_centre_ecef(box, world):
    return (world @ np.array([box[0], box[1], box[2], 1.0]))[:3]

def region_centre_ecef(region):
    w, s, e, n, lo, hi = region
    lon, lat, h = (w + e) / 2, (s + n) / 2, (lo + hi) / 2
    a, f = 6378137.0, 1 / 298.257223563
    e2 = f * (2 - f)
    N = a / math.sqrt(1 - e2 * math.sin(lat) ** 2)
    return np.array([(N + h) * math.cos(lat) * math.cos(lon),
                     (N + h) * math.cos(lat) * math.sin(lon),
                     (N * (1 - e2) + h) * math.sin(lat)])

def centres(node, world=np.eye(4), path="root"):
    world = world @ mat(node.get("transform"))
    bv = node["boundingVolume"]
    c = box_centre_ecef(bv["box"], world) if "box" in bv else region_centre_ecef(bv["region"]) if "region" in bv else None
    yield path, c
    for i, child in enumerate(node.get("children", [])):
        yield from centres(child, world, f"{path}/{i}")

pts = dict(centres(root))
root_c = pts["root"]
for path, c in pts.items():
    r = np.linalg.norm(c)
    if not 6.30e6 < r < 6.42e6:
        print(f"{path}: bounding volume centre {r / 1000:,.0f} km from Earth's centre — wrong frame")
    elif np.linalg.norm(c - root_c) > 50_000:
        print(f"{path}: {np.linalg.norm(c - root_c) / 1000:.0f} km from the root — outside the tileset")

Every tile’s bounding volume centre must lie near the Earth’s surface — between about 6,300 and 6,420 km from the centre, which covers ocean trenches to mountain tops for the WGS84 ellipsoid. A box written in local ENU metres under a child that has no transform, while the root’s transform is not inherited the way the author assumed, sits a few hundred metres from the Earth’s centre. A box written in ECEF under a parent that does carry a transform ends up twice as far out as it should be. Either way the child is never in view.

Child bounding volumes in the wrong frame The Earth is drawn with the tileset's root box on the surface. A correct child box lies inside the root box. A child box written in local metres without the root's transform sits at the Earth's centre. A child box written in ECEF under a parent transform is translated a second time and sits far out in space. Only the first is ever inside the camera's view. root box + correct child local metres, no transform ECEF under a transform |centre| must be 6,300–6,420 km after composing every transform not to scale
The distance of each bounding volume from the Earth's centre, after applying the accumulated transforms, is a one-line test that catches both frame errors.

4. Confirm child content actually loads

python
import requests

BASE = "https://tiles.example.org/district/"

def content_uris(node, base=BASE):
    uri = node.get("content", {}).get("uri")
    if uri:
        yield base + uri
    for c in node.get("children", []):
        yield from content_uris(c, base)

failed = []
for i, url in enumerate(content_uris(root)):
    if i >= 200:
        break
    r = requests.head(url, timeout=15, allow_redirects=True)
    if r.status_code != 200:
        failed.append((url, r.status_code))
print(f"{len(failed)} of the first 200 content URIs fail", failed[:5])

Under REPLACE refinement, CesiumJS keeps drawing a parent until its children are ready. A child whose content 404s is never ready, so the parent stays — and the tileset looks exactly as though refinement is broken. Case-sensitive storage is a frequent cause: a tiler on macOS wrote Content/0/0.glb, the tileset says content/0/0.glb, and the Linux-backed object store treats them as different keys. If requests succeed here but fail in the browser, the problem is CORS or encoding, covered in fixing CORS and content-encoding errors on tile servers.

Expected Output & Verification

For a tileset produced by a script that copied the leaf error upward and wrote child boxes without their transform:

text
camera  5000 m from root → SSE     0.19 px stays
camera  1000 m from root → SSE     0.94 px stays
camera   200 m from root → SSE     4.68 px stays
camera    50 m from root → SSE    18.71 px refines
3 issues [('root', 'all children share the parent's error 1.0: no reason to refine'), …]
root/0: bounding volume centre 0 km from Earth's centre — wrong frame
0 of the first 200 content URIs fail []

Confirm in the runtime with Cesium’s inspector, which shows each tile’s bounding volume and geometric error live:

javascript
viewer.extend(Cesium.viewerCesium3DTilesInspectorMixin);
tileset.debugShowBoundingVolume = true;
tileset.debugShowGeometricError = true;
tileset.debugColorizeTiles = true;

After the fix, the root’s label should show a geometric error in the hundreds of metres, each level’s value should roughly halve, and flying towards the city should switch the tile colours level by level. A child bounding volume drawn far from the city, or not at all, confirms a frame problem the script flagged.

Triage order for a tileset that never refines A four-step checklist in order of how quickly each can be tested. First, root and parent geometric errors: zero, tiny, equal to children or larger than parents. Second, child bounding volume centres after composing transforms. Third, HEAD requests for child content. Fourth, in the browser, CORS and content-encoding for the same URLs. 1 geometric error: zero, tiny, equal to children, or larger than the parent 2 bounding volume centres 6,300–6,420 km from Earth's centre after transforms 3 child content returns 200 (case-sensitive paths) 4 in the browser: CORS, Content-Encoding, MIME type JSON onlyJSON only networkbrowser
The first two checks need nothing but the tileset JSON and find most cases in seconds.

Common Errors

The root refines only on very large screens. Screen-space error scales with viewport height, so a geometric error that is marginal on a 1080 px viewport refines on a 4K monitor and never on a phone. Size errors from the geometry, not by trial on one display.

Refinement works in one viewer and not another. Runtimes differ in default maximumScreenSpaceError and in how they treat an unspecified refine. Make refine explicit on the root and verify errors against the runtime with the strictest defaults you support.

Only one quadrant refines. Three children have correct bounding volumes and one was written in the wrong frame, often because it was produced by a different pipeline stage. The per-node centre check lists exactly which.

Frequently Asked Questions

Can a very large maximumScreenSpaceError cause this?

Yes, if the application set it to hundreds of pixels to save memory. The tree is then correct and simply never considered worth refining. Check the value in the running viewer before editing the tileset.

Does the top-level geometricError matter?

It is the error of the whole tileset when it is referenced as an external tileset, and the runtime uses it to decide whether to load the tileset’s root at all. Keep it at least as large as the root’s.

Why does the tileset refine after I zoom in and reload the page?

A restored camera position close to the tiles produces a large enough error to trigger refinement at start-up, while flying in from a distance can leave the root selected if its error is marginal. That is a geometric-error problem showing itself only at some distances.

Back to Streaming & Runtime Diagnostics for 3D Tiles.