Shrinking a .glb: How to Get a Game Model Under 1 MB

Why a .glb ballooned in the first place, and the compression levers to fix it — geometry compression, texture resizing, unused-data removal — in the order that does the least damage.

Creative MilaOctober 12, 2026
Shrinking a .glb: How to Get a Game Model Under 1 MB

Shrinking a .glb: How to Get a Game Model Under 1 MB

Getting glb compression under 1mb is not one operation. It is a sequence of decisions, and the order matters more than the tools. Most people reach for geometry compression first because the mesh is the part they built in Blender and the part they can see. That is usually the wrong first move. On a typical game-ready character or prop, textures occupy the majority of the file, and geometry compression on a file that is 80 percent PNG will not move the total under a megabyte. You will spend an afternoon on Draco, lose silhouette quality, and end up back where you started.

This post is a diagnostic order. Figure out what is actually large, then apply levers from least destructive to most destructive, checking the result after each one. The target is a .glb that loads fast in a browser-based 3D world, renders correctly in the engine, and does not silently regress the next time someone re-exports from the source file.

What is actually in a .glb

A .glb is a glTF binary container. It is not a mesh format. It is a package holding several payloads packed into one binary blob:

  • One or more JSON chunks describing the scene graph, node hierarchy, materials, animations, skins, and morph targets.
  • A binary buffer holding vertex positions, normals, UVs, tangents, joint weights, joint indices, index buffers, and animation keyframe data.
  • Embedded images, either as raw PNG/JPEG bytes or as GPU-compressed texture data.

That structure tells you where the bytes go. Vertex data is usually the second-largest chunk. Images are usually the largest. JSON is almost never significant unless you have thousands of nodes or very long animation clip metadata, and even then it rarely exceeds a few hundred kilobytes of text.

You cannot reason about glb compression under 1mb without knowing the split. A 4 MB character with 3.5 MB of textures and 400 KB of mesh needs a completely different plan than a 4 MB environment kit with 3.6 MB of mesh and 300 KB of textures. The first needs texture work. The second needs geometry work. Applying the wrong lever to the wrong file wastes time and degrades quality for nothing.

Measuring the split before you touch anything

Do not guess. Open the file and read the chunk sizes. Any glTF inspector will show you a breakdown of accessors by byte length and images by byte length. If you want the raw view, a .glb is a 12-byte header followed by length-prefixed chunks; you can parse the JSON chunk and sum the buffer view lengths grouped by their usage. In practice, a viewer that lists each image with its dimensions and encoded size is enough.

Write down three numbers before you change anything:

  1. Total image bytes, and the encoded size and pixel dimensions of each individual image.
  2. Total vertex attribute bytes, split by attribute (position, normal, UV, tangent, skin weights, morph deltas).
  3. Index buffer bytes, and the primitive count.

Those three numbers determine every decision that follows. If images dominate, start in the texture section. If attribute bytes dominate, look at what attributes you are shipping and whether the engine actually consumes all of them.

A 2048×2048 PNG with an alpha channel can be several megabytes on its own. Four of them on a single character is a file that no amount of Draco will rescue. The first time I chased geometry on a model like that, I cut the mesh by two-thirds and the file went from 9.1 MB to 8.4 MB.

Geometry is rarely the heavy part — textures usually are

Strip unused data before compressing anything

Deletion is lossless. It costs nothing in quality and needs no special engine support. Do it first. Everything you delete is data you never have to compress, never have to decode at runtime, and never have to debug.

Materials and texture slots you do not ship

Exporters are generous. A model that was assembled from several source files often carries material definitions that reference nothing, or that reference textures which are then embedded anyway because a checkbox defaulted to on. Check for:

  • Materials with zero assignment to any primitive.
  • Texture slots present in the glTF material but never sampled by a shader you actually use. If your engine material is a single unlit or PBR base color plus a normal map, the exported metalness, roughness, occlusion, and emissive textures are dead weight.
  • Duplicate images. The same 1K normal map embedded twice under different names because two materials referenced it separately. Deduplicating identical image bytes is a pure win.

Morph targets and animations

Morph targets are expensive. Each target adds a full set of delta positions, and often delta normals, for every affected vertex. A face rig with 40 blendshapes can outweigh the entire base mesh. If you are not driving those blendshapes at runtime, remove them at export rather than compressing them.

Animations are the same story. A file exported with the full action library carries every clip, at whatever sampling rate the exporter used. Unused clips are pure dead weight. Beyond deletion, there is a second lever: many exporters bake every frame at 30 or 60 fps, when the engine interpolates keyframes anyway. Reducing the sampling to keyframes-only, or to a lower rate on clips that do not need fine timing, can cut animation bytes substantially without visible change.

Deciding what stripping actually bought you

Re-measure after stripping. If the file is already under 1 MB, stop. Do not apply texture or geometry compression to a file that meets its budget, because every subsequent step is lossy. The most common failure in this whole process is applying levers reflexively rather than because the measurement demanded it.

