Making the 3D Feed Load, Not Just Run: Cutting a Web Game's Asset Payload 67%


I recently wrote about why our 3D game feed crashed a $150 Android phone — the culprit was geometry, nine million triangles the GPU had to chew through every frame. That post was about making the feed run.

This one is about making it load.

Klooboo, our game platform, has a feed you swipe through like TikTok — except each card is a live 3D world running in the browser. Same feed, different problem. Even once a world ran smoothly, getting it onto the screen felt bad:

  • Every time you swiped back to a world you’d already seen, it re-downloaded from scratch.
  • On a slower connection, the live preview didn’t wait for its assets. You’d watch the world assemble itself — a flat sky, then the ground, then props popping in one by one over a second or two.

Three fixes and a trick took the feed from 53.6 MB of assets down to 17.8 MB and replaced the pop-in with a clean hand-off from a poster to the loaded world. Here’s each one, including a measurement that sent me chasing a number that wasn’t real.

The first surprise: most of our “188 MB of models” doesn’t ship

My first move was to measure. I ran du over the assets folder and nearly fell out of my chair:

188 MB of GLB models
 68 MB of PNG textures

188 MB of 3D models for a handful of worlds? That would be a catastrophe over a phone connection. I started planning aggressive compression before I’d even confirmed the number meant anything.

It didn’t. du counts everything on disk, and most of what was on disk was working files that never ship — raw un-decimated exports, an opt-test/ scratch folder, source art, models that actually live on a CDN. All of it hidden behind per-directory .gitignore files, because our CI builds from a clean git checkout. Anything gitignored simply isn’t there when the site is built.

The number that matters is what’s tracked, not what’s on disk:

git ls-files public/assets | grep -E '\.(glb|png)$' \
  | while read f; do stat -f%z "$f"; done \
  | awk '{s+=$1} END{printf "%.1f MB\n", s/1048576}'

The real shipped payload was ~20 MB of models and ~34 MB of textures — a third of the scary number, and a completely different optimization problem. The models were already fairly lean; the textures were the fat.

Lesson one: when you audit asset weight, measure what actually ships (git ls-files), never what’s sitting in your working tree (du). They can differ by 3×, and the difference will send you optimizing the wrong thing.

Fix 1 — A cache header that was backwards

The “re-downloads every time you swipe back” complaint had a clean cause. Our nginx config cached the wrong tier of files.

Build assets get content-hashed filenames (index-a1b2c3.js), so they’re safe to cache forever. Our config did that correctly. But the world posters and thumbnails — served from /worlds/ under stable, un-hashed names — fell through to the catch-all location / block, which set Cache-Control: no-cache. So the code you rarely change was cached for a year, and the 1 MB images you see on every swipe were revalidated (and often re-downloaded) every single time.

I confirmed it against production:

$ curl -sI https://play.klooboo.com/worlds/supermarket-dash.png | grep -i cache
cache-control: no-cache

$ curl -sI https://play.klooboo.com/assets/index-*.js | grep -i cache
cache-control: public, max-age=31536000, immutable

The caching was applied to exactly the wrong tier. The fix is a dedicated location block:

location /worlds/ {
    add_header Cache-Control "public, max-age=86400" always;
    try_files $uri =404;
}

A day, not a year, and deliberately not immutable: these filenames are stable, so a regenerated world image needs to be able to replace the old one. But a day is more than enough to kill the “re-download on every swipe” problem — the browser serves it straight from disk instead.

Fix 2 — One 1024px image doing a 112px job

The same file was pulling double duty. Each world’s key-art is a 1024×1024 poster shown full-screen behind the live preview. Perfectly reasonable at that size. But that exact same file was also the thumbnail in the little strip at the bottom of the screen — where it renders at 112×72 pixels.

So to paint a thumbnail smaller than a postage stamp, the browser was decoding a full 1024² image. For two of the worlds, that meant downloading a 1.5 MB PNG to draw a 112-pixel thumbnail — roughly a 130× area over-fetch.

The fix is boring and effective: generate a small ‹id›-thumb.jpg at ~480px (crisp even on a 3× retina strip) and point the thumbnail and the little next-game peek at it, while the full poster keeps the big file. A one-line helper derives the thumbnail path from the poster path:

// /worlds/foo.png (poster) → /worlds/foo-thumb.jpg (strip + peek)
const thumbArt = (art?: string) =>
  art?.replace(/^\/worlds\/(.+)\.(png|jpe?g|webp)$/i, "/worlds/$1-thumb.jpg") ?? art;

For the four-world Runner strip, that’s the difference between loading ~3.8 MB of full posters and ~316 KB of thumbnails.

Fix 3 — Textures: PNG → WebP, from the original

The textures were the real weight — skies, grounds, backdrops, road surfaces, all shipping as PNG. Every one of them, it turned out, was fully opaque (no alpha channel), which meant I could throw the whole set at a lossy format without losing anything that mattered.

WebP is the obvious choice, and one detail is worth calling out: convert from the original PNG, not from an intermediate JPEG. It’s tempting to re-compress the JPEGs you already made, but stacking two lossy passes (PNG → JPEG → WebP) puts visible banding into smooth sky gradients — exactly the surfaces that fill the most screen. Going straight PNG → WebP keeps a single lossy step:

cwebp -q 82 -m 6 original-sky.png -o sky.webp

