1294 MODELS INDEXED Account
GUIDE · THREE.JS PERFORMANCE

Draco vs Meshopt vs uncompressed GLB, measured

UPDATED
19 MIN READ · BY 3D ASSET

Draco and Meshopt both shrink GLB geometry several times; neither touches textures, and which one wins depends on the model and on how the file is delivered. Draco usually makes the smallest file for static geometry. Meshopt also compresses animation and morph targets, and behind gzip or Brotli it was the smaller transfer for all five of our test files. Before you pick either for files other people will open in Blender, Unity, Unreal or Godot, check that their importer supports the extension. The decision table is at the end; everything in between is measured.

What the three options actually do

A GLB stores geometry as plain arrays: 32-bit floats for every position, normal and UV, 16- or 32-bit integers for triangle indices. That is fast to load — the bytes go to the GPU almost as they are — and large. There are three standard ways to shrink it, and they work very differently.

What it doesDecoded intoCompresses
Quantization
KHR_mesh_quantization
Stores attributes as 8- or 16-bit integers instead of floats. Not a codec: no decoder, the GPU reads the integers directly.The same small integersVertex attributes, morph targets
Draco
KHR_draco_mesh_compression
Quantizes, predicts each vertex from its neighbours along the mesh connectivity and entropy-codes the rest into an opaque bit stream. Google’s library, decoded with a WASM module in Web Workers.The types the file’s accessors declare. In our files: 32-bit floats for positions, normals, UVs and colours; skin indices stayed 16-bitTriangle meshes only: positions, normals, UVs, colours, skin weights, indices
Meshopt
EXT_meshopt_compression
A byte-oriented, lossless codec for whole buffer views, designed so that gzip or Brotli can squeeze its output further, plus optional lossy filters for normals, rotations and other values. The tools that write it quantize and reorder the data first. Its decoder is 25 KB of JS with the WASM inlined (6 KB with Brotli).Exactly the bytes the encoder stored: with the tools we used, quantized 8- and 16-bit integersAny buffer: meshes, morph targets, animation keyframes, instance transforms

In practice Draco is the stronger codec for a single static mesh, and in a typical pipeline its output decodes back to floats. Meshopt is weaker on its own, but its output still contains structure that a generic compressor finds, and it covers animation, which Draco leaves as raw floats. At default settings every tool quantizes positions to about the same precision, so the visual result is nearly identical; how the bits are packed is what differs.

What is being compared. We compare the tool settings people actually ship, not the bare extensions. The differences below include the codec itself plus quantization, vertex reordering and, for gltfpack, scene and animation changes (merged buffer views, resampled keyframes, extra nodes). A Meshopt file written without quantization would not have small GPU buffers, for example: that comes from quantizing, not from EXT_meshopt_compression.

KHR_meshopt_compression is the Khronos version of the same compression, with an updated codec version. Its spec was still a release candidate in November 2026; three.js reads it from r183, but most tools still write and read the EXT_ version used in this article.

The test files

A colourful low-poly village with tiled roofs, a windmill, a tower and a river
A close-up of the largest single-scene test file, our Cozy village: 239,802 triangles in 305 meshes, rendered here from its Draco version. Every image in this article is a real render of the files we measured.
A green T-72 tank with textured armour plates
T-72: textured PBR, 19 textures, 29,940 triangles.
An oval island with a small village of colourful houses, trees and a river
Cozy village: 239,802 triangles in 305 meshes, one material with 2 textures.
A red voxel demon with horns and wings
Pit demon: skinned, 10 animation clips.
A small lake with lily pads and reeds
Lake: 13 morph targets for the water ripples.

We used real files from our packs, chosen to stress different parts of a GLB, all in their uncompressed desktop-tool form (the dcc/ copy in each pack) so that no earlier compression skewed the results:

FileSizeTrianglesMeshesTexturesAnimationDeformation
T-72 tank4.17 MB29,9408819 (1.86 MB)26 clips (9 KB)—
Cozy village12.8 MB239,8023052 (260 KB)——
Pit demon699 KB3,5961—10 clips (169 KB)skinned, 72 bones
Lake373 KB1,11222 (260 KB)1 clip (26 KB)13 morph targets
Military catalogue106.0 MB1,507,8754,51883 (10.3 MB)841 clips (336 KB)—

Each was encoded five ways with the tools people actually use: quantized only and Meshopt with glTF Transform 4.4 (quantize() with the default interleaved vertex layout, and meshopt({ level: 'high' })), Draco with glTF Transform’s defaults (14-bit positions, 10-bit normals, 12-bit UVs, edgebreaker), and Meshopt with gltfpack 1.3 at -cc -kn -km -ke — its higher-compression mode, keeping node, material and extras names. Textures were left untouched everywhere, so every difference below is geometry and animation.

File size, and the size that actually crosses the network

Cozy village: 239,802 triangles, 305 meshes

  • File on disk
  • Transferred with Brotli
