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.24 and scipy>=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.

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

Which surface you sample decides which question you answer Sampling the simplified surface and measuring to the source asks how far the drawn geometry strays from the truth, which is what the client's refinement test needs. Sampling the source and measuring to the simplified surface asks a different question and typically returns a smaller number, because the source has detail the simplification never had to approximate. sample the simplified surface — 0.51 m sample the source — 0.19 m "how far is what I draw from the truth" "how well is the source represented on average" Dashed is the source, solid the simplification. Only the left question is the one geometricError is defined as. The collapsed parapet is the entire difference between the two numbers
Both are legitimate measurements and only one of them is geometric error. The direction is not a detail — it is a factor of two or three on a typical building.

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.

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

A parent's error is the worst case beneath it Four children deviate from the source by 0.42, 0.51, 0.38 and 0.47 metres. The parent that stands in for all four must declare at least 0.51, the worst of them, because refining it has to be worthwhile wherever any child is worse. Averaging them would understate the error and stop refinement too early. parent — 0.51 m 0.42 m 0.51 m 0.38 m 0.47 m the worst child sets the parent Average them (0.45) and the client stops refining while one quarter of the block is still 13% worse than it believes
The maximum is not a conservative choice, it is the definition. A parent stands in for its whole subtree, so its error is the subtree's worst case.

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.

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

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

The same tileset on three viewport heights A tile with half a metre of geometric error, viewed from eight hundred metres, projects to about seven pixels of error on a 900-pixel viewport, eleven on a 1440-pixel one and seventeen on a 2160-pixel one. A threshold of sixteen pixels therefore stops refining on two of the three and keeps going on the third. geometricError 0.51 m at 800 m, under a 16-pixel threshold maxScreenSpaceError = 16 px 900 px viewport — 7.1 px, stops 1440 px viewport — 11.3 px, stops 2160 px — 17.0 px, refines Nothing about the tileset changed. The viewport did, and it moved the tile across the threshold. Tune maxScreenSpaceError against the largest display you intend to support, then verify on the smallest
Pixels, not metres, are what the runtime compares. A threshold that has never been checked against a large display is a bandwidth surprise waiting for a demo.

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.

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

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

Back to Hierarchical LOD Structuring.