Problem / Scenario
After optimizing DrawCall and Overdraw on a weapon UI panel, the rendering side looked good. But CPU profiling revealed a significant logic-side bottleneck: Canvas.BuildBatch was taking too long, causing the main thread to stall waiting for the worker thread.
What We Observed
We used GOT Online (GameOptim's performance optimization tool) for profiling, as Unity Editor's built-in Profiler adds its own overhead which can skew results.
After a 10-minute test, under Engine → UI, we found:
-
Rendering.EmitWorldScreenspaceCameraGeometry: 0.43 ms CPU cost
Understanding the Signal
EmitWorldScreenspaceCameraGeometry is a low-level UGUI rendering function that executes after Canvas.BuildBatch completes on the worker thread. High cost in this function on the main thread indicates that BuildBatch on the worker thread is the actual bottleneck—the main thread is waiting.
This is a useful diagnostic shortcut: if you see high EmitWorldScreenspaceCameraGeometry time, BuildBatch is your culprit.
Root Cause Analysis
The weapon UI panel had a grid of weapon icons (static) plus several rotating circular background elements (dynamic). All lived under the same child Canvas.
The problem chain:
- Rotating circles change their transform every frame
- Changed elements are marked as dirty
-
Canvas.BuildBatchmust rebuild batches for the entire Canvas, not just the dirty elements - Static elements (icons, backgrounds, frames) get re-batched every frame despite never changing
- The more elements on the Canvas, the more expensive each rebuild
This is a classic case of paying for work you don't need.
Optimization: Static/Dynamic Separation
The principle is simple: put elements that change at different frequencies on different Canvases.
Implementation
We split the UI into two Canvases:
-
Dynamic Canvas: only the rotating circle elements
- Rebuilds every frame
- Small element count → fast rebuild
-
Static Canvas: weapon icons, frames, backgrounds, all non-animated elements
- Rarely rebuilds (only when content actually changes)
- Large element count → but almost never rebuilt
Why It Works
BuildBatch cost scales with the number of elements on the Canvas. By isolating frequently-changing elements to their own small Canvas:
- Per-frame BuildBatch processes far fewer elements
- The large static Canvas rarely triggers rebuilds
- Total BuildBatch work across both Canvases is dramatically less than one Canvas rebuilding everything every frame
Result
After the change:
-
Rendering.EmitWorldScreenspaceCameraGeometry: 0.43 ms → 0.09 ms - ~79% reduction in BuildBatch-related main-thread cost
- The main thread is essentially no longer waiting on the worker thread
Key Takeaway
Static/dynamic separation is one of the highest-impact, lowest-effort UGUI optimizations when BuildBatch is a bottleneck.
How to diagnose:
- Profile with a device-side tool (GOT Online or similar) for accurate numbers
- Look for high
EmitWorldScreenspaceCameraGeometrycost - This correlates with slow
Canvas.BuildBatchon the worker thread
How to fix:
- Identify elements by update frequency (static, occasional, every-frame)
- Group same-frequency elements on the same Canvas
- Minimize the number of elements on frequently-updated Canvases
Trade-offs to consider:
- More Canvases add management complexity
- Elements on different Canvases can't batch with each other (potential DrawCall increase)
- Each Canvas has small fixed overhead
In practice, when BuildBatch is a real bottleneck, the CPU savings almost always justify the trade-offs.
Top comments (0)