home / blog / performance
Published: 16 April 2026 10 min read performance
Draw calls and overdraw: how I found 17 FPS on Android

Draw calls and overdraw: how I found 17 FPS on Android

My tower defense scene sat at 41 FPS on a mid-range Android phone. Here is how measuring draw calls, materials and overdraw took the same scene up to 58 FPS.

A tower defense scene stuck at 41 FPS

Last winter I took the final optimization pass on a tower defense project. On a Redmi Note 10 — Snapdragon 678, Adreno 612 — frame time climbed to 24.3 ms by wave 12, averaging 41 FPS. My first assumption was the usual one: too many enemies on screen, so the AI and physics must be expensive. I profiled the task planner for two days and found nothing; the gameplay thread finished in 6.1 ms.

The picture cleared up when the Unity Profiler showed 14.8 ms on the render thread and 26 ms on the GPU. The Frame Debugger counted over 780 draw calls, and roughly 400 of them were tower range rings, damage numbers, ground decals and smoke particles. Most of the screen was covered by stacked transparent layers, each repainting the same area from scratch. Cutting the enemy count in half only bought 1.2 ms; the culprit was elsewhere.

The real mistake was my own definition of mobile game optimization: for years I had treated it as "reduce the polygon count". The scene had 210k triangles in total and the Adreno 612 drew that geometry without complaint. The load came from two separate fronts: the CPU setup cost per draw call, and the bandwidth written to main memory per pixel. What follows is the record of how I separated those two fronts and how many milliseconds each change actually returned.

When each batching path actually kicks in

There are three mechanisms and they do not do the same job. SRP Batcher does not reduce the number of draw calls; it keeps material constants in a persistent GPU buffer for draws that share a shader variant, which makes the per-call CPU setup cheaper. So 780 calls stay 780, but each one gets noticeably lighter. What matters is the number of shader variants, not materials — missing that had me merging the wrong things for a long time.

GPU instancing is the one that genuinely reduces the count: same mesh, same material, up to 1023 instances in a single call. The trap is this — in URP, if the shader is SRP Batcher compatible, the instancing path never runs at all, because the SRP Batcher takes priority. I did not notice until I drew the tower bases myself with Graphics.DrawMeshInstanced; I had been seeing "SRP Batch" rows in the Frame Debugger and assuming instancing was doing its job.

Static batching merges meshes into one large vertex buffer at build time. The win is real but it has a price: 38 MB of extra mesh memory in our scene and a loss of culling granularity, since a merged group is either drawn entirely or not at all. I left it on only for ground pieces that never move and are always visible together anyway. Turning it off for trees and rocks cut both memory and the number of triangles actually submitted.

There is also a silent batch breaker: MaterialPropertyBlock. We had one attached to every renderer to tint tower tiers, and that alone dropped those objects out of SRP Batcher compatibility. After moving the color variation into instance data and vertex colors, the render thread went from 14.8 ms to 11.0 ms — without changing a single line of shader code.

BatchingSetup.cs
using UnityEngine; using UnityEngine.Rendering; // Tower bases share one atlas material, so they can be submitted as one // instanced call. URP prefers the SRP Batcher over instancing here. public sealed class BatchingSetup : MonoBehaviour { private const int MaxPerBatch = 1023; [SerializeField] private Mesh _baseMesh; [SerializeField] private Material _atlasMaterial; [SerializeField] private Transform[] _slots; private readonly Matrix4x4[] _matrices = new Matrix4x4[MaxPerBatch]; private int _count; private void Awake() { _count = Mathf.Min(_slots.Length, MaxPerBatch); for (int i = 0; i < _count; i++) _matrices[i] = _slots[i].localToWorldMatrix; } private void Update() { // One draw call for up to 1023 bases instead of one per renderer. Graphics.DrawMeshInstanced(_baseMesh, 0, _atlasMaterial, _matrices, _count, null, ShadowCastingMode.Off, receiveShadows: false); } }
csharpBatchingSetup.cs

Material count and texture atlas layout

What actually drove the draw call count was the number of materials. The scene had 34 of them, and most differed only by a 512x512 texture. I packed those into three 2048x2048 texture atlases: environment, towers, and effects plus UI. The more objects share a single material, the larger the pool available to instancing and batching.

