A GLB file's contents shown as stacked blocks, with the mesh and texture blocks greyed out and only a thin animation strip highlighted

Cutting the download that happens before a match starts

Engine & Performance10 min readUpdated
ClaudeBuilt the thing
Adam SturrockDecided what mattered

Written up later from the commit history. Dated to when the work landed.

A standard one versus one made a player wait on 307.9 MB across 143 files before the match started. There is no clever engineering answer to a wait like that. There is only a list of what is in it, read one line at a time.

A lizardmen step pyramid in dense jungle, two saurus guards on the steps and glowing glyphs running up the stonework

The files in question are glTF binaries, .glb, the standard single-file format for 3D models on the web. One .glb can hold several different kinds of thing at once — meshes, the textures wrapped around them, a skeleton, and animation clips — which is convenient and is exactly how this went wrong.

Going through the list turned up something strange. Every unit in the game ships an animation file alongside its character model, and 82% of that file was a 3D model and a texture that the game read, decoded and threw away without ever drawing. Across the 147 units that ship one: 50.1 MB of image and 65.3 MB of model out of 140.9 MB, and that is at the smallest texture size.

What the exporter writes and what the game reads

The service that generates these models exports one file per animation clip, and each of those files carries the full character model. The step that combines the clips into a single animation file keeps one copy of that model in the result. Neither half is wrong on its own. A clip file you can open and look at is a reasonable thing for a 3D generation service to hand you.

The game never looks at it. Its model loader pulls the list of animation clips out of that file and reads nothing else, joining them to whatever clips the character model itself shipped with. The two inspector screens do the same thing from the other direction. So the model inside the animation file has never been drawn anywhere in this project, not once.

What it cost per match was a second copy of every unit's texture over the network, the work of decoding that texture, and the work of decoding roughly 31,000 triangles of character model, all discarded a fraction of a second later.

Look inside your own model files before you trust their names

You do not need a tool for this and you do not need to write one. The glTF Transform command line has shipped an inspector for years, it needs no install step, and it prints a table for each kind of thing in the file.

# The package's only binary is gltf-transform, so npx runs it by that name.
npx --package @gltf-transform/cli@4 gltf-transform inspect path/to/animations.glb

Run it on one lizardmen worker, a file whose 9.46 MB sits almost exactly on the 9.5 MB median across all 147 units, and the answer is on two of the tables it prints. The meshes table shows one model at 32,594 vertices and 1.88 MB. The textures table shows a single 2048 by 2048 image at 6.66 MB, with a second column putting it at 22.37 MB once it is unpacked into video memory. The animations table lists the 13 clips the file exists for, totalling 698.67 KB.

So 8.54 MB of a 9.46 MB file is model and texture, in a file named after its animations, and everything downstream had taken the name at face value. That unit's shipped file is now 371 KB.

A lizardmen host advancing past a smoking volcano, an armoured bastiladon carrying a skink priest at its centre

Three habits come out of that, none of them specific to this game:

  1. Inspect by kind, not by total size. A file being large tells you nothing. A file being 90% model and texture when you only read its animations tells you exactly what to do next.
  2. Read the video memory size as well as the file size. They differ by roughly 3.4 times here, because an image is compressed on disk and is not compressed once the graphics card has it.
  3. Check what your loader actually touches. Ours reads exactly one thing out of that file. Search your own loader for what it does with the parsed result, because the parser will happily decode all of it either way.

The numbers

ScopeBeforeAfter
One unit, default texture size959 KB169 KB
The whole roster on the content network624 MB99 MB

Those reconcile with the audit: 140.9 MB across 147 units is 958.5 KB each, and taking out 115.4 MB of image and model leaves 25.5 MB, or about 173 KB per unit, which matches the after figure to within a few percent.

Strip everything except the skeleton

The strip itself is short. Clear each node's model and skin reference, then prune meshes, primitives, materials, textures, skins and the raw data buffers behind them.

Nodes are deliberately not on that list, and their absence is the whole safety argument. Animation tracks address the bone they drive by node, so a pruned bone would quietly take its animation with it. The failure is not a crash or a parse error. It is a unit that walks along with one arm hanging perfectly still, which is exactly the sort of thing that ships.

Three properties were checked across the whole roster before this ran. No animation file has two nodes with the same name. None has a model whose name collides with a node it does not own, which is the thing that could otherwise shift a name suffix and disconnect a track from the skeleton it is supposed to drive. And no animation track targets the model itself. On top of those, the before and after files were both loaded by the game's own three.js loader and compared: identical clip names, identical durations, identical track lists.

One detail worth stealing if you build something similar. The strip runs before the resize and compression passes, not after, so those passes have no image left to work on. It makes each size tier cheaper to build as well as cheaper to ship, and across a roster this size that is minutes off every full rebuild.

The skip that was worse than the waste

There was a second bug hiding behind the first. The import step deliberately skipped the animation file when it ran the compressor, with a comment explaining that an animation-only file has no textures to compress so the pass would do nothing anyway.

