
Getting a Unity game to run at 60fps on a recent flagship phone is one problem.
Getting the same frame rate on an Android device that has already spent five years in someone's pocket is a much more useful optimization exercise.
Our target sounded simple:
Keep gameplay close to a stable 60fps without turning the scene into an empty low-quality version of itself.
At 60fps, the entire frame has roughly 16.7 milliseconds to complete.
That budget has to cover:
Scripts
Physics
Animation
Rendering
UI
Particles
Driver work
GPU work
We initially assumed the main problem was polygon count.
It wasn't.
One of the biggest bottlenecks was the amount of rendering work we were asking the CPU and GPU to organize every frame.
That pushed us deep into Unity mobile optimization, especially draw calls, materials, batching, UI rendering, transparency, and profiling.
Here's what actually made the difference.
First: "Too Many Draw Calls" Isn't a Diagnosis
Our first performance report basically said:
FPS: bad
Draw calls: high
That's not enough information.
A draw call represents rendering work sent through the graphics pipeline. Reducing unnecessary draw calls can reduce CPU-side overhead, but simply forcing the number lower does not guarantee better performance.
We started measuring:
CPU frame time
GPU frame time
Batches
SetPass calls
Triangles
Vertices
Overdraw
GC allocations
Memory
That distinction mattered.
If the GPU is already saturated by a full-screen transparent effect, combining three tiny meshes probably isn't the highest-value optimization.
The first rule became:
Profile first. Optimize the thing actually consuming the frame.
Test on the Old Phone, Not the Editor
Our desktop development machines were terrible representatives of the target hardware.
Inside the Editor:
Looks smooth.
On the older Android phone:
Frame spikes.
Heat.
Dropped frames.
Slow scene transitions.
So the profiling loop became:
Build
↓
Run on target Android device
↓
Capture profiler data
↓
Inspect slow frames
↓
Change one system
↓
Build again
This sounds slower than optimizing entirely inside the Editor.
In practice, it saved time because we stopped optimizing problems the actual device didn't have.
For serious Unity mobile optimization, target-device profiling needs to be part of the development loop because a Unity project can behave very differently across desktop, flagship mobile, and lower-powered Android hardware.
The real phone became part of our development environment.
1. Material Count Was Hurting Us More Than Mesh Count
Our environment contained plenty of small props:
Crates
Barrels
Plants
Signs
Walls
Furniture
Ground details
Many looked almost identical.
But their material setup wasn't identical.
We had cases like:
Crate_01 → Wood_A
Crate_02 → Wood_B
Crate_03 → Wood_C
The textures were different, but the shader setup was basically the same.
That prevented those objects from sharing rendering work as effectively as they could.
We began consolidating materials.
Instead of:
50 objects
+
30 materials
we moved closer to:
50 objects
+
a small reusable material set
The visual result barely changed.
The render workload did.
2. Texture Atlases Helped More Than Expected
Material consolidation naturally led to texture atlasing.
Imagine five props:
Chair
Table
Crate
Shelf
Cabinet
Originally:
Chair → chair.png
Table → table.png
Crate → crate.png
Shelf → shelf.png
Cabinet → cabinet.png
That can lead to several separate materials.
Compatible assets can instead share an atlas:
environment_atlas.png
┌─────────┬─────────┐
│ Chair │ Table │
├─────────┼─────────┤
│ Crate │ Shelf │
├─────────┴─────────┤
│ Cabinet │
└───────────────────┘
Now several objects can potentially use the same material.
The key word is potentially.
Atlasing doesn't magically solve batching. Objects still need compatible rendering state.
But it removed one reason otherwise similar objects were being rendered separately.
3. We Shared Materials Instead of Accidentally Cloning Them
This was a surprisingly easy mistake to introduce through gameplay scripts.
Consider:
Renderer renderer = GetComponent<Renderer>();
renderer.material.color = Color.red;
Working with renderer.material can create a unique material instance for that renderer.
Repeat that across a large number of objects and your carefully shared material setup begins fragmenting.
We started auditing every place where runtime code modified materials.
The lesson was straightforward:
Material optimization can be broken by runtime code just as easily as by the art pipeline.
If multiple objects can genuinely share a material, keep them sharing it unless the visual requirement says otherwise.
4. Static Geometry Became Actually Static
A lot of scenery never moved.
Yet some of it wasn't configured in a way that let us take advantage of static rendering optimizations.
Examples included:
Buildings
Floor sections
Walls
Decorative props
Road pieces
Environmental structures
Once we identified truly static geometry, static batching became one of the options worth testing.
But there was a catch.
Static batching isn't free.
It can increase memory requirements.
That matters enormously on older mobile hardware.
So our decision wasn't:
Static object?
→ Enable static batching.
It became:
Static object?
↓
Would batching reduce meaningful rendering cost?
↓
What happens to memory?
↓
Profile both versions.
That final step matters.
On an older Android device, exchanging a rendering bottleneck for a memory problem isn't much of an optimization.
5. Repeated Objects Became an Instancing Problem
Then we found another category:
Same mesh
Same material
Many copies
Think:
Trees
Grass clusters
Rocks
Lamp posts
Repeated environment props
These can be natural candidates for GPU instancing when the project's shader and rendering setup supports it.
Conceptually, instead of treating:
Rock
Rock
Rock
Rock
Rock
as completely unrelated objects, instancing allows repeated geometry to be submitted more efficiently.
We didn't enable it everywhere and assume victory.
We first identified groups with:
Same mesh
+
Compatible material
+
Many visible instances
and tested those.
6. The Render Pipeline Changed the Optimization Strategy
Reducing the raw number of draw calls isn't the only way to improve rendering performance.
In projects using URP or another Scriptable Render Pipeline, features such as the SRP Batcher can reduce CPU time spent preparing compatible rendering work.
That changed one of our assumptions.
Our original thinking was:
Different materials always equal terrible performance.
The better question became:
Is the project structured so the selected rendering pipeline can process those materials efficiently?
This is why optimization advice must match the Unity version and render pipeline.
A technique that worked well for:
Built-in Render Pipeline
isn't automatically the best strategy for:
URP
or a newer Unity rendering setup.
Don't copy an optimization checklist from an old tutorial without measuring whether it still fits your project.
7. Dynamic Batching Wasn't a Magic Button
Dynamic batching sounds perfect:
Enable setting
→ Fewer draw calls
→ Faster game
Reality is more complicated.
Batching can itself require CPU work.
So we tested:
Without batching
vs.
With batching
on the actual target device.
Not:
Batching = automatically faster
A feature intended to reduce rendering overhead can still be the wrong choice for a particular scene, pipeline, or hardware profile.
8. The Frame Debugger Explained Why Batches Broke
Sometimes two objects looked identical but still rendered separately.
Instead of guessing, we inspected the frame.
Common causes included differences in:
Materials
Shader variants
Textures
Lightmaps
Renderer settings
Mesh attributes
Passes
The workflow became:
Find repeated draw calls
↓
Inspect the objects
↓
Identify the render-state difference
↓
Decide whether the difference is necessary
That last question matters.
Sometimes separate rendering is correct.
Optimization shouldn't destroy intentional visual behavior just to make a counter smaller.
9. Transparency Was Expensive in More Than One Way
Mobile GPUs made us respect transparency.
Our effects included:
Smoke
Fog
Particles
Glass
Energy effects
UI overlays
Vegetation
Transparent objects can increase sorting work and create significant overdraw.
Imagine repeatedly shading the same pixel:
Background
↓
Transparent fog
↓
Particle
↓
Another particle
↓
UI effect
The final screen shows one pixel.
The GPU may have processed that part of the screen several times.
So we started asking:
Does this effect need transparency?
Does it cover too much screen space?
Does it need this many particles?
Could an opaque or cutout approach work?
Can the effect disappear sooner?
Some of our best GPU optimizations were completely invisible to the player.
10. Particle Systems Were Tiny Rendering Factories
Particle effects are easy to add.
One explosion.
Then dust.
Then sparks.
Then smoke.
Then glow.
Suddenly:
Explosion
├── Flash
├── Sparks
├── Smoke
├── Debris
└── Glow
looks like one effect to the player but represents several materials, emitters, and rendering passes.
We audited particle systems based on:
Material count
Shader complexity
Maximum particles
Screen coverage
Emission rate
Lifetime
Overdraw
Rather than deleting effects, we made each effect justify its cost.
For example:
Before:
150 particles × long lifetime
After:
60 particles × shorter lifetime
If the difference isn't noticeable during real gameplay, the cheaper version wins.
11. UI Was Creating More Work Than Expected
The 3D world wasn't our only rendering problem.
Our UI included:
HUD
Health
Currency
Mission text
Buttons
Icons
Popups
Damage indicators
Menus
Some of those elements changed constantly.
Putting everything inside one giant constantly-changing Canvas wasn't always the best structure.
We separated UI by update frequency.
Conceptually:
Canvas_Static
├── Frame
├── Decorative icons
└── Background
Canvas_Dynamic
├── Health
├── Ammo
└── Timer
Frequently changing elements were no longer mixed unnecessarily with mostly static interface elements.
That made both profiling and optimization easier.
12. We Stopped Updating UI Every Frame
We found code like:
void Update()
{
scoreText.text = player.Score.ToString();
}
That ran even when the score hadn't changed.
The better pattern was event-driven:
public void OnScoreChanged(int score)
{
scoreText.text = score.ToString();
}
Instead of:
60 frames
→ 60 updates
we moved toward:
Score changes once
→ UI changes once
This alone doesn't solve a rendering bottleneck.
But mobile optimization is often the accumulation of dozens of small decisions like this.
13. We Created a Render Budget Per Scene
This became one of the most useful changes.
Rather than discovering near the end that a level was too expensive, we tracked budgets during development.
Not universal numbers.
Project-specific budgets.
For representative scenes we monitored:
CPU frame time
GPU frame time
Batches
SetPass changes
Triangles
Visible objects
Particles
Memory
The goal wasn't to invent a magical industry-standard draw-call limit.
There isn't one number that fits every Unity game and every Android device.
The goal was noticing regressions.
If yesterday's scene required:
X batches
and today's required:
X + 40%
we wanted to know why.
Budgets turned optimization into normal development work instead of emergency cleanup.
14. We Optimized the Worst Frames, Not the Average
Average FPS can hide the exact problem players notice.
Suppose:
59
60
60
60
60
29
60
60
60
The average can still look respectable.
The dropped frame is what the player feels.
So we started looking for spikes.
Capture representative gameplay
↓
Find a slow frame
↓
CPU-bound or GPU-bound?
↓
Inspect the responsible system
↓
Optimize
↓
Capture again
For draw-call optimization, we especially wanted to know whether rendering was creating main-thread, render-thread, or GPU pressure.
That is much more useful than simply saying:
"The game has 200 draw calls."
15. Older Android Hardware Forced Us to Think About Device Tiers
One Android phone performing well does not prove the Android build is optimized.
The ecosystem contains devices with very different:
CPUs
GPUs
RAM
Thermal limits
Screen resolutions
Graphics drivers
OS versions
A flagship phone can hide expensive rendering work that becomes immediately visible on older or budget hardware.
That's why Android game performance optimization benefits from testing across real device tiers rather than relying on a single high-end test phone or emulator.
Our minimum-target device became especially important.
If an optimization only helped the flagship while making memory pressure worse on the lower-end device, it wasn't automatically a win.
16. 60fps Became a Frame-Budget Problem
This changed how we talked about performance.
Instead of:
"Can we get another five FPS?"
we asked:
"Which system is consuming too much of the frame?"
At 60fps:
One frame ≈ 16.7 ms
That does not mean rendering gets all 16.7ms.
The frame contains several workloads:
Frame budget
│
├── Gameplay
├── Physics
├── Animation
├── Rendering
├── UI
├── Audio
└── Other engine work
If rendering consumes most of the available time, every other system has less room.
This is why Unity mobile optimization eventually became less about isolated tricks and more about maintaining budgets.
What Actually Helped
There wasn't one magic checkbox.
The useful improvements worked together:
Profile on target hardware
↓
Reduce unnecessary materials
↓
Share compatible materials
↓
Atlas compatible textures
↓
Choose batching deliberately
↓
Instance repeated geometry where appropriate
↓
Reduce transparent overdraw
↓
Simplify particles
↓
Separate frequently changing UI
↓
Test across Android device tiers
↓
Track rendering budgets
↓
Profile again
Notice the last step.
Every optimization returned to measurement.
A Practical Unity Mobile Optimization Checklist
Before calling a mobile rendering pass complete, I now ask:
[ ] Was profiling done on target Android hardware?
[ ] Is the game CPU-bound or GPU-bound?
[ ] Are materials reused wherever practical?
[ ] Are compatible textures/materials candidates for atlasing?
[ ] Are scripts accidentally creating material instances?
[ ] Is static batching used only where it actually helps?
[ ] Are repeated meshes candidates for instancing?
[ ] Is the batching strategy appropriate for the render pipeline?
[ ] Have suspicious draw calls been inspected?
[ ] Are transparent effects creating excessive overdraw?
[ ] Are particle materials and passes under control?
[ ] Is UI separated according to update frequency?
[ ] Are UI values updated only when necessary?
[ ] Have older and lower-tier Android devices been tested?
[ ] Do representative scenes have performance budgets?
[ ] Have frame spikes been examined rather than just average FPS?
[ ] Was performance measured again after every major change?
The Biggest Lesson
Getting close to 60fps on older Android hardware wasn't about finding a hidden Unity setting.
It was about making rendering predictable.
We started with:
"Reduce draw calls."
We ended with a much better mental model:
Measure
↓
Understand why rendering work exists
↓
Remove unnecessary state changes
↓
Share resources
↓
Choose the right batching strategy
↓
Control overdraw
↓
Control UI and particles
↓
Test across device tiers
↓
Measure again
That's the part of Unity mobile optimization I wish we'd taken more seriously earlier.
The best optimization wasn't getting an impressive draw-call number down to an even smaller impressive number.
It was getting the frame consistently inside its budget on the actual devices players were going to use.
And when a five-year-old Android phone can run the game smoothly without the art team removing everything interesting from the screen, that's a much more satisfying result.
Top comments (0)