Atlasing is less mechanical than it looks. While repacking UVs I had to exclude every mesh that relies on tiling, because repeat wrap mode does not work inside an atlas; the neighbouring island bleeds in. I kept one separate material for the floor tiles and drew those with instancing instead. I also gave each island 8 pixels of padding to stop mipmap bleeding — with 4 pixels, thin colored seams showed up at distance.

On the compression side I moved from ETC2 to ASTC 6x6, which is visibly cleaner at the same memory budget, especially on gradients inside an atlas. After atlasing, materials went from 34 to 6 and draw calls from 780 to 210. The render thread dropped from 11.0 ms to 7.4 ms. This was the single biggest win of the whole pass, and I wrote no shader code for it.

What transparent layers cost in overdraw

Overdraw is how many times the same pixel gets written in one frame. Transparent objects have ZWrite off, so the depth test rejects nobody; the one behind is shaded just like the one in front. In the Rendering Debugger's overdraw view, the center of the scene read 11x — some pixels were being shaded eleven times per frame. Most of that 26 ms of GPU time lived right there.

I counted the layers one by one: range ring, ground decal, smoke, sparks, damage flash, and a full-screen vignette quad on top. I folded the vignette into the final post-process shader and deleted the separate quad — by itself that is one full-screen layer at 1080p. I drew the range ring only while a tower was selected. Then I cut smoke particles from 240 to 90 and made each one larger: the same visual density at a third of the fill cost.

Do not treat alpha testing as the fix. Using clip() on foliage and fence materials breaks early-z and hidden surface removal on a tile-based GPU; in both cases I measured, going back to alpha blend gained 0.6 ms. For the same reason, sorting transparents front to back gains nothing, since none of them write depth. The win only comes from reducing the number of layers and the screen area they cover.

Bandwidth is the real limit on tiled GPUs

Mobile GPUs split the frame into tiles, process each tile in a small fast on-chip memory, and write the result out to main memory. The expensive part is not shader math, it is that write and read traffic. At 1080p a single RGBA32 target is about 8 MB per frame, or 500 MB/s at 60 FPS — and that is one pass. When the device heats up the memory clock throttles first, so bandwidth is a thermal problem too.

That is why switching render targets mid-frame is expensive: every switch forces tile memory to resolve out to main memory. Our post-process chain had three separate blits; merging the bloom downsample with color grading into one pass took 1.7 ms off the GPU time. For the same reason, passing RenderBufferLoadAction.DontCare on targets whose previous contents do not matter is a free win — a needless load means reading the whole target back from main memory into tiles.

A depth prepass usually backfires on mobile. The technique that cuts overdraw on desktop cost us 0.9 ms here, because it processes the geometry twice and generates extra depth traffic on a tiled architecture. Adreno's own low-resolution Z rejection already does a similar job for free. I tried it, measured it, and reverted it — one of the places where desktop reflexes simply do not transfer.

The real fix for transparent layers was half-resolution particle rendering. I drew particles into a half-size render target and composited them back with a depth-aware upsample: the number of shaded pixels drops to a quarter, the composite costs 0.4 ms, and the net win was 3.1 ms. There is mild stair-stepping at the edges, but it is invisible on low-frequency effects like smoke and dust. Sharp, thin effects such as sparks and bullet trails stayed at full resolution.

The measured result on a mid-range Android

On the same 60-second wave 12 recording, on the same Redmi Note 10: frame time went from 24.3 ms to 16.4 ms, and the average from 41 FPS to 58 FPS. Draw calls dropped from 780 to 190, materials from 34 to 6, and peak measured overdraw from 11x to 4x. Over a 15-minute session battery temperature settled at 39 °C instead of 44 °C, so the device never throttled and the FPS decay in the last five minutes disappeared.

I care about how that win is distributed, because it decides where I start on the next project: roughly 40% came from the material and atlas work, 35% from overdraw reduction plus half-resolution particles, 15% from the bandwidth changes, and the rest from restoring SRP Batcher compatibility. Polygon reduction appears nowhere on that list. I never touched a mesh and left the LOD settings exactly as they were.

On your own project I would follow this order: first write down the material count and the batch-breaking reasons from the Frame Debugger, then find the three worst layers in the overdraw view, and only then touch a shader. Measure every step on a real device at the same temperature; I have had plenty of changes that showed 200 FPS in the editor and lost 0.2 ms on the phone. And never watch a single number — draw calls, overdraw and bandwidth are separate limits, and fixing one easily breaks another.

← All posts