Resize and re-encode textures

If images dominate, texture work is the next lever. It is lossy, but the loss is controllable and often invisible at the size the model is actually drawn on screen.

Resolution is the biggest and safest dial

Halving a texture's linear dimensions quarters its pixel count. Going from 4096 to 2048, or 2048 to 1024, removes most of the bytes and is usually imperceptible for a model viewed at gameplay distance. The question to answer is not "what is the highest resolution this looks good at" but "how many screen pixels will this texture occupy." A prop that fills a tenth of a 1080p frame does not need 4096 pixels of base color.

For a character, the base color and normal map usually deserve more resolution than roughness, metalness, or occlusion. Those channels are low-frequency in most stylized art and survive a halving or a quartering without visible change. Normal maps are the exception in the other direction: they are high-frequency by nature, and halving them produces visible smoothing on hard edges and surface detail.

Re-encoding format

After resolution, format. A PNG base color with no alpha should be a JPEG. A mask or occlusion map with no color information should be a single-channel image, not a three-channel one. Those two changes alone can cut texture bytes by a large factor without any GPU-compression format at all.

Then the GPU formats. KTX2 is a container for Basis Universal compressed textures. Basis transcodes at load time to whatever the GPU supports, typically BC formats on desktop and ASTC or ETC on mobile. A KTX2 file is already compressed on disk, so it downloads smaller and uploads to the GPU without a full decode of an uncompressed bitmap.

You need engine support for this. In a Three.js context, loading KTX2 requires the KTX2Loader and a transcoder module. If you are building in a browser-based 3D world builder, confirm the runtime path loads KTX2 before you convert, because a KTX2 texture in a pipeline that expects PNG will fail at load and leave the model untextured.

What breaks if you push texture compression too far

Chroma subsampling in aggressive JPEG settings produces color fringing on high-contrast edges. Base color textures with sharp graphic elements and flat fills are the worst case: JPEG ringing shows up as a halo around every hard edge. If your art style depends on crisp color boundaries, keep the base color as PNG or use a higher JPEG quality, and take the bytes out of a different channel.

With KTX2 and Basis, push the quality slider down and you get block artifacts in smooth gradients and on normal maps. Normal maps are especially sensitive because their RGB channels encode a direction vector, and quantizing them produces lighting banding that is very visible on curved surfaces under a moving light. If your scene has animated lighting, test the normal map under motion, not just in a static preview.

I have shipped more fixes for "the model looks fine in the preview but banded in game" than for any other texture problem. The static thumbnail hides normal-map quantization completely. Rotate a light around the model before you accept a KTX2 setting.

Texture resolution, not polygon count, sets most file sizes

Geometry compression

Only after deletion and texture work should you touch the mesh. If the attributes still dominate and you are over budget, geometry compression is the lever. It is the one with the real engine dependencies.

Draco

Draco compresses mesh geometry by quantizing vertex attributes and then entropy-coding the result. In glTF, it lives inside the file as a compressed extension rather than as the plain binary buffer. The file stays a .glb, but the mesh data cannot be read without a decoder.

Quantization is where the quality loss lives. Positions are quantized to a fixed number of bits per component. At the default of 14 bits, most models are visually identical. Drop to 11 or 10 bits and you start to see the symptoms: coincident vertices that should weld fail to weld, thin geometry develops shimmer, and UV seams drift because UVs are quantized separately from positions. Skin weights and joint indices quantize too, and pushing those low produces visible deformation artifacts at joints rather than surface artifacts.

Engine support is mandatory. Three.js needs DRACOLoader configured with a decoder path. The decoder is a separate WebAssembly module that has to be fetched. If your deployment does not serve it, the loader fails. This is the single most common geometry-compression mistake: a file that works locally because the decoder is sitting in the project folder, and fails in production because the decoder was never bundled.

The order within geometry

If you must compress geometry, compress the largest attributes first and consider replacing rather than compressing others.

  • Tangents are derivable. If your engine computes tangents in the shader or you do not use normal mapping, drop them entirely at export instead of compressing them.
  • Vertex colors, if unused, drop them.
  • Split attributes: if positions need 14 bits, UVs may be fine at 12 and weights at 8. Uniform quantization across all attributes wastes budget on the ones that tolerate coarser treatment.
  • Index buffers: use 16-bit indices when the vertex count allows it. A mesh under 65,536 vertices does not need 32-bit indices, and the exporter will not always choose this for you.

Where geometry compression stops helping

Past a certain point, further quantization does not reduce the file meaningfully because you have hit the floor of what the entropy coder can do, and every additional bit removed produces a visible defect. When you reach that floor and you are still over budget, the answer is fewer vertices, not more aggressive compression: decimate the mesh, remove interior geometry, or cut back the number of draw calls by merging. That is a modeling decision, not an export setting, and it belongs in the source file so it does not get undone.

