paint-brush
How to Optimize UIs in Unity: Slow Performance Causes and Solutionsby@sergeybegichev
641 reads
641 reads

How to Optimize UIs in Unity: Slow Performance Causes and Solutions

by Sergei BegichevAugust 25th, 2024
Read on Terminal Reader
Read this story w/o Javascript

Too Long; Didn't Read

See how to optimize UI performance in Unity using this detailed guide with numerous experiments, practical advice, and performance tests to back it up!
featured image - How to Optimize UIs in Unity: Slow Performance Causes and Solutions
Sergei Begichev HackerNoon profile picture

See how to optimize UI performance in Unity using this detailed guide with numerous experiments, practical advice, and performance tests to back it up!


Hello! I’m Sergey Begichev, Client Developer at Pixonic (MY.GAMES). In this post, I’ll be discussing UI optimization in Unity3D. While rendering a set of textures may seem simple, it can lead to significant performance issues. For instance, in our War Robots project, unoptimized UI versions accounted for up to 30% of the total CPU load — an astonishing figure!


Typically, this problem arises under two conditions: one, when there are numerous dynamic objects, and two, when designers create layouts that prioritize reliable scaling across different resolutions. Even a small UI can generate a noticeable load under these circumstances. Let’s explore how this works, identify the causes of the load, and discuss potential solutions.

Unity’s Recommendations

First, let’s review Unity’s recommendations for UI optimization, which I have summarized into six key points:


  1. Split up your canvases into sub-canvases
  2. Remove unnecessary Raycast Target
  3. Avoid using expensive elements (Large List, Grid views, etc.)
  4. Avoid layout groups
  5. Hide canvas instead of Game Object (GO)
  6. Use animators optimally


While points 2 and 3 are intuitively clear, the rest of the recommendations can be problematic to imagine in practice. For instance, the advice to “split up your canvases into sub-canvases” is certainly valuable, but Unity doesn’t provide clear guidelines on the principles behind this division. Speaking for myself, in practical terms, I want to know where it makes the most sense to implement sub-canvases.


Consider the advice to “avoid layout groups.” While they can contribute to a high UI load, many large UIs come with multiple layout groups, and reworking everything can be time-consuming. Moreover, layout designers who eschew layout groups may find themselves spending significantly more time on their tasks. Therefore, it would be helpful to understand when such groups should be avoided, when they can be beneficial, and what actions to take if we cannot eliminate them.


This ambiguity in Unity’s recommendations is a core issue — it’s often unclear what principles we should apply to these suggestions.

UI Construction Principles

To optimize UI performance, it’s essential to understand how Unity constructs the UI. Understanding these stages is crucial for effective UI optimization in Unity. We can broadly identify three key stages in this process:


  1. Layout. Initially, Unity arranges all UI elements based on their sizes and designated positions. These positions are calculated in relation to screen edges and other elements, forming a chain of dependencies.


  2. Batching. Next, Unity groups individual elements into batches for more efficient rendering. Drawing one large element is always more efficient than rendering multiple smaller ones. (For a deeper dive into batching, refer to this article.)


  3. Rendering. Finally, Unity draws the collected batches. The fewer batches there are, the faster the rendering process will be.


While there are other elements involved in the process, these three stages account for the majority of issues, so for now, let’s focus on them.


Ideally, when our UI remains static — meaning nothing moves or changes — we can build the layout once, create a single large batch, and render it efficiently.


However, if we modify the position of even one element, we must recalculate its position and rebuild the affected batch. If other elements depend on this position, we’ll then need to recalculate their positions too, causing a cascading effect throughout the hierarchy. And the more elements that need adjustment, the higher the batching load becomes.


So, changes in a layout can trigger a ripple effect throughout the entire UI, and our goal is to minimize the number of changes. (Alternatively, we can aim to isolate changes to prevent a chain reaction.)


As a practical example, this issue is particularly pronounced when using layout groups. Each time a layout is rebuilt, every LayoutElement performs a GetComponent operation, which can be quite resource-intensive.

Multiple Tests

Let’s examine a series of examples to compare the performance results. (All tests were conducted using Unity version 2022.3.24f1 on a Google Pixel 1 device.)


In this test, we’ll create a layout group featuring a single element, and we’ll analyze two scenarios: one where we change the size of the element, and another where we’re utilizing the FillAmount property.


RectTransform changes:


FlllAmount changes:


In the second example, we’ll try to do the same thing, but in a layout group with 8 elements. In this case, we’ll still only be changing one element.


RectTransform changes:


FlllAmount changes:


If, in the previous example, changes to the RectTransform resulted in a load of 0.2 ms on the layout, this time the load increases to 0.7 ms. Similarly, the load from batching updates rises from 0.65 ms to 1.10 ms.


Although we’re still modifying just one element, the increased size of the layout significantly impacts the load during the rebuild.


In contrast, when we adjust the FillAmount of an element, we observe no increase in load, even with a larger number of elements. This is because modifying FillAmount does not trigger a layout rebuild, resulting in only a slight increase in batching update load.


Clearly, using FillAmount is the more efficient choice in this scenario. However, the situation becomes more complex when we alter the scale or position of an element. In these cases, it’s challenging to replace Unity’s built-in mechanisms that don’t trigger layout rebuild.


This is where SubCanvases come into play. Let’s examine the results when we encapsulate a changeable element within a SubCanvas.


We’ll create a layout group with 8 elements, one of which will be housed within a SubCanvas, and then modify its transform.


RectTransform changes in SubCanvas:


As the results indicate, encapsulating a single element within a SubCanvas almost eliminates the load on the layout; this is because SubCanvas isolates all changes, preventing a rebuild in the higher levels of the hierarchy.


