A glTF file's biggest cost is usually its vertex data: positions, normals,
UVs and indices, one binary blob per attribute. Typed vertex
attributes already shrinks that blob by storing
each attribute in the smallest integer type that holds it
(KHR_mesh_quantization). This step covers the two extensions that
compress it further still, EXT_meshopt_compression and
KHR_draco_mesh_compression, and needs nothing beyond passing a decoder
in - the extensions do the rest.
Quantization shrinks data by storing fewer bits per number; it is
essentially free to decode, because the GPU already divides an integer by
its maximum value to get 0..1 or -1..1 (the typed
attributes step covered that mapping). The two
extensions below shrink the file further by compressing the bytes, the
way a .zip does, and have to be un-compressed with actual CPU work
before the GPU ever sees them.
EXT_meshopt_compression (meshoptimizer,
produced by its gltfpack tool) treats each attribute stream and the
index buffer as a sequence of similar, small numbers and encodes the
differences between consecutive elements with an entropy coder, rather
than the raw values. Two optional pre-processing filters make more
attributes fit that pattern: OCTAHEDRAL maps a unit vector (a normal or
a tangent) to two bytes on the octahedron's unfolded faces instead of
three components, and QUATERNION does the analogous thing for
rotations. Decoding is a straight, branch-light byte-stream expansion -
fast enough to run on the main thread for most models - and the
compression ratio is decent: on top of quantization, another 2-3x
smaller.
KHR_draco_mesh_compression (Draco)
compresses the mesh, not just its attribute streams: it re-derives the
triangle connectivity with an Edgebreaker-style
traversal (each new triangle is described relative to the ones already
visited, which takes only a couple of bits per triangle instead of three
indices) and predicts each vertex's attributes from its already-decoded
neighbours before entropy-coding the (small) prediction error. The result
compresses several times smaller than meshopt for the same quantization,
at the cost of a heavier decoder (a few hundred KB of WebAssembly) and
slower decoding, since the traversal cannot be as branch-light as
meshopt's flat byte expansion. Meshopt suits models that stream in on
every frame or page load; Draco suits models that are downloaded once and
kept, where the extra decode time is paid back by less network transfer.
Both extensions compress in a way that has nothing to do with quantized
attribute storage (KHR_mesh_quantization) but pairs well with it - a
gltfpack-optimized file typically uses both together, quantizing first
and compressing the quantized bytes.
Both compression formats need a decoder: meshoptimizer is a small
WebAssembly module, draco3d a larger one. Bundling either would force
every magic-pixels user to ship it, even the majority who load
uncompressed or merely quantized files - so, matching the
loader's general approach to third-party code,
loadGltf/parseGltf accept a decoder as an option and call it only
when a file actually needs it:
import { MeshoptDecoder } from 'meshoptimizer';
import { loadGltf } from 'magic-pixels';
const model = await loadGltf('model.glb', { meshopt: MeshoptDecoder });
import { createDecoderModule } from 'draco3d';
import { loadGltf } from 'magic-pixels';
const model = await loadGltf('model.glb', {
draco: await createDecoderModule(),
});
Neither option is typed against the npm package directly (magic-pixels
has no dependency on either): MeshoptDecoder and DracoDecoderModule
are structural interfaces, just the handful of properties and methods
parseGltf actually calls. Anything with that shape works - the
meshoptimizer package's export, the CDN build's MeshoptDecoder
global, draco3d's createDecoderModule() result, or the CDN build's
DracoDecoderModule({...}) factory. A file that uses either extension
without the matching option throws an error naming the option, so the
failure points straight at the fix.
Draco's Edgebreaker-style traversal can take tens of milliseconds for a
large mesh, long enough to drop frames if it runs on the same thread as
rendering. Meshopt's decode is individually much cheaper - a flat,
branch-light byte expansion - but a file can carry many compressed
bufferViews (one per attribute stream, plus indices), and decoding them
one at a time still costs main-thread time that could instead overlap
with everything else a load is doing (other bufferViews, images). So both
options.draco and options.meshopt have a second shape besides an
initialized module - { decoderPath | moduleUrl, workerLimit? } - that
decodes in a pool of Web Workers instead, the way three.js's DRACOLoader
does for Draco:
import { loadGltf } from 'magic-pixels';
const model = await loadGltf('model.glb', {
draco: {
decoderPath: 'https://www.gstatic.com/draco/versioned/decoders/1.5.7/',
},
meshopt: {
moduleUrl:
'https://cdn.jsdelivr.net/npm/meshoptimizer@1.2.0/meshopt_decoder.mjs',
},
});
Each worker fetches and initializes the decoder itself, from
decoderPath/moduleUrl, the first time it is asked to decode -
magic-pixels itself still never bundles or fetches either. DracoWorkerPool
and MeshoptWorkerPool spawn workers lazily, up to workerLimit (default
4), reused round-robin, and dispose them once the file's own loading
finishes: for Draco that means once every primitive is decoded, for
meshopt once every compressed bufferView is (dispatched with Promise.all,
so several can decode concurrently across the pool rather than one after
another). Which shape an option has decides which path runs: an
initialized module decodes synchronously, right there in parseGltf; a
decoderPath/moduleUrl decodes on a worker.
Both workers are bundled as classic scripts, not ES modules, and both
for the same reason even though only one needs it: the Draco decoder is
only published as a classic script that importScripts() pulls in, and
module workers cannot call importScripts(). Meshopt's decoder is a
real ES module, loaded with a plain dynamic import() instead - which
works in a classic script too, so meshopt-worker.ts can be built the
same way as draco-worker.ts rather than needing a second worker
"flavour". That is also why each ships as its own bundle
(dist/draco-worker.js, dist/meshopt-worker.js, built from separate
build.js entry points) rather than inside the library's main ESM
bundle. Both pools exchange plain typed arrays over postMessage, not
the BufferAttributes the rest of the loader deals in - wrapping the
result back into attributes (Draco) or a patched bufferView (meshopt)
happens on the main thread once it arrives. A bufferView's bytes are
copied before being handed to a worker, because the transfer list that
avoids a copy would otherwise detach the buffer from under every other
bufferView that shares it; the decoded result has no such sharing, so it
comes back by transfer, not copy.
The pooling mechanics - spawning up to a limit, round-robin reuse, correlating a response back to its request by id, terminating on dispose
WorkerPool<Response>
(src/loaders/gltf/worker-pool.ts). It knows nothing about Draco or
meshopt: it just moves a { id }-tagged message to a worker and resolves
a promise when a { id }-tagged response comes back, leaving what the
message contains, and what a "successful" response looks like, entirely
to DracoWorkerPool/MeshoptWorkerPool.The two extensions also compress differently shaped things, which shows up in where the loader hooks in:
EXT_meshopt_compression marks individual bufferViews as
compressed - vertex attributes and indices are still ordinary
accessors pointing at ordinary bufferViews once decoded, so the loader
decodes every compressed bufferView once, up front, before any accessor
is read; from there on, accessors, sparse substitution, de-interleaving
and bounding boxes all work exactly as with an uncompressed file.KHR_draco_mesh_compression marks a whole primitive as compressed:
the compressed bytes describe an entire mesh (connectivity and
attributes together), so there is no bufferView to patch. The loader
decodes the primitive directly into geometry attributes and an index
array, bypassing the accessor path for that primitive only; the
accessors still carry the shape (count, componentType, type) the
decoded data is read into, since Draco's own attributes are just flat
numbers with no glTF type attached.src/loaders/gltf/worker-pool.ts: WorkerPool<Response>, the pooling
primitive both compression formats share - spawn up to workerLimit,
round-robin reuse, send()/correlate by id, dispose(). Generic over
the response shape; format-specific request building and result
unwrapping live in DracoWorkerPool/MeshoptWorkerPool instead.src/loaders/gltf/meshopt.ts: decodeMeshoptBufferViews(json, buffers, decoder).
A pure function - it changes nothing in place - that finds every
bufferView carrying EXT_meshopt_compression and decodes each one
(sequentially for a plain MeshoptDecoder, concurrently via
MeshoptWorkerPool for { moduleUrl }) with
decodeGltfBuffer(target, count, size, source, mode, filter), returning
a patched json/buffers pair with those bufferViews redirected at the
decoded bytes. A meshopt-compressed buffer set also includes a data-less
"fallback" buffer (present only for bookkeeping; every real bufferView
is always redirected by its own extension), which the loader recognises
and skips rather than trying to fetch.src/loaders/gltf/meshopt-worker-protocol.ts / meshopt-worker.ts /
meshopt-worker-pool.ts: the same three-way split as Draco's below -
message shapes, the worker's entry point (import()s the decoder
module from moduleUrl, caches one module promise per URL), and
MeshoptWorkerPool/MeshoptWorkerOptions built on WorkerPool.src/loaders/gltf/draco.ts: extractDracoRequest(json, primitive, ext, buffers)
resolves a primitive's compressed bufferView and its attributes' shape
(componentType, type, normalized) into a self-contained
DracoRequest - the only part that needs the glTF JSON. decodeDraco(module, request)
does the actual decode: reads the bytes into a DecoderBuffer, decodes
to a Draco Mesh, then reads each attribute by its unique id and the
triangle indices through the decoder module's _malloc/HEAP* pair -
the only Draco API that returns every component type, not floats only,
which matters because JOINTS/WEIGHTS and quantized positions are
integers. Every Draco object is destroy()ed before returning, decoded
or not. Touching only typed arrays and the module, it runs unchanged on
the main thread or inside a worker. decodeDracoPrimitive() composes
both plus wrapDracoResult() for the synchronous, main-thread path.src/loaders/gltf/draco-worker-protocol.ts: DracoWorkerRequest/DracoWorkerResponse,
the postMessage shapes shared by the pool and the worker so there is
one definition of the protocol on both sides of the boundary.src/loaders/gltf/draco-worker.ts: the worker's entry point, bundled
separately (see below). Caches one decoder module promise per
decoderPath, calls decodeDraco() on each decode message, and
transfers the result's typed arrays back rather than copying them.src/loaders/gltf/draco-worker-pool.ts: DracoWorkerPool, DracoWorkerOptions,
built on WorkerPool. Copies a request's bytes before transferring
them; unwraps a 'decoded' response into DracoResult, throws for an
'error' one.src/loaders/gltf/loader.ts: GltfLoaderOptions.meshopt/.draco
(each <Decoder> | <WorkerOptions>, told apart by whether
decoderPath/moduleUrl is present); parse() calls
decodeMeshoptBufferViews() right after the buffers are loaded and
keeps the original, un-patched document as result.json even though
the patched one is what accessors actually read from; loadPrimitive()
branches to decodeDracoPrimitive() or a lazily-created
DracoWorkerPool depending on which shape options.draco has, and the
pool is disposed once parse() returns (meshopt's own pool, scoped to
one decodeMeshoptBufferViews() call, is disposed there instead - the
whole document's compressed bufferViews are known up front, unlike
Draco's per-primitive decoding spread across the parse). Both
extensions were added to SUPPORTED_EXTENSIONS, so a file that
requires one passes the early extension check and fails later with the
more specific "pass options.meshopt/options.draco" error instead.build.js: two extra esbuild entry points bundle draco-worker.ts
and meshopt-worker.ts to dist/draco-worker.js / dist/meshopt-worker.js
in the iife format a classic worker needs, exposed as the
magic-pixels/draco-worker / magic-pixels/meshopt-worker exports -
not imported directly, but each pool resolves its worker as a URL next
to its own module, e.g. new URL('./draco-worker.js', import.meta.url).src/loaders/gltf/types.ts: GltfMeshoptCompression (a bufferView's
extension object) and GltfDracoMeshCompression (a primitive's).src/loaders/gltf/fixtures/: quantized() (a KHR_mesh_quantization
fixture using Int16/Uint8 attributes, needing nothing from this
step but pinning down that the extension is accepted without warning),
meshoptCompressed() (a bufferView layout shaped like real gltfpack -cc output, verified against the real tool while writing this step),
dracoCompressed() and draco-stub.ts's createDracoStub() (a stub
Draco module implementing just the calls decodeDraco makes, backed by
a fixed decoded mesh instead of a real .drc bitstream), and
draco-worker-stub.ts's / meshopt-worker-stub.ts's
createFakeDracoWorkerClass() / createFakeMeshoptWorkerClass() (a
fake Worker that decodes with the same stub logic as the synchronous
tests, round-tripped through postMessage/onmessage so each pool's
tests exercise the real protocol without a real thread).The glTF loader example
decodes both formats in a worker pool (draco: { decoderPath },
meshopt: { moduleUrl }), and its model list has two compressed entries -
a box compressed with gltfpack -cc and the Khronos sample box
Draco-compressed - embedded directly in the page so the demo needs no
server for them. Things to change:
sample() helper wraps
loadGltf(url); point it at any EXT_meshopt_compression file (run
gltfpack -cc -i model.gltf -o model.glb on a Khronos sample to make
one) or a KHR_draco_mesh_compression sample from the glTF sample
assets (several
models have a glTF-Draco/ variant, CesiumMilkTruck and BoomBox
among them) and pass the matching option.Box/glTF-Binary/Box.glb from the sample list is
about 15 KB; the embedded compressed box is under 2 KB, for the same
eight vertices - most of the saving on a model this tiny is the
quantization, but the ratio grows with mesh size.draco-worker.js/meshopt-worker.js
threads instead of blocking the page.EXT_meshopt_compression specification:
the extension's bufferView layout and the fallback-buffer mechanism.KHR_draco_mesh_compression specification:
the primitive-level extension this step reads.DRACOLoader source:
another implementation of the same Draco decode path and worker pool.postMessage's transfer
list, both of which WorkerPool relies on.