Draco vs Meshopt Compression for glTF Tiles
This page compares the two geometry compression extensions a glTF tile can use — KHR_draco_mesh_compression and EXT_meshopt_compression — on the numbers that decide a streaming digital twin: bytes over the wire after HTTP Brotli, decode time on the client, how each treats quantisation, and which runtimes load it, with a benchmark you can rerun on your own building tiles produced in EPSG:4978.
Why you hit this
Draco became the default answer for compressing 3D Tiles content because it was first, it produces the smallest files at rest, and Cesium’s tooling adopted it early. Meshopt arrived later with a different trade: files that are larger on disk but compress further under the Brotli or gzip the CDN already applies, and decode an order of magnitude faster. For a twin that streams thousands of small tiles to mid-range phones, decode time is often the bottleneck rather than bytes, and the right choice can differ between building tiles, terrain and point-like content. The Draco quantisation settings themselves are tuned in tuning Draco quantization for building meshes.
Prerequisites
- Node.js 18+ with
@gltf-transform/cli@4,@gltf-transform/core@4,@gltf-transform/extensions@4,draco3dgltfandmeshoptimizer. - Python 3.10+ with
brotli>=1.1for measuring transfer size. - A representative sample of tile content: 50–200
.glbfiles from real shards rather than one hero building, uncompressed, with positions stored relative to tile centres as in ECEF and ENU frames for tileset transforms.
How the Two Extensions Work
Draco replaces a primitive’s vertex and index buffers with a single compressed bitstream. It quantises each attribute to a chosen bit depth, predicts vertex positions from connectivity using its EdgeBreaker or sequential encoder, and entropy-codes the residuals. The result is very dense, and it has to be decoded in full — usually by a WebAssembly module of a few hundred kilobytes — before the GPU can see it.
Meshopt works at the buffer-view level. Vertex and index data stay in the usual glTF layout, reordered for locality and filtered, then passed through a fast, byte-oriented codec whose output is designed to compress well again with a general-purpose compressor. It is normally paired with KHR_mesh_quantization, which stores positions and normals as integers in the glTF itself, so quantisation is visible and inspectable rather than hidden inside a codec. Decoding is a tight loop that runs at hundreds of megabytes per second in WebAssembly and can be done lazily per buffer.
Step-by-Step
1. Produce both variants from the same input
import subprocess
from pathlib import Path
SRC = Path("sample/raw")
OUT = {"draco": Path("sample/draco"), "meshopt": Path("sample/meshopt")}
for d in OUT.values():
d.mkdir(parents=True, exist_ok=True)
for glb in sorted(SRC.glob("*.glb")):
subprocess.run(["npx", "gltf-transform", "draco", str(glb), str(OUT["draco"] / glb.name),
"--method", "edgebreaker", "--quantize-position", "14",
"--quantize-normal", "10", "--quantize-texcoord", "12"], check=True)
subprocess.run(["npx", "gltf-transform", "quantize", str(glb), str(OUT["meshopt"] / glb.name),
"--quantize-position", "14", "--quantize-normal", "10",
"--quantize-texcoord", "12"], check=True)
subprocess.run(["npx", "gltf-transform", "meshopt", str(OUT["meshopt"] / glb.name),
str(OUT["meshopt"] / glb.name), "--level", "high"], check=True)
print("variants written")
Holding quantisation equal — 14 bits for positions, 10 for normals, 12 for texture coordinates — is what makes the comparison fair. Most published comparisons that show one codec far ahead are comparing different bit depths. Fourteen position bits on a 200 m tile gives a grid of about 12 mm, which is below what a building tile at any realistic screen-space error can show.
2. Measure bytes at rest and after Brotli
import brotli
def sizes(folder):
raw = br = 0
for glb in sorted(folder.glob("*.glb")):
data = glb.read_bytes()
raw += len(data)
br += len(brotli.compress(data, quality=5)) # a typical on-the-fly CDN setting
return raw, br
base_raw, base_br = sizes(SRC)
print(f"{'variant':<9}{'at rest':>12}{'after brotli':>15}{'vs raw+br':>11}")
print(f"{'raw':<9}{base_raw / 1e6:>10.2f}MB{base_br / 1e6:>13.2f}MB{1:>10.2f}×")
for name, folder in OUT.items():
r, b = sizes(folder)
print(f"{name:<9}{r / 1e6:>10.2f}MB{b / 1e6:>13.2f}MB{b / base_br:>10.2f}×")
Brotli quality 5 approximates what a CDN applies on the fly; pre-compressed assets served with quality 11 shrink meshopt output further still. The column that matters is “after brotli”, because it is what the user downloads.
3. Measure decode time in the runtime environment
Decode cost has to be measured in JavaScript, where it is actually paid. The script below uses glTF Transform’s NodeIO with both decoders registered and times a full read of each file.
// bench_decode.mjs — node bench_decode.mjs sample/draco sample/meshopt
import { NodeIO } from "@gltf-transform/core";
import { ALL_EXTENSIONS } from "@gltf-transform/extensions";
import draco3d from "draco3dgltf";
import { MeshoptDecoder } from "meshoptimizer";
import { readdirSync } from "node:fs";
import { join } from "node:path";
await MeshoptDecoder.ready;
const io = new NodeIO().registerExtensions(ALL_EXTENSIONS).registerDependencies({
"draco3d.decoder": await draco3d.createDecoderModule(),
"meshopt.decoder": MeshoptDecoder,
});
for (const dir of process.argv.slice(2)) {
const files = readdirSync(dir).filter((f) => f.endsWith(".glb"));
for (const f of files.slice(0, 5)) await io.read(join(dir, f)); // warm up
const t0 = performance.now();
for (const f of files) await io.read(join(dir, f));
const ms = performance.now() - t0;
console.log(`${dir}: ${files.length} tiles, ${(ms / files.length).toFixed(2)} ms/tile`);
}
Node on a desktop CPU gives a relative ordering, not a phone’s absolute numbers. Multiply by the ratio measured on a target device for one codec — the procedure is in benchmarking Draco decode on mobile GPUs — and apply it to both.
4. Turn the measurements into a per-content decision
def choose(bytes_draco_br, bytes_meshopt_br, ms_draco, ms_meshopt, rtt_ms=80, mbps=20, tiles_per_view=150):
"""Estimate time-to-geometry for a typical view on a target connection."""
def view_cost(total_bytes, ms_per_tile):
download_s = total_bytes * 8 / (mbps * 1e6)
return download_s + tiles_per_view * ms_per_tile / 1000
per_tile = lambda total: total / 120 # the sample had 120 tiles
d = view_cost(per_tile(bytes_draco_br) * tiles_per_view, ms_draco)
m = view_cost(per_tile(bytes_meshopt_br) * tiles_per_view, ms_meshopt)
return ("draco" if d < m else "meshopt"), round(d, 2), round(m, 2)
print(choose(5.84e6, 6.71e6, ms_draco=9.1 * 4, ms_meshopt=0.8 * 4)) # ×4: phone vs desktop
The model is crude and still more honest than choosing by file size: download time falls with bandwidth, decode time does not. On a 20 Mbit/s mobile connection with a phone four times slower than the benchmark machine, the 13% size advantage of Draco is worth about 0.07 s for a 150-tile view, while its decode costs about 5 s more of main-thread or worker time. On a fixed fibre connection to a desktop the gap narrows, and on a very slow link the ordering can flip.
Expected Output & Verification
variant at rest after brotli vs raw+br
raw 58.40MB 41.02MB 1.00×
draco 6.02MB 5.84MB 0.14×
meshopt 12.87MB 6.71MB 0.16×
sample/draco: 120 tiles, 9.14 ms/tile
sample/meshopt: 120 tiles, 0.81 ms/tile
('meshopt', 5.53, 0.62)
Verify that compression did not change the geometry beyond the quantisation you chose. Decode both variants back to positions and compare with the source:
// compare_positions.mjs — maximum vertex displacement per variant, in metres
const doc = await io.read(process.argv[2]);
const ref = await io.read(process.argv[3]);
const pos = (d) => d.getRoot().listMeshes()[0].listPrimitives()[0].getAttribute("POSITION");
const a = pos(doc), b = pos(ref);
let maxErr = 0;
const va = [0, 0, 0], vb = [0, 0, 0];
for (let i = 0; i < Math.min(a.getCount(), b.getCount()); i++) {
a.getElement(i, va); b.getElement(i, vb);
maxErr = Math.max(maxErr, Math.hypot(va[0] - vb[0], va[1] - vb[1], va[2] - vb[2]));
}
console.log(`max position error ${(maxErr * 1000).toFixed(1)} mm over ${a.getCount()} vertices`);
Draco’s EdgeBreaker encoder reorders vertices, so compare by nearest neighbour or by bounding-box extents rather than index for that variant; meshopt keeps the order from its own reordering pass, which is stable across runs. Both should report a maximum error at or below half the quantisation cell — about 6 mm for 14 bits over 200 m.
Common Errors
Error: Missing required extension "EXT_meshopt_compression" in the viewer. The runtime version predates meshopt support or its decoder was not configured — in three.js, GLTFLoader.setMeshoptDecoder(MeshoptDecoder) must be called. Check the extension list for the exact runtime version you ship before switching a production tileset.
Meshopt files larger than the raw input. Quantisation was skipped, so floating-point buffers went into a codec designed for quantised integers. Run gltf-transform quantize first, as step 1 does, or use gltfpack, which quantises by default.
Draco tiles show cracks between neighbouring buildings. Each tile was quantised to its own bounding box, so shared edges snap to different grids. Quantise to a common grid across a tile set, or keep enough position bits that the difference stays below a pixel at the tile’s switching distance.
Frequently Asked Questions
Can one tileset mix both codecs?
Yes. Each glTF declares its own extensions, and a runtime with both decoders loads either. Mixing is a reasonable migration path — new shards in meshopt, old shards untouched until they are rebuilt.
Does gltfpack give the same result as gltf-transform meshopt?
It uses the same meshoptimizer library and adds its own simplification and quantisation defaults, so its output is usually smaller and less configurable. Use whichever makes the quantisation settings explicit in your pipeline; the comparison above holds for both.
What about texture compression?
It is independent and usually matters more for bytes. Geometry codecs do nothing for images; use KTX2 with Basis Universal, covered in atlas packing and KTX2 Basis compression.
Related Guides
- Tuning Draco Quantization for Building Meshes — the bit depths used above
- Benchmarking Draco Decode on Mobile GPUs — measuring on real devices
- Optimizing Mesh Triangle Count for Web Rendering — reducing what gets compressed