Vol. 07 · Dispatch2026-07-06

Draco compression, from an upload script in Node to a GLTFLoader in the browser

Sound Race's 13 GLBs are compressed with Draco on upload and inflated with a WASM decoder in the browser. This post walks the whole pipeline — the @gltf-transform + draco3dgltf server side, the "if compressed is bigger, keep original" safety valve, why the decoder files live in /public/draco/gltf/ instead of on a CDN, and the exact three lines of GLTFLoader wiring that connect them.

by MetaWorldOS Engineering
DracoGLBThree.jsWebAssemblyNode

TL;DR — The upload script (scripts/sound-race-upload-assets.ts) reads each GLB into memory, runs it through @gltf-transform’s draco() transform (backed by the draco3dgltf wasm module), and uploads the compressed bytes to OSS. If the “compressed” file is somehow larger than the original — which happens on tiny meshes where Draco’s overhead exceeds the payload — we upload the original instead. On the client, DRACOLoader gets pointed at /public/draco/gltf/, which contains three files copied verbatim from three.js’s node_modules/three/examples/jsm/libs/draco/gltf/. GLTFLoader.setDRACOLoader(dracoLoader) is the one line that connects them, and after that every .glb we ship — Draco or not — loads without the caller knowing which is which.

Code: scripts/sound-race-upload-assets.ts (server), src/features/gamecenter/games/sound-race/game/render/SceneAssets.ts (client).

Sound Race’s 13 3D assets are Hunyuan-generated GLBs — one per pickup, hazard, environment prop, and the player ship. Hunyuan’s default output is a raw GLB with indexed triangle meshes and no geometry compression. That produces beautiful, fully-textured models with per-file sizes in the tens to low-hundreds of megabytes. Fine on my desktop; catastrophic on someone’s phone in a country with a metered plan.

Draco compression is the standard fix. It’s a Google-maintained mesh compression library (google/draco on GitHub), it’s part of the glTF ecosystem via the KHR_draco_mesh_compression extension, and it’s typically 3-5× smaller for indexed triangle meshes. The tradeoff is that the browser needs a wasm decoder to inflate the bytes back into vertices — but that decoder is small and cached across all your GLBs, so you pay it once.

Getting Draco integrated end-to-end is one of those tasks that reads simple in a tutorial and then sprouts three or four “wait, why doesn’t this work” moments in practice. Here’s what all of them actually look like.

Server side: @gltf-transform + draco3dgltf

@gltf-transform is Don McCurdy’s library for round-tripping and mutating glTF/GLB documents in Node. It has an extensions system, and the Draco extension (KHRDracoMeshCompression) is one of the officially-supported ones. For encoding you also need draco3dgltf, the actual Draco encoder wasm bindings.

// scripts/sound-race-upload-assets.ts
import { NodeIO } from '@gltf-transform/core'
import { KHRDracoMeshCompression } from '@gltf-transform/extensions'
import { draco } from '@gltf-transform/functions'
// @ts-ignore — draco3dgltf ships without types; used only via createEncoder/DecoderModule
import draco3d from 'draco3dgltf'

async function compressGlbWithDraco(buf: Buffer, slug: string): Promise<Buffer> {
  const io = new NodeIO()
    .registerExtensions([KHRDracoMeshCompression])
    .registerDependencies({
      'draco3d.encoder': await draco3d.createEncoderModule(),
      'draco3d.decoder': await draco3d.createDecoderModule(),
    })
  try {
    const doc = await io.readBinary(new Uint8Array(buf))
    await doc.transform(draco())
    const out = await io.writeBinary(doc)
    const outBuf = Buffer.from(out)
    if (outBuf.length >= buf.length) {
      log(`  ${slug}: draco output not smaller — keeping original`)
      return buf
    }
    return outBuf
  } catch (e) {
    err(`  ${slug}: draco compression failed, uploading uncompressed — ${(e as Error).message}`)
    return buf
  }
}

Two things about that setup are load-bearing.

First: the encoder AND decoder both go into registerDependencies. draco() needs the encoder to compress; NodeIO.readBinary needs the decoder to be able to parse any incoming Draco geometry (Hunyuan’s output isn’t Draco-compressed, but this same script has processed re-exports before, and the decoder needs to be there for the read-round-trip). Registering only the encoder produces a runtime error the first time you point the script at an already-compressed input.

