Generating Instanced Tiles for Trees
This page turns a municipal tree register of 1.2 million records into instanced 3D Tiles content — one small mesh per species, positioned by EXT_mesh_gpu_instancing, with per-instance rotation, scale and colour variation, packed into tiles that respect a draw-call budget, and verified against the register.
Why you hit this
A city tree layer is the clearest case for instancing there is: a handful of distinct geometries repeated a million times with different transforms. Baking each tree as unique geometry produces hundreds of gigabytes and a viewer that cannot render it. Instancing sends the mesh once and a transform per copy, which turns 1.2 million trees into a few hundred megabytes and one draw call per tile per species.
The same technique applies to street furniture, lamp posts, bollards, hydrants and parked vehicles, and the register-to-tileset shape of the problem is identical. What changes is the mesh count and the attributes.
Prerequisites
- Python 3.10+ with
geopandas,numpy,pyproj,pygltflibor direct JSON/GLB writing. gltf-transformfrom npm, for inspection and compression.- One low-poly mesh per species class, Y-up, origin at the trunk base, in metres.
- The tree register with position, species, height and crown diameter.
Step-by-Step
1. Reduce the register to instanceable classes
import json
import math
from collections import Counter
from pathlib import Path
import geopandas as gpd
import numpy as np
SPECIES_TO_MESH = {
"Tilia cordata": "broadleaf_round",
"Acer platanoides": "broadleaf_round",
"Quercus robur": "broadleaf_broad",
"Betula pendula": "broadleaf_narrow",
"Pinus sylvestris": "conifer_tall",
"Picea abies": "conifer_tall",
}
DEFAULT_MESH = "broadleaf_round"
def classify(register_path):
gdf = gpd.read_file(register_path)
gdf["mesh"] = gdf["species"].map(SPECIES_TO_MESH).fillna(DEFAULT_MESH)
gdf["height_m"] = gdf["height_m"].clip(lower=1.5, upper=42.0)
gdf["crown_m"] = gdf["crown_m"].fillna(gdf["height_m"] * 0.45).clip(lower=0.8, upper=28.0)
counts = Counter(gdf["mesh"])
unmapped = sorted(set(gdf.loc[~gdf["species"].isin(SPECIES_TO_MESH), "species"].dropna()))
return gdf, {"trees": len(gdf), "by_mesh": dict(counts),
"unmapped_species": len(unmapped), "examples": unmapped[:5]}
trees, summary = classify("input/tree_register.gpkg")
print(json.dumps(summary, indent=2))
Five meshes for 340 species is the right ratio. A viewer at street level cannot distinguish a lime from a maple at any realistic frame budget, and each additional mesh is another draw call in every tile that contains it — so the mapping should collapse species into silhouette classes, not preserve botanical distinctions.
Clipping height and crown diameter matters because registers contain data-entry errors: a 340 m tree from a units mistake will produce a bounding volume covering the district and a scale factor that makes one instance fill the screen.
2. Compute the per-instance transform
from pyproj import CRS, Transformer
TO_ECEF = Transformer.from_crs(CRS.from_epsg(25832), CRS.from_epsg(4978), always_xy=True)
def instance_arrays(subset, reference_height_m=8.0, reference_crown_m=4.0, seed=7):
"""Positions relative to the tile centre, plus rotation and non-uniform scale."""
rng = np.random.default_rng(seed)
x, y, z = TO_ECEF.transform(subset.geometry.x.values,
subset.geometry.y.values,
subset["ground_h_m"].values)
pts = np.column_stack([x, y, z])
centre = pts.mean(axis=0)
local = (pts - centre).astype(np.float32)
yaw = rng.uniform(0.0, 2.0 * math.pi, len(subset)).astype(np.float32)
rotation = np.zeros((len(subset), 4), dtype=np.float32)
rotation[:, 1] = np.sin(yaw / 2.0) # quaternion about Y (up, in glTF)
rotation[:, 3] = np.cos(yaw / 2.0)
vert = (subset["height_m"].values / reference_height_m).astype(np.float32)
horiz = (subset["crown_m"].values / reference_crown_m).astype(np.float32)
jitter = rng.normal(1.0, 0.035, (len(subset), 3)).astype(np.float32)
scale = np.column_stack([horiz, vert, horiz]) * jitter
return {"centre_ecef": centre, "translation": local,
"rotation": rotation, "scale": scale}
Random yaw is what stops an instanced avenue looking like a wallpaper pattern; it costs four floats per instance and is the single largest visual improvement available. The 3.5% scale jitter does the same job for silhouette, breaking up the identical outlines that otherwise read as artificial at a glance.
Non-uniform scale — crown diameter horizontally, height vertically — is what makes one mesh serve a young lime and a mature oak. It requires the source mesh to be modelled at a known reference size, which is why reference_height_m is explicit rather than assumed.
Storing translations relative to the tile centre keeps them small enough for float32, exactly as in ECEF and ENU frames for tileset transforms. Absolute ECEF translations in float32 would quantise tree positions to about 25 cm.
3. Write the glTF with EXT_mesh_gpu_instancing
import struct
def build_instanced_glb(mesh_gltf_path, arrays, out_path):
"""Attach instance attributes to every node that has a mesh."""
doc = json.loads(Path(mesh_gltf_path).read_text())
buffers, views, accessors = [], list(doc.get("bufferViews", [])), list(doc.get("accessors", []))
blob = bytearray()
def add(array, comp_type, type_str):
data = np.ascontiguousarray(array, dtype=np.float32).tobytes()
offset = len(blob)
blob.extend(data)
while len(blob) % 4:
blob.append(0)
views.append({"buffer": 0, "byteOffset": offset, "byteLength": len(data)})
acc = {"bufferView": len(views) - 1, "componentType": comp_type,
"count": int(len(array)), "type": type_str}
if type_str == "VEC3":
acc["min"] = [float(v) for v in np.min(array, axis=0)]
acc["max"] = [float(v) for v in np.max(array, axis=0)]
accessors.append(acc)
return len(accessors) - 1
t = add(arrays["translation"], 5126, "VEC3")
r = add(arrays["rotation"], 5126, "VEC4")
s = add(arrays["scale"], 5126, "VEC3")
doc["bufferViews"] = views
doc["accessors"] = accessors
doc.setdefault("extensionsUsed", [])
if "EXT_mesh_gpu_instancing" not in doc["extensionsUsed"]:
doc["extensionsUsed"].append("EXT_mesh_gpu_instancing")
doc.setdefault("extensionsRequired", [])
if "EXT_mesh_gpu_instancing" not in doc["extensionsRequired"]:
doc["extensionsRequired"].append("EXT_mesh_gpu_instancing")
for node in doc["nodes"]:
if "mesh" in node:
node.setdefault("extensions", {})["EXT_mesh_gpu_instancing"] = {
"attributes": {"TRANSLATION": t, "ROTATION": r, "SCALE": s}}
doc["buffers"] = [{"byteLength": len(blob)}]
write_glb(doc, bytes(blob), out_path)
return {"instances": int(len(arrays["translation"])), "bytes": out_path and Path(out_path).stat().st_size}
def write_glb(gltf_json, bin_blob, out_path):
js = json.dumps(gltf_json, separators=(",", ":")).encode("utf-8")
js += b" " * ((4 - len(js) % 4) % 4)
bin_pad = bin_blob + b"\x00" * ((4 - len(bin_blob) % 4) % 4)
total = 12 + 8 + len(js) + 8 + len(bin_pad)
with open(out_path, "wb") as f:
f.write(struct.pack("<III", 0x46546C67, 2, total))
f.write(struct.pack("<II", len(js), 0x4E4F534A)); f.write(js)
f.write(struct.pack("<II", len(bin_pad), 0x004E4942)); f.write(bin_pad)
EXT_mesh_gpu_instancing is the 3D Tiles 1.1 way to do this, and it replaces the older i3dm tile format entirely: the content is ordinary glTF that any glTF tool can read, with instance attributes hanging off the node. The three attributes are optional individually — a tileset with only TRANSLATION is valid and is the right choice for bollards, which need neither rotation nor scale.
Listing the extension in extensionsRequired is deliberate. A client that ignores it would draw one tree at the tile origin and nothing else, which looks like a data problem rather than a capability problem; declaring it required makes the failure explicit.
Instance attributes must not be Draco-compressed — the extension’s accessors are read as instance data, and Draco applies to mesh primitives. Quantising them with KHR_mesh_quantization is supported and halves the transform payload.
4. Pack instances into tiles
def pack_tiles(gdf, tile_m=250.0, max_instances=6000):
"""Grid the register, then split dense cells so no tile exceeds the budget."""
xs = gdf.geometry.x.values
ys = gdf.geometry.y.values
i = np.floor((xs - xs.min()) / tile_m).astype(np.int64)
j = np.floor((ys - ys.min()) / tile_m).astype(np.int64)
gdf = gdf.assign(_i=i, _j=j)
tiles = []
for (ti, tj), cell in gdf.groupby(["_i", "_j"]):
if len(cell) <= max_instances:
tiles.append({"key": f"{ti}_{tj}", "rows": cell})
continue
parts = math.ceil(len(cell) / max_instances)
order = np.argsort(cell.geometry.x.values, kind="stable")
for p, chunk in enumerate(np.array_split(order, parts)):
tiles.append({"key": f"{ti}_{tj}_p{p}", "rows": cell.iloc[chunk]})
sizes = [len(t["rows"]) for t in tiles]
return tiles, {"tiles": len(tiles), "max": max(sizes), "median": int(np.median(sizes)),
"empty_cells_skipped": True}
tiles, pack_stats = pack_tiles(trees)
print(pack_stats)
The instance count per tile is the real budget, not the byte count: 40 bytes per instance means even 20,000 trees is under a megabyte, but the vertex shader still transforms every instance in a visible tile whether or not it covers a pixel. Six thousand per tile keeps a dense park tile within a frame at 60 Hz on integrated graphics.
Splitting a dense cell by sorted x rather than by another grid level keeps the sub-tiles spatially coherent, so frustum culling still works on them — a random split would give every part a bounding volume covering the whole cell.
5. Write one content file per mesh class per tile
def emit_tile(tile, out_dir="output/content", meshes_dir="assets/meshes"):
written = []
for mesh_name, subset in tile["rows"].groupby("mesh"):
arrays = instance_arrays(subset)
out = Path(out_dir) / f"{tile['key']}__{mesh_name}.glb"
out.parent.mkdir(parents=True, exist_ok=True)
stats = build_instanced_glb(Path(meshes_dir) / f"{mesh_name}.gltf", arrays, out)
written.append({"uri": out.name, "mesh": mesh_name,
"instances": stats["instances"], "bytes": stats["bytes"],
"centre_ecef": [float(v) for v in arrays["centre_ecef"]],
"max_height_m": float(subset["height_m"].max())})
return written
def tile_bounding_sphere(entries, crown_margin_m=14.0):
"""One sphere covering every instance group in the tile."""
centres = np.array([e["centre_ecef"] for e in entries])
c = centres.mean(axis=0)
spread = float(np.linalg.norm(centres - c, axis=1).max()) if len(centres) > 1 else 0.0
reach = max(e["max_height_m"] for e in entries) + crown_margin_m
return [*map(float, c), spread + reach]
Grouping by mesh class inside the tile is what keeps the draw calls at five rather than one: each content file holds one mesh and its instances, and the client issues one instanced draw per file. It also means a tile with only limes ships one file, not five.
The bounding volume has to include the crown reach above the trunk base, and forgetting that is a common cause of trees disappearing when the camera looks up at them — the volume covers the ground positions, the camera frustum misses it, and the tile is culled while its geometry is on screen.
6. Assemble the tileset with a sensible refinement policy
def build_tileset(tiles, out="output/trees/tileset.json", tile_m=250.0):
children = []
for tile in tiles:
entries = emit_tile(tile)
if not entries:
continue
sphere = tile_bounding_sphere(entries)
for entry in entries:
children.append({
"boundingVolume": {"sphere": sphere},
"geometricError": 0.0,
"content": {"uri": f"content/{entry['uri']}"},
"extras": {"instances": entry["instances"], "mesh": entry["mesh"]},
})
all_c = np.array([c["boundingVolume"]["sphere"][:3] for c in children])
root_c = all_c.mean(axis=0)
root_r = float(np.linalg.norm(all_c - root_c, axis=1).max() + tile_m)
doc = {"asset": {"version": "1.1"},
"geometricError": 1024.0,
"root": {"boundingVolume": {"sphere": [*map(float, root_c), root_r]},
"geometricError": 1024.0, "refine": "ADD", "children": children}}
Path(out).parent.mkdir(parents=True, exist_ok=True)
Path(out).write_text(json.dumps(doc, sort_keys=True, separators=(",", ":")))
return {"tiles": len(children), "instances": sum(c["extras"]["instances"] for c in children)}
print(build_tileset(tiles))
ADD refinement with a flat tree is right for a tree layer: there is no coarse version of a tree worth showing, so the behaviour you want is “load trees within this distance, show nothing beyond it”. The root’s geometric error of 1024 m sets that distance — with a 16-pixel error budget on a 1080-pixel viewport, trees start loading at roughly 60 km, which is too far; 256 m is a better starting value and brings them in at about 15 km.
An alternative worth considering for very large registers is a two-level tree where the upper level holds only the largest 5% of trees, so a distant view shows the canopy structure without a million instances.
Expected Output & Verification
{
"trees": 1204418,
"by_mesh": {"broadleaf_round": 702118, "broadleaf_broad": 214005,
"broadleaf_narrow": 168820, "conifer_tall": 119475},
"unmapped_species": 334,
"examples": ["Aesculus hippocastanum", "Alnus glutinosa", "Carpinus betulus"]
}
{'tiles': 412, 'max': 6000, 'median': 2864, 'empty_cells_skipped': True}
{'tiles': 1188, 'instances': 1204418}
The instance total in the tileset must equal the register count exactly, and that equality is the main correctness check — 1,188 content files for 412 tiles means about three mesh classes per tile, which is what you expect from a mixed urban register.
Verify the instance data round-trips through the glTF, because a wrong accessor count or type produces trees at the origin rather than an error:
import subprocess
def inspect_instances(glb_path):
proc = subprocess.run(["npx", "gltf-transform", "inspect", glb_path],
capture_output=True, text=True)
doc_json = subprocess.run(["npx", "gltf-transform", "copy", glb_path, "/dev/stdout", "--format", "gltf"],
capture_output=True, text=True)
doc = json.loads(doc_json.stdout) if doc_json.returncode == 0 else {}
rows = []
for node in doc.get("nodes", []):
ext = node.get("extensions", {}).get("EXT_mesh_gpu_instancing")
if not ext:
continue
attrs = ext["attributes"]
counts = {k: doc["accessors"][v]["count"] for k, v in attrs.items()}
types = {k: doc["accessors"][v]["type"] for k, v in attrs.items()}
rows.append({"node": node.get("name", "?"), "counts": counts, "types": types,
"consistent": len(set(counts.values())) == 1,
"types_ok": types.get("ROTATION") in (None, "VEC4")
and types.get("TRANSLATION") in (None, "VEC3")})
return {"nodes_with_instancing": len(rows), "rows": rows[:3],
"required": "EXT_mesh_gpu_instancing" in doc.get("extensionsRequired", [])}
print(json.dumps(inspect_instances("output/content/12_9__broadleaf_round.glb"), indent=2))
All three attributes must have identical counts; a mismatch is accepted by the parser and produces undefined placement for the surplus instances. The types_ok check catches the most common authoring bug, writing rotation as a VEC3 of Euler angles instead of a VEC4 quaternion.
Then verify the geographic placement against the register independently of the glTF:
def placement_check(tileset_path, register, sample=2000, tol_m=0.25):
from pyproj import Transformer
to_geo = Transformer.from_crs(CRS.from_epsg(4978), CRS.from_epsg(4326), always_xy=True)
doc = json.loads(Path(tileset_path).read_text())
spheres = np.array([c["boundingVolume"]["sphere"] for c in doc["root"]["children"]])
reg = register.sample(min(sample, len(register)), random_state=3)
to_ecef = Transformer.from_crs(CRS.from_epsg(25832), CRS.from_epsg(4978), always_xy=True)
x, y, z = to_ecef.transform(reg.geometry.x.values, reg.geometry.y.values,
reg["ground_h_m"].values)
pts = np.column_stack([x, y, z])
inside = np.zeros(len(pts), dtype=bool)
for s in spheres:
d = np.linalg.norm(pts - s[:3], axis=1)
inside |= d <= s[3]
return {"sampled": len(pts), "covered": int(inside.sum()),
"uncovered": int((~inside).sum()),
"all_covered": bool(inside.all())}
print(placement_check("output/trees/tileset.json", trees))
Every sampled register position must fall inside at least one tile’s bounding sphere. An uncovered tree means either a tile was dropped during emission or its sphere is too tight — both of which produce trees that never appear, and neither of which the validator detects.
Performance Notes
- 40 bytes per instance with all three attributes; 12 bytes with translation only. A million trees is 40 MB of transform data across the whole tileset.
- Quantise with
KHR_mesh_quantizationto halve that: translations to unsigned short relative to the tile, rotations to signed byte. Visually indistinguishable for trees. - Keep the source mesh under 1,000 triangles. The mesh cost is paid per instance in the rasteriser even though the vertices are sent once; a 20,000-triangle tree model is what actually kills frame rate.
- Alpha-blended foliage is expensive. Prefer alpha-cutout over blending for crown cards — it avoids the sorting and the overdraw.
- One content file per mesh class per tile, not one per species. Draw calls scale with files in view.
- Writing 1,188 GLB files takes about 4 minutes single-threaded, almost all of it in
np.tobytesand disk. It shards per tile if needed.
Common Errors
All trees appear at one point. The client ignored the extension, or the instance accessors are not attached to a node that has a mesh. Declare the extension as required and attach to mesh nodes.
Trees are 25 cm off their surveyed positions. Translations stored as absolute ECEF in float32. Store relative to the tile centre and put the centre in the tile transform.
Trees disappear when the camera tilts up. The bounding volume covers ground positions only. Add the crown reach.
Some trees are enormous. An unclipped height value from a register error, multiplied into the scale attribute. Clip on read.
Every tree faces the same way. Rotation attribute omitted or all-identity. Add random yaw.
The GLB is rejected as invalid. Instance accessors need no bufferView target, but they do need correct count, type and byte alignment. Every buffer view offset must be a multiple of 4.
Draco compression breaks the instancing. Draco applies to mesh primitives, not instance attributes. Compress the mesh, leave the instance accessors alone.
Frequently Asked Questions
Should I use i3dm instead?
No. i3dm is the legacy 1.0 format; EXT_mesh_gpu_instancing in glTF content is the 1.1 equivalent, is readable by ordinary glTF tooling, and is what current viewers prefer.
Can instances have per-instance colour?
Yes, through a custom attribute such as _FEATURE_ID plus a metadata property table, or a per-instance _COLOR_0 where the client supports it. Feature IDs are the portable route and also give you picking.
How do I make trees pickable?
Assign a feature ID per instance and a property table with the register’s identifier, using the 3D Tiles metadata system. The client then resolves a click to a row in the register.
Related Guides
- Writing Tileset JSON from Python — the tileset structure these tiles go into
- Styling Tiles by Metadata with Cesium3DTileStyle — driving appearance from register attributes
- Merging Meshes to Cut Draw Calls — the non-instanced side of the same budget
Back to Automated Tile Generation.