Writing / 3D Tooling

Draco was silently dead

My in-browser 3D compressor had been shipping uncompressed geometry since the day it launched. Nothing errored. Every export still said "done", and the file did get smaller, which is exactly why it took months to catch.

OptimizeGLB is a small tool I built for a problem I kept hitting: a 3D model that looks fine in Blender and is unusable on the web. You drop a .glb in the browser, pick a preset, and it comes back smaller. Everything runs client-side in a Web Worker, so the file never leaves the machine. That "never leaves your device" promise is why it exists at all - people will not upload a client's character model to a stranger's server.

The Web and Mobile AR presets both asked for Draco, Google's mesh compression codec. Draco is the big lever in glTF: it re-encodes vertex data and routinely takes geometry down by an order of magnitude. It is the reason a 3D model can live on a product page at all.

It had never once run.

The number that gave it away

I was benchmarking a game character - a 48MB soldier, half a million triangles, the kind of asset that makes the point. Web preset, Draco on. Output: 20.25MB.

Source
47.8 MB
Raw GLB, ~500k triangles
What shipped
20.25 MB
Textures compressed, geometry untouched
What it should be
2.08 MB
Same preset, Draco actually running

Look at the first two numbers on their own. 47.8 down to 20.25 is a 58% saving. A progress bar filled, a green check appeared, a file downloaded and it was less than half the size it started at. If I had not been holding a third number in my head - what this model should weigh - I would have called that a good day.

A partial success is a far better disguise than a failure. Nothing was broken enough to look broken.

The texture pipeline was doing all of that work by itself. WebP took the image atlases from many megabytes down to very little, and the geometry sailed through untouched. The saving was real. It was just coming from the half of the pipeline I was not asking about.

Three failures stacked into silence

The root cause took ten minutes to find once I knew to look, and it is embarrassingly mundane.

Draco ships as WebAssembly with a JavaScript glue file, and I was loading it from Google's static host at the versioned decoder path. That path is exactly what its name says: it hosts the decoder. The encoder is not there. The request for draco_encoder.js returned a 404 page.

Then three things happened in a row, each individually defensible:

Every one of those is the polite thing to do locally. Stacked, they turn a hard failure into a product that quietly does not do the thing it is named after.

The one-line check that would have caught it on day one

Here is what stings. glTF files declare their own extensions. A Draco-compressed file lists KHR_draco_mesh_compression in extensionsUsed. It is right there in the output, in plain JSON, and it is not a heuristic or a size threshold - it is the file stating what was done to it.

So the test is not "is the output smaller". The test is:

// after the pipeline runs, before you call it a success
if (preset.draco && !json.extensionsUsed?.includes('KHR_draco_mesh_compression')) {
  throw new Error('Draco was requested but is not present in the output');
}

Assert on the artifact, not on the process. The process will happily tell you it ran. The artifact tells you what actually happened to it.

The rule I took from this: never let a requested capability fail into a console warning. If someone asked for a specific transformation and it did not occur, that is an error, not a note. A tool that silently substitutes a lesser result is worse than one that stops, because it spends your trust instead of your time.

Fixing it, and the trap inside the fix

The repair was to fetch both glue scripts from a host that actually carries the encoder - the Draco repo on jsDelivr - and to check at fetch time whether what came back is JavaScript or an HTML error page. If it starts with a doctype, it is not a codec.

That surfaced a second, sharper edge. The Draco glue declares itself as a plain var DracoEncoderModule, with a UMD tail at the bottom that exports it for CommonJS or AMD. Inside a module worker, evaluated through new Function(), neither of those environments exists. The module builds correctly and then has nowhere to go: the variable is scoped to the function I just created, the UMD tail matches nothing, and the global stays undefined. Everything succeeds and nothing is reachable.

The fix is one line appended to the source before evaluation:

src += '\nself.DracoEncoderModule = DracoEncoderModule;';

Which is a nice miniature of the whole bug. Loading a library is three separate questions - did it download, did it evaluate, is it reachable - and code that only checks the first one will report success at every step of a chain that produced nothing.

When degrading gracefully is the wrong instinct

I am generally a fan of falling back. In Lens, my reading tool for people with dyslexia, read-aloud runs on a metered neural voice and drops to the browser's built-in speech when the monthly budget runs out. Lower fidelity, still free, and crucially the word-by-word highlight keeps working. That fallback is correct: the user's ability to keep going matters more than the quality of any one component.

Compression is the opposite shape. Nobody is mid-task and dependent on the tool staying alive. The entire output of the operation is the claim being made about the file. Degrading there does not preserve the experience, it falsifies the result and hands you a model you will put on a website believing it is nine times smaller than it is.

So the distinction I now draw, and it has held up everywhere since:

OptimizeGLB now throws if a preset requests Draco and Draco is not available. It is a worse experience in the rare case the CDN is down. It is a vastly better tool.

What it was worth

With the encoder actually running, that soldier ends up somewhere very different. Straight compression takes it to 1.66MB. Run through the full pipeline I built afterwards - weld the duplicate vertices, retopologise to a few thousand quads, bake the lost detail into a normal map, then compress - the same character lands at 365KB, which is a budget a real-time scene can actually afford.

None of that would have mattered while the codec was a no-op. And I would have gone on believing the tool worked, because it kept handing me smaller files and a green check, which is what I had taught it to do.

The bug I am most wary of now is not the one that crashes. It is the one that returns a plausible number.

Try it
OptimizeGLB - compress 3D models in your browser
Open OptimizeGLB →