DEV Community

Cover image for Optimizing Canvas.BuildBatch Cost via Static/Dynamic UI Separation
GameOptim
GameOptim

Posted on

Optimizing Canvas.BuildBatch Cost via Static/Dynamic UI Separation

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:

  1. Rotating circles change their transform every frame
  2. Changed elements are marked as dirty
  3. Canvas.BuildBatch must rebuild batches for the entire Canvas, not just the dirty elements
  4. Static elements (icons, backgrounds, frames) get re-batched every frame despite never changing
  5. 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 EmitWorldScreenspaceCameraGeometry cost
  • This correlates with slow Canvas.BuildBatch on 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)