Why is my Character Creator GLB so big?
Every optimization guide tells you to compress your textures. For a rigged character that advice is close to useless, and the settings most people reach for next will quietly break the face.
It is the morph targets. On a raw Character Creator 4 export they are roughly 96% of the file, and the textures you were about to compress are a rounding error. Draco will not help you, and quantization will break your shape keys.
Where the bytes actually are
A Character Creator 4 export ships 400 or more facial morphs. Each one stores a POSITION delta and a NORMAL delta, per affected vertex, as uncompressed float32. That is the whole story of the file size.
Here are two characters we measured, at opposite ends of the scale.
| Asset | Morph targets | Raw export | Shipped | Reduction |
|---|---|---|---|---|
| CC4 avatar, full facial rig | 400+ | 236 MB | 24 MB | 90% |
| Stylized low-poly character | 23 | 4.18 MB | 2.26 MB | 46% |
The second character is the more useful one to look at, because it is small enough to break down completely:
| Component | Size | Share |
|---|---|---|
| Morph targets | 3.3 MB | 81% |
| Geometry, Draco compressed | 510 KB | 12% |
| Textures, WebP, 18 maps | 302 KB | 7% |
| JSON | 100 KB | 2% |
Twenty-three blend shapes cost six times what all the geometry and textures cost together. This character has 23. A CC4 export has more than 400.
Why Draco does not save you
The standard advice is Draco, usually stated with confidence. It is good advice for a prop and it does almost nothing for a character, for a reason that is easy to verify.
Draco does not compress morph targets. In Blender's glTF
exporter, io_scene_gltf2/io/exp/draco.py:146 touches
primitive.targets only to set preserve_triangle_order.
Draco covers the base mesh attributes and nothing else.
It is slightly worse than neutral. Preserving triangle order to keep the morph targets valid makes Draco compress the base mesh a little less well than it otherwise would.
So on the character above, Draco is working on the 510 KB slice while the 3.3 MB slice sits untouched.
The settings that break the face
Having found that Draco does not help, the natural next move is to reach for something stronger. Each of these produced a broken deliverable for us, and none of them failed loudly.
Quantization destroys morph deltas on small-scale models
This is the worst one, because the file loads fine and looks fine until a shape key is activated.
The character was authored small, with a bounding box around 0.017 units.
Running quantize() scaled the morph POSITION deltas by roughly
40,000 times, turning a 0.04 unit displacement into 1,580
units. Activate any shape key and the vertices are thrown to infinity. The
symptom people report is "my shape keys stopped working", which sounds like
an exporter problem and is not.
Setting quantizationVolume: 'mesh' makes it worse in a second
way: it clones the shared skin per mesh, in our case turning one skin into
nine. Using 'scene' keeps the single skin and still corrupts the
morphs. There is no safe setting here. Do not quantize a character.
Meshopt breaks Blender round-trip
EXT_meshopt_compression produces a file Blender refuses, with
"Extension EXT_meshopt_compression is not available on this addon version".
If the client ever needs to open the asset, this rules meshopt out regardless
of what it saves.
WebP textures render white in some viewers
EXT_texture_webp leaves the material bindings intact but viewers
without support cannot load the image, so meshes arrive untextured and white.
Fine if you control the runtime. Not fine for a deliverable that gets opened
in whatever the client has.
Pruning deletes textures that look constant
The default prune() collapses near-solid textures into
baseColorFactor and removes them. On our model it ate the eyebrow
texture and turned the brows into solid dark red. The fix is
keepSolidTextures: true.
What actually works
This pipeline is lossless to functionality. Morphs stay bit-exact, the file still imports into Blender, and it loads in any viewer.
- Strip the vertex colors. CC4 writes
COLOR_0andCOLOR_1that stylized web work does not use. - Weld, dedup, and prune gently, restricting prune to geometry property types and keeping solid textures.
- Drop the morph NORMAL deltas and keep the POSITION deltas. This is the single biggest win, roughly halving the file on its own.
- Prune accessors again afterwards. Detaching the normal deltas does not shrink anything. The orphaned accessors sit in the buffer until a second pass removes them, which is easy to miss because the first pass reports success.
- Leave the textures as JPEG or PNG, and skip quantize, meshopt and WebP entirely.
That took the CC4 avatar from 236 MB to 24 MB with the morphs intact.
Fewer materials means smaller file
This one is not widely known and it is worth more than most compression settings.
Every material primitive carries every morph target. A mesh split across nine materials stores all its blend shapes nine times.
Splitting the head into its own object, so the shape keys ride only the head's five primitives instead of all nine, took the character from 4.18 MB to 2.26 MB. No decimation, no texture change: vertex and triangle totals were identical before and after, and the textures stayed at 301.8 KB.
The same split fixed a bug we had been chasing separately. Fourteen visemes were all reporting an identical maximum delta of 0.16418, which turned out to be stray body vertices being dragged by every facial shape. Moving the head to its own object removed them, and the deltas dropped to a sane 0.008 to 0.03.
Delete the textures that are not textures
Ten of that character's eighteen exported maps were single-valued. Every metallic map was solid black, every roughness map but one was solid white, and they were shipping as 2048 and 4096 pixel PNGs.
Detect them by reading the pixel buffer and testing for zero range, then
unlink the node and write the constant into the Principled socket instead.
The exporter emits metallicFactor and roughnessFactor
rather than an image. It is provably lossless and it removed five of the six
ORM textures, landing the whole texture payload at 302 KB.
Use exact peak-to-peak, not standard deviation, for the constancy test.
Float32 std over a 4096 pixel square image accumulates enough
error to return a spurious nonzero and wrongly reject a genuinely constant
map.
Also check for duplicate image datablocks pointing at the same file. A
Body_Base_Color.png and a .001 of it are bit
identical and get exported twice.
How to prove you did not break it
Compression damage to morph targets is invisible until someone animates the face, which is usually after delivery. Run this before you ship.
- Compare max POSITION delta per morph, source against output, across every primitive. Checking only the first primitive gives false zeros, because a CC4 body is split into roughly thirteen material primitives.
- Expect a uniform ratio. If the armature has a scale on it, every morph shifts by exactly that factor, in our case 1.01577. Uniform is correct. Per-morph variation is corruption.
- Audit for stray vertices by comparing max against the 99.9th percentile of displacement per key. A ratio far above 1.5, or the same maximum repeating across unrelated keys, means stray geometry rather than authoring.
- Run the Khronos glTF validator and expect zero errors.
GENERATED_TANGENT_SPACEwarnings on a CC4 asset are pre-existing, since CC4 exports no TANGENT attribute. - Render it headless and drive a morph at full strength. Headless Chromium needs SwiftShader flags or WebGL hands you a pure white frame and a passing test.
How these were measured
Byte sizes are the shipped files, not what a modelling package reports. Component splits were taken from the glTF buffer views, so the morph share is the actual bytes attributed to morph accessors rather than an estimate.
The two characters are different pipelines on purpose. The CC4 avatar was compressed after export with gltf-transform. The stylized character was exported from Blender 5.1 with the exporter doing the work, Draco and WebP enabled. Both are production deliverables rather than test scenes, which is also why neither is named here.
One exporter setting is worth stating because it silently ruins the file:
export_apply must stay off. Applying modifiers on export
destroys shape keys.
If you are sizing 3D assets for the web more generally, the companion note covers what a GLB should weigh when it is not a character, and why texture count predicts file size better than triangle count.
How big should a GLB file be?