Generating Discrete glTF LOD Chains and Compressing Them With Draco

A single full-resolution building glTF is fine to download once; a city of them is not. The way 3D Tiles keeps a metropolitan twin interactive is to ship each feature as a short chain of discrete glTF levels of detail — a full-resolution mesh at the leaf and a few progressively coarser versions above it — and then to compress every one of those meshes with Draco so the bytes on the wire shrink by an order of magnitude without changing the vertex layout the renderer expects. This page is a runnable workflow for exactly that: producing per-level meshes with quadric edge-collapse in trimesh, measuring the real geometricError each level introduces, encoding each glTF with KHR_draco_mesh_compression through the gltf-transform CLI, choosing quantization bits that hold sub-centimetre positions, and verifying extensionsUsed before the meshes are wrapped as b3dm tiles. Geometry here is authored in a local East-North-Up (ENU) metric frame and placed onto the globe by a root transform into EPSG:4978 (geocentric WGS84, metres) — the frame CesiumJS renders in. This is the encoding half of LOD management; the decimation theory behind step 2 lives in automated mesh decimation and is not repeated here.

Prerequisites

Pin every component. Draco is a native codec wrapped by several tools, and a mismatch between the encoder that wrote a mesh and the decoder that reads it is a silent corruption, not a loud error. The combinations below are tested together.

Component Pinned version Purpose
Python 3.10–3.12 Orchestration, geometry math, subprocess control
trimesh 4.4+ Mesh load, QEM decimation, surface sampling for error
numpy 1.26+ Error arrays, bounding-box math, transform assembly
pyproj 3.6+ Local anchor → EPSG:4979 → EPSG:4978 placement
gltf-transform (CLI) 4.0+ (Node 18+) glTF Draco encode, quantization bit control, inspect
3d-tiles-tools 0.4+ (Node 18+) glTF → b3dm, tileset packaging
3d-tiles-validator 0.5+ Schema + extension validation of the tileset

Input formats. Author each feature as a .glb (glTF 2.0 binary) or .ply/.obj that trimesh can load with vertex positions, and — where textured — a TEXCOORD_0 attribute and material. Draco compresses POSITION, TEXCOORD_0, NORMAL, COLOR_0, and generic attributes; whatever you want quantized must exist on the primitive before encoding. Meshes must be winding-consistent, or coarse LODs develop shading seams that Draco quantization then bakes in permanently.

Coordinate reference system — state it, never infer. Vertices are authored in a local ENU frame in metres, with the mesh centred near its own origin so that float32 positions keep their precision (full projected eastings such as EPSG:25832 would waste mantissa bits on the six-figure integer part). The feature is placed onto the WGS84 ellipsoid by the root tile’s 4×4 transform, which maps that ENU frame into EPSG:4978 (ECEF). The anchor point is computed from the dataset’s projected CRS (for a central-European twin, EPSG:25832) through EPSG:4979 (geographic 3D) so the vertical datum is handled, then into EPSG:4978. Keeping geometry local and delegating georeferencing to the transform is also what makes Draco quantization predictable: positional error scales with the mesh bounding-box extent, not with an absolute ECEF coordinate in the millions of metres. That relationship is the whole subject of tuning Draco quantization for building meshes.

Concept

A discrete LOD chain is an ordered list of independent meshes of the same feature: LOD0 at full resolution, then LOD1, LOD2, … each with fewer triangles. It is not progressive mesh streaming — every level is a complete, standalone glTF, which is what 3D Tiles refinement expects, because the runtime swaps whole tiles rather than reconstructing detail incrementally. Each level carries a geometricError: the world-space distance in metres between that simplified mesh and the full-resolution source. The renderer projects that error through the camera and refines only when the on-screen error exceeds its budget, so the number must be measured per level, never guessed.