0 MB2 MB4 MB6 MB8 MB10 MB12 MB14 MBUncompressedFile on disk, Uncompressed: 13 MB13 MBTransferred with Brotli, Uncompressed: 1.8 MB1.8 MBQuantized (interleaved)File on disk, Quantized (interleaved): 7.4 MB7.4 MBTransferred with Brotli, Quantized (interleaved): 1.4 MB1.4 MBDracoFile on disk, Draco: 1.7 MB1.7 MBTransferred with Brotli, Draco: 1.1 MB1.1 MBMeshopt (glTF Transform)File on disk, Meshopt (glTF Transform): 2.6 MB2.6 MBTransferred with Brotli, Meshopt (glTF Transform): 1.1 MB1.1 MBMeshopt (gltfpack -cc)File on disk, Meshopt (gltfpack -cc): 2.1 MB2.1 MBTransferred with Brotli, Meshopt (gltfpack -cc): 0.86 MB0.86 MB
gzip -9 and Brotli 11 computed on each file with Node’s zlib, the way a static host or CDN serves pre-compressed files. Geometry, animation and textures are the bytes of their buffer views inside the GLB; the rest is the JSON chunk.
All values
EncodingFilegzip -9Brotli 11GeometryAnimationTextures
Uncompressed12.8 MB2.97 MB1.82 MB12.3 MB—264 KB
Quantized (interleaved)7.38 MB2.37 MB1.42 MB6.80 MB—264 KB
Quantized (separate streams)7.42 MB2.15 MB1.24 MB6.80 MB—264 KB
Draco1.74 MB1.23 MB1.08 MB1.19 MB—264 KB
Draco, 11-bit1.52 MB1.05 MB935 KB975 KB—264 KB
Meshopt, medium3.01 MB1.56 MB1.29 MB2.27 MB—264 KB
Meshopt (glTF Transform)2.63 MB1.31 MB1.12 MB1.89 MB—264 KB
Meshopt (gltfpack -c)2.24 MB1.05 MB908 KB1.68 MB—264 KB
Meshopt (gltfpack -cc)2.09 MB976 KB855 KB1.53 MB—264 KB

On disk, Draco wins clearly: 1.74 MB, 14% of the original. glTF Transform’s Meshopt is 2.63 MB, gltfpack’s 2.09 MB. Then put the files behind Brotli, as any properly configured server does with text: the uncompressed GLB collapses from 12.8 MB to 1.82 MB (float arrays of a regular model are very repetitive), Draco gains less, though still a useful 38% (1.08 MB), and gltfpack’s Meshopt drops to 855 KB, the smallest transfer of all.

Why glTF Transform’s Meshopt is larger than gltfpack’s here: some of the village’s meshes have UVs outside 0–1 (tiled textures), and glTF Transform’s quantize() skips those — the log says “Skipping TEXCOORD_0; out of [0,1] range” — leaving them as floats. gltfpack quantizes them anyway and moves the scale into KHR_texture_transform. Its JSON is also smaller: glTF Transform writes one buffer view per accessor (479 KB of JSON here), gltfpack packs streams together (292 KB).

Pit demon: skinned character, 10 animation clips

  • File on disk
  • Transferred with Brotli
0 MB0.25 MB0.5 MB0.75 MBUncompressedFile on disk, Uncompressed: 0.70 MB0.70 MBTransferred with Brotli, Uncompressed: 0.16 MB0.16 MBQuantized (interleaved)File on disk, Quantized (interleaved): 0.47 MB0.47 MBTransferred with Brotli, Quantized (interleaved): 0.15 MB0.15 MBDracoFile on disk, Draco: 0.26 MB0.26 MBTransferred with Brotli, Draco: 0.14 MB0.14 MBMeshopt (glTF Transform)File on disk, Meshopt (glTF Transform): 0.19 MB0.19 MBTransferred with Brotli, Meshopt (glTF Transform): 0.07 MB0.07 MBMeshopt (gltfpack -cc)File on disk, Meshopt (gltfpack -cc): 0.13 MB0.13 MBTransferred with Brotli, Meshopt (gltfpack -cc): 0.05 MB0.05 MB
Same method. The animation column is the keyframe data of all 10 clips.
All values
EncodingFilegzip -9Brotli 11GeometryAnimationTextures
Uncompressed699 KB190 KB157 KB454 KB169 KB—
Quantized (interleaved)471 KB185 KB152 KB226 KB169 KB—
Quantized (separate streams)471 KB178 KB148 KB226 KB169 KB—
Draco262 KB152 KB135 KB16 KB169 KB—
Draco, 11-bit261 KB152 KB135 KB16 KB169 KB—
Meshopt, medium264 KB140 KB131 KB56 KB123 KB—
Meshopt (glTF Transform)188 KB75 KB68 KB48 KB55 KB—
Meshopt (gltfpack -c)159 KB84 KB77 KB43 KB61 KB—
Meshopt (gltfpack -cc)130 KB58 KB52 KB41 KB34 KB—

The character shows the other half of the story. Draco shrinks its mesh from 454 KB to 16 KB — but the 10 animation clips stay at 169 KB of raw floats, now 65% of the file. Meshopt compresses keyframes too: 55 KB with glTF Transform, 34 KB with gltfpack, which also resamples to 30 fps and quantizes rotations to 12 bits by default. Over the wire: 135 KB with Draco, 52 KB with gltfpack.

