首页 / 博客 / 性能
发布: 2026年8月14日 约 10 分钟 性能
Unity Profiler: Find the Real Bottleneck, 22 ms to 8 ms

Unity Profiler: Find the Real Bottleneck, 22 ms to 8 ms

Frame time hit 22 ms and everyone blamed the shaders. The Unity Profiler capture said otherwise; here is how measuring took it down to 8.7 ms.

A 22 ms frame and a wrongly blamed shader

On a mobile tower defense prototype the game started hitching visibly after wave 14. On a Redmi Note 11 the frame time climbed to 22.4 ms against a 16.7 ms budget, with spikes past 40 ms every two or three seconds. The hitch never landed in the same place twice, which made blaming any single script hard. The first guess on the team was the usual one: "mobile can't take this many transparent effects".

I tethered the device and took a 600 frame capture with the Unity Profiler. Nowhere in that capture was there a meaningful shader cost. Most Unity performance optimization arguments start exactly here, on top of a guess nobody has measured.

The rest of this post is the order in which I read that capture: first whether we were CPU or GPU bound, then the GC Alloc rows, and only last the draw call count. The order matters. Reversed, I would have spent three days simplifying shaders that changed nothing. The project runs on Unity 2022.3 LTS with URP, built for Android through IL2CPP.

CPU or GPU bound? Answer that first

Switching the Profiler to Timeline view and putting the main thread next to the render thread closes half the questions by itself. If you are GPU bound, the main thread shows a wide waiting block under Gfx.WaitForPresentOnGfxThread or Semaphore.WaitForSignal. That block means the CPU is idle and the card cannot keep up.

Our capture said the opposite. PlayerLoop alone ate 19.8 ms of the 22.4, while the GPU finished its work in 9.1 ms. The card was done early and waiting; the bottleneck was entirely on the CPU, and touching a shader would have had no measurable effect on frame time. In the same capture Gfx.WaitForPresentOnGfxThread sat at 0.4 ms, so there was nothing being waited on.

Starting to optimize before making that split is the most common mistake I see. Shortening Update loops in a GPU bound scene changes nothing, and halving texture resolution in a CPU bound scene changes just as little. Making the split takes two minutes; a week spent in the wrong direction does not come back. I do not change a single line of code before that call is made.

One warning: a capture taken in the editor will not make that split correctly. The editor runs its own UI, the scene camera and the profiler window itself on the same main thread. The same scene measured 31 ms in the editor and 22.4 ms on device, and it was not only the absolute numbers that differed but the ratio between the sections.

Reading the GC allocation column

Sorting the Hierarchy view by the GC Alloc column gave 312 KB allocated per frame. That number alone does not cause a hitch, but it accumulates until it triggers the collector, and on the frame it triggers you see a 1.9 ms GC.Collect. Every 40 ms spike in the capture landed on exactly those frames. The collector fired roughly every 2.6 seconds, an interval that is nothing more than the per frame allocation divided into the heap growth threshold.

The sources were boringly familiar. The HUD ran _scoreText.text = "Score: " + _score every frame, and that single line produced 96 KB; writing through a StringBuilder and only assigning when the value actually changed took it to zero. Inside EnemySpawner, the chain _points.Where(p => !p.Blocked).OrderBy(p => p.Distance) ran per frame rather than per wave, and every LINQ Where and OrderBy allocates its own iterator and comparer object. I do not ban LINQ outright; it is fine in loading and editor code, but in the gameplay loop that one chain cost 140 KB per frame.

The third source was Physics.OverlapSphere, which returns a fresh Collider[] on every call. Writing into a pre-allocated array with OverlapSphereNonAlloc zeroed that row. The same class also called GetComponent<EnemyPool>() inside Update: that call allocates nothing, but across 240 objects it cost 1.1 ms per frame. Caching it once in Awake was enough.

When you read that column, remember it is per frame. 8 KB per frame looks harmless; at 60 FPS it is 28 MB a minute, and on a phone the collector will eventually claim it with a stop-the-world spike. I aim for under 1 KB per frame in the gameplay loop, and in most scenes a flat zero is a realistic target. Account for the Profiler's own allocations as well; the first few frames of any capture come out dirty.

