Quick answer: A single dirty Graphic rebuilds the entire Canvas. Split static UI (HUD frame, buttons) and dynamic UI (score text, timer) into separate Canvases so dynamic changes only rebuild small canvases. Use RectMask2D instead of Mask, and disable objects not on screen.
Here is how to fix Unity Canvas rebuilding every frame and eating your frame budget. The Profiler shows Canvas.SendWillRenderCanvases costing 4ms per frame. Your game is a simple 2D scene with a HUD, yet the UI is the most expensive thing in the build. The culprit is almost always a single Canvas containing both static elements (health bar background, button frames) and a dynamic element (score text updating each frame). That one changing text forces the whole Canvas to re-batch.
The Symptom
- Profiler shows Canvas.SendWillRenderCanvases consuming 2-10ms per frame
- UI-heavy scenes drop framerate while 3D-heavy scenes run fine
- Disabling the UI Canvas restores framerate dramatically
- UI Batch count is high (50+) on scenes with moderate UI complexity
- Mobile builds throttle or get hot from UI rendering
What Causes This
Unity’s UI rebuilds per Canvas. When any Graphic component inside a Canvas becomes dirty, Unity rebuilds the entire Canvas’s geometry: every UI element’s vertices, UVs, and colors are re-sorted into batches. If the Canvas holds 200 elements and one Text changes, all 200 are re-batched. This happens on the main thread and cannot be jobified.
Text is especially expensive. Text rebuilds generate character quads from the font atlas. A long string changing each frame (score, timer, debug overlay) is a guaranteed per-frame rebuild. TextMeshPro is faster than legacy Text but still rebuilds its Canvas when content changes.
Layout groups amplify cost. VerticalLayoutGroup, HorizontalLayoutGroup, and GridLayoutGroup trigger layout rebuilds when any child changes. A layout rebuild walks all children, computes positions, then fires the geometry rebuild. Nested layout groups multiply the cost.
Graphic Raycaster raycasts every Canvas every frame. A Canvas with Graphic Raycaster tests every Graphic against the pointer each Update. Many canvases with raycasters = expensive input processing.
Mask (not RectMask2D) breaks batching. Stencil Mask creates render state boundaries that split batches. More batches = more draw calls = more rebuild work.
The Fix
Step 1: Split by update frequency. Create multiple Canvases for different change rates.
- Canvas_Static: HUD frame, background panels, button frames. Never changes.
- Canvas_Dynamic: Score, timer, health text. Changes every frame.
- Canvas_Popup: Dialogs, menus. Changes when opened.
A changing score rebuilds only Canvas_Dynamic (5 elements). Canvas_Static (100 elements) never rebuilds.
using UnityEngine;
using UnityEngine.UI;
public class ScoreUI : MonoBehaviour
{
[SerializeField] private Text scoreText;
private int lastScore = -1;
public void SetScore(int newScore)
{
if (newScore == lastScore) return; // skip identical
scoreText.text = newScore.ToString();
lastScore = newScore;
}
}
Only setting text when it changes prevents unnecessary dirty flags. A per-frame SetText with the same value still triggers a rebuild internally.
Step 2: Prefer RectMask2D over Mask. For rectangular clipping (scroll views, inventory slots inside a frame), use RectMask2D. It does not break batching and it culls children that are fully off-screen.
Replace Mask + Image with RectMask2D on the scroll content parent. Draw calls drop immediately.
Step 3: Disable Raycast Target on non-interactive Graphics. Every Image and Text has Raycast Target on by default. Decorative graphics (background panels, frames, labels) never need to receive input. Turn Raycast Target off in the Inspector or via code:
foreach (var g in GetComponentsInChildren<Graphic>())
{
if (!(g is Button) && !(g is Toggle))
g.raycastTarget = false;
}
Step 4: Remove unused Canvas components. Every Canvas has a Canvas Renderer, Graphic Raycaster (optional), Canvas Scaler (optional). If a Canvas is purely for rendering and never clicked, remove the Graphic Raycaster. If you have nested Canvases that inherit everything from the parent, consider consolidating.
Step 5: Use CanvasGroup for fading instead of per-Graphic alpha. Changing alpha on 20 Images individually dirties each one and rebuilds the canvas. A CanvasGroup alpha change does not dirty the children — it multiplies at render time only.
CanvasGroup group = panel.GetComponent<CanvasGroup>();
group.alpha = 0.5f; // no rebuild triggered
Step 6: Disable off-screen content. If a menu panel is not visible, deactivate its GameObject (SetActive(false)) rather than hiding it via alpha. Inactive objects are not batched and do not rebuild.
Profiling Workflow
Open Profiler, enable the UI section. Frame debug a problematic frame and look at:
Canvas.SendWillRenderCanvases— per-canvas rebuild timeCanvas.BuildBatch— batch construction costUIBatches— total draw calls for UIUIVertices— total vertex count
A good rule of thumb for mobile: total UI batch count under 10, rebuild time under 1ms per frame. On desktop you can tolerate 2-3ms but any scene where UI dominates the frame is too expensive.
// Log per-canvas cost in builds via UnityEngine.Profiling.Profiler
using UnityEngine.Profiling;
void LateUpdate()
{
long bytes = Profiler.GetTotalAllocatedMemoryLong();
Debug.Log($"Allocated: {bytes / 1024 / 1024}MB");
}
“Canvas batching is atomic per canvas. Any dirty graphic rebuilds the whole thing. Split aggressively.”
TextMeshPro Notes
TMP Text changes also rebuild the Canvas. Prefer TMP_Text.SetText(StringBuilder) for frequently changing numeric text — avoids GC allocations from string concatenation but still triggers a rebuild. For extreme cases (combat damage numbers, flying text), use object pooling with pre-built text objects and just move them.
See Unity UI Lagging on Mobile for mobile-specific UI performance.
Split canvases by update rate. RectMask2D not Mask. Raycast Target off on decorations. CanvasGroup for fades.