And the tank shows where neither matters much. 1.86 MB of its 4.17 MB are PNG textures, which no geometry codec touches. Draco took the geometry from 2.12 MB to 266 KB, a dramatic 13% — and the whole transfer from 2.36 MB to 2.02 MB. Every encoding finished between 2.00 MB and 2.09 MB. For a model like this, converting textures to WebP or KTX2 saves more than choosing between geometry codecs; the glb/ copy of the same tank in our pack, with WebP textures and Draco, is 1.59 MB.

Over the wire (Brotli)UncompressedQuantizedDracoMeshopt (glTF Transform)Meshopt (gltfpack -cc)
T-72 tank2.36 MB2.13 MB2.02 MB2.09 MB2.00 MB
Cozy village1.82 MB1.42 MB1.08 MB1.12 MB855 KB
Pit demon157 KB152 KB135 KB68 KB52 KB
Lake248 KB241 KB238 KB237 KB220 KB
Military catalogue39.4 MB27.6 MB20.1 MB24.1 MB18.9 MB

“Quantized” in the charts and this table is glTF Transform’s default interleaved layout. The command we recommend later adds --vertex-layout separate, which compresses better: 1.24 MB with Brotli for the village instead of 1.42 MB. See the GPU memory section for why.

The catalogue is every vehicle of the Military pack in one file: 1,507,875 triangles, 329 materials, 83 textures. Brotli at quality 9 for the files above 20 MB, 11 for the rest.

The CDN detail that changes the result