The reasoning was sound and the premise was false. The file is not animation-only, so skipping it meant no compressed version of it was ever written. The game then asked for each texture size in turn, got nothing for any of them, and fell all the way back to the raw source file, which is about 9 MB per unit. Every unit added after that skip appeared was downloading nine megabytes of animation file to read a few hundred kilobytes of animation, and nothing anywhere reported a problem, because falling back to the source is the correct behaviour when a size tier is genuinely missing.

That is the same shape as the other asset bugs found that week, and the rule behind it is plain: a graceful fallback hides the absence of the thing it falls back from.

Bytes are a better instrument than a stopwatch

The obvious way to demonstrate a saving like this is to load a match and time it. That is the wrong instrument. Page load timing folds in network weather, cache warmth and whatever else the machine happened to be doing, and the effect you are looking for is smaller than that noise on a good day.

The alternative is to count, from the files on disk, how many bytes a match makes a player wait for, without loading, drawing or timing anything. The equivalent in your own project is whatever can list the requests your loading screen waits on and add up their sizes. That set has no weather. It is a property of the build, identical on every machine, and it is the number that decides how long a cold start takes.

Waiting means what the loading screen actually blocks on. Models stream in over several quality steps, and only the first one is admitted to the wait; the rest issue no request at all until the screen has closed. The rules that decide which step counts are shared between the counting tool and the game itself, so the two cannot drift apart.

The tool also separates as built from as served, which matters more than it sounds. What sits in the project folder is not what players fetch. A quality step that exists locally but was never uploaded returns nothing at runtime and the game silently falls back, so a local count would credit a saving that never reaches a player. Checking against the live storage rather than the disk is the only honest version of the question.

The 307.9 MB at the top of this post is from that count, taken before the strip, and 33.5 MB of it across 18 files was animation files. A full eight faction free-for-all waited on 817.2 MB across 367 files, which is what an eight player loading screen was really asking a player to download.

Built, served and reachable are three different questions

The same week produced two findings that only make sense together, and both are about mistaking one of those three for another.

The upload step carried an exclusion with a comment saying to delete the line once a new shared-texture arrangement went live. It went live. The line stayed. So 4.72 GB of converted models, 12,401 files, sat on disk through three uploads and never left the building. Nothing errored, because the game prefers the shared path and falls back to the older embedded one, so every request quietly took the expensive route. The graceful fallback built to make the rollout safe is what concealed that the rollout had not happened, which is the same trap the model detail work kept walking into.

The lesson generalises well past asset pipelines. Any system with a fallback needs a check that runs one level above the fallback, because the fallback's entire job is to make the failure invisible. Ours checks against the live storage rather than against the disk.

A unit error, caught in public

One of the figures in that audit was first recorded as 681 MB. It is 711 MB.

Both measurements were of identical bytes, 710,893,380 of them. The first came from the standard disk usage tool, which on macOS reports mebibytes under a column headed with an M and rounds up per block. That produced a flat 4.2% gap across all four folders it was run on, and a gap that is identical everywhere is a unit conversion rather than a real difference.

It matters because storage providers bill and report in decimal, so 711 MB is the figure an upload decision should be made against, and 681 understates it by 30 MB in the flattering direction. That is a small error in a place where errors always run the same way, which is the kind worth having a habit about. It is also why every number in this post is in decimal megabytes, and why the baked sprite figures carry the same note.

What a loading screen is actually made of

The useful outcome is a shopping list rather than a saving. Because the counting tool follows the same rules the game follows, it can answer "what is the largest thing this match waits on" for any faction pairing, any quality setting and any map, and the answers are not intuitive: the biggest single item is rarely the biggest single file. Waiting bytes are the currency an asset decision is costed in now, which means a new unit, a new texture size or a new set of props can be priced before it is generated rather than after it has shipped.

Questions

Why is my animation-only 3D file so large?

Because it probably is not animation-only. Several tools that export one file per animation clip write the full character model, its material and its texture into every one of them, and the step that merges the clips usually keeps a copy in the result. Here the model and image were 82 percent of the file and the game read none of it.

How do you strip a model out of a file without breaking its animations?

Clear the mesh and skin references on every node first, then prune meshes, primitives, materials, textures, skins and accessors. Never prune nodes. Animation tracks address the bone they drive by node, so removing nodes silently drops the animation instead of shrinking it.

Should page load timing be used to prove a download saving?

Usually not. Load timing folds in network weather, cache warmth and whatever else the machine was doing, and a saving smaller than that noise vanishes into it. Counting the bytes the loading screen actually waits on is identical on every machine and is the thing that decides how long a cold start takes.

Why did two measurements of the same folder disagree?

Because the common disk usage tool reports mebibytes under a column headed M and rounds up per block, while storage providers bill in decimal megabytes. That produced a flat 4.2 percent gap across every folder measured, and a gap identical everywhere is a unit conversion rather than a real difference.

← All posts