Choosing a lever: a compatibility and trade table

Lever

Lossless?

Engine support needed

Typical visible cost

Fails when

Strip unused materials/textures

Yes

None

None

Never, if the data is genuinely unused

Deduplicate images

Yes

None

None

Two materials need different sampler settings on shared bytes

Drop unused morphs/animations

Yes

None

None

A clip is triggered by code you did not search for

Reduce animation sampling

Nearly

None

Timing drift on fast clips

Clips rely on per-frame baked physics

Halve texture resolution

No

None

Softening on normal maps

Texture is viewed close-up or fills the screen

PNG to JPEG

No

None

Edge ringing on flat-fill art

Texture has alpha or sharp graphic edges

Single-channel masks

No

None

Minor precision loss

Channel is used for something other than a mask

KTX2 / Basis

No

KTX2Loader + transcoder

Block artifacts in gradients

Decoder not bundled at runtime

Draco geometry

No

DRACOLoader + decoder

Seam drift, weld failures

Decoder not deployed, or quantization too low

Decimation

No

None

Silhouette and detail loss

Applied without re-baking normals

A before and after checklist

Run this in order and re-measure at each step. The measurement is what tells you when to stop.

Before:

  1. Record total file size, image bytes, attribute bytes, index bytes, primitive count, and clip count.
  2. List every image with dimensions and encoded format.
  3. List every attribute present per primitive.
  4. Confirm which animations and morph targets the runtime actually drives.

After each change:

  1. Re-import the exported file into a clean engine scene, not a cached one.
  2. Check every texture slot resolves. Missing normal maps sometimes fail silently to a flat default.
  3. Verify UV seams on the parts of the model that were quantized.
  4. Move a light around the model and confirm no normal-map banding.
  5. Play every animation clip that survived stripping.
  6. Confirm the file still loads in the target browser, with the decoder paths the production build actually serves.

If a step regresses, revert that lever rather than compensating with the next one. Compensating produces a file where you cannot tell which setting caused the defect.

The mistake that silently reverts the win

Here is the one that eats afternoons. You resize textures in the source file, export a .glb, hit your budget, ship. Weeks later someone re-exports from the same Blender file to add a mesh fix. The exporter writes the current state of the source, and if the texture nodes still reference the original 4K images, or if the export settings reverted to embedding full-resolution source images, the new .glb is back to 9 MB. Nothing in the pipeline flagged it. The file got bigger and nobody noticed until a load-time complaint.

Re-exporting after a texture resize without re-checking the mesh and texture state is the silent revert. Two things cause it:

  • The resized textures were applied at export time by a script or an external tool, not saved into the source file. The next export, run without that tool, embeds the originals.
  • The export preset cached the previous image settings, and a re-export from a different machine or a reset Blender session used defaults.

The fix is to make the budget a checked property, not a remembered one. Put the file size and the image byte total into whatever build step produces the .glb, and fail the build when the total exceeds the budget. If you have no build step, keep a manifest listing each asset's expected size and image dimensions, and diff it after every export. The re-check should include the mesh, not just the textures: a re-export can also silently reintroduce morph targets, tangents, or animation clips that were stripped the first time, because stripping happened in the export dialog and the dialog did not persist.

The second-most-common variant: someone fixes a UV and re-exports, restoring tangents that were previously dropped. The mesh looks identical in the viewport and the file is 300 KB larger. Tangents were the third-largest attribute in the original file. Check the attribute list, not just the texture list.

Putting it together for a real asset

Take a character with 4K base color and normal maps, full blendshape set, and a baked 60 fps animation library. The plan writes itself from the measurements:

  1. Strip the morph targets not driven at runtime and the clips not triggered by code. Lossless, no support needed.
  2. Drop tangents if the shader computes them; drop occlusion and emissive textures if the material does not sample them. Lossless.
  3. Halve base color and normal to 2K; quarter roughness, metalness, and occlusion to 1K or lower. Convert base color to JPEG at high quality if there is no alpha; convert masks to single-channel.
  4. Convert to KTX2 if the runtime loads it. Verify the decoder is served.
  5. Reduce animation sampling from every frame to keyframes where the curves tolerate it.
  6. If still over budget, apply Draco with positions at 14 bits, UVs and normals one or two bits lower, and weights at 8. Verify seams and joints.
  7. Re-measure. If still over, decimate in the source and re-bake normals.

Each step is independently reversible and independently measurable. That is the whole method: diagnosis first, lossless levers first, lossy levers in ascending order of damage, and a re-check after every export so the win does not quietly disappear on the next commit. Getting glb compression under 1mb is a process you can repeat, not a setting you find once.


A derived contact sheet survives compression unchanged

Keep going


Build the world it walks in

A character, a tile or a model is a material with nowhere to stand. Every world on the 3D game builder was made from one written description and published as a page you can play — including Mini World and The Cyclops' Island.

Open the 3D Game Builder →