The Bug Was 9 Million Triangles, Not the Resolution: Making a WebGL Runner Playable on a $150 Android
Klooboo, our game platform, has a feed you swipe through like TikTok — except each card is a live 3D world running in the browser. A BMX rider doing night-city backflips, a dino sprinting through a lava canyon, a diver in a coral reef. They look great on my laptop and on a recent iPhone.
Then I loaded the feed on a Samsung Galaxy A16 — a ~$150 Android phone with a Mali-G57 GPU and 4 GB of RAM — and it crashed the coral reef world about five seconds into play. The ones that didn’t crash ran like a slideshow.
My first instinct was the same one everyone reaches for: the phone is weak, so render fewer pixels. Drop the device pixel ratio, render at half resolution, upscale. It’s the standard mobile-WebGL move and it’s a one-line change.
It barely helped. That’s when I stopped guessing and started measuring — and found that the thing killing this phone wasn’t pixels at all.
Why resolution was the wrong lever
A GPU frame has two big cost centers, and they’re almost independent:
- Fragment work — how many pixels you shade. This scales with resolution. Rendering at a lower device pixel ratio (DPR) cuts it directly.
- Geometry work — how many vertices and triangles the GPU has to transform, before it ever thinks about a single pixel. This is set by your meshes, and resolution does nothing to it.
Lowering DPR only touches the first bucket. If your frame is expensive because of the second bucket, you can render at one-quarter resolution and still be underwater — you’re just drawing the same mountain of geometry into a smaller canvas.
That was exactly our situation. So the real question became: how much geometry are we actually pushing per frame?
The measurement trap
three.js makes this look easy. Every scene has a graph of meshes, each with a known triangle count. Walk the graph, add them up, done.
I did that first. It reported something like 7.9 million triangles in the coral reef and 18.6 million in another world. Alarming numbers — but wrong ones.
Scene-graph traversal counts every mesh you have, not every mesh you draw. A runner streams the world toward the camera: props spawn far ahead, slide past, and despawn behind you. At any instant, a big chunk of them are outside the camera frustum and get culled before the GPU touches them. Summing the whole graph roughly doubled the real cost and would have sent me optimizing things that were already free.
The honest way to measure what the GPU actually processes is to count draw calls, at the source. I monkey-patched the WebGL context’s drawElements and drawArrays, tallied the vertices each call submitted, and bucketed the total per animation frame:
const gl = renderer.getContext();
let triThisFrame = 0;
const realDrawElements = gl.drawElements.bind(gl);
gl.drawElements = (mode, count, type, offset) => {
triThisFrame += count / 3; // count is indices; 3 per triangle
return realDrawElements(mode, count, type, offset);
};
// ...at the end of each rAF: record triThisFrame, then reset to 0.
That only ever sees geometry that survived culling and was genuinely handed to the driver. The real numbers came back lower than the scene-graph guess — but still far too high:
- Coral reef: ~4.8M triangles/frame
- Supermarket: ~7M
- Lava canyon: ~9.3M
- BMX night city: ~4.08M
A Mali-G57 is comfortable somewhere around 1–2M triangles per frame. We were asking it for four to nine times that, every frame, sixty times a second. No resolution setting was ever going to save us.
Where nine million triangles comes from
None of these worlds were hand-modeled. The props are generated — a text or image prompt goes to an image-to-3D pipeline (Tripo) and comes back as a GLB. That workflow is a superpower for a solo shop: I can art-direct a whole world without a 3D artist. But raw generated meshes are dense. A single rock, barrel, or coral fan routinely lands at 50,000 to 150,000 triangles — detail you’d need a macro lens to notice.
Now stream about thirty of those toward the camera at once, add the hero character and the ground, and the arithmetic writes itself: tens of props × ~100k triangles each = millions per frame. The scene looked fine because the art was fine. The geometry underneath it was authored for a film render, not a phone.
The fix: decimate the meshes, not the canvas
The fix is mesh decimation — collapse each mesh down to the smallest triangle count that still looks the same at the size it’s actually drawn. gltf-transform does this well, combining vertex welding, a meshoptimizer-backed simplifier, and texture down-sizing in one pass:
npx @gltf-transform/cli weld in.glb w.glb
npx @gltf-transform/cli simplify w.glb s.glb --ratio 0.1 --error 0.001
npx @gltf-transform/cli resize s.glb out.glb --width 512 --height 512
weld merges duplicate vertices so the simplifier can actually collapse edges. simplify --ratio 0.1 targets roughly 10% of the original triangles, with an error bound that stops it from wrecking the silhouette. resize drops the oversized textures — a 512² map is plenty for a prop that’s a few hundred pixels tall on screen. I wrapped this into one script and ran every world’s asset list through it.
The results, measured with the same draw-call counter as before:
| World | Before | After |
|---|---|---|
| BMX night city | 4.08M | 443k |
| Supermarket | ~7M | 746k |
| Lava canyon | 9.3M | 779k |
| Coral reef | 4.8M | 831k |
| Jungle temple | 1.38M | (already lean) |
Every world dropped under the phone’s budget, most by an order of magnitude. And here’s the part I keep coming back to: there is no visible quality loss. I went looking for it on-device, and I’m the kind of person who notices when a shadow is two pixels off. At the size these props render, 10% of the triangles carries the same silhouette as 100%. We were paying for detail the screen could never show.
The second bug: a WebGL context leak in the feed
Fixing geometry stopped the slideshow, but the feed could still crash after enough swiping — and that was a completely separate bug hiding behind the same symptom.
Each feed card mounts its own three.js renderer, which means its own WebGL context. When you swipe away, we tore the engine down and called renderer.dispose(). Reasonable — except dispose() frees the resources inside a context (textures, buffers, programs) but does not free the context itself. Browsers cap the number of live WebGL contexts per page at around 16; past that, the oldest ones get force-killed. So a long swipe session slowly leaked contexts until the browser started evicting live ones, and on a 4 GB phone that tipped over into a crash.
The missing line was explicit:
renderer.dispose();
renderer.forceContextLoss(); // actually releases the WebGL context
forceContextLoss() tells the browser it can reclaim the context immediately instead of waiting to garbage-collect it. With that in place, swiping through the whole feed holds steady at one or two live contexts instead of climbing toward the cliff.
Knowing when you’re on a weak phone
The last piece was deciding who gets the stripped-down experience, without punishing capable devices. I added a small tier check:
export function isLowEndDevice(): boolean {
if (navigator.deviceMemory && navigator.deviceMemory <= 4) return true;
return hasWeakGPU(); // matches Mali-G57, older Adreno/PowerVR via
// WEBGL_debug_renderer_info's UNMASKED_RENDERER
}
navigator.deviceMemory is coarse (it reports 2, 4, or 8, and iOS doesn’t expose it at all), so I back it up by reading the unmasked GPU renderer string and matching known-weak parts. On a low-end device the feed shows a static poster for each world and only spins up the live 3D engine when you actually tap in — and when it does, it renders at DPR 1.0 and skips the post-processing composer entirely. Everyone else keeps the full experience.
The takeaways
The whole episode comes down to a few things I’d tell my past self:
- On mobile WebGL, geometry is usually the budget, not pixels. Reach for triangle count before you reach for resolution. Lowering DPR is cheap and sometimes right, but it can’t fix a frame that’s expensive because of what’s in it.
- Measure what the GPU draws, not what your scene holds. Scene-graph totals include culled meshes and can be off by 2×. Count draw calls at the WebGL boundary if you want a number you can trust.
- AI-generated 3D assets need a decimation step before they ship. They’re wonderful for building worlds fast, but they arrive with film-grade triangle counts. A
weld → simplify → resizepass is now a required stage in our asset pipeline, not an optimization we do if there’s time. renderer.dispose()is not the whole cleanup. If you mount and unmount WebGL contexts dynamically, callforceContextLoss()too, or you’ll leak your way to the browser’s context cap.
The payoff was the best kind of anticlimax: the coral reef now runs smoothly on the $150 Samsung, no crashes, no slideshow, and — as far as my very picky eyes can tell — looks exactly the way it did before. That’s the standard I want for every world we ship: if it doesn’t run on the cheap phone, it isn’t done.