WebP loads through a normal <img>, and every browser we target (Safari 14+ included, plus the in-app WebView) decodes it natively — so this was a pure asset swap with no rendering-code changes. The result across all 28 textures:

27.4 MB (PNG)  →  1.4 MB (WebP)     -95%

The super/shelf texture alone went from 2.0 MB to 211 KB. The bmx/beach-sky went from 1.1 MB to 10 KB.

Fix 4 — The models: the win is bytes, and the decoder that renders black if you forget it

The models needed a different tool than the sibling post’s decimation. There, the goal was fewer triangles — a runtime GPU cost. Here, the goal is fewer bytes — a download cost. Those are different levers, and the second one has a much sharper tool.

These are AI-generated meshes whose textures are already small WebP, so the weight is almost entirely geometry. I compared three ways to shrink it on our heaviest model (a 1.9 MB BMX ramp):

ApproachResultNeeds a runtime decoder?
Quantize only1.3 MB (−32%)No
Meshopt496 KB (−74%)Yes
Simplify 50%1.1 MB (−42%), but alters the meshNo

Meshopt won on both ratio and fidelity — it’s near-lossless (14-bit quantization; vertices don’t visibly move), unlike simplification, which permanently changes the geometry. One gltf-transform pass does it:

npx @gltf-transform/cli weld    in.glb  w.glb
npx @gltf-transform/cli meshopt w.glb   out.glb

But EXT_meshopt_compression comes with a sharp edge: three.js can’t read a meshopt-compressed model unless you register the decoder, and if you forget, the model doesn’t error — it renders black or simply doesn’t appear. We create GLTFLoader instances in five different places across the runner and the various games, and every one of them needs the decoder. Miss a single site and one world silently loses its models.

The fix is to make it impossible to create a loader without the decoder. One factory, used everywhere:

// lib/gltfLoader.ts — the ONLY way to make a loader in this codebase
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
import { MeshoptDecoder } from "three/examples/jsm/libs/meshopt_decoder.module.js";

export function createGltfLoader(): GLTFLoader {
  const loader = new GLTFLoader();
  loader.setMeshoptDecoder(MeshoptDecoder); // self-contained JS+WASM, no files to serve
  return loader;
}

The nice thing about the meshopt decoder specifically is that it’s a self-contained module with the WASM embedded — there are no decoder files to host and serve, unlike Draco. Registering it is one line.

Since I couldn’t hand-inspect 52 models, I verified the compression the way you should verify anything you can’t eyeball: with a harness. A small Node script loaded every compressed model through three.js’s actual MeshoptDecoder and asserted each one decoded to non-empty geometry. (A fun wrinkle: in Node the parse “fails” on texture loading with Image is not defined — but geometry decodes before textures in the loader, so hitting that error is itself proof the geometry came through.) All 52 passed. Models went from 19.7 MB to 7.9 MB (−60%).

The trick: hold the poster until the world is actually loaded

Compression made everything smaller, but it didn’t fix the feel. On a slow connection you still watched the world build itself — sky, then ground, then props — because the code revealed the live 3D canvas on a fixed 620-millisecond timer, whether or not anything had finished loading.

The layering was already right for a better approach: the world’s poster sits behind the live canvas. So the fix is to keep the canvas hidden (poster showing) until the world’s assets are genuinely done, then cross-fade. All I needed was an honest “is it loaded yet?” signal.

three.js hands you one almost for free. Every loader — textures, models — funnels through a shared LoadingManager, and since only one preview loads at a time, watching the default manager is an accurate proxy for “this world is ready.” The subtlety is that assets arrive in waves (the sky finishes, the queue briefly empties, then props start), so a naive “queue is empty → reveal” fires too early. The signal has to wait for the queue to drain and stay quiet:

// Resolve once loads have gone quiet — drain + a debounce that a new wave resets.
manager.onStart = () => clearTimeout(settle);          // a new wave — cancel the reveal
manager.onLoad  = () => settle = setTimeout(reveal, 250); // queue drained — arm it

Wrap that with a grace period (so a fully-cached world, which triggers no loads, reveals instantly) and a hard timeout (so a failed asset can never strand the poster over a ready world), and you get a preview that only ever shows a complete world. On repeat visits, three.js’s in-memory cache — which, embarrassingly, was off by default and I’d never enabled — makes the whole thing near-instant.

The takeaways

  • Measure what ships, not what’s on disk. git ls-files, not du. A gitignored working tree can make your asset problem look 3× worse than it is and point you at the wrong fix.
  • Cache the tier that changes rarely and the tier the user hits constantly — and check which is which. We had it exactly backwards: a one-year cache on hashed code, no-cache on the images loaded every swipe.
  • One asset can’t serve two sizes. A full-screen poster and a 112px thumbnail are different jobs; give the thumbnail its own small file.
  • Convert from the source, not an intermediate. PNG → WebP in one lossy step; PNG → JPEG → WebP bands your gradients.
  • Compress for the right cost. Fewer triangles is a rendering win; fewer bytes is a download win. Meshopt shrinks bytes near-losslessly — but wrap the decoder in a factory so a compressed model can never render black because one loader forgot it.
  • “Loaded” is a real, observable event. Don’t reveal a live scene on a timer. Watch the loader, wait for quiet, and hold a poster over the gap.

Making it run and making it load are two different problems. The first is about the frame you draw. The second is about everything that has to arrive before you can draw it at all — and on a phone, over a real connection, that second one is what people actually feel.