Draco is orthogonal to the LOD chain. It compresses one mesh by quantizing each attribute to a fixed number of integer bits over that attribute’s range, then entropy-coding the connectivity with the edgebreaker algorithm. Quantization is the only lossy step and the only one that touches accuracy: a POSITION quantized to n bits across a bounding box of extent E metres carries a worst-case positional error of about E / 2^n. For a 30 m building at 14 bits that is roughly 30 / 16384 ≈ 1.8 mm — invisible — while the connectivity coding is lossless. So the two dials are separate: decimation sets how many triangles a level has (and its geometricError), and Draco quantization sets how many bytes those triangles cost (and a sub-millimetre floor under the position error). You choose the triangle budget for the screen-space footprint and the quantization bits for the accuracy tolerance, then measure both.

glTF LOD chain with Draco payloads shrinking per level A full-resolution LOD0 mesh is quadric-decimated into LOD1 and LOD2 with falling triangle counts and rising geometricError, and each level is Draco-compressed into a b3dm payload whose byte size shrinks from megabytes to kilobytes. LOD 0 · 180k tris geometricError 0.0 m LOD 1 · 40k tris geometricError 0.35 m LOD 2 · 9k tris geometricError 1.40 m Draco b3dm 2.9 MB Draco b3dm 0.72 MB Draco b3dm 0.17 MB POSITION 14 bit · TEXCOORD 12 · NORMAL 10 — authored in local ENU metres, placed to EPSG:4978
Each level is an independent decimation with a measured geometricError; Draco quantization then shrinks the byte payload per level while holding sub-centimetre positions.

Step-by-Step Workflow

The workflow takes one authored .glb per feature, produces a three-level chain, measures each level’s error, Draco-encodes each level with explicit quantization bits, verifies the extension landed, and hands the results to the tiling step. Every stage is idempotent and drops into CI.

1. Fix the ENU frame and the ECEF placement

Do not bake the ellipsoid position into vertices. Author geometry around a local origin in metres and compute the root transform once, so all LODs share the same placement and Draco quantization sees a small, stable bounding box.

python
import numpy as np
from pyproj import Transformer

# Dataset anchor in a projected metric CRS (ETRS89 / UTM 32N, EPSG:25832).
# Go via geographic-3D EPSG:4979 so ellipsoidal height is handled, then to ECEF.
to_geographic = Transformer.from_crs("EPSG:25832", "EPSG:4979", always_xy=True)
to_ecef = Transformer.from_crs("EPSG:4979", "EPSG:4978", always_xy=True)

anchor_e, anchor_n, anchor_h = 691_500.0, 5_335_800.0, 519.0   # EPSG:25832 metres
lon, lat, h = to_geographic.transform(anchor_e, anchor_n, anchor_h)
cx, cy, cz = to_ecef.transform(lon, lat, h)

def enu_to_ecef_transform(lon_deg, lat_deg, x, y, z):
    """Column-major 4x4 ENU -> ECEF (EPSG:4978) transform for the root tile."""
    lo, la = np.radians(lon_deg), np.radians(lat_deg)
    east = np.array([-np.sin(lo), np.cos(lo), 0.0])
    north = np.array([-np.sin(la) * np.cos(lo), -np.sin(la) * np.sin(lo), np.cos(la)])
    up = np.array([np.cos(la) * np.cos(lo), np.cos(la) * np.sin(lo), np.sin(la)])
    m = np.identity(4)
    m[:3, 0], m[:3, 1], m[:3, 2] = east, north, up
    m[:3, 3] = [x, y, z]
    return m.flatten(order="F").tolist()          # column-major for tileset.json

root_transform = enu_to_ecef_transform(lon, lat, cx, cy, cz)
assert len(root_transform) == 16

2. Generate the discrete LOD chain with QEM

Decimate the source once per level with quadric edge-collapse, always from the original mesh — chaining decimations compounds error. trimesh.simplify_quadric_decimation wraps a fast QEM implementation and takes an absolute face_count, which keeps a heterogeneous batch of buildings on uniform budgets. The algorithmic detail (boundary weighting, degeneracy fallback) belongs to automated mesh decimation; here we only need the chain of meshes.

python
import trimesh
from pathlib import Path

