Benchmarking Draco Decode on Mobile GPUs
This page measures what a Draco-compressed tile actually costs on a phone — the WASM decode on the CPU, the buffer upload to the GPU, and the first draw — and turns those numbers into a quantization and compression-level choice per device class. The measurement matters because mobile is where the frame budget is tightest and where desktop intuitions are most wrong: a tile that decodes in 4 ms on a laptop routinely takes 30 ms on a mid-range Android device, which is two frames gone before anything is drawn.
Why you hit this
Compression settings are almost always chosen on a workstation, where decode is nearly free and download is the only visible cost. On a phone the balance inverts: the network is often better than expected and the CPU is much worse, so a setting tuned for bytes produces a client that stutters on exactly the devices most viewers use. Nothing in the tileset reports this, and the desktop numbers are all reassuring.
The compression choices this feeds back into are in glTF LOD generation with Draco compression and tuning Draco quantization.
Prerequisites
- Real devices, or at minimum Chrome’s device emulation with CPU throttling set to 4× or 6×. Emulation is a rough proxy for CPU and says nothing about the GPU driver.
- Remote debugging:
chrome://inspectfor Android, Safari’s Web Inspector for iOS. - A set of test tiles encoded at several quantization and compression settings from the same source mesh.
EXT_disjoint_timer_query_webgl2where available, for real GPU timings rather than submission times.
Step-by-Step
1. Time the decode in isolation
Decode the same buffer repeatedly without touching the GPU, so the number is purely the WASM cost.
async function benchDecode(url, rounds = 20) {
const buf = await (await fetch(url)).arrayBuffer();
const loader = new DracoLoader(); // whichever wrapper your client uses
await loader.ready();
// Warm up: the first call pays JIT and WASM instantiation.
for (let i = 0; i < 3; i++) await loader.decode(buf.slice(0));
const times = [];
for (let i = 0; i < rounds; i++) {
const copy = buf.slice(0); // decode consumes the buffer
const t0 = performance.now();
await loader.decode(copy);
times.push(performance.now() - t0);
}
times.sort((a, b) => a - b);
return {
bytes: buf.byteLength,
p50: times[rounds >> 1],
p95: times[Math.floor(rounds * 0.95)],
min: times[0],
};
}
Discarding the first few rounds is not optional. WASM instantiation and JIT warm-up dominate the first call by an order of magnitude, and including them makes every configuration look identical.
2. Time the GPU upload separately
Upload is a different cost with a different fix, and on mobile drivers it is frequently the larger of the two.
function benchUpload(gl, positions, indices, rounds = 20) {
const times = [];
for (let i = 0; i < rounds; i++) {
const vb = gl.createBuffer();
const ib = gl.createBuffer();
const t0 = performance.now();
gl.bindBuffer(gl.ARRAY_BUFFER, vb);
gl.bufferData(gl.ARRAY_BUFFER, positions, gl.STATIC_DRAW);
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, ib);
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, indices, gl.STATIC_DRAW);
gl.finish(); // force the driver to complete
times.push(performance.now() - t0);
gl.deleteBuffer(vb); gl.deleteBuffer(ib);
}
times.sort((a, b) => a - b);
return { p50: times[rounds >> 1], p95: times[Math.floor(rounds * 0.95)] };
}
gl.finish() is what makes this a measurement rather than a submission timer. Without it you are timing how long it took to queue the command, which on a mobile driver is a small fraction of the real cost.
3. Sweep the settings that actually trade against each other
Compression level trades encode time and size; quantization bits trade size and precision. Only a sweep on-device shows which one your decode is sensitive to.
const grid = [];
for (const level of [3, 7, 10]) {
for (const bits of [11, 12, 14]) {
const url = `/bench/block_l${level}_q${bits}.glb`;
const d = await benchDecode(url);
grid.push({ level, bits, kb: (d.bytes / 1024).toFixed(1),
decode_p50: d.p50.toFixed(1), decode_p95: d.p95.toFixed(1) });
}
}
console.table(grid);
The finding that recurs: decode time is far more sensitive to compression level than to quantization bits, because level controls how much entropy coding the decoder has to undo while bits mostly change the payload size. So on mobile the right move is usually to drop the level and keep the precision, which is the opposite of the desktop instinct.
4. Get real GPU timings where the extension exists
CPU-side timers measure submission. EXT_disjoint_timer_query_webgl2 measures the GPU.
const ext = gl.getExtension('EXT_disjoint_timer_query_webgl2');
function gpuTime(drawFn) {
if (!ext) return Promise.resolve(null);
const q = gl.createQuery();
gl.beginQuery(ext.TIME_ELAPSED_EXT, q);
drawFn();
gl.endQuery(ext.TIME_ELAPSED_EXT);
return new Promise((resolve) => {
const poll = () => {
const available = gl.getQueryParameter(q, gl.QUERY_RESULT_AVAILABLE);
const disjoint = gl.getParameter(ext.GPU_DISJOINT_EXT);
if (available && !disjoint) {
resolve(gl.getQueryParameter(q, gl.QUERY_RESULT) / 1e6); // ns → ms
gl.deleteQuery(q);
} else if (disjoint) {
resolve(null); // the GPU was interrupted; discard
} else {
requestAnimationFrame(poll);
}
};
requestAnimationFrame(poll);
});
}
Discarding disjoint results matters. A thermal throttle or a context switch invalidates the query, and a benchmark that keeps those samples reports a bimodal distribution nobody can act on.
5. Turn the sweep into a per-device-class budget
The output of the exercise is a table your build pipeline can key on.
const BUDGET_MS = 16.7;
const RESERVE = 0.4; // leave 40% of the frame for drawing
function pickSettings(measurements, deviceClass) {
const allowed = BUDGET_MS * (1 - RESERVE);
return measurements
.filter((m) => m.device === deviceClass && m.decode_p95 + m.upload_p95 < allowed)
.sort((a, b) => a.kb - b.kb)[0] || null; // smallest that fits the budget
}
console.log('mid-range:', pickSettings(all, 'mid'));
console.log('flagship :', pickSettings(all, 'flagship'));
Expected Output & Verification
A representative sweep on a mid-range Android device:
┌───────┬──────┬───────┬────────────┬────────────┐
│ level │ bits │ kB │ decode_p50 │ decode_p95 │
├───────┼──────┼───────┼────────────┼────────────┤
│ 3 │ 11 │ 214.8 │ 12.4 │ 15.1 │
│ 3 │ 14 │ 246.1 │ 12.9 │ 15.8 │
│ 7 │ 11 │ 198.2 │ 21.7 │ 26.4 │
│ 7 │ 14 │ 228.7 │ 22.3 │ 27.1 │
│ 10 │ 14 │ 223.4 │ 31.4 │ 38.9 │
└───────┴──────┴───────┴────────────┴────────────┘
upload p50 9.2 ms | p95 12.6 ms
The decision falls straight out of it. Level 10 costs 31 ms of decode to save 5 kB against level 7 — worthless on this device. Level 3 at 14 bits costs 13 ms, keeps full precision, and is 10% larger than level 7. With a 10 ms budget for decode plus upload on a 60 fps target, only the level-3 rows fit at all.
Verify the numbers are real by checking three things: the warm-up rounds were discarded, the p95 is within about 25% of the p50 (a wider spread means thermal throttling and the device needs a cool-down between configurations), and the disjoint counter stayed at zero for any GPU timings.
Common Errors
Every configuration decodes in the same time. The warm-up was not discarded, so all the measurements are dominated by WASM instantiation. Run three throwaway decodes first.
Decode times climb steadily through the sweep. The device is thermally throttling. Insert a pause between configurations and randomise their order, then check that the first and last measurement of the same configuration agree.
Upload appears free. gl.finish() was omitted, so the timer measured command submission. On a desktop driver the difference is small; on mobile it is most of the cost.
Emulated throttling gives a different answer from a real device. It will. CPU throttling in DevTools models the CPU only and nothing about the GPU driver, memory bandwidth or thermal behaviour. Use it for a first pass and confirm on hardware.
Frequently Asked Questions
Which devices should I test?
Two: the median device in your analytics and the tenth percentile. The flagship tells you nothing you need, and the very oldest device is usually out of scope. If you have no analytics, a three-year-old mid-range Android is a reasonable stand-in for the tenth percentile.
Can I ship different tiles per device class?
Yes, by serving a different tileset alias to clients that report a low-power device, but it doubles the build and the storage. The usual better answer is to pick settings that fit the tenth-percentile budget and accept slightly larger tiles everywhere.
Does meshopt compare better than Draco on mobile?
Frequently, yes — EXT_meshopt_compression decodes considerably faster for a modest size penalty, which is exactly the trade mobile wants. Benchmark it with the same harness before switching; the advantage varies with mesh topology.
A note on what these numbers are for. The output is not a single global setting but a budget per device class, and the budget is the thing worth writing down: how many milliseconds of the frame you are willing to spend on getting a tile onto the GPU, on the tenth-percentile device you intend to support. Once that number exists, every compression question answers itself by measurement, and the arguments about whether level 10 is worth it stop being arguments.
The second use is regression detection. Encoder versions change, WASM decoders get faster and occasionally slower, and a browser update can move decode cost by twenty per cent in either direction. Running this sweep on one device once per release, and storing the five headline numbers, turns that into something you notice rather than something a viewer reports.
Related Guides
- Performance Profiling and Benchmarking — where device benchmarking fits
- Tuning Draco Quantization for Building Meshes — choosing the bits these numbers justify
- Measuring Tile Load Times in the Cesium Frame Loop — the client-side budget this feeds