I built a sneaker configurator that runs in a browser tab — orbit the shoe,
switch colourways and materials, watch the price update. It works. But the
first version had a problem that had nothing to do with 3D, and everything to
do with whether anyone would ever see it.
The model was 16.7 MB.
On a laptop on office wifi that is a two second wait. On a mid-range Android
phone on mobile data in Bangladesh, where I built this, it is not a slow
experience. It is an abandoned one. The user leaves before the first frame.
This is a write-up of how I got it to 2.4 MB, and of the second problem I hit
on the way — that the obvious way to recolour a 3D model destroys the thing
you built it to show.
Where the weight actually is
The first thing worth saying: "the model is too big" is not a diagnosis. A
glTF file is geometry plus textures plus everything else, and those three
respond to completely different treatments. Before optimising anything I
wanted to know the split.
npx gltf-transform inspect model.glb
That prints a table of meshes, materials and textures with their sizes. In my
case the split was roughly:
- Textures — the large majority of the file
- Geometry — a meaningful chunk, but smaller than I expected
- Everything else — noise
This matters because it tells you where to spend effort. Draco is the famous
answer and it is a good one, but Draco compresses geometry. If textures are
80% of your file, Draco alone will not save you.
Geometry: Draco
Draco quantises vertex attributes and entropy-codes the result. The important
parameter is quantisation bits per attribute, and the important insight is
that different attributes tolerate very different precision.
npx gltf-transform draco model.glb model-draco.glb \
--quantize-position 14 \
--quantize-normal 10 \
--quantize-texcoord 12 \
--quantize-color 8
Position needs the most precision — drop it too far and surfaces visibly
shift. Normals tolerate far less than people expect; at 10 bits I could not
see a difference on curved surfaces under moving light. Texture coordinates
sit in between, and getting them wrong shows up as texture swimming at the
seams, which is a distinctive and ugly artefact worth knowing by sight.
The defaults are conservative. Walking each value down until you can see the
damage, then stepping back one, is worth more than any single setting I could
give you, because the right numbers depend on your model's scale and how close
the camera gets.
On the loading side, Draco needs its decoder:
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'
import { DRACOLoader } from 'three/examples/jsm/loaders/DRACOLoader.js'
const draco = new DRACOLoader()
draco.setDecoderPath('/draco/')
draco.setDecoderConfig({ type: 'js' })
const loader = new GLTFLoader()
loader.setDRACOLoader(draco)
One thing that cost me time: the decoder is itself a download. Serve it
yourself rather than from a CDN you do not control, and make sure it is
cached — otherwise you have traded model bytes for decoder bytes on every
cold load.
Textures: the part that actually mattered
Draco got me a good cut. It did not get me to 2.4 MB. The textures did.
The mistake in the source asset was authoring every map at the resolution you
would want if the camera could get arbitrarily close. But in a configurator,
the camera cannot. There is a defined orbit distance and a maximum zoom, and
those set a real upper bound on how much texture detail can ever reach a
pixel.
So I re-authored the set at sizes chosen for the actual viewing distance, and
separated the maps by how much precision each one needs:
- Base colour — the map the eye judges. Keeps the most resolution.
- Normal — needs resolution, but tolerates compression artefacts poorly, so treat it differently from colour.
- Roughness / metalness / AO — these can go dramatically smaller than feels comfortable. They drive shading response, not perceived detail.
Then KTX2 with Basis Universal, which matters for a reason beyond file size:
npx gltf-transform uastc model.glb model-ktx2.glb \
--level 4 --rdo-quality 4 --zstd 18
A JPEG texture has to be decoded to raw RGBA before the GPU can use it. A
2048×2048 texture becomes 16 MB in GPU memory regardless of how small the JPEG
was. KTX2 stays compressed on the GPU. On a mid-range phone, where GPU memory
is the actual ceiling, this is often the difference between running and
crashing — and it is invisible if you only measure download size.
The second problem: colour
With the file small enough, the configurator had a different failure. Swapping
a colourway made the shoe look wrong.
The obvious implementation is to multiply a tint over the base colour texture:
// Don't do this
material.color.set(selectedColour)
The texture was baked with lighting and shadow information in it. Multiplying
a saturated colour over that drags everything toward flat. The fabric weave
disappears, the baked shadows go muddy, and a red colourway stops reading as
the same shoe in red and starts reading as a red silhouette. Which defeats
the entire point of showing it in 3D rather than as a photograph.
What worked was separating luminance from chroma and replacing only the
chroma. The luminance channel carries the weave, the stitching and the baked
shadow — all the information that makes it look like a material. The chroma
channel carries the colour, and that is the only part a colourway swap should
touch.
// In the fragment shader, on the base colour sample
vec3 texel = texture2D(map, vUv).rgb;
// Perceptual luminance - the weights are not arbitrary,
// the eye is far more sensitive to green than to blue
float luma = dot(texel, vec3(0.2126, 0.7152, 0.0722));
// Re-apply the target colour at the texel's own brightness
vec3 tinted = tintColour * luma;
// Blend back toward the original where the texel is near-neutral,
// so white midsoles and metal eyelets do not get tinted
float chroma = max(max(texel.r, texel.g), texel.b)
- min(min(texel.r, texel.g), texel.b);
gl_FragColor.rgb = mix(texel, tinted, smoothstep(0.02, 0.15, chroma));
That last blend is the part I would not have guessed at the start. Without it,
recolouring to blue also turned the white midsole blue. Masking by the texel's
own chroma means neutral areas stay neutral and only the already-coloured
regions take the new colourway — which is exactly what happens when a factory
actually dyes a material.
What it came to
16.7 MB → 2.4 MB 86% smaller
60 fps mid-range Android, not just a laptop
0 page reloads every option change is client-side
What I would tell myself at the start
Measure the split before optimising. I spent my first effort on geometry
because Draco is what everyone writes about. The textures were the larger
problem.
Quantisation tolerance is not uniform. Normals survive far more
aggressive settings than positions. Treating every attribute the same leaves
a lot of bytes on the table.
Download size is not the only budget. KTX2 mattered more for GPU memory
than for the network, and GPU memory is what actually fails on a mid-range
phone.
Test on the worst device you expect, early. Everything above is obvious
on a laptop and invisible until you are holding a cheap phone on a slow
connection. I had one, which is probably why I noticed.
The configurator is live at https://3d-bay-ten.vercel.app — orbit it, swap
the colourways, check the load on your phone.
I'm Md. Raju Ahmed, a freelance real-time 3D developer in Rajshahi, Bangladesh. I build
real-time 3D on the web with Three.js, React Three Fiber and Next.js, and the
applications it lives in. Portfolio: https://rifatsarkerraju.com
Top comments (0)