Three.jsWeb 3DPerformanceGLBBlender3D Pipeline

My 3D page shipped 1.9 MB, and 90% of it was never compressed

·5 min read

I always knew 3D pages were heavy. I had never actually measured one.

Then I opened the network panel to see what Midnight Run really ships.

ResourceTransferredOn disk
xinyi-night-loop-city.glb1,329 KB1,329 KB
midnight-assets.glb385 KB385 KB
three.module.js142 KB564 KB
MidnightRun.js20 KB64 KB
GLTFLoader.js14 KB44 KB
Total1,894 KB
What Midnight Run transfers on a first load, measured in the browser

The two GLB files come to 1,714 KB — 90% of the page.

But what actually stopped me was not that 90%. It was the right-hand column.

The JavaScript is compressed four-fold. The GLB loses nothing.

three.js is 564 KB on disk and arrives as 142 KB — the server strips three quarters of it. GLTFLoader behaves the same: 44 KB on disk, 14 KB on the wire.

The two GLB files transfer at exactly their file size.

The response headers on the live site say why:

Resourcecontent-typecontent-encoding
index.jsapplication/javascriptbr
kof-3d-studio.glbmodel/gltf-binary(none)
Two kinds of asset on the same site, treated differently

Cloudflare decides whether to compress from an allowlist of content types. application/javascript is on that list. model/gltf-binary is not.

So the largest file on the whole site is the one file getting no compression at all. Nothing warns you about this. It sat there quietly for months.

Ask the cheapest question first: what would compression even give?

Before reaching for Draco and friends, I wanted to know what plain compression would do. So I ran gzip and brotli over the two files as they are:

FileOriginalgzipbrotli
kof-3d-studio.glb2,926 KB407 KB293 KB (−90%)
xinyi-night-loop-city.glb1,329 KB613 KB567 KB (−57%)
Generic compression, no change to the file contents

Brotli alone takes ninety percent off the 3D Studio scene.

That number looks absurd, but it makes sense: an uncompressed GLB is a large field of 32-bit floats, neighbouring vertex coordinates are highly similar, and that is exactly what a general-purpose compressor eats for breakfast.

The catch is that on Cloudflare Pages I cannot make it compress that content type. The decision is not mine.

Which leaves one route: bake the compression into the file itself.

Three ways to bake compression into a GLB

The glTF ecosystem offers three common options. I ran each through gltf-transform:

Method3D StudioMidnight Run city
Original2,926 KB1,329 KB
Quantise993 KB (−66%)1,102 KB (−17%)
Meshopt675 KB (−77%)658 KB (−50%)
Draco489 KB (−83%)437 KB (−67%)
The same models, three compression methods

Draco wins on both. On this table alone the conclusion is obvious: use Draco.

The table is missing something.

The decoder is not free

A compressed file means nothing to the browser until a decoder is loaded, and the two decoders are not remotely the same size:

DecoderSize
Draco (wasm + wrapper)245 KB
Meshopt28 KB
Decoder sizes as shipped with three.js

Draco's decoder is nearly nine times larger. Adding it back flips the answer:

PageMethodModelsDecoderTotal
3D StudioOriginal2,927 KB2,927 KB
Draco489 KB245 KB734 KB
Meshopt675 KB28 KB704 KB
Midnight RunOriginal1,714 KB1,714 KB
Draco505 KB245 KB750 KB
Meshopt771 KB28 KB799 KB
What a first load actually costs, model plus decoder

On the 3D Studio page meshopt comes out 30 KB ahead of Draco, despite compressing noticeably worse.

Midnight Run goes the other way, with Draco ahead by 49 KB. That page carries two models and more geometry overall — enough to amortise the 245 KB decoder.

As a rule you can carry away: Draco only wins once it saves more than roughly 216 KB over meshopt. Below that, the larger decoder eats the advantage.

Decoders are also cached, so how many 3D pages a visitor sees is another variable. A single one-off visit and a site with five 3D scenes do not have the same optimum.

Textures are the other half

Neither Draco nor meshopt touches textures. In the Xinyi city file, textures are a quarter of the bytes:

ItemJPEGWebP
Embedded textures334 KB125 KB (−63%)
Share of the GLB25%11%
Converting the embedded textures to WebP

Geometry and textures compress independently, so the complete answer does both:

TreatmentSize
Original1,329 KB
Draco437 KB
Draco + WebP228 KB (−83%)
The Xinyi city file with everything applied

One thing I did not expect: those 2.9 MB contain no textures

Halfway through measuring I noticed the 3D Studio file has zero textures in it.

The whole 2,926 KB is geometry — 85,976 vertices with position, normal and UV all stored as 32-bit floats.

That explains why brotli does so well on it, and why quantisation alone removes two thirds. Those floats simply do not need that precision. In a scene eleven metres wide, seven decimal places of position is meaningless.

Had I measured this at the time, that scene would never have been 2.9 MB in the first place.

So what happens now

This is a measurement, not a fix. I have not wired compression into the loading path yet — that touches the initialisation of both 3D scenes, and choosing between Draco and meshopt also depends on whether I am willing to ship two decoders for the sake of a difference between two pages.

But the numbers are clear enough:

PageNowCompressedReduction
3D Studio2,927 KBabout 734 KB−75%
Midnight Run1,714 KBabout 541 KB−68%
If all of it were applied

On a slow mobile connection that is several seconds. Several seconds before the visitor sees anything at all.

If you want to measure your own 3D page

In this order, because each step costs more than the one before. Stop when it is good enough.

One. Open the network panel and compare the transferred column against the size column. If they match, that file is not being compressed — go and check its content-type.

Two. Check whether your CDN compresses that content type. This is the only optimisation with zero client-side cost, so take it wherever you can.

Three. Measure quantisation once. It needs no decoder at all, and it is startlingly effective on scenes carrying more float precision than they need.

Four. Only then consider meshopt or Draco, and always count the decoder in the total. The method with the best compression ratio is not necessarily the one with the smallest transfer.

Five. Handle textures separately. WebP is close to sixty percent for free.

All of this ran through gltf-transform, one command per method, and it was far quicker than I expected. The slow part was never the compression. It was being willing to look at those two columns in the first place.

The scene measured here: Midnight Run

Sources