Computing Geometric Error for 3D Tiles Levels
This page derives the geometricError value on every node of a 3D Tiles tree from something measurable — the actual deviation between a level’s geometry and the source — instead of the usual practice of picking a root value and halving it down the tree. The number is in metres, the client divides it by camera distance to decide whether to refine, and getting it wrong produces either a tileset that never sharpens or one that fetches far more than it draws. Neither failure raises an error anywhere.
Why you hit this
geometricError is the only tuning knob a tileset exposes to a client it has never met. Every runtime decision — refine or stop, fetch or skip, which of two siblings to load first — comes from comparing this one number against a screen-space threshold. Teams usually seed it from the root’s bounding-volume diagonal and halve at each level, which is a reasonable prior and is wrong the moment decimation is not uniform: a level that removed 90% of a facade’s triangles and 10% of a roof’s does not have one error. The result is a city where some blocks refine correctly and others sit blurred at a level the client believes is good enough.
The tree this attaches to is built in hierarchical LOD structuring; the deviation it is derived from comes out of automated mesh decimation.
Prerequisites
- Python 3.10+ with
trimesh>=4.0,numpy>=1.24andscipy>=1.11(for the KD-tree behind the deviation measurement). - A source mesh and the decimated meshes for each LOD level, all on the same local origin and in the same projected metric CRS.
- The tile tree already built, so each node knows its bounding volume and its children.
Step-by-Step
1. Measure the deviation each level actually introduced
Geometric error is defined as the maximum distance between the rendered geometry and the geometry it stands in for. That is a Hausdorff distance, and it is measurable rather than assumable.
import numpy as np
import trimesh
from scipy.spatial import cKDTree
def deviation(source: trimesh.Trimesh, simplified: trimesh.Trimesh, samples: int = 200_000):
"""One-sided Hausdorff: how far the simplified surface strays from the source."""
pts, _ = trimesh.sample.sample_surface(simplified, samples)
tree = cKDTree(source.vertices)
d_vertex, _ = tree.query(pts, k=1)
# closest_point is exact but slow; run it only on the worst 1% found above.
worst = pts[np.argsort(d_vertex)[-samples // 100:]]
closest, d_exact, _ = trimesh.proximity.closest_point(source, worst)
return float(d_exact.max()), float(np.percentile(d_vertex, 95))
src = trimesh.load("block_lod0.ply", force="mesh")
for level in range(1, 5):
simp = trimesh.load(f"block_lod{level}.ply", force="mesh")
hmax, p95 = deviation(src, simp)
print(f"LOD {level}: Hausdorff {hmax:.3f} m, 95th percentile {p95:.3f} m")
Sampling the simplified surface and querying against the source — rather than the reverse — is the direction that matters. It answers “how far is what I am drawing from the truth”, which is the question the client’s refinement test is implicitly asking. The two-stage approach keeps it affordable: a fast vertex-KD-tree pass over every sample, then the exact point-to-triangle computation on only the worst one per cent.
2. Take the maximum over the subtree, not the node
A parent node stands in for everything beneath it, so its error is the largest deviation anywhere in its subtree — not the deviation of its own geometry.
def assign_errors(node, measured):
"""Post-order walk: a node's error is max(its own deviation, its children's errors)."""
if not node["children"]:
node["geometricError"] = measured[node["id"]]
return node["geometricError"]
child_max = max(assign_errors(c, measured) for c in node["children"])
node["geometricError"] = max(measured[node["id"]], child_max)
return node["geometricError"]
root = {"id": "r", "children": [
{"id": "r0", "children": []}, {"id": "r1", "children": []},
{"id": "r2", "children": []}, {"id": "r3", "children": []},
]}
measured = {"r": 2.10, "r0": 0.42, "r1": 0.51, "r2": 0.38, "r3": 0.47}
assign_errors(root, measured)
print({n["id"]: round(n["geometricError"], 2) for n in [root] + root["children"]})
This is where the halving heuristic diverges from reality. A parent whose four children deviate by 0.42, 0.51, 0.38 and 0.47 m has an error of at least 0.51, because refining it must be worthwhile wherever any child is worse. Halving from the root would have produced a number unrelated to any of them.
3. Assert strict monotonicity down the tree
The client refines while the parent’s error exceeds the threshold and the child’s does not. If a child’s error is greater than or equal to its parent’s, that comparison never becomes favourable and the subtree is never fetched.
def assert_monotonic(node, path="root"):
for i, child in enumerate(node["children"]):
pe, ce = node["geometricError"], child["geometricError"]
assert ce < pe, (
f"{path}/{i}: child error {ce:.3f} >= parent {pe:.3f} — "
"refinement stops here and the subtree is unreachable")
assert_monotonic(child, f"{path}/{i}")
return True
assert_monotonic(root)
print("geometricError decreases strictly at every edge")
Run this in CI, not by hand. The reference validator does not check it, the tileset stays perfectly valid with an inversion in it, and the only symptom is a region of the city that refuses to sharpen however close the camera gets.
4. Convert the number into pixels before trusting it
geometricError is in metres. What decides refinement is its projection onto the screen, which depends on viewport height and field of view — so the same tileset behaves differently on a laptop and a 4K display.
import math
def screen_space_error(geometric_error_m, distance_m, viewport_h_px, fov_y_deg=60.0):
return (geometric_error_m * viewport_h_px) / (2.0 * distance_m * math.tan(math.radians(fov_y_deg) / 2.0))
for h in (900, 1440, 2160):
for d in (200, 800, 3000):
sse = screen_space_error(0.51, d, h)
verdict = "refine" if sse > 16 else "stop"
print(f"viewport {h}px distance {d:>4} m -> {sse:5.1f} px {verdict}")
The output is the check worth doing before shipping. A maxScreenSpaceError of 16 tuned against a 900-pixel viewport refines roughly 2.4× more aggressively at 2160 pixels, which is the usual explanation for a tileset that streams comfortably in development and saturates a connection on a large monitor.
5. Write the values into the tileset and record the derivation
The tileset carries the number; the manifest should carry where it came from, so a later rebuild can reproduce it.
import json
def to_tileset(node):
out = {
"boundingVolume": {"box": node["box"]},
"geometricError": round(node["geometricError"], 4),
"refine": "REPLACE",
}
if node["children"]:
out["children"] = [to_tileset(c) for c in node["children"]]
else:
out["content"] = {"uri": f"{node['id']}.b3dm"}
return out
tileset = {
"asset": {"version": "1.1"},
"geometricError": round(root["geometricError"], 4),
"root": to_tileset(root),
"extras": {
"errorDerivation": "one-sided Hausdorff, 200k surface samples, max over subtree",
"sourceMesh": "block_lod0.ply",
"measuredAt": "2026-08-07",
},
}
print(json.dumps(tileset, indent=2)[:400])
Expected Output & Verification
A healthy run prints a strictly decreasing sequence, roughly but not exactly halving:
LOD 1: Hausdorff 0.104 m, 95th percentile 0.021 m
LOD 2: Hausdorff 0.238 m, 95th percentile 0.049 m
LOD 3: Hausdorff 0.511 m, 95th percentile 0.118 m
LOD 4: Hausdorff 1.207 m, 95th percentile 0.284 m
geometricError decreases strictly at every edge
Two things to read out of it. The Hausdorff figure is consistently four to five times the 95th percentile, which is normal — the maximum is set by a handful of collapsed features while the bulk of the surface is far closer. And the ratio between levels is around 2.2 rather than exactly 2, which is why deriving beats halving: the real decimation did not produce a clean factor of two, and a tileset that claims it did is misinforming the client at every level.
If the sequence is not strictly decreasing, the decimation chain is at fault rather than the measurement — a level that removed fewer triangles than its parent, or a mesh that was decimated from the wrong source.
Common Errors
geometricError of 0 on an interior node. Zero means “this geometry is exact”, so the client stops refining immediately and never loads the children. Only a leaf whose content is the source geometry should be zero, and even then only if it genuinely is.
Error measured on the wrong side. Sampling the source and querying the simplified surface answers a different question and typically returns a smaller number. Sample the simplified surface, query the source.
Values derived before the local-origin shift. Deviation computed against raw UTM coordinates in float32 inherits the coordinate quantum — several centimetres at a 585,000 m easting — and reports it as geometric error. Shift to a local origin first.
Frequently Asked Questions
Can I keep using the halving heuristic?
As a starting point, yes; as the shipped value, only if you have confirmed the decimation really is uniform across archetypes. It rarely is. Deriving costs one measurement pass at build time and removes an entire class of “some blocks never sharpen” reports.
What root geometricError should a city tileset use?
Whatever the measurement gives you — typically a few hundred metres for a city-wide root, because the root’s geometry stands in for everything. Setting it artificially high makes the first refinement fire immediately; setting it low makes the tileset appear empty until the camera is close.
Does Draco compression change the number?
Slightly. Quantization moves vertices onto a lattice, so it adds its own deviation on top of the decimation’s. If the quantization lattice is fine relative to the decimation error — which it should be — the addition is negligible; if you are quantizing aggressively, measure after encoding rather than before.
Related Guides
- Hierarchical LOD Structuring — the tree these values attach to
- Implementing Quadtree LOD for Urban Models — building the tree itself
- Automated Mesh Decimation for Digital Twins — where the deviation comes from
- Streaming & Runtime Diagnostics — diagnosing an inversion from the client side
Back to Hierarchical LOD Structuring.