Choosing ADD vs REPLACE Refinement for 3D Tiles
This page decides between the two refinement modes in 3D Tiles — ADD, where a tile’s children are drawn on top of it, and REPLACE, where they are drawn instead of it — for each kind of content a digital twin streams, and audits an existing tileset.json for the failure each mode invites: holes under REPLACE when children do not cover their parent, and duplicated geometry under ADD when a child repeats what its parent already drew.
Why you hit this
Refinement is one word in a tile’s JSON and it changes what the runtime draws, how much memory it holds and what visual defects appear. Point cloud tilers emit ADD because a coarse node holds a sample and children hold the rest. Mesh tilers emit REPLACE because a coarse node is a simplified copy of what its children contain. Trouble starts when content is merged, hand-edited or produced by a pipeline that did not think about it: a building tileset marked ADD that draws every LOD at once and z-fights with itself, or a REPLACE tileset with an empty child quadrant that leaves a hole in the city as soon as the camera comes close. The hierarchy these settings apply to is built in implementing quadtree LOD for urban models.
Prerequisites
- Python 3.10+ with
numpy>=1.24; the audit reads tileset JSON and glTF with the standard library pluspygltflib>=1.16. - A tileset whose bounding volumes are
regions in radians (EPSG:4979) orboxes in a consistent frame; the audit converts both to axis-aligned extents for coverage checks. - Content that carries stable feature identifiers —
EXT_mesh_featuresfeature IDs or a batch tablebuilding_id— if you want the duplicate check to name the duplicated objects.
What Each Mode Draws
With REPLACE, once the runtime decides a tile is not detailed enough, it loads the children and stops drawing the parent. CesiumJS by default keeps drawing the parent until all renderable children are loaded, so the view never shows a gap during loading; the price is that a child that fails to load keeps its parent on screen indefinitely. With ADD, the parent keeps drawing and each child adds its content on top as it arrives, which suits content where the parent is a genuine subset of the final picture.
refine is inherited: a tile without its own value uses its parent’s, and the root must declare one. A single tileset can therefore switch modes between subtrees — REPLACE for building meshes, ADD for a subtree of vegetation instances attached under the same parent.
Step-by-Step
1. Match the mode to how the content was built
| Content | Built as | Mode | Why |
|---|---|---|---|
| Point clouds | disjoint samples per octree node | ADD |
each node holds different points |
| Building meshes with decimated parents | simplified copies of children | REPLACE |
parent duplicates child geometry |
| Photogrammetry meshes | simplified copies | REPLACE |
as above |
| Terrain meshes | simplified copies | REPLACE |
as above |
| Instanced trees, street furniture | new objects per level | ADD |
finer levels add smaller objects |
| Coarse massing + detailed façades | façade detail only in children | ADD |
child adds what parent lacks |
The rule behind the table is a single question: does the parent’s content still belong on screen when the children are visible? If the parent is a lower-detail version of the children, the answer is no and the mode is REPLACE. If the parent is a part of the final picture, the answer is yes and the mode is ADD.
2. Load the tree and resolve inherited refinement
import json
from pathlib import Path
def walk(node, parent_refine=None, depth=0, base=Path("."), path="root"):
refine = node.get("refine", parent_refine)
assert refine in ("ADD", "REPLACE"), f"{path}: no refine and nothing to inherit"
yield path, node, refine, depth, base
for i, child in enumerate(node.get("children", [])):
yield from walk(child, refine, depth + 1, base, f"{path}/{i}")
tileset_path = Path("tiles/city/tileset.json")
ts = json.loads(tileset_path.read_text())
nodes = list(walk(ts["root"], base=tileset_path.parent))
modes = {}
for _, _, refine, depth, _ in nodes:
modes.setdefault(depth, set()).add(refine)
print({d: sorted(m) for d, m in sorted(modes.items())})
Seeing modes per depth is a quick sanity check. A mesh tileset should report REPLACE at every depth; a point cloud ADD at every depth; a mixed tileset should switch at an identifiable level, not flicker between modes at random nodes, which usually means two tools wrote parts of the tree.
3. Check REPLACE nodes for coverage holes
import math
def extent(bv):
if "region" in bv:
w, s, e, n, lo, hi = bv["region"]
return (w, s, e, n)
raise ValueError("audit expects region bounding volumes")
def covered_fraction(parent, children, samples=40):
w, s, e, n = extent(parent["boundingVolume"])
boxes = [extent(c["boundingVolume"]) for c in children if "content" in c or c.get("children")]
hit = 0
for i in range(samples):
for j in range(samples):
lon = w + (i + 0.5) * (e - w) / samples
lat = s + (j + 0.5) * (n - s) / samples
if any(b[0] <= lon <= b[2] and b[1] <= lat <= b[3] for b in boxes):
hit += 1
return hit / samples ** 2
holes = []
for path, node, refine, depth, _ in nodes:
kids = node.get("children", [])
if refine == "REPLACE" and "content" in node and kids:
frac = covered_fraction(node, kids)
if frac < 0.98:
holes.append((path, depth, round(frac, 3)))
print(f"{len(holes)} REPLACE nodes whose children leave part of the parent uncovered", holes[:5])
A REPLACE parent is hidden when it refines, so any area of the parent’s footprint that no child covers disappears from the screen. Children without content and without descendants are ignored in the coverage test, because they contribute nothing to draw. The sampling approach is coarse and fast; a flagged node is worth a closer look, not an automatic failure — a parent whose content genuinely stops at a coastline can have uncovered water.
4. Check ADD subtrees for duplicated content
from collections import Counter
from pygltflib import GLTF2
def feature_ids(glb_path):
g = GLTF2().load_binary(str(glb_path))
ids = []
for mesh in g.meshes:
for prim in mesh.primitives:
if prim.extras and "building_ids" in prim.extras:
ids.extend(prim.extras["building_ids"])
return ids
seen = Counter()
for path, node, refine, depth, base in nodes:
uri = node.get("content", {}).get("uri")
if refine == "ADD" and uri and uri.endswith(".glb"):
for bid in set(feature_ids(base / uri)):
seen[bid] += 1
dupes = {bid: n for bid, n in seen.items() if n > 1}
print(f"{len(dupes)} objects appear in more than one ADD level", list(dupes.items())[:5])
Under ADD, an object present in both a parent and a child is drawn twice at the same position — a reliable source of z-fighting and of doubled triangle counts. The script looks for identifiers stored in primitive extras; adapt feature_ids to wherever your pipeline records them, such as a property table in EXT_structural_metadata, described in attaching EXT_structural_metadata to building tiles.
Expected Output & Verification
{0: ['REPLACE'], 1: ['REPLACE'], 2: ['REPLACE'], 3: ['REPLACE'], 4: ['ADD'], 5: ['ADD']}
3 REPLACE nodes whose children leave part of the parent uncovered [('root/2/1/3', 3, 0.75), ('root/0/3/0', 3, 0.5), ('root/3/3/2', 3, 0.875)]
0 objects appear in more than one ADD level []
Verify visually and numerically in the runtime. For each flagged REPLACE node, fly the camera to the uncovered quadrant and step in until the parent refines; content that disappears confirms the hole. For ADD subtrees, compare the triangle count the runtime reports for a view with the sum of unique triangles in the loaded tiles — a ratio well above 1 means duplication the identifier check missed.
Common Errors
Buildings flicker between two shapes when zooming. A mesh subtree is marked ADD, so a decimated parent and its detailed child overlap. Set REPLACE on the subtree root and let children inherit it.
A district stays coarse forever in one corner. One child of a REPLACE node fails to load — a 404 or a decode error — and CesiumJS keeps the parent visible until all children are ready. Check the network panel for the missing child; the refinement setting is doing its job.
Point cloud density jumps when the camera moves slightly. The point cloud was written with REPLACE, so every refinement drops the parent’s sample and draws only the child’s. Point cloud octrees built as disjoint samples need ADD.
Frequently Asked Questions
Does skipLevelOfDetail change how REPLACE behaves?
Yes. With skipLevelOfDetail enabled, CesiumJS may jump directly to deeper descendants without loading intermediate levels and can briefly draw a mix of levels. It reduces bandwidth for fast zooms and makes coverage holes and loading gaps more visible, so audit coverage before enabling it.
Can implicit tilesets mix modes?
An implicit tileset’s refinement is declared once at the implicit root and applies to the whole generated subtree. To mix modes, place separate implicit subtrees under explicit parent tiles with different refine values.
Is ADD always cheaper in bandwidth?
Only when levels are truly disjoint. A point cloud stored with ADD transmits each point once; a mesh wrongly stored with ADD still transmits every level and then draws them all.
Related Guides
- Computing Geometric Error for 3D Tiles Levels — when each level refines
- Tuning Maximum Screen Space Error in Cesium — how far refinement goes
- Tracking Down Z-Fighting Between Terrain and Buildings — the other common source of flicker