def build_lod_chain(source_path, budgets=(40_000, 9_000)):
    """LOD0 = source; each further level is one QEM pass from the source."""
    source = trimesh.load(source_path, force="mesh", process=False)
    assert source.is_winding_consistent, f"{source_path}: inconsistent winding"
    chain = {"LOD0": source}
    for i, face_count in enumerate(budgets, start=1):
        if face_count >= len(source.faces):
            chain[f"LOD{i}"] = source
            continue
        chain[f"LOD{i}"] = source.simplify_quadric_decimation(face_count=face_count)
    return chain

chain = build_lod_chain(Path("building_enu.glb"))
for name, m in chain.items():
    print(f"{name}: {len(m.faces):,} faces")

3. Measure geometricError per level

Sample the source surface densely and take the nearest-surface distance from those samples to each decimated level. Report the 99th percentile, which is robust to a single bad collapse while still bounding the visible artefact. LOD0 is the source, so its error is exactly 0.

python
import numpy as np

def geometric_error(source, simplified, n_samples=40_000):
    if simplified is source:
        return 0.0
    samples, _ = trimesh.sample.sample_surface(source, n_samples)
    _, dist, _ = simplified.nearest.on_surface(samples)
    return float(np.percentile(dist, 99))          # metres, in the ENU frame

source = chain["LOD0"]
errors = {name: geometric_error(source, m) for name, m in chain.items()}
for name, ge in errors.items():
    print(f"{name}: geometricError {ge:.3f} m")
# e.g. LOD0 0.000, LOD1 0.35x, LOD2 1.4x  -> monotonic INCREASE toward coarser levels

4. Export each LOD to a standalone glTF

Write each level to its own .glb before Draco encoding. trimesh exports glTF 2.0 with positions, normals, and (when present) texture coordinates and materials intact — exactly the attributes Draco will quantize.

python
def export_levels(chain, out_dir):
    out_dir = Path(out_dir); out_dir.mkdir(parents=True, exist_ok=True)
    paths = {}
    for name, mesh in chain.items():
        p = out_dir / f"building_{name}.glb"
        mesh.export(p)                             # glTF 2.0 binary, uncompressed
        paths[name] = p
    return paths

raw_paths = export_levels(chain, "lods_raw")

5. Draco-compress with explicit quantization bits

Drive gltf-transform draco through subprocess, setting the per-attribute quantization bits explicitly rather than accepting defaults. Positions get 14 bits (sub-centimetre on a building-sized box), texture coordinates 12, normals 10 — the accuracy/size tradeoff is quantified in the quantization tuning guide. The edgebreaker method gives the smallest connectivity payload for closed manifold meshes.

python
import subprocess

def draco_encode(in_glb, out_glb, pos=14, tex=12, nrm=10):
    """Compress one glTF with KHR_draco_mesh_compression via gltf-transform."""
    subprocess.run(
        ["gltf-transform", "draco", str(in_glb), str(out_glb),
         "--method", "edgebreaker",
         "--quantize-position", str(pos),
         "--quantize-texcoord", str(tex),
         "--quantize-normal", str(nrm)],
        check=True,
    )
    return out_glb

comp_dir = Path("lods_draco"); comp_dir.mkdir(exist_ok=True)
comp_paths = {name: draco_encode(p, comp_dir / p.name)
              for name, p in raw_paths.items()}

6. Verify the extension, then wrap as b3dm

A Draco encode that silently no-ops produces a valid but uncompressed glTF, so assert KHR_draco_mesh_compression is actually declared before wrapping the level as a b3dm tile. Parse the GLB JSON chunk directly — no extra dependency — then convert with 3d-tiles-tools.

python
import struct, json

def gltf_json_chunk(glb_path):
    data = Path(glb_path).read_bytes()
    magic, _version, _length = struct.unpack("<III", data[:12])
    assert magic == 0x46546C67, f"{glb_path}: not a GLB (bad magic)"      # 'glTF'
    chunk_len, chunk_type = struct.unpack("<II", data[12:20])
    assert chunk_type == 0x4E4F534A, f"{glb_path}: first chunk is not JSON"  # 'JSON'
    return json.loads(data[20:20 + chunk_len])

