A build that holds a steady 60 fps in the Editor and drops frames on a three-year-old Android phone is not a mystery, it is a measurement problem: you are reading the wrong machine. This post walks through the order we follow when hunting mobile frame time with the Unity Profiler and the Frame Debugger — which signal to check first, what each one rules out, and where the Editor will actively mislead you.
Start with a frame budget, not a feeling
"It feels laggy" is not a bug report. Convert your target frame rate into milliseconds and treat that as a hard budget:
- 60 fps = 16.7 ms per frame
- 30 fps = 33.3 ms per frame
That number is the total for everything: your scripts, physics, animation, UI rebuilds, culling, command submission and the wait for the display. If you are targeting 60 fps on mobile and your simulation code alone takes 9 ms, you have already spent more than half the budget before rendering starts. Write the budget down and split it into rough slices per system before you profile, so that when you see a number you immediately know whether it is acceptable.
Two extra facts about phones make this stricter than on desktop. Sustained load causes thermal throttling, so the frame times you measure in the first 30 seconds of a session are the optimistic case. And a mid-range device is not a slower version of your dev machine — it has a different graphics API, less memory bandwidth and far weaker single-thread performance.
Why the Editor lies to you
Editor profiling is useful for relative comparisons and nothing more. Unity's own manual describes in-Editor profiling as giving approximate results and recommends connecting to a build on the target device instead (Unity Manual: Profiling your application). The reasons pile up quickly:
- The Editor renders through your desktop graphics API, not GLES/Vulkan/Metal on the device.
- Editor-only work (inspectors, gizmos, asset serving, scene view) shows up in the capture.
- Shader variants, texture compression and script backend (IL2CPP vs Mono) differ from your player build.
- Your desktop CPU never throttles the way a phone in a plastic case does.
If a bug only exists on device, the Editor cannot tell you why. Get the capture from the phone.
Step 1: capture from a Development Build on the device
Profiling a player requires the Development Build option to be enabled in Build Settings; without it the Profiler cannot attach to the running app (Unity Manual). Enable Autoconnect Profiler if you want to catch startup frames, and remember that remote profiling talks over a documented port range (54998–55511), so a corporate firewall or VPN can silently break the connection (Unity Manual: Profiler window).
Practical habits that save time:
- Leave Deep Profile off for the first pass. It instruments every method call and inflates frame times enough to change which code looks expensive. Use it later, on a narrow scenario.
- Record a few hundred frames that include the actual bad moment — a wave spawn, a scene load, a full-screen transition. A 30-frame capture of a calm menu tells you nothing.
- Compare like with like. Same device, same build settings, same scene entry point, before and after each change. One variable at a time.
Step 2: decide CPU-bound or GPU-bound before changing anything
This is the step people skip, and skipping it is how you spend a week optimizing shaders for a C# problem. Unity pipelines CPU and GPU work, which is why each side effectively gets the whole frame to finish its own workload (Unity Manual: Highlights Profiler module). So a long main thread frame does not automatically mean the main thread is doing the work — it may be waiting.
Open the CPU module in Timeline view and read the thread rows:
- GPU-bound: the main thread sits in markers like
Gfx.WaitForPresentOnGfxThreadwhile the render thread showsGfx.PresentFrameor<GraphicsAPI>.WaitForLastPresent(Unity: Best practices for profiling game performance). - CPU-bound: the main thread is busy end to end with your own markers, scripts, UI or physics, and the render thread has idle gaps.
- Neither: frame time is pinned flat at 16.7 or 33.3 ms with time in
WaitForTargetFPS. That is VSync or a target frame rate cap, not a bottleneck. Stop optimizing and go do something useful.
The GPU Usage module gives you GPU timings, but it is not enabled by default and only works in Play mode or in builds (Unity Manual: GPU Usage Profiler module). On Android it depends on driver support, and developers have reported captures where GPU timings simply come back as zeros (Unity Discussions thread). If that happens, fall back on the wait-marker pattern above plus the resolution test in Step 5.
Step 3: CPU-bound — three suspects, in this order
Switch to Hierarchy view on a bad frame and check them one at a time.
1. Garbage collection
Sort by the GC.Alloc column, not by time. Managed allocations per frame are the most common source of periodic spikes: string building in Update, LINQ in a hot loop, boxing in delegates, arrays returned from physics queries, closures allocated every frame. A spike that appears every few seconds with no gameplay change is almost always a collection. The incremental collector spreads that cost across frames but does not make it free — the real fix is allocating nothing steady-state, using pools and preallocated buffers.
2. Your own scripts
Named markers make this far faster than reading Unity's internal call tree. Add ProfilerMarker to the systems you suspect and they appear in both Hierarchy and Timeline views:
using Unity.Profiling;
using UnityEngine;
public class EnemyDirector : MonoBehaviour
{
static readonly ProfilerMarker k_Targeting = new ProfilerMarker("Sim.Targeting");
static readonly ProfilerMarker k_Pathing = new ProfilerMarker("Sim.Pathing");
void Update()
{
using (k_Targeting.Auto())
UpdateTargets();
using (k_Pathing.Auto())
UpdatePaths();
}
void UpdateTargets() { /* target selection */ }
void UpdatePaths() { /* path refresh */ }
}Markers are cheap, static and safe to leave in the code. Once they are in place you can answer "did targeting get slower?" in one capture instead of guessing. Shared systems benefit most here, since a marker set written once follows the code into every project that uses it — something we touched on in Two Games, One Codebase.
3. Rendering submission and UI rebuilds
Look for Camera.Render, batching and Canvas.SendWillRenderCanvases. UI-heavy games get bitten here: one giant Canvas means any single element change rebuilds the whole thing. Splitting static and animated UI into separate canvases usually costs nothing visually and removes the rebuild from the frame. Card layouts are a typical offender because they combine text, masks and per-card animation — the readability tradeoffs we hit in Kart Üçlüsü are described in Card UI Readability vs Aesthetics.
Step 4: Draw calls, with the Frame Debugger
The Frame Debugger steps through a single frame draw call by draw call and, for each one, reports why it could not be batched with the previous call. That reason string is the fastest route to fixing batching: different material instance, different texture, a shadow-casting flag, a canvas that broke the order. Remote use requires a Development Build and a platform that supports multithreaded rendering (Unity Manual: Frame Debugger), so if the device refuses to cooperate, reproduce the same scene in the Editor for structural questions only — batching structure usually transfers even when timings do not.
Things worth checking while stepping:
- How many draw calls are UI versus world geometry?
- Are sprites drawing from one atlas or from several, alternating?
- Do material property changes create per-instance materials at runtime?
- Is anything rendered that is never visible — off-screen effects, disabled-but-drawn layers, an unused extra camera?
In a tower defense the answer is usually mass: dozens of simultaneous enemies, projectiles and health bars, each cheap on its own. That is the case where atlasing and instancing pay off more than shader work, and it shaped how we structure spawn-heavy scenes in Thornguard: Tower Defense.
Step 5: GPU-bound — test fill rate before you touch shaders
Mobile GPUs run out of memory bandwidth long before they run out of math. Before rewriting a shader, run the resolution test: lower the rendering resolution (URP render scale, or Screen.SetResolution) to roughly half and re-measure.
- Frame time drops sharply: you are fill-rate or bandwidth bound. Look at transparent overdraw, stacked full-screen effects, large particle quads and unnecessary render targets.
- Frame time barely moves: pixels are not the problem. Look at vertex counts, skinning, and the number of SetPass calls.
Overdraw is the usual answer on phones. Two full-screen transparent layers plus a background and a blur can cost more than the entire gameplay scene under them.
The order, written down
- Convert the target frame rate into a millisecond budget.
- Build with Development Build enabled and capture on the weakest device you support.
- Classify the frame: CPU-bound, GPU-bound, or VSync-capped.
- If CPU: GC allocations first, then your marked systems, then UI and submission cost.
- If GPU: resolution test first, then overdraw, then geometry and passes.
- Change one thing, recapture on the same device, keep the number.
Most of the wins are unglamorous — an allocation removed from an Update, a canvas split in two, one atlas instead of four. The value of a fixed diagnosis order is that it stops you from optimizing the thing you happen to find interesting rather than the thing that costs milliseconds. If a system stays expensive after two honest passes, the remaining option is scope, and that has its own cost, which we wrote about in Cutting Features.
Comments
Be the first to comment.
