“Cut your draw calls and it gets faster” is the first thing anyone hears about Unity optimization. We tried it for real, in a scene of 20,000 cubes riding a wave. Draw calls went from 20,001 down to 153. Frame time still only moved from 8.948 ms to 8.709 ms. That is a difference of 0.24 ms at p50. At that size you cannot see it, even with the thing running in front of you.
Draw the same picture without creating a GameObject per cube and frame time becomes 1.678 ms. In FPS, 111.8 to 596.0. Draw calls in that mode: 60.
Seen from 20,001, both 153 and 60 are about equally small. Yet one of them stayed at 8.709 ms and the other became 1.678 ms. What produced that gap was not the number of draw calls.
The three modes we measured
The scene is shared. 20,000 cubes are laid out in a disc with a traveling sine wave running through them. Every frame rewrites all 20,000 positions, and the camera orbits slowly around them. Only two things differ: how the cubes are drawn, and whether each cube gets a GameObject of its own. All three modes run the same executable, with only the startup arguments changed.
- NAIVE: one GameObject and one MeshRenderer per cube, with GPU instancing disabled on the material
- INSTANCED_RENDERER: the same GameObject setup as NAIVE, with only GPU instancing enabled on the material
- INSTANCED_DIRECT: no per-cube GameObject; a single script draws them with
Graphics.RenderMeshInstanced
The measurement environment is Unity 6000.4.11f1, Built-in Render Pipeline (forward rendering), the Mono scripting backend, and the D3D12 graphics API. The GPU is an RTX 4070 Ti, the CPU a Core i5-13500, the resolution 1920x1080, vSync off, and shadows disabled in every mode. Dynamic and static batching are explicitly off, and we confirmed that the batch counters read 0. Each mode ran a 15-second warmup followed by 120 seconds of measurement. The values below are p50 unless stated otherwise.
In Unity, the render thread and GPU time stay at 0 unless you enable Frame Timing Stats in Player Settings. This whole breakdown rests on that one checkbox.
Results
| Mode | FPS | Frame time | Frame time p95 | Game thread | Render thread | GPU time | Memory |
|---|---|---|---|---|---|---|---|
| NAIVE | 111.8 | 8.948 ms | 10.644 ms | 8.920 ms | 0.480 ms | 0.909 ms | 169.0 MB |
| INSTANCED_RENDERER | 114.8 | 8.709 ms | 12.919 ms | 8.680 ms | 0.519 ms | 0.703 ms | 176.6 MB |
| INSTANCED_DIRECT | 596.0 | 1.678 ms | 2.774 ms | 1.668 ms | 0.091 ms | 0.232 ms | 56.6 MB |
The draw counters break down like this.
| Mode | Standard draw calls | Instanced draw calls | Instance batches | SetPass Calls | Triangles |
|---|---|---|---|---|---|
| NAIVE | 20,001 | 0 | 0 | 153 | 240,002 |
| INSTANCED_RENDERER | 1 | 152 | 149 | 153 | 240,002 |
| INSTANCED_DIRECT | 20 | 40 | 59 | 2 | 240,002 |
Where this article says “draw calls”, it means standard plus instanced. That is 20,001 for NAIVE, 1 + 152 = 153 for INSTANCED_RENDERER, and 20 + 40 = 60 for INSTANCED_DIRECT. The same number 153 also sits in the SetPass Calls column, but that is a different metric. Of NAIVE’s 20,001, 20,000 are the cubes and the remaining 1 is everything else in the frame.
Here is what the three modes look like on screen.
The overlay numbers are single-frame values from the moment of capture. They do not match the p50 figures in the tables.
For INSTANCED_RENDERER, here are the numbers that do not flatter it. FPS went up from 111.8 to 114.8 at p50, but on the average it goes down, from 111.2 to 109.0. p95 frame time got worse, from 10.644 ms to 12.919 ms. Memory grew from 169.0 MB to 176.6 MB. What cutting draw calls from 20,001 to 153 bought was 0.24 ms at p50.
The bottleneck was the game thread, not the render thread
The answer was in NAIVE’s breakdown from the start. Frame time was 8.948 ms. Against that, the three concurrent components were the game thread at 8.920 ms, the render thread at 0.480 ms, and GPU time at 0.909 ms. The game thread here is what the Unity Profiler shows as the main thread. Submitting draw calls is the render thread’s job. Even if you could drive the render thread to 0 ms, frame time would still be set by the game thread’s 8.920 ms.
Reading this breakdown is simple. Whichever value sits closest to frame time is the place that decides the frame.
- The game thread is roughly equal to frame time: the CPU logic side is the bottleneck
- The render thread approaches frame time: the CPU rendering side is the bottleneck
- GPU time approaches frame time: the GPU is the bottleneck
Draw-call reduction pays off most in the second case. This scene was the first. The game thread’s 8.920 ms and frame time’s 8.948 ms are nearly identical.
INSTANCED_RENDERER is the mode we measured to test that reading. Standard draw calls dropped from 20,001 to 1, replaced by 152 instanced draw calls and 149 batches. The render thread went from 0.480 ms to 0.519 ms, slightly up if anything. In the two modes that keep the GameObjects, the render thread never moved from around 0.5 ms. In this D3D12 configuration, issuing draw calls was not a load heavy enough to affect frame time. GPU time, on the other hand, fell from 0.909 ms to 0.703 ms. That is a real gain, but the GPU was never the bottleneck, so it does not surface in frame time.
What worked was not creating 20,000 GameObjects
In INSTANCED_DIRECT we changed the composition of the scene itself, not just the way draw calls are issued. It creates no per-cube GameObject, Transform, or MeshRenderer; one script rewrites an array of matrices every frame and draws from it. Graphics.RenderMeshInstanced draws at most 1,023 instances per call. So the 20,000 are split into chunks of 1,023 and handed over that way. That comes to 20 calls, but the counter breakdown does not map one-to-one onto the number of calls.
private void RenderInstanced()
{
for (int start = 0; start < _objectCount; start += MaxInstancesPerCall)
{
int chunk = Mathf.Min(MaxInstancesPerCall, _objectCount - start);
Graphics.RenderMeshInstanced(_renderParams, _cubeMesh, 0, _matrices, chunk, start);
}
}
The game thread was 8.920 ms in NAIVE and still 8.680 ms in INSTANCED_RENDERER. It came down to 1.668 ms. What disappeared is the CPU time that 20,000 GameObjects were demanding every frame. Component updates, per-GameObject culling, and Transform synchronization are what that time consists of.
This mode does change two things at once, though. We read the game thread’s fall from 8.680 ms to 1.668 ms as mainly the GameObject side’s contribution. That is because INSTANCED_RENDERER, which kept the GameObjects and changed only the render path, did not move the game thread. Even so, these three modes alone cannot fully separate the two factors. SetPass Calls going from 153 to 2, and GPU time going from NAIVE’s 0.909 ms to 0.232 ms, is the render path’s effect, which is a separate story from whether GameObjects exist.
The memory drop is likewise a result of dropping the GameObjects. 169.0 MB to 56.6 MB, down 112 MB. That works out to about 5.6 KB per GameObject, but the figure is nothing more than the difference divided by the count. What the SDK sends is process-wide allocated memory (Profiler.GetTotalAllocatedMemoryLong). The INSTANCED_DIRECT side newly allocates about 1.28 MB for 20,000 Matrix4x4 values. Even so, those 112 MB are not a reduction in the drawn data such as meshes and vertices. The triangle count stayed at 240,002 in all three modes.
Telling which case your own scene is in
All you look at is frame time and its three components. Check which of the game thread, the render thread, and GPU time sits closest to frame time. The Framedash Unity SDK sends those four automatically as perf_heartbeat (frame_time_ms, game_thread_ms, render_thread_ms, gpu_time_ms).
In this measurement, what Framedash handled was collecting them and comparing them. We changed the build_id in BeginAutomatedSession per mode to keep the three runs apart, then took the diff from the CLI.
framedash perf-diff --baseline <NAIVE build_id> --candidate <INSTANCED_DIRECT build_id> \
--threshold 5 --fail-on-regression
The diff came back like this.
- Frame time p50: 9.011 ms to 1.701 ms (-81.12%)
- GPU time: 0.9257 ms to 0.2406 ms (-74.00%)
- Memory: 175.7 MB to 59.4 MB (-66.20%)
- 133 samples each, with a verdict of “No performance regression beyond 5%”
These p50s differ slightly from the table above because the samples being aggregated are different. The server side aggregates the events sent once per second during the 120-second automated session after the warmup together with the perf_heartbeat the SDK sends every 10 seconds. That is 133 samples per mode. The table’s p50, by contrast, comes from per-frame values. Because the server output is quoted as it came back, the number of digits differs from the tables too. Run the same command from CI and you can check the difference between builds at this granularity before you release.
How far these results carry
Everything above is one workload measured in one configuration. Change the premise of D3D12, the Built-in Render Pipeline, Mono, and a desktop GPU, and the proportions in the breakdown change with it. On mobile graphics APIs where driver overhead is larger, on URP or HDRP where the SRP Batcher is at work, or in an IL2CPP build, nothing guarantees the render thread’s share matches what we saw here. Enable shadows and you add passes, which changes the weight of a single draw call too.
The construction of the measurement has its own caveats. The counter values come from a Development build and the timing values from a Release build, so that profiler overhead never mixes into the timings. Counters vary widely frame to frame, and their minimum and maximum are not trustworthy. That is why we show p50 alone for the counters. The workload also rewrites all 20,000 positions every frame, which is worth confirming before you map it onto your own scene.
Measure, then cut
Draw-call reduction pays off most when the render thread is closing in on frame time. Measure first and you can judge, before you touch anything, whether cutting draw calls is a 0.24 ms job like this one. If the game thread is nearly equal to frame time, the next thing to suspect is the number of GameObjects.
The order goes like this. First, enable Frame Timing Stats. Then line up frame time and the three components and see which one is closest. Cutting comes after that.
- The definitions of the metrics used in this measurement are collected in the data model.
- The setup steps for the SDK are in the Unity SDK guide.