EnemySpawner.cs
public class EnemySpawner : MonoBehaviour { [SerializeField] private Transform[] _points; [SerializeField] private LayerMask _blockers; // Reused every wave, so the physics query allocates nothing private readonly Collider[] _hits = new Collider[8]; private readonly List<Transform> _free = new List<Transform>(16); // Cached once instead of a GetComponent call per frame private EnemyPool _pool; private void Awake() => _pool = GetComponent<EnemyPool>(); public void SpawnWave(int count) { _free.Clear(); for (int i = 0; i < _points.Length; i++) { // NonAlloc writes into _hits; the plain overload returns a new array if (Physics.OverlapSphereNonAlloc(_points[i].position, 1.2f, _hits, _blockers) == 0) _free.Add(_points[i]); } int spawned = Mathf.Min(count, _free.Count); for (int i = 0; i < spawned; i++) _pool.Get(_free[i].position); } }
csharpEnemySpawner.cs

When Deep Profile lies to you

Deep Profile wraps instrumentation around every method call. In short, frequently called methods that means the cost of measuring is larger than the method itself. With Deep Profile on, our scene went from 22.4 ms to 61 ms per frame. At that point what you are measuring is no longer your game but a copy inflated by instrumentation.

The real problem is not that it slows down, it is that it reorders. According to Deep Profile the most expensive line was the Vector3.Distance calls; with it off and the same block measured through a manual ProfilerMarker, it came out at 0.3 ms. The measuring tool had rewritten the profile.

So my usage settled into this: I turn Deep Profile on only to answer "which subtree holds the cost", never to read absolute durations. Once the suspect region is found I turn it off and place a ProfilerMarker around that block by hand; its overhead is negligible and it also works in a real IL2CPP build. I never let a Deep Profile capture taken in the editor become the basis of an optimization decision.

Counting draw calls in Frame Debugger

With the CPU side fixed the main thread was down to 11.3 ms, but the render thread still held 4.6 ms. I opened the Frame Debugger: 480 draw calls. There were 240 enemies in the scene, so two separate draws per enemy. Pausing during play and stepping through the frame one draw at a time is the fastest way to see what each call belongs to.

Next to every draw the Frame Debugger states why the SRP Batcher broke the chain, and that line is the information worth reading. We had two reasons: each enemy had its own material instance created for its health bar (touching renderer.material clones the material), and two different atlases were in use. After passing the color through a MaterialPropertyBlock and moving everything onto one atlas, the count dropped to 96. The break reason usually reads "Objects have different materials" or "Node has different shader keywords", and the fix is a scene setting rather than a code change.

I tried the alternatives too. Static batching was out from the start because the enemies move; GPU instancing kicked in on its own once the material cloning was gone, with nothing extra to write. I also considered merging meshes, but the enemies have to be destroyed individually, so that road was closed. The render thread went from 4.6 ms to 1.8 ms.

Draw call count is not a quality metric on its own. Of the remaining 96 draws, 40 came from the interface, and a single Canvas was being rebuilt every frame; splitting static and changing elements into two Canvases took Canvas.SendWillRenderCanvases from 2.1 ms to 0.4 ms. The Frame Debugger does not show that row, you find it in the UI section of the Profiler.

The numbers left after the measuring

Where it ended: main thread 22.4 ms to 8.7 ms, render thread 4.6 ms to 1.8 ms, per frame allocation 312 KB to 4 KB. Across five uninterrupted minutes of play not one GC.Collect spike showed up. The Redmi Note 11 held a stable 60 FPS. On an older Snapdragon 660 device the same build measured 11.9 ms per frame, so the gain was not specific to one phone.

The amount of changed code was surprisingly small: one StringBuilder, two cached references, one NonAlloc call, one MaterialPropertyBlock and a Canvas split. Not a single shader was touched and not a single model was retopologized. The whole job took half a day, and reading the capture took two hours of it.

The part you can apply fits in three lines: start every optimization session with a Profiler capture taken on real hardware, make the first question "CPU or GPU", and answer the second one by looking at the GC Alloc column. A capture taken in the editor will mislead you; a guess will mislead you far more. Pick the next step the same way: take a fresh capture and continue with whatever the most expensive row is there.

← 全部文章