Indie Game Lab

Cocos Creator 3.8 Draw Call Optimization: Cutting Frame Time on Real Devices

By Haitao Pan · Game Developer & Studio Owner · 11 min read

TL;DR: Download size is only half the battle — the other half is what happens every frame. I shipped a 750×1334 portrait mini-game on Cocos Creator 3.8 and learned the hard way that low-end phones don't choke on your bundle, they choke on your draw calls. Three moves did most of the work: atlas batching (one draw call per atlas instead of per sprite), killing overdraw (the same skydome that fattened my bundle also wrecked my fill rate), and building the UI grid in code with a shared material instead of instantiating hundreds of prefab nodes. Here's the runtime teardown.

This is the same project I wrote up in my Cocos Creator mini-game post-mortem and my bundle-size teardown. The bundle article is about what you ship; this one is about what you render. Different budget, same lesson: measure, then cut.

1. Why draw calls matter more than you think on mobile

A draw call is one command to the GPU to draw a batch of geometry with one material. The GPU itself is rarely the bottleneck on a phone — the CPU-to-GPU handoff is. Every time the renderer has to switch material, texture, or blend state, it closes one batch and opens another. A screen with 200 separate sprites and 200 material/texture states can issue 200 draw calls, and on a mid-range Android SoC that alone can eat your entire 16 ms frame budget before your game logic runs a line.

The trap: in the editor on a decent laptop it all runs at 60fps and you never notice. The player on a three-year-old phone sees 22fps and bounces. So I treat draw-call count as a first-class metric, right next to bundle size — not something I check "if it feels slow."

2. The one rule that fixes most of it: atlas + batching

Cocos can batch sprites that share a material and a texture atlas into a single draw call. The catch is that "share" is strict: two sprites on different atlases, or with different materials, break the batch and each becomes its own draw call. So the work is mostly hygiene:

Before: 6 sprites, 6 draw calls Interleaved atlases force a state switch every sprite. Grouped by atlas → 1 draw call each Atlas A (red) + Atlas B (blue) = 2 draw calls After: 2 draw calls Atlas A ×3 → 1 call Atlas B ×3 → 1 call
Same six sprites, 3× fewer draw calls — just by grouping them by atlas instead of letting the renderer switch state per sprite.

3. Overdraw: the skydome that also killed my fill rate

Remember the 3D skydome I deleted in the bundle-size teardown? It was a double offender. It cost download size and it was a full-screen mesh drawn behind everything, forcing the GPU to shade every pixel twice — once for the skydome, once for whatever was in front. On a low-resolution canvas that's wasted fill rate; on a high-DPI phone it's a straight tax on frame time.

The fix for overdraw is the same instinct as for bundle size: if the player can't name it, it shouldn't cost you pixels. Flat gradient backdrop, no full-screen transparent layers stacked on top of each other, and no large semi-transparent UI panels overlapping the game view. Every transparent layer you stack is another pass over the whole screen.

4. UI grids: why I stopped using prefabs for cells

This is the one that surprised me. My game has a grid inventory — dozens of cells on screen at once. My first version instantiated each cell as a prefab node. Cocos is good at this, but every cell carried its own node, its own transform update, and — worse — its own draw setup. With a 10×10 grid that's 100 nodes, each potentially a separate draw call if their materials drifted apart.

The rebuild: I construct the grid in code and give every cell the same shared material and atlas. Instead of 100 near-identical nodes fighting the batcher, the grid collapses into a handful of draw calls. The cells are data + one render path, not 100 prefab instances. The inspiration came straight from the performance budget I set in the kubi case study — when node count is a budget line, you stop reaching for prefabs by default.

The lesson transfers beyond grids: any time you're about to instantiate N similar visual elements, ask whether they can share one material and one atlas. Shared state is batched state.

Frame-time budget (ms per frame) Before — 33ms (~28 fps) Draw calls · 15.5 Overdraw · 8.5 Script · 9 After — 15ms (60 fps, headroom to spare) Draw · 4 O · 2 S · 4 freed headroom (~10ms) Draw-call + overdraw were ~70% of the frame. Batching the grid and killing the full-screen skydome reclaimed almost all of it.
Directional numbers from my device tests — your split will differ, but the shape holds: draw calls and overdraw dominate the frame.

5. What I'd measure first next time

  1. Turn on the profiler on a real device, not the editor. The editor hides the exact problem you're solving. A $100 used Android phone is the most honest perf review you'll ever get.
  2. Read the draw-call count before touching code. If it's in the hundreds on your main screen, batching hygiene is your fastest win — bigger than any clever shader.
  3. Count nodes, not just sprites. Prefab sprawl shows up as both node updates and broken batches. The idle-loop discipline of cutting systems applies to your scene graph too: fewer nodes, fewer surprises.
  4. Budget overdraw like memory. Every full-screen or stacked-transparent layer is a line item. Audit them the same way you audit textures.

6. Takeaways

If your mini-game runs fine on your laptop but stutters on real phones, don't reach for a "faster engine" — reach for the draw-call count. Pack sprites into shared atlases and reuse one material so the renderer can batch; kill full-screen and stacked-transparent layers that waste fill rate; and when you're spawning many similar elements, build them in code with a shared material instead of instantiating prefab nodes. Those three moves took my main screen from a ~28fps slog to a comfortable 60 with headroom to spare. Download size gets the headlines; frame time gets the players to stay.

7. Related reads

← Back to all field notes