Skip to content
All articles

Engineering 6 min read 1,425 words

Making 3D fast enough for the browser and for AR

A 34 MB GLB takes 14 seconds to appear on a phone. The same model, correctly compressed, takes under one — and the pipeline to get there is six commands.

A client shipped a product configurator with a 34.2 MB GLB of a motorcycle: 480,000 triangles, three 4096 PNG texture sets, no compression of any kind. On the office fibre connection it appeared in about two seconds and everyone signed off. On a mid-range Android over a real 4G connection it took fourteen seconds to download, another three to parse and upload to the GPU, and then ran at 22 fps because 84 MB of uncompressed textures does not fit comfortably in that device’s budget. Analytics showed a 60% drop-off before first frame.

The same model, after six commands, was 1.9 MB and appeared in under a second. Nothing about it looked meaningfully worse in a viewer at 1200 px. This is the ordinary case, not a heroic optimisation — most 3D on the web is oversized by an order of magnitude because nobody ran the pipeline.

Know your budget before you optimise

“Fast enough” depends entirely on the delivery target. These are the numbers I hold teams to, and they are achievable for almost any single-object scene.

Target Download size Triangles Draw calls Texture VRAM Time to first frame
Desktop web viewer, one object ≤ 5 MB ≤ 200k 1–4 ≤ 32 MB < 2 s
Mobile web viewer, one object ≤ 2.5 MB ≤ 100k 1–3 ≤ 16 MB < 3 s on 4G
iOS AR Quick Look (USDZ) ≤ 10 MB ≤ 100k < 5 s
Android Scene Viewer (GLB) ≤ 8 MB ≤ 100k < 5 s
WebXR scene, 20 objects ≤ 12 MB total ≤ 25k each, 500k total ≤ 60 ≤ 128 MB < 5 s
Mobile game prop, runtime 1.5k–8k shares one material 512–1024 map

Time to first frame is the number that correlates with people staying, and it is not just download. Budget roughly 20–30% on top of transfer time for parse, decompression and GPU upload on mobile.

The bytes are almost always textures

People instinctively attack triangle count first, because it is the number they can see in the DCC. It is usually the smaller problem. A 2048×2048 albedo saved as PNG is commonly 5–8 MB; the same map as KTX2 with ETC1S is around 1 MB. A full PBR set — albedo, normal, roughness, metallic, occlusion — as five PNGs will comfortably exceed 25 MB before a single vertex is counted.

Memory tells the same story. An uncompressed RGBA8 2048 map occupies 16 MB of VRAM, about 21 MB with mipmaps. That is per map. Transcoded ETC1S landing in a native block format is roughly 2 MB for the same map, and it stays compressed on the GPU rather than being expanded at upload.

Three moves, in this order of impact:

  1. Resize. Ask what screen size the object actually occupies. A prop that fills 30% of a 1080p viewport does not benefit from a 4096 map; 1024 is usually indistinguishable and is a 16× reduction in pixels.
  2. Channel-pack. Occlusion, roughness and metalness go into the R, G and B channels of one texture — the glTF spec expects exactly this arrangement. Three files become one. Often roughness and metalness can be constant scalar factors with no texture at all.
  3. Compress with KTX2. ETC1S for albedo, emissive and ORM. UASTC for normal maps, which show ETC1S artefacts badly. Both transcode to whatever block format the device supports at load.

Geometry: Draco or Meshopt

Draco compresses harder — often 6–10× on vertex data — but decoding runs in a WASM module on the CPU and costs real milliseconds, which is felt most on the devices you are trying to help. Meshopt compresses less, roughly 2–4× before gzip, but decodes several times faster and pairs with vertex quantization so the buffers stay small in memory too.

For a single web-delivered object where total bytes dominate, Draco often wins. For scenes with many meshes, for WebXR, or anywhere you care about time to first frame more than raw transfer, use Meshopt. Do not use both.

Before either, reduce the mesh itself. Generated and scanned meshes carry enormous redundancy, and a quadric simplifier with a tight error bound will remove 60–80% of triangles with no visible change to the silhouette.

What the six commands actually did