However, it’s important to note that changes within the canvas will not influence the positioning of elements outside of it. Therefore, if we expand the elements too much, there exists a risk that they may overlap with neighboring elements.


Let’s proceed by wrapping 8 layout elements in a SubCanvas:


The previous example demonstrates that, while the load on the layout remains low, the batching update has doubled. This means that, although dividing elements into multiple SubCanvases helps reduce the load on layout build, it increases the load on batch assembly. Consequently, this could lead us to a net negative effect overall.


Now, let’s conduct another experiment. First, we’ll create a layout group with 8 elements and then modify one of the layout elements using the animator.


The animator will adjust the RectTransform to a new value:


Here, we see the same result as in the second example where we changed everything manually. This is logical because it makes no difference what we use to change RectTransform.


The animator changes RectTransform to a similar value:


Animators previously faced an issue where they would continuously overwrite the same value every frame, even if that value remained unchanged. This would inadvertently trigger a layout rebuild. Fortunately, newer versions of Unity have resolved this problem, eliminating the need to switch to alternative tweening methods solely for performance improvements.


Now, let’s examine how changing the text value behaves within a layout group with 8 elements and whether it triggers a layout rebuild:


We see that the rebuild is also triggered.


Now, we’ll change the value of TextMechPro in the layout group of 8 elements:


TextMechPro also triggers a layout rebuild, and it even looks like it puts more load on batching and rendering than regular Text.


Changing the TextMechPro value in SubCanvas in a layout group of 8 elements:


SubCanvas has effectively isolated the changes, preventing layout rebuild. Yet, while the load on batching updates has decreased, it remains relatively high. This becomes a concern when working with text, as each letter is treated as a separate texture. Modifying the text consequently affects multiple textures.


Now, let’s evaluate the load incurred when turning a GameObject (GO) on and off within the layout group.


Turning on and off a GameObject inside a layout group of 8 elements:


As we can see, turning on or off a GO also triggers a layout rebuild.


Turning on a GO inside a SubCanvas with a layout group of 8 elements:


In this case, SubCanvas also helps to relieve the load.


Now, let’s check what the load is if we turn on or off the entire GO with a layout group:


As the results show, the load reached its highest level yet. Enabling the root element triggers a layout rebuild for the child elements, which, in turn, results in a significant load on both batching and rendering.


So, what can we do if we need to enable or disable entire UI elements without creating excessive load? Instead of enabling and disabling the GO itself, you can simply disable the Canvas or the Canvas Group component. Additionally, setting the alpha channel of the Canvas Group to 0 can achieve the same effect while avoiding performance issues.



Here’s what happens to the load when we disable the Canvas Group component. Since the GO remains enabled while the canvas is disabled, the layout is preserved but simply not displayed. This approach not only results in a low layout load but also significantly reduces the load on batching and rendering.


Next, let’s examine the impact of changing the SiblingIndex within the layout group.


Changing SiblingIndex inside a layout group of 8 elements:


As observed, the load remains significant, at 0.7 ms for updating the layout. This clearly indicates that modifications to the SiblingIndex also trigger a layout rebuild.


Now, let’s experiment with a different approach. Instead of changing the SiblingIndex, we’ll swap the textures of two elements within the layout group.


Swapping textures of two elements in a layout group of 8 elements:


As we can see, the situation has not improved; in fact, it has gotten worse. Replacing the texture also triggers a rebuild.


Now, let’s create a custom layout group. We’ll construct 8 elements and simply swap the positions of two of them.


Custom layout group with 8 elements:


The load has indeed significantly decreased — and this is expected. In this example, the script simply swaps the positions of two elements, eliminating heavy GetComponent operations and the need to recalculate the positions of all elements. As a result, there is less updating required for batching. While this approach seems like a silver bullet, it’s important to note that performing calculations in scripts also contributes to the overall load.


As we introduce more complexity into our layout group, the load will inevitably increase, but it won’t necessarily reflect in the Layout section since the calculations occur in scripts. So, it’s crucial to monitor the efficiency of the code ourselves. However, for simple layout groups, custom solutions can be an excellent option.

Conclusions

Rebuilding the layout presents a significant challenge. To address this issue, we must identify its root causes, which can vary. Here are the primary factors that lead to layout rebuilds:


  1. Animation of elements: movement, scale, rotation (any change of the transform)
  2. Replacing sprites
  3. Rewriting text
  4. Turning GO on and off, adding/removing GO
  5. Changing sibling index


It’s important to highlight a few aspects that no longer pose problems in newer versions of Unity but which did in earlier ones: overwriting the same text and repeatedly setting the same value with an animator.


Now that we’ve identified the factors that trigger a layout rebuild, let’s summarize our solution options:


  1. Wrap a GameObject (GO) that triggers a rebuild in a SubCanvas. This approach isolates changes, preventing them from affecting other elements up the hierarchy. However, be cautious — too many SubCanvases can significantly increase the load on batching.


  2. Turn on and off the SubCanvas or Canvas Group instead of the GO. Use an object pool rather than creating new GOs. This method preserves the layout in memory, allowing for quick activation of elements without the need for a rebuild.


  3. Utilize shader animations. Changing the texture using a shader will not trigger a layout rebuild. However, keep in mind that textures might overlap with other elements. This method effectively serves a similar purpose as using SubCanvases, but it does require writing a shader.


  4. Replace Unity’s layout group with a custom layout group. One of the key issues with Unity’s layout groups is that each LayoutElement calls GetComponent during rebuilding, which is resource-intensive. Creating a custom layout group can address this issue, but it has its own challenges. Custom components may have specific operational requirements that you need to understand for effective use. Nonetheless, this approach can be more efficient, especially for simpler layout group scenarios.