Second: doc.transform(draco()) is the whole pipeline. It walks every mesh primitive in the document, replaces its position/normal/texcoord accessors with Draco-compressed equivalents, and adds KHR_draco_mesh_compression to extensionsUsed and extensionsRequired in the JSON header. When writeBinary runs, the resulting GLB has all its geometry chunks in Draco form and is ~3-5× smaller for typical game-prop meshes. There is no per-mesh loop for us to write.

Quantization: the defaults are fine

draco() accepts an options object where you can override the quantization levels. The defaults are:

  • position: 14 bits
  • normal: 10 bits
  • texcoord: 12 bits
  • color: 8 bits
  • generic: 12 bits

At 14 bits per position axis you get 16384 quantization steps across the mesh’s bounding box. For a 1-meter game prop that’s ~0.06mm resolution — visually indistinguishable from the raw floats. For a 1-kilometer terrain mesh it’s 6cm, which is fine unless you’re doing sub-pixel shader work at ground level. We haven’t touched these.

The one thing you can’t tune with defaults is textures. Draco doesn’t touch texture data at all — that’s KHR_texture_basisu / KTX2’s job. Our GLBs have jpeg-in-glb textures already, which are fine, so we don’t have a KTX2 pass. If you’re doing this for a game with 4K PBR textures, you want KTX2 next.

The “compressed is bigger” safety valve

if (outBuf.length >= buf.length) {
  return buf   // upload the original instead
}

This looks paranoid until it fires. Draco’s overhead has a fixed cost (the extension declaration in the JSON, a few bytes per accessor to describe the compression setup), so on very small meshes — a single quad, a helper cube, a placeholder — the compressed output can be a hundred bytes bigger than the raw. Not a big deal in absolute terms, but if you assume “compressed is always smaller” and blindly upload the transform output, you’ve made those specific assets worse.

More importantly: Hunyuan occasionally hands us a mesh whose geometry is already reasonably terse — mostly-uv-mapped, few unique vertices, high compressibility already achieved at the source. Draco can lose against those inputs by a small margin. The safety valve means we never regress; we might miss compression on a few small assets, but the baseline is never worse than “upload the raw file that the artist gave us.”

Client side: DRACOLoader in three lines

Once the GLB has KHR_draco_mesh_compression in its extensionsUsed, the browser needs a decoder or the file won’t parse at all. Three.js’s GLTFLoader doesn’t ship a Draco decoder — you plug one in via DRACOLoader:

// SceneAssets.ts
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'
import { DRACOLoader } from 'three/examples/jsm/loaders/DRACOLoader.js'

const loader = new GLTFLoader()
const dracoLoader = new DRACOLoader()
dracoLoader.setDecoderPath('/draco/gltf/')
loader.setDRACOLoader(dracoLoader)

That’s the entire wiring. setDecoderPath('/draco/gltf/') tells DRACOLoader where to fetch its wasm module. setDRACOLoader(dracoLoader) registers the loader with GLTFLoader, and from that point on every glb the loader parses will lazily instantiate the decoder if the file uses KHR_draco_mesh_compression, and skip the whole decode path if it doesn’t.

The three lines are position-independent — you can wire them anywhere before your first loader.parseAsync or loader.loadAsync call. In our case they live in the loadSoundRaceAssets function because that’s the one place we construct a GLTFLoader; all thirteen assets share the same instance.

Where the wasm files come from

The trickiest part isn’t the code — it’s making sure the decoder wasm is actually served at /draco/gltf/. Three.js does not handle this for you. You have two choices:

  1. Point setDecoderPath at a public CDN like https://www.gstatic.com/draco/versioned/decoders/1.5.6/.
  2. Serve the decoder yourself.

We chose option 2 — no external CDN dependency, no CDN outage risk, no cross-origin request. The decoder is 700 KB across three files (JS wrapper + wasm + wasm loader glue), it’s cacheable indefinitely, and it’s shipped once per site rather than once per game.

The three files that need to be at public/draco/gltf/:

public/draco/gltf/
├── draco_decoder.js         (~500 KB)
├── draco_decoder.wasm       (~190 KB)
└── draco_wasm_wrapper.js    (~60 KB)

Sizes are approximate — they float slightly across three.js releases — but the shape doesn’t change. Note that we ship the decoder, not the encoder. Compression happens once on the server (see above); the client only needs to inflate.