Step Size Transfer at 20 Mbps Texture VRAM
Raw export: 480k tris, 3× 4096 PNG sets 34.2 MB 13.7 s 84 MB
Prune unused data, weld duplicate vertices 31.6 MB 12.6 s 84 MB
Simplify to 120k triangles 14.8 MB 5.9 s 84 MB
Resize maps to 1024, pack ORM 6.1 MB 2.4 s 21 MB
KTX2: ETC1S albedo/ORM, UASTC normals 3.7 MB 1.5 s 7 MB
Meshopt geometry compression 1.9 MB 0.8 s 7 MB

Running it is not exotic. The gltf-transform CLI does all of it:

gltf-transform prune in.glb s1.glb
gltf-transform weld s1.glb s2.glb
gltf-transform simplify s2.glb s3.glb --ratio 0.25 --error 0.001
gltf-transform resize s3.glb s4.glb --width 1024 --height 1024
gltf-transform etc1s s4.glb s5.glb --quality 200
gltf-transform meshopt s5.glb out.glb --level high

Check the result in a viewer at your real display size before and after. The --error value on simplify is the one to tune; 0.001 is conservative, 0.01 will start eating small features.

Draw calls and materials

A renderer issues roughly one draw call per mesh primitive per material. A model exported with 40 separate objects and 40 materials costs 40 draw calls even if it is only 30k triangles, and on a mid-range phone that is a worse problem than the triangles. Merge meshes that share a material, and atlas materials that differ only by colour.

In three.js, renderer.info.render.calls after a frame tells you the truth in one line. If it is above 50 for a single product model, something is wrong with the export, not with the device.

Level of detail is usually not worth it for a single-object viewer — the object is always at the same distance and you just pay for two meshes. In a WebXR scene with twenty objects, LODs earn their keep quickly.

AR has extra rules

AR is where models that were fine in a viewer fall over, because the platform viewers are stricter and the user is standing in a room comparing your object to real ones.

  • Scale is in metres and it is not optional. glTF units are metres. A chair that imports at 0.01 scale will appear as a doll’s house chair on someone’s floor with no way to correct it.
  • Origin at the base, centred. AR placement puts the origin on the detected plane. An origin at the object’s centre makes it sink halfway into the floor.
  • Y-up, facing -Z. Rotate at export, not at runtime, because the platform viewers do not read your runtime code.
  • Avoid double-sided materials. They double fragment cost and cause shadow artefacts. Fix the normals instead.
  • Be careful with transparency. Sorting is unreliable and transmission extensions are inconsistently supported in platform AR viewers. Alpha-cut where you can; save real glass for the desktop viewer.
  • Ship both formats. USDZ for iOS Quick Look, GLB for Android Scene Viewer, generated from the same source. The GLB, FBX and OBJ you download from a shop or a studio like MeshyFlix cover the authoring side, but the AR delivery pair is its own conversion step.

With <model-viewer>, the practical setup is ar-modes="webxr scene-viewer quick-look", a separate ios-src pointing at the USDZ, a poster image, and reveal="interaction" so nothing downloads until the user asks for it.

Perceived speed is half the work

A poster image that matches the model’s first frame makes a two-second load feel instant, because there is never a blank rectangle. Lazy-load anything below the fold. Preload the environment map, which is frequently a forgotten 2–4 MB HDR sitting on the critical path. Serve everything with a long cache header and gzip or Brotli on top of the GLB — Meshopt output compresses further, Draco output does not.

Do and don’t

  • Do test on a real mid-range Android with network throttled to 10 Mbps and CPU at 4× slowdown. Desktop Chrome on fibre tells you nothing.
  • Do keep the uncompressed master. Optimisation is destructive and you will need to redo it at different settings.
  • Do automate the pipeline in CI so nobody hand-optimises and nobody forgets.
  • Don’t ship 4K textures because the source had them. Nobody has ever noticed the difference in a 600 px viewport.
  • Don’t use ETC1S on normal maps. The banding is obvious and it will read as faceting.
  • Don’t apply Draco and then gzip and expect gains; it is already entropy-coded.
  • Don’t optimise before the art is locked. You will do it twice.

Measure one model today

Open your heaviest live model in Chrome with the network tab open and throttling set to a custom 10 Mbps profile. Write down three numbers: total transferred bytes, time from navigation to first rendered frame, and renderer.info.render.calls. Then run the six commands above on that file and record the same three numbers. If the gap is what it usually is, put the pipeline into your build step this week and stop shipping raw exports.