Slate & UMG Performance
UI is the perf bucket no one watches until it ships. Property bindings tick every frame. Clipping fragments draw batches. Materials in UI multiply overdraw. The senior workflow uses InvalidationBox + Global Invalidation + RetainerBox judiciously, and bans the bound-property anti-pattern. This tutorial covers the Slate paint pipeline, visibility states as a perf primitive, the invalidation primitives, materials in UI, and the Common UI architecture from Lyra.
The Slate paint pipeline
Per-frame Slate work runs in four phases:
- Prepass — layout calculation; widgets compute their desired size.
- Tick —
NativeTick+ property bindings + active timers. - Paint — widgets emit draw elements.
- Hit-Test — the hit-test grid is updated.
UMG (User Motion Graphics) sits on top of Slate — UUserWidget wraps SObjectWidget; Blueprint UI ultimately runs through Slate. The optimization story is the same: minimize what runs each tick, cache what can be cached, batch draws.
Visibility states as a perf primitive
Six visibility values, each with a different perf shape:
- Visible — ticks, paints, receives input.
- HitTestInvisible — ticks, paints, ignores input.
- SelfHitTestInvisible — widget itself ignores input but children can receive. Useful for non-interactive container panels.
- Collapsed — not in layout at all. Cheapest "hidden" state. Use when you'd rather lay out around the gap.
- Hidden — takes layout space but doesn't paint. Use when you want the slot reserved.
- Volatile (a flag, not a visibility) — opt-out of caching. Use sparingly.
Default Visible is rarely the right pick for non-interactive elements. Most static text and decorative panels should be SelfHitTestInvisible — it eliminates them from the hit-test grid entirely.
InvalidationBox
InvalidationBox caches its children's drawn state. While cached: not pre-passed, not ticked, not painted. Manual invalidation when state changes via InvalidateLayoutAndVolatility() or property change.
Use InvalidationBox around:
- Static UI sections that update on event, not every frame.
- HUD elements that change once per second (mini-map, score).
- Menu pages that don't animate.
Global Invalidation
Slate.EnableGlobalInvalidation caches widget data at the SWindow level — rather than wrapping every static section in its own InvalidationBox. Off by default. Many studios assume it's on because docs talk about caching as the default mode — always assert the CVar at boot.
Slate.EnableGlobalInvalidation 1
RetainerBox
RetainerBox renders its contents to a render target, then composites the RT instead of re-painting children. Two superpowers:
- Frequency / phase decoupling — the RT can be redrawn every Nth frame independent of game frame rate.
- Async render — expensive UI sections can render off-thread.
Costs:
- Render target memory — each RetainerBox allocates one.
- Resolve cost — the composite step.
Best use: mobile UI hitting bandwidth limits — cap UI redraw rate at 30 Hz with RetainerBox while game runs at 60 Hz. Worst use: wrapping an animated widget that would invalidate the retainer every frame anyway.
Materials in UI — the overdraw trap
Per Epic UMG Optimization docs: "the total GPU time of a frame can be doubled or more by having overdraw." UI is a giant overdraw producer.
Per the Virtuos Unreal Fest 2024 talk: simple generic materials beat per-widget custom shaders. The reason is batching — widgets sharing a material can batch into a single draw call; widgets with unique materials each get their own.
Mobile sampler budget — "five texture samplers available" per mobile material; using all five is "fairly expensive" (Epic Mobile Performance docs). UI materials authored at desktop quality don't ship clean on mobile without auditing.
Layered translucency in UI stacks: each translucent layer pays full pixel-shading cost. Tooltip over chat over HUD over background — that's 4× pixel cost on the overlap region.
Font cache
FSlateFontCache rasterizes glyphs on demand and caches them in a glyph atlas. The trap on global / multi-language UI: atlas thrashing — many languages, many sizes, many fonts evict each other.
Mitigations:
- Composite fonts — one font asset per language, swapped at runtime, instead of one giant font with every glyph.
- Offline atlases for shipped languages — pre-bake at known sizes.
- Limit font sizes — each unique size is a separate atlas entry. 14 / 18 / 24 / 36 instead of every-int-from-12-to-48.
Widget tick & the bound-property trap
EWidgetTickFrequency on UUserWidget controls whether the widget ticks. Most widgets should be Auto (don't tick unless animated) or explicitly Never. Always is the default for some BP-defined widgets and is rarely correct.
The bound-property trap. Per BenUI / Unreal Garden: properties bound via the Bind dropdown are evaluated every frame per widget property. "If the logic within the bound function is even slightly complicated they can become real performance hogs." Senior teams ban Bind in style guides and use:
- C++ delegates — event-driven, fires only on change.
INotifyFieldValueChanged— UE 5.5+ proper observable property pattern.- Manual
SetText/SetVisibilityin event handlers — explicit but predictable.
UUserWidget::NativeTick runs every frame on every visible widget. Default UMG widgets often inherit a NativeTick that does measurable work. Override only when needed; profile with stat slate.
Common UI & Lyra patterns
Common UI is Epic's modern UI framework, used by Fortnite and Lyra. Senior patterns:
UCommonActivatableWidgettrees — activatable widget stacks; only the topmost gets input.ICommonPoolableWidgetInterface— widget pooling. Reuse activated widgets instead of destroy/spawn.- Input routing only to topmost painted nodes — deep stacks of deactivated widgets cost no input cycles.
GetDesiredFocusTarget/GetDesiredInputConfig— declarative input/focus config.
The Lyra Common UI patterns are the cleanest UE5 UI architecture in public code. If you're starting a new UI system, start there.
Profiling workflow
stat slate— top-level Slate timing categories. Start here.stat group enable slateverbose+stat group enable slateveryverbose— verbose stat groups.stat dumpave -root=stat_slate -num=120 -ms=0— averaged over 120 frames; primary recipe per Epic Slate Insights docs.SlateDebugger.Invalidate— visualize per-frame invalidation; highlights widgets being repainted.SlateDebugger.Paint— visualizes every widget painted per frame.- Widget Reflector — in-editor; inspect hierarchies, hit-test grids, the Slate API used per widget.
The 5-rule UMG style guide
- Default visibility is SelfHitTestInvisible for non-interactive content. Visible only when input is needed.
- Bind is banned. Use C++ delegates, INotifyFieldValueChanged, or explicit setter calls in event handlers.
- InvalidationBox + Global Invalidation for static sections. Don't wrap animated content.
- Materials shared, not unique. Per-widget custom shaders break batching. Use one master UI material with parameter instances.
- No Tick on UUserWidget unless required.
EWidgetTickFrequency::Autoor::Never;::Alwaysrequires justification.
PerfGuard can capture STAT_SlateRenderingGTTime, STAT_SlateNumPaintedWidgets, STAT_SlateNumTickedWidgets, STAT_SlateNumWidgets between baseline and candidate. PR-time gates: alert when painted-widgets-per-frame rises >X% without a feature flag, when Global Invalidation flips off in [ConsoleVariables], or when a UMG asset adds a clip flag (parsed from asset diff). UI regressions are the canonical "passes profiling, dies on lower-spec mobile" class — PerfGuard catches them before QA.
- Mobile Performance — UI on mobile bandwidth-bound GPUs.
- Object Pooling Patterns — ICommonPoolableWidgetInterface deep dive.
- Memory & VRAM Optimization — UI texture pool budgets.