The origin of these files is node_modules/three/examples/jsm/libs/draco/gltf/. Three.js keeps them there ready-made. You copy them (or symlink at build time; a postinstall cp step works too):

cp node_modules/three/examples/jsm/libs/draco/gltf/draco_decoder.js public/draco/gltf/
cp node_modules/three/examples/jsm/libs/draco/gltf/draco_decoder.wasm public/draco/gltf/
cp node_modules/three/examples/jsm/libs/draco/gltf/draco_wasm_wrapper.js public/draco/gltf/

You do NOT want the sibling draco_encoder.js — that’s for encoding, which is server-side, and shipping it to the browser is a couple of hundred kilobytes for zero purpose.

If you go looking on Google’s own Draco releases page you’ll find several distributions — draco_decoder.js at various versions, both plain-JS and wasm variants, and additional decoders for wavelet-encoded content. The one you want is the “gltf” variant — the one three.js bundles. Google’s other Draco distributions have different API surfaces and won’t work with three.js’s DRACOLoader. Following a random StackOverflow answer that says “just download from google’s site” is exactly how people end up with a wasm module that loads but produces “unable to decode” errors in every glb.

Load-time cost

The three-file decoder is a one-time browser load per session. DRACOLoader initializes lazily — the wasm doesn’t fetch until the first Draco-encoded mesh needs decoding. Once instantiated, all subsequent decodes reuse the same wasm instance. There is no per-mesh setup penalty after the first.

The decode itself runs in a Web Worker. Three.js’s DRACOLoader spins up a pool automatically (default limit: 4 workers, hard-coded — you can lower it via dracoLoader.setWorkerLimit(n), or disable workers entirely with n = 0). Workers are the reason we .slice(0) the ArrayBuffer before caching (see the IDB blob cache post) — the decoder receives the buffer via a transfer, which neuters the original.

Debugging the pipeline

The two most common failure modes:

“Failed to parse: Unknown extension: KHR_draco_mesh_compression”. Client didn’t wire DRACOLoader, or setDRACOLoader was called on a different GLTFLoader instance than the one loading the file. Confirm with a console.log that the same loader is both .setDRACOLoader-ed and .parseAsync-ed.

“DRACOLoader: WASM module could not be initialized” or a 404 in the Network tab. setDecoderPath doesn’t match where the files actually live. The path is server-relative (leading /) and needs a trailing slash, and the three files need to be exactly at that path. If you’re deploying to a subpath, remember to prepend it.

Neither of these is a bug in Draco or in three.js — they’re both configuration mismatches, and they’re the reason people bounce off Draco integration and end up shipping uncompressed 90MB GLBs to production.

— Read Next —

Recommended Dispatches

More engineering deep-dives into 3D rendering, physics simulation, and game architecture

IndexedDB

When signed URLs break your browser cache, put the bytes in IndexedDB

Signed OSS URLs rotate every request, so the disk cache never matches. Sound Race redownloaded 13 GLBs on every visit until we sat an IndexedDB layer in front of GLTFLoader. Here's the code, why Cache Storage doesn't help, and one invalidation edge case we're leaving as tech debt.

Read dispatch
Three.js

Rapier boots async, your engine boots sync — here's the seam

Rapier's wasm loader returns a Promise, but a three.js render loop starts on `new`. If you naively `await RAPIER.init()` in the constructor, you get a black screen while wasm downloads. If you fire off `.then(...)` and forget about it, your first ticks crash on `world.step` because the world doesn't exist yet. Tank Battle threads this needle with a `physReady` flag, a purely-cosmetic pre-physics render loop, and a check-and-wait "deploy" gate.

Read dispatch
Three.js

Zero art assets — building every polygon, texture, and shader in code

Pokémon of the Forest ships no GLB files, no PNG textures, no baked normal maps. Every creature is marching-cubes-meshed from Wyvill metaballs at boot; every leaf is a 2D-canvas Bézier fill baked into a CanvasTexture; every terrain patch is an analytic heightfield sampled onto a PlaneGeometry; every skin material is a MeshPhysicalMaterial with a custom subsurface-wrap term injected via onBeforeCompile. This post walks the seven techniques that let a browser game with ~24k lines of TypeScript render Pallet Town without downloading a single texture.

Read dispatch

Get an email when we publish a new post — engineering deep-dives, ~once a month, no marketing.