Article written by Ryan B.
Licensees often ask for general pointers on how they can lower the cost of rendering the UI in their project. When we optimize UI for our internal projects, here are our rules of thumb:
- Eliminate anything running per frame (tick/paint), especially in blueprints (tick/paint). Use blueprints purely for event based logic or short lived animations.
- Eliminate usage of bound attributes. Attributes, while useful, are not compatible with performance given how they poll every frame. Eliminating attributes bound to blueprint callable functions is especially important.
- Use invalidation. Invalidation panels prevent layout from occurring every frame on anything in the tree contained in an invalidation panel. In 4.23, there were a lot of restrictions for invalidation panels. For example, they could not support any widget ticking inside of them. 4.25 has overhauled them completely. More on that later.
Two useful tools for UI optimization are retainer panels and invalidation panels, though both come with some caveats. Retainers collapse the entire tree into a single texture. They also support phase-based and framerate limited rendering, meaning each retainer can draw on a separate frame to avoid all the UI layout happening on the same frame. For example, you can draw your minimap on one frame and then your health bar on another frame. You can also tell retainers to render less frequently, such as rendering your minimap at 30 fps instead of 60. With phase-based rendering they can reduce draw calls by limiting redraws on the same frame. The downside to them is that they have high overhead when they rerender, so you really have to be careful. They also cost more memory since they each have their own render target. They also suffer from the same pre-4.25 problems as invalidation panels. We use them on mobile hardware, but we prefer to use invalidation panels first.
The downside to invalidation panels (in 4.23) is that when one widget invalidates, the entire tree in the invalidation panel is laid out and rendered again.
The downside to both is that neither work with attributes. Any core widget with a bound attribute is marked volatile, meaning it will redraw every frame. Neither work with ticking and timers either. Basically, widgets do not tick or run timers if they are in one of these panels.
In 4.25 and beyond, LayoutCaching has been removed. It has been superseded by Global Invalidation, enabled via “Slate.EnableGlobalInvalidation”. Global invalidation makes it possible to remove the layout and painting overhead that Slate performs every frame across the entire UI. It also solves the need to hand place invalidation panels everywhere. Since invalidation panels have some overhead, getting the granularity right can be difficult. With global invalidation, when one widget is invalidated, it is put into a dirty list. Next frame, we evaluate how that widget changed and decide on what level of the tree layout needs to be performed. For example, when changing a color on some image brush, only that widget needs to be redrawn. When changing the size however, we may need to perform invalidation on parents since their size may be affected by their children. We do all of that automatically, resulting in a minimal set of widgets that need to be invalidated. It supports registering for tick and timers as well.
Global invalidation is a very aggressive change to Slate that aims to solve the constant layout overhead of Slate once and for all. We’re still finding bugs with it, but by 4.25 it is pretty stable. We don’t turn it on by default because there are simply too many cases across all projects where widgets perform a ton of per-frame logic in a tick or paint call. Enabling those effectively disables painting and layout from getting called which would break many things that weren’t prepared for it. In Fortnite, we turn it on for the HUD only for this reason, as that’s the only perf-critical area for us. We’re also very restrictive with what goes into widgets in our HUD, going as far as writing custom tools and tests to trap ticking widgets and attributes. Again, regarding attributes, we currently have not solved that problem, so every widget with bound attributes is marked volatile and drawn every frame. It also does not solve individual widgets which are expensive (such as widgets that do a ton of logic in a tick or paint call).
In 4.25, retainers and invalidation panels have been turned into “mini” global invalidation regions. If you’re not ready to completely turn on global invalidation, invalidation panels are much improved and get all the benefits of global invalidation. The downside is that there will be the additional overhead from everything not in one of these invalidation panels.
If most of your HUD is static, invalidation works really well. We mark our minimap volatile since it needs to run at a high frame rate. That’s really the only widget we allow to work this way. In Paragon, our minimap was behind a retainer and rendered at 30hz due to the sheer volume of data on the minimap. In general our minimaps are very custom. We don’t have a tree of widgets in the minimap, it’s a single widget with instanced draw elements to make it as efficient as possible.