Meshopt’s best numbers assume the server compresses the GLB. Many do not. model/gltf-binary is not on Cloudflare’s default list of compressed content types, Vercel’s list has no model/* types, and nginx compresses only text/html unless you add types to gzip_types. GitHub Pages did gzip a .glb in our test on 5 November 2026. Check your own host: open DevTools ▸ Network, click the .glb request and look for content-encoding: br or gzip in the response headers.

Fast 4GBrotli: transferredBrotli: loaded inNo compression: transferredNo compression: loaded in
Cozy village, Uncompressed1.82 MB1.9 s12.8 MB12 s
Cozy village, Draco1.08 MB1.5 s1.74 MB2.1 s
Cozy village, Meshopt (glTF Transform)1.12 MB1.4 s2.63 MB2.8 s
Cozy village, Meshopt (gltfpack -cc)855 KB1.2 s2.09 MB2.3 s
Pit demon, Uncompressed157 KB325 ms699 KB811 ms
Pit demon, Draco135 KB545 ms262 KB665 ms
Pit demon, Meshopt (glTF Transform)68 KB409 ms188 KB530 ms
Pit demon, Meshopt (gltfpack -cc)52 KB405 ms130 KB466 ms
T-72 tank, Uncompressed2.36 MB2.4 s4.17 MB4.0 s
T-72 tank, Draco2.02 MB2.3 s2.31 MB2.5 s
T-72 tank, Meshopt (glTF Transform)2.09 MB2.4 s2.63 MB2.8 s
T-72 tank, Meshopt (gltfpack -cc)2.00 MB2.3 s2.41 MB2.6 s

Cold load on the fast 4G profile, median of 3 fresh contexts, same method as the load-time charts below; the decoders themselves were served with Brotli in both columns.

Without HTTP compression the static files flip back: Draco’s 1.74 MB beats gltfpack’s 2.09 MB for the village, and an uncompressed village is a 12.8 MB download instead of 1.82 MB. The animated character does not flip: Draco leaves its keyframes as floats, so Meshopt stays smaller even without Brotli, 130 KB against 262 KB (the lake with morph targets: 289 KB against 310 KB). So the first question is whether your server compresses .glb, and the second is what is in the file. If you cannot control the server (a marketplace, a CMS, a client’s S3 bucket), Draco is usually smaller for static geometry; for animation and morph targets, compare both. If you can, Meshopt plus Brotli was smaller for every file we tested, and you can pre-compress at build time (file.glb.br) instead of relying on on-the-fly compression.

Load time: network, decoder, decode

Cozy village: time until the model is loaded

  • Fast 4G (9 Mbit/s, 150 ms)
  • Slow 4G (1.6 Mbit/s, 560 ms) + 4× CPU slowdown
0 s2 s4 s6 s8 s10 s12 sUncompressedFast 4G (9 Mbit/s, 150 ms), Uncompressed: 1.9 s1.9 sSlow 4G (1.6 Mbit/s, 560 ms) + 4× CPU slowdown, Uncompressed: 10 s10 sQuantized (interleaved)Fast 4G (9 Mbit/s, 150 ms), Quantized (interleaved): 1.5 s1.5 sSlow 4G (1.6 Mbit/s, 560 ms) + 4× CPU slowdown, Quantized (interleaved): 8.0 s8.0 sDracoFast 4G (9 Mbit/s, 150 ms), Draco: 1.5 s1.5 sSlow 4G (1.6 Mbit/s, 560 ms) + 4× CPU slowdown, Draco: 7.2 s7.2 sMeshopt (glTF Transform)Fast 4G (9 Mbit/s, 150 ms), Meshopt (glTF Transform): 1.4 s1.4 sSlow 4G (1.6 Mbit/s, 560 ms) + 4× CPU slowdown, Meshopt (glTF Transform): 7.1 s7.1 sMeshopt (gltfpack -cc)Fast 4G (9 Mbit/s, 150 ms), Meshopt (gltfpack -cc): 1.2 s1.2 sSlow 4G (1.6 Mbit/s, 560 ms) + 4× CPU slowdown, Meshopt (gltfpack -cc): 5.7 s5.7 s
Cold page, empty cache, file served with Brotli. Time from calling GLTFLoader.load() until the callback fires: GLB download, decoder download and start-up, decode, texture decode. Not included: GPU upload and shader compilation before the first frame. Median of 3 fresh browser contexts; the three runs were within 1–7% of each other. With only three runs, we do not draw conclusions from timing differences below roughly 10%. NVIDIA GeForce RTX 4060 Laptop GPU, Chrome 156, ANGLE → Direct3D 11, three.js r180.
All values, including the local (unthrottled) run
EncodingGLB transferredLocalFast 4G (min–max)Slow 4G + 4× CPU (min–max)
Uncompressed1.82 MB125 ms1.9 s (1,909–1,927 ms)10 s (10,160–10,294 ms)
Quantized (interleaved)1.42 MB88 ms1.5 s (1,515–1,536 ms)8.0 s (7,979–8,067 ms)
Draco1.08 MB121 ms1.5 s (1,465–1,492 ms)7.2 s (7,164–7,302 ms)
Meshopt (glTF Transform)1.12 MB76 ms1.4 s (1,414–1,463 ms)7.1 s (6,953–7,079 ms)
Meshopt (gltfpack -cc)855 KB83 ms1.2 s (1,161–1,202 ms)5.7 s (5,655–5,888 ms)

On a phone-class connection the download is everything. The decode itself took tens of milliseconds on our desktop; on slow 4G the uncompressed village needed 10 s, Draco 7.2 s and gltfpack’s Meshopt 5.7 s — the order of the Brotli sizes, shifted by one more cost: the decoder.

The decoder is a download too. DRACOLoader fetches its WASM module and wrapper (345 KB, 75 KB with Brotli) the first time it meets a Draco file, then starts up to four Web Workers and compiles the module in each. It does this only when the first Draco mesh needs decoding, after the GLB has arrived; preload() starts downloading the decoder files earlier (the workers are still created on the first decode). In our cold runs on slow 4G the phase between “GLB downloaded” and “model ready” took 1.1 s for Draco against 151 ms for Meshopt. The Meshopt decoder is 25 KB of JavaScript with the WASM inlined (6 KB with Brotli); we loaded it with a dynamic import(), which cost 610 ms of round trips on slow 4G and is included in our timings. Bundled with your app code it needs no separate request, though its bytes and start-up remain.

Pit demon: time until the model is loaded

  • Fast 4G (9 Mbit/s, 150 ms)
  • Slow 4G (1.6 Mbit/s, 560 ms) + 4× CPU slowdown
0 s1 s2 s3 sUncompressedFast 4G (9 Mbit/s, 150 ms), Uncompressed: 0.33 s0.33 sSlow 4G (1.6 Mbit/s, 560 ms) + 4× CPU slowdown, Uncompressed: 1.5 s1.5 sQuantized (interleaved)Fast 4G (9 Mbit/s, 150 ms), Quantized (interleaved): 0.32 s0.32 sSlow 4G (1.6 Mbit/s, 560 ms) + 4× CPU slowdown, Quantized (interleaved): 1.4 s1.4 sDracoFast 4G (9 Mbit/s, 150 ms), Draco: 0.54 s0.54 sSlow 4G (1.6 Mbit/s, 560 ms) + 4× CPU slowdown, Draco: 2.3 s2.3 sMeshopt (glTF Transform)Fast 4G (9 Mbit/s, 150 ms), Meshopt (glTF Transform): 0.41 s0.41 sSlow 4G (1.6 Mbit/s, 560 ms) + 4× CPU slowdown, Meshopt (glTF Transform): 1.6 s1.6 sMeshopt (gltfpack -cc)Fast 4G (9 Mbit/s, 150 ms), Meshopt (gltfpack -cc): 0.40 s0.40 sSlow 4G (1.6 Mbit/s, 560 ms) + 4× CPU slowdown, Meshopt (gltfpack -cc): 1.5 s1.5 s
Cold page, empty cache, file served with Brotli. Time from calling GLTFLoader.load() until the callback fires: GLB download, decoder download and start-up, decode, texture decode. Not included: GPU upload and shader compilation before the first frame. Median of 3 fresh browser contexts; the three runs were within 1–7% of each other. With only three runs, we do not draw conclusions from timing differences below roughly 10%. NVIDIA GeForce RTX 4060 Laptop GPU, Chrome 156, ANGLE → Direct3D 11, three.js r180.
All values, including the local (unthrottled) run
EncodingGLB transferredLocalFast 4G (min–max)Slow 4G + 4× CPU (min–max)
Uncompressed157 KB21 ms325 ms (322–331 ms)1.5 s (1,421–1,470 ms)
Quantized (interleaved)152 KB21 ms320 ms (307–330 ms)1.4 s (1,384–1,413 ms)
Draco135 KB45 ms545 ms (544–568 ms)2.3 s (2,280–2,295 ms)
Meshopt (glTF Transform)68 KB22 ms409 ms (399–409 ms)1.6 s (1,601–1,638 ms)
Meshopt (gltfpack -cc)52 KB25 ms405 ms (396–422 ms)1.5 s (1,509–1,535 ms)

For a small file the decoder can cost more than it saves. The character’s Brotli transfer is 157 KB uncompressed and 135 KB with Draco — a 22 KB saving, smaller than the Draco decoder itself. On fast 4G it loaded in 325 ms uncompressed, 320 ms quantized and 545 ms with Draco; both Meshopt files took about 405 ms, too close to rank. The decoder is paid once per visit, so the balance changes when a page loads many Draco models; for a product viewer with one small model, quantization alone (no decoder) is often the best choice.

Full parse from memory. Once the bytes are in memory and the decoders are warm, the totals were close. This is GLTFLoader.parseAsync() as a whole, not the geometry decoder alone: it includes building three.js objects and materials and decoding images. Parsing the village took 53 ms with Draco, 31 ms with gltfpack’s Meshopt and 36 ms uncompressed; with the CPU slowed 4× (an artificial slowdown of a desktop CPU, not a model of any particular phone) 120 ms, 116 ms and 119 ms. On these files the difference in full parse time was small. Even the 106 MB catalogue parsed in a similar time whichever way it was encoded, and blocked the main thread for hundreds of milliseconds in every case:

Military catalogue, 1,507,875 trianglesParse timeLongest main-thread blockParse, 4× CPULongest block, 4× CPU
Uncompressed644 ms383 ms2.0 s1.2 s
Draco645 ms202 ms2.1 s782 ms
Meshopt (glTF Transform)553 ms336 ms2.0 s1.2 s
Meshopt (gltfpack -cc)529 ms278 ms1.8 s1.0 s
Meshopt (gltfpack -cc), useWorkers(4)484 ms211 ms1.8 s767 ms

Full parse from memory: the file is fetched first, then GLTFLoader.parseAsync() runs on the in-memory bytes; median of 4 warm runs after one discarded. “Longest main-thread block” is the longest longtask entry during the parse — the time the page could not respond to input. Texture decoding (83 images) is included in every row.

The geometry codec is not where that time goes. The catalogue has 4,518 primitives, 329 materials and 83 images: building that many three.js objects and decoding the textures dominates, and both happen whatever the geometry encoding. DRACOLoader decodes in up to four Web Workers, so Draco’s own work is off the main thread already. MeshoptDecoder decodes on the main thread unless you call MeshoptDecoder.useWorkers(n); with four workers the longest block of the catalogue fell from 1.0 s to 767 ms at 4× CPU. For a model this big the real fixes are structural: split it into files you load as needed, and merge meshes that share a material.

GPU memory: quantized data stays small

What happens after decoding matters for memory, and here the pipelines really differ. In our Draco files positions, normals, UVs and colours came back as 32-bit floats (three.js picks the type from each accessor; skin indices stayed 16-bit), so a smaller file did not mean smaller GPU buffers. How much a Draco file uploads also depends on topology: Draco welds duplicate vertices, so it can end up a little smaller or larger than the original. Quantized and Meshopt files hand three.js 8- and 16-bit integers, and WebGL draws them as they are; the dequantization is a scale in the node’s matrix, applied in the vertex shader.

Vertex and index data uploaded to the GPUUncompressedDracoQuantized, separate streamsMeshopt (glTF Transform)Meshopt (gltfpack -cc)
T-72 tank2.30 MB2.28 MB1.18 MB1.18 MB943 KB
Cozy village12.3 MB13.7 MB6.80 MB5.74 MB6.00 MB
Pit demon876 KB *468 KB221 KB193 KB180 KB

Sum of the typed arrays three.js uploads as WebGL buffers for every attribute and index of the loaded scene, each buffer counted once. * The uncompressed character is inflated by the interleaving issue described next: its interleaved buffer mixes 32-bit floats with 16-bit skin indices, so three.js uploads it twice. Its real vertex data is close to the Draco figure.

One trap here: plain quantization with glTF Transform made GPU memory larger. Its default writer interleaves all attributes of a mesh into one buffer view, and a quantized mesh mixes 16-bit positions and normals with 8-bit colours (and floats, where UVs could not be quantized) in that view. three.js r180 builds one InterleavedBuffer per component type (the cache key includes it), so the same interleaved block is uploaded once for the 16-bit attributes and again for the 8-bit ones: the quantized tank used 4.09 MB against 2.30 MB uncompressed, the village 17.2 MB against 12.3 MB. Writing the same file with separate vertex streams — io.setVertexLayout(VertexLayout.SEPARATE) in glTF Transform — fixed it: 1.18 MB for the tank, 6.80 MB for the village — and it compressed better too, 1.24 MB with Brotli instead of 1.42 MB. The Meshopt writers already use separate streams. The same thing happens without any quantization when an interleaved buffer mixes types: the uncompressed character interleaves 16-bit skin indices with float attributes and uploaded 876 KB instead of about 468 KB. On the command line, add --vertex-layout separate to any glTF Transform command.

Half the vertex memory or less matters most on integrated and mobile GPUs, where vertex fetch shares bandwidth with everything else, and in a browser tab that has to fit next to other pages. We measured buffer sizes, not frame time; smaller attributes usually help, but check rendering performance on your target devices.

Quality: what quantization costs

Every encoding we tested except “uncompressed” is lossy, because every tool quantizes first: positions are snapped to a grid. (Meshopt’s byte codec itself is lossless; quantization and its optional filters are not.) At the defaults — 14 bits per axis over each mesh’s bounding box — the grid step on the 8.5 m tank is about 0.5 mm. We rendered every variant from the same camera and counted pixels whose colour moved by more than 8/255. The share is of the whole frame, background included, so it is only a rough indicator: a small defect in an important spot barely moves it. Look at the heat maps, not just the number.

T-72 close-up, 1600×1000Pixels changedLargest channel change
Draco1,974 (0.12%)64/255
Meshopt (glTF Transform)2,067 (0.13%)86/255
Meshopt (gltfpack -cc)6,072 (0.38%)96/255
Draco, 11-bit (positions 11, normals 8, UVs 10 bits)22,928 (1.43%)104/255
Close-up of a tank turret with two round hatches
Uncompressed.
Grey version of the turret with a faint red band on one hatch ring
Meshopt (glTF Transform): changed pixels in red. Note the faint band on the hatch ring.
Grey version of the turret with red lines along edges and scattered red dots
Draco at 11/8/10 bits: errors along every edge and speckles where UVs moved on the texture.

At default settings the differences are a fraction of a percent of the pixels, mostly single pixels on silhouettes. The one visible pattern is a faint shading band on the hatch ring. We first blamed the 8-bit normals that glTF Transform’s level: 'high' writes, and tested that: level: 'medium' (10-bit normals) gave exactly the same diff, and so did quantization alone, even with 16 bits for every attribute. gltfpack shows the band too; Draco does not. So it is not normal precision. It follows the files that keep integer attributes and move the dequantization scale into the node, but we did not isolate the cause. It is a shift of a few colour levels; if a glossy hero model must match the source exactly, compare renders before you ship.

Pushing the bits down is where it breaks, and not where you would expect. The quantization grid is per mesh (quantizationVolume: 'mesh' by default), so a large flat mesh gets a coarse grid. In our village the ground and the river bed are separate large meshes; at 11 bits the step is several centimetres, the river surface drops through the ground and grass shows through the water:

A blue river between grass banks with reeds
Uncompressed.
The same river with a patch of green grass showing through the water surface
The same view with Draco at 11-bit positions: on the right the water surface sank below the ground and grass shows through.

Two practical points follow. Treat 14-bit positions as a starting point, not a guarantee: the grid step is the mesh’s largest extent divided by about 2bits, so for big meshes and coplanar layers (water, decals, road markings, terrain) compare that step with the smallest gap between surfaces and with the error you can accept, and raise the bits if it is close. And if separate meshes must meet exactly (modular walls, terrain tiles), quantize them on one grid (quantizationVolume: 'scene' in glTF Transform), or a hairline crack can open along the seam. The trade-off: one grid over the whole scene is coarser than per-mesh grids at the same bit count, so you may need more bits.

Animation. Compressing keyframes is lossy too, and gltfpack changes them more than the others: it resamples every clip at 30 Hz and quantizes rotations. We played all 10 clips of the character in every version once, without looping (LoopOnce, clamped at the end), sampled every clip on a 120 Hz grid over the time both versions cover (2,798 poses in total) and compared every bone’s world position and the foot and toe bones separately with the original. Clip length is listed on its own because it affects looping and timed events, not the pose:

Pit demon, 2.4 m tallLargest bone offset observedLargest foot/toe bone offsetClip length changeBrotli size
Dracononenonenone135 KB
Meshopt (glTF Transform)< 1 mm< 1 mmnone68 KB
Meshopt (gltfpack -cc)7.6 cm1.3 cmup to 26 ms52 KB
Meshopt (gltfpack -cc -af 0)2.7 mm< 1 mmnone57 KB

Draco leaves keyframes untouched, and glTF Transform’s Meshopt stayed under half a millimetre. gltfpack at its defaults gave the largest offset we observed, 7.6 cm at a fingertip in a fast part of the death animation; the foot and toe bones moved by up to 13 mm, and clip lengths changed by up to 26 ms. Sampling cannot prove these are the true maxima. Whether that is visible depends on the animation and the camera; it matters most for hand contacts, foot planting and events timed to the clip end. With -af 0 (keep the original keys) clip lengths stayed the same and the largest offset fell to 3 mm and the Brotli transfer grew only from 52 KB to 57 KB, still far below Draco’s 135 KB. The lake’s morph-target weights moved by at most 0.023 (on a 0–1 scale) at defaults and 0.002 with -af 0.

Topology changes too. Draco welds duplicate vertices and drops degenerate triangles: the tank went from 29,940 to 28,834 triangles, the character from 7,124 to 7,080 vertices. gltfpack also removes degenerate triangles. That is usually invisible when rendering (check yours), and it breaks anything that addresses vertices by index: per-vertex data you add at run time, vertex-picking tools, or morph targets authored outside the file.

Code gotchas after quantization

Draco hands three.js float positions in the original units. Quantized and Meshopt files do not, and code that reads geometry directly can silently get the wrong numbers. This is what three.js r180 built for the first mesh of the tank:

Encodingposition arrayScale on the node abovegeometry.boundingBox max (x)
Uncompressed, DracoFloat32Array1metres, as authored
glTF Transform quantize / MeshoptInt16Array, normalized3.47≤ 1 (the mesh is scaled up by its parent)
gltfpackUint16Array, not normalized0.0005up to 16383 (integer grid units)

Rendering, raycasting and Box3.setFromObject() are fine — they apply world matrices. What breaks: physics colliders built from geometry.attributes.position.array, bounds read from geometry.boundingBox without the world matrix, merging geometries from different files with mergeGeometries (mismatched types fail), and code that expects mesh.scale to be 1. Read positions through the mesh and its world matrix:

import * as THREE from 'three';

// World-space positions for float, normalized and integer attributes alike.
// getVertexPosition() also applies morph targets and, on a SkinnedMesh, the current
// bone pose, so call it after mixer.update() and scene.updateMatrixWorld().
function worldPositions(mesh) {
  const count = mesh.geometry.attributes.position.count;
  const out = new Float32Array(count * 3);
  const v = new THREE.Vector3();
  for (let i = 0; i < count; i++) {
    mesh.getVertexPosition(i, v).applyMatrix4(mesh.matrixWorld);
    v.toArray(out, i * 3);
  }
  return out;
}

Two gltfpack defaults to know about. It renames meshes (ours became mesh_0, mesh_1…; -kn keeps named nodes, not mesh names), and it resamples every animation at 30 Hz (see the animation table above): the tank’s sparse turret keyframes grew from 9 KB to 17 KB. Add -af 0 to keep the original keys. Quantization also adds nodes — the dequantization scale needs a parent — so the tank’s 263 nodes became 395 with glTF Transform and 481 with gltfpack. Look objects up by name, not by child index.

Compatibility: who can open what

This is often the deciding factor. A three.js app loads both codecs once the decoders are attached; desktop tools and engines are much less uniform. We imported every variant into the three Blender versions we had installed:

Blender importerUncompressedQuantizedDracoMeshopt
Blender 3.3.12OpensOpensOpensFails: “Extension EXT_meshopt_compression is not available on this addon version”
Blender 4.1.1OpensOpensOpensFails: “Extension EXT_meshopt_compression is not available on this addon version”
Blender 5.1.2OpensOpensOpensFails: “Extension EXT_meshopt_compression is not available on this addon version”

Blender opened the quantized files and decoded Draco fine; Meshopt failed in all three. The glTF importer gained Meshopt support in Blender 5.2 LTS (July 2026), so anything older — and many studios pin an LTS — cannot open a Meshopt GLB at all. For the rest we checked the importers’ documentation and source:

ToolDracoMeshopt (EXT)QuantizedBasis
three.js GLTFLoaderYes, with DRACOLoaderYes, with setMeshoptDecoder() (r122+)Yes (r111+)Tested, r180
React Three Fiber / drei useGLTFYes (decoder from a CDN by default)YesYesDocs and source
Babylon.jsYesYes (5.0+)YesDocs
PlayCanvasYesMerged in Oct 2026, not in a release yetYesDocs, merged PR
Unity — glTFastWith com.unity.cloud.dracoWith com.unity.meshopt.decompressYesDocs, glTFast 6.14
Unreal Engine 5 — built-in glTF importerNoNoNot listedDocs
Unreal — glTFRuntime pluginWith glTFRuntimeDracoYesYesPlugin docs
Godot 4No: import failsNoNot listed (an open PR adds all three)Forum report, open PR
BlenderYes (2.92+)From 5.2 LTSYesTested 3.3, 4.1, 5.1; 5.2 from release notes
glTF-ValidatorNot validated insideNot validated insideYesDocs

“Not listed” means the documentation does not mention the extension: no confirmation either way, not proof that import fails. Only the three.js and Blender rows are our own tests.

That is why our packs ship two copies of every model: glb/ for the web, with Draco geometry and WebP textures, and dcc/ with plain geometry and PNG/JPEG textures for Blender, Unity, Unreal and Godot. Compress for a known runtime; keep an uncompressed master for everything else. Converting back is one command — gltf-transform copy in.glb out.glb decodes Draco and Meshopt when the decoders are installed — but every lossy pass compounds, so re-encode from the master, not from a compressed copy.

Setting it up

In three.js, attach both decoders once and GLBs load whether they use Draco, Meshopt or neither. Host the decoder files yourself so a third-party CDN is not on the critical path:

import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
import { DRACOLoader } from 'three/addons/loaders/DRACOLoader.js';
import { MeshoptDecoder } from 'three/addons/libs/meshopt_decoder.module.js';

// Copy node_modules/three/examples/jsm/libs/draco/ to /draco/ in your public folder.
const draco = new DRACOLoader().setDecoderPath('/draco/');
draco.preload(); // optional: start downloading the decoder files early

const loader = new GLTFLoader()
  .setDRACOLoader(draco)
  .setMeshoptDecoder(MeshoptDecoder);

// Large Meshopt files: decode off the main thread.
MeshoptDecoder.useWorkers(4);

const gltf = await loader.loadAsync('/models/village.glb');

Encoding, from the command line:

# Draco, glTF Transform defaults (14/10/12 bits)
npx @gltf-transform/cli draco in.glb out.glb

# Meshopt, glTF Transform (level high writes 8-bit normals; --level medium keeps 10)
npx @gltf-transform/cli meshopt in.glb out.glb

# Meshopt, gltfpack: higher compression, keep names, keep original animation keys
npx gltfpack -i in.glb -o out.glb -cc -kn -km -ke -af 0

# Quantize only: smaller, no decoder needed. Separate streams avoid the double
# upload in three.js described above (our size charts used the default layout)
npx @gltf-transform/cli quantize in.glb out.glb --vertex-layout separate

# Back to plain floats
npx @gltf-transform/cli dequantize in.glb out.glb

The # lines are comments in bash, zsh and PowerShell; in Windows cmd.exe leave them out. The commands themselves are the same everywhere. Textures are a separate step: gltf-transform webp or etc1s/uastc for KTX2, or gltfpack’s -tw / -tc.

What to use: a decision table

SituationUseWhy
Web app, you control the server and it compresses .glb (or you pre-compress)Meshopt (gltfpack -cc)Smallest transfer with Brotli for all five of our files, a 6 KB decoder, compact GPU memory, compresses animation and morph targets.
Web, static geometry, hosting you do not control or HTTP compression offDracoSmallest static file without help from the server; the decoder is a one-off cost per visit. With animation or morph targets compare both: Meshopt was smaller for our character even without Brotli.
Animated characters, many clipsMeshoptDraco does not touch keyframes; in our character they were most of the Draco file. With gltfpack, add -af 0 if hand contacts or event timing matter.
Small models, one or two per pageQuantized only, or nothingA 75 KB (Brotli) decoder download can outweigh what it saves; quantization needs no decoder.
Texture-heavy modelsEither — then fix the texturesGeometry was 51% of our tank; WebP or KTX2 saves more.
Files for Blender, Unity, Unreal, Godot usersUncompressedGodot and Unreal’s built-in importer read neither codec; Blender reads Meshopt only from 5.2.
Master files and archivesUncompressedEvery pipeline we tested is lossy; re-encode from the master.

How we measured. NVIDIA GeForce RTX 4060 Laptop GPU, Chrome 156, ANGLE → Direct3D 11, three.js r180, 1280×800, device pixel ratio 1. A small Node server served every file with Brotli (or uncompressed for the no-compression table), Cache-Control: no-store; network and CPU throttling through the Chrome DevTools Protocol, profiles close to DevTools’ “Fast 4G” and “Slow 4G”. Every load timing is the median of three fresh browser contexts with an empty cache; the min–max range is in the data tables under each chart. The interval starts at the GLTFLoader.load() call and includes the Meshopt decoder’s dynamic import() and the Draco decoder download, which DRACOLoader starts when it meets the first Draco mesh; it ends at the loader callback, before GPU upload and shader compilation. “Transferred” is the GLB’s own encoded body size; decoder files are counted separately. Sizes and quality numbers are deterministic. Animation was compared with every clip played once (no looping) and sampled at 120 Hz over the time both versions cover. Blender imports ran headless (blender -b, factory settings). The models are real game assets from our packs — your own files will compress differently, so measure the same way on two or three of them before you settle a pipeline.

Frequently asked questions

Is Draco or Meshopt better for three.js?

Both load in three.js with one line each. If your server compresses .glb with gzip or Brotli, Meshopt was smaller over the wire for all five of our test files. Without HTTP compression Draco gave the smaller static files, but Meshopt stayed smaller for the animated character.

Does Draco or Meshopt compress textures?

No. Textures need their own conversion to WebP or KTX2, which is often the bigger saving.

Does gzip or Brotli on a GLB help?

A lot on uncompressed and Meshopt files: our uncompressed village went from 12.8 MB to 1.82 MB with Brotli alone. Even Draco-compressed GLBs can benefit substantially: Brotli reduced our Draco village by another 38%, although the benefit depends on the file’s remaining data. Check that your host actually compresses the model/gltf-binary content type — many do not by default.

Can I use Draco and Meshopt in the same file?

Technically a file can mix them per mesh, but tools write one or the other. There is no benefit in stacking them on the same mesh.

How do I remove compression from a GLB?

With glTF Transform: npx @gltf-transform/cli copy in.glb out.glb decodes Draco and Meshopt, and dequantize turns quantized attributes back into floats. In Blender 5.2+, import and re-export with compression off. The quantization error stays — decompression is not a restore.

Why does my Meshopt model have a scale of 3.47 (or 0.0005)?

Quantized positions are stored as integers in a unit box; the scale and offset move into the node above the mesh. Use world matrices (Box3.setFromObject, applyMatrix4(mesh.matrixWorld)) instead of raw arrays or mesh.scale.

Is KHR_meshopt_compression the same as EXT_meshopt_compression?

The same compression as a Khronos extension, with an updated codec version. In November 2026 its spec was still a release candidate; three.js r183+ and gltfpack 1.x (-ce khr) support it, most other tools do not yet. Use the EXT_ version unless you control the loader.

Sources

More guides