def wrap_b3dm(glb_path, b3dm_path):
    doc = gltf_json_chunk(glb_path)
    used = doc.get("extensionsUsed", [])
    assert "KHR_draco_mesh_compression" in used, f"{glb_path}: Draco not applied"
    subprocess.run(["3d-tiles-tools", "glbToB3dm",
                    "-i", str(glb_path), "-o", str(b3dm_path), "-f"], check=True)
    return b3dm_path

tiles_dir = Path("tiles"); tiles_dir.mkdir(exist_ok=True)
for name, p in comp_paths.items():
    wrap_b3dm(p, tiles_dir / f"building_{name}.b3dm")

These b3dm payloads, each tagged with its measured geometricError from step 3 and placed by root_transform from step 1, are the leaf and refinement content the automated tile generation step assembles into a tileset.json, and the 3D Tiles batch tiling pipelines step encodes in parallel across a whole city.

Validation & Verification

Never assume the encode worked from its exit code. Confirm three things: the extension is declared and required, decoded positions still match the source within tolerance, and the payload actually shrank. Start with gltf-transform inspect, which prints the extensions and per-mesh stats.

bash
gltf-transform inspect lods_draco/building_LOD0.glb

Then assert the invariants in code. Load the encoded glTF back through trimesh (which decodes Draco on read) and compare its vertices to the source bounding box, and confirm the extension is both used and required.

python
def verify_encode(source_path, encoded_path, max_pos_error_m=0.01):
    doc = gltf_json_chunk(encoded_path)
    assert "KHR_draco_mesh_compression" in doc.get("extensionsUsed", [])
    assert "KHR_draco_mesh_compression" in doc.get("extensionsRequired", [])

    src = trimesh.load(source_path, force="mesh", process=False)
    dec = trimesh.load(encoded_path, force="mesh", process=False)   # Draco-decoded

    # Decoded positions must sit within the quantization error of the source surface.
    _, dist, _ = src.nearest.on_surface(dec.vertices)
    worst = float(dist.max())
    assert worst < max_pos_error_m, f"decoded drift {worst*1000:.2f} mm too high"

    raw_kb = Path(source_path).stat().st_size / 1024
    enc_kb = Path(encoded_path).stat().st_size / 1024
    return {"worst_mm": worst * 1000, "raw_kb": raw_kb,
            "enc_kb": enc_kb, "ratio": raw_kb / enc_kb}

print(verify_encode("lods_raw/building_LOD0.glb", "lods_draco/building_LOD0.glb"))

Expected values. For a 180k-triangle building spanning roughly 30 m, POSITION at 14 bits gives a decoded worst-case drift of a few millimetres (well under the 1 cm gate), and the .glb shrinks from tens of megabytes to a few — a 6–12× ratio is typical for closed manifold geometry, higher when normals and texcoords dominate the vertex. A ratio near 1.0 means the extension did not apply; a decoded drift of tens of centimetres means too few position bits for the extent. Finally, run the assembled tileset through 3d-tiles-validator so the client never receives a b3dm whose inner glTF requires an extension the tileset forgot to advertise.

Performance & Scale

Draco encoding is CPU-bound and single-threaded per invocation, so the throughput lever at city scale is parallelism across features, not faster encoding of one mesh. Each gltf-transform call spawns a Node process; for tens of thousands of features that startup cost is real, so batch by feature and distribute across physical cores.

python
from concurrent.futures import ProcessPoolExecutor
import os

def encode_one(job):
    raw, out = job
    return draco_encode(raw, out)

jobs = [(p, comp_dir / p.name) for p in raw_paths.values()]
with ProcessPoolExecutor(max_workers=os.cpu_count()) as pool:
    list(pool.map(encode_one, jobs))

Two further levers matter. First, encode once, reuse the level. A feature’s LOD2 rarely changes between builds, so cache Draco output keyed by the source-mesh hash and re-encode only changed features — incremental rebuilds on a steady twin touch a few percent of tiles. Second, quantize, do not over-decimate. Draco’s connectivity coder already exploits shared topology, so a level compressed at 14/12/10 bits is often smaller than a more aggressively decimated level left uncompressed, while keeping more triangles and a lower geometricError. Measure both paths against your byte budget rather than assuming fewer triangles always wins. For the parallel b3dm encode across an entire dataset — merging co-located features into batched tiles and sizing the pool to physical cores — see 3D Tiles batch tiling pipelines.

Failure Modes & Gotchas

Draco silently no-ops and the payload does not shrink. If a primitive has no indices, or gltf-transform cannot find the native Draco module, the draco command can pass through geometry uncompressed while still exiting 0. The tell is a compression ratio near 1.0 and an absent KHR_draco_mesh_compression in extensionsUsed. Always run the step-6 assertion; treat a missing extension as a build failure, not a warning.

Too few position bits warp a georeferenced building. POSITION quantization error is roughly extent / 2^bits, and the extent that matters is the mesh’s own ENU bounding box, not the ECEF magnitude. A 300 m block quantized at 11 bits carries ~15 cm of positional error — enough to bow a facade. Keep geometry local and centred (step 1), size the bit budget to the extent, and confirm decoded drift in the step’s verification. The full bit-versus-extent relationship is worked in the quantization tuning guide.

Normals quantized too coarsely shade in facets. NORMAL at 8 bits is visibly banded on smooth curved surfaces under directional light. 10 bits is the safe floor for architectural geometry; drop to 8 only for hard-edged or untextured background assets where the banding never faces the camera.

geometricError is not monotonic across the chain. For a Draco chain the error must increase from the full-resolution leaf (LOD0, error 0) toward coarser levels, and when those levels are mapped onto a 3D Tiles tree the child (finer) tile must carry the smaller error. Mixing up which level is the leaf produces the classic “detail vanishes as you zoom in” refinement bug. Assign the measured error from step 3 to the tree consistently and assert monotonicity down the tree in the tiling step.

UV seams smeared by quantization plus decimation. Aggressive QEM that merges vertices across a texture seam, followed by low TEXCOORD bits, tears the atlas. Split on UV islands before decimating and keep TEXCOORD at 12 bits for textured facades; see preserving UV seams during mesh decimation.

Frequently Asked Questions

Is a discrete glTF LOD chain the same as Draco progressive compression?

No. A discrete chain is several independent, complete glTF meshes at different triangle counts, which is what 3D Tiles refinement swaps between. Draco compresses each of those meshes individually and is not a progressive or streaming codec in this workflow — decoding a Draco primitive gives you the whole mesh at once. Decimation controls the number of levels and their triangle counts; Draco controls the byte size of each level.

Why encode with gltf-transform instead of gltf-pipeline or draco_encoder directly?

All three call the same Draco library, but they differ in control. gltf-transform draco exposes per-attribute quantization bits and the edgebreaker method as first-class flags and round-trips the full glTF material and texture graph, which is what you want for textured building levels. gltf-pipeline and the raw draco_encoder are viable, but you give up some of that per-attribute control or have to reassemble the glTF yourself. The workflow standardises on gltf-transform for that reason.

How do I confirm Draco actually applied without a viewer?

Parse the GLB JSON chunk and check that KHR_draco_mesh_compression appears in both extensionsUsed and extensionsRequired, then compare file sizes for a real reduction. gltf-transform inspect prints the same information for a human. The step-6 and validation snippets do exactly this; a ratio near 1.0 or a missing extension means the encode did not take.

What quantization bits should I start with for buildings?

POSITION 14, TEXCOORD 12, NORMAL 10 is a sound default that holds sub-centimetre positions on building-sized meshes while still compressing hard. These are starting points tied to the mesh extent, not universal constants — a 5 m kiosk tolerates fewer position bits than a 400 m tower. The quantization tuning guide derives the bit budget from the bounding box and an accuracy tolerance.

Does Draco change the geometricError I measured?

Not meaningfully if you quantize sensibly. geometricError is dominated by decimation — the triangles you removed — and is metres in scale, while POSITION quantization at 14 bits adds only millimetres on a building-sized box. Measure geometricError on the decimated mesh before Draco, then verify that decoded positions stay within your accuracy tolerance so quantization never becomes the dominant error term.

Back to LOD Management & Optimization Strategies.