Укрощение батчинга за счет оптимизации масок UI

от автора

Часто мы сталкиваемся с задачей оптимизации интерфейсов, и приходится отлаживать то, что давно работает, но периодически усложняется. На таких экранах проблемы могут нарастать как снежный ком — до тех пор, пока не станут заметны невооруженным глазом. И когда придет время улучшить производительность, придется выбирать: либо переделывать все заново и сразу хорошо, либо решать проблемы по очереди.

В какой-то момент мы в War Robots столкнулись с необходимостью оптимизировать экран акций: обнаружилось, что для отрисовки этого экрана Unity совершала более 300 батчей. Для сравнения: куда более сложный экран ангара, содержащий 3D-сцену, 3D- и 2D-интерфейсы, эффекты и анимации, рисовался примерно за 100 батчей.

В этой статье я расскажу о том, как нам удалось починить динамический батчинг, упростить иерархию и поднять FPS в интерфейсе.


Прежде всего, давайте разберемся, что же такое батч. 

Батч (batch) — это одна команда от ЦП, содержащая в себе данные и инструкцию, по которой GPU создает изображение на экране. Один кадр состоит из множества таких батчей — примерно как слои в любом графическом редакторе. Нельзя сказать, что в общем случае уменьшение количества батчей означает больше FPS, но нередко можно получить выигрыш производительности именно за счет такой оптимизации. 

В Unity есть возможность включить автоматический процесс совмещения таких команд — батчинг. Если две или более команды, идущие подряд, должны отрисовываться одним и тем же материалом, то данные от всех этих команд объединяются и отправляются одним батчем.

Как было раньше

Для показа товара по акции создается префаб такого вида:

Как видно, довольно простой префаб сам по себе требует довольно много батчей для своей отрисовки — 26 (в статистике еще учтен один батч от камеры, которая обновляет фон). Но куда хуже картина становится при создании второго такого же префаба:

Количество батчей удвоилось — а значит, у нас полностью сломан батчинг между одинаковыми сущностями! Так происходит из-за того, что мы используем стандартный компонент Unity — Mask. Здесь он нужен для диагональных полос на фоне: 

А вот как это выглядит в иерархии:

Здесь выделены те объекты, к которым применяются маски: 

  • new-back — ограничивает отрисовку изображения границами префаба, 

  • angle-glow — за счет поворота трансформа создают косые ленты. 

Тут стоит отметить, что градиент на лентах достигается за счет подкрашенной текстуры грейскейла: таким образом достигается любой цвет и любая простая форма без использования дополнительных текстур.

Однако в этом же месте и возникает проблемность использования компонента маски: она полностью ломает батчинг. Более того, сам компонент добавляет два батча: до и после отрисовки спрайта, на который воздействует маска, со специальными настройками шейдера. Именно из-за такого поведения оказывается невозможно сбатчить спрайты внутри одного префаба и между соседними префабами.

Таким образом, для отрисовки всего лишь четырех спрайтов требуется десять батчей. Несколько префабов также не могут сбатчиться между собой — а с учетом не самой лучшей оптимизации префабов даже без учета масок количество батчей в реальной ситуации исчислялось бы сотнями.

Что мы сделали

Нам хотелось полностью сохранить визуал, но исправить проблему поломки батчинга. Для этого мы написали собственные маски для работы в UI. При этом необходимо было сделать универсальное решение, не требующее серьезных ресурсов.

Мы объединили вместе два объекта — границу маски и изображение. Идея в том, сразу рисовать изображение уже с примененной на него маской. Для этого нужно создать новый материал и написать для него шейдер, в котором и будет считаться форма маски:

CGPROGRAM     #pragma vertex vert     #pragma fragment frag      #include "UnityCG.cginc"      #pragma multi_compile __ UNITY_UI_ALPHACLIP          struct appdata_t     {         float4 vertex   : POSITION;         float4 color    : COLOR;         float2 texcoord : TEXCOORD0;     };      struct v2f     {         float4 vertex   : SV_POSITION;         fixed4 color    : COLOR;          float2 uv : TEXCOORD0;     };      fixed4 _Color;     fixed4 _TextureSampleAdd;      v2f vert(appdata_t IN)     {         v2f OUT;         OUT.vertex = UnityObjectToClipPos(IN.vertex);         OUT.color = IN.color * _Color;         OUT.uv = IN.texcoord;         return OUT;     }      sampler2D _MainTex;     fixed4 _MainTex_ST;     sampler2D _AlphaTex;     fixed4 _AlphaTex_ST;      fixed4 frag(v2f IN) : SV_Target     {         float4 color = (tex2D(_MainTex, IN.uv * _MainTex_ST.xy + _MainTex_ST.zw) + _TextureSampleAdd) * IN.color;         const float mask_alpha = (tex2D(_AlphaTex, IN.uv * _AlphaTex_ST.xy + _AlphaTex_ST.zw) + _TextureSampleAdd).a;         color.a *= mask_alpha;         return color;     } ENDCG 

Применив такой шейдер, можно избавиться от поломки батчинга — но у него есть одна существенная проблема: для двух разных изображений можно использовать одинаковую текстуру маски только в том случае, если форма, по которой должна браться маска, полностью совпадает. В нашем случае толщина лент отличается, и это требовало использования разных текстур. А тот факт, что ленты должны обрезаться по диагонали, приводило к необходимости использовать эти технические текстуры в высоком разрешении, занимая память. Иначе возникали проблемы анти-алиасинга: изображение становилось ступенчатым.

Поэтому мы начали искать решение дальше и нашли возможность задавать форму маски новым способом. Чтобы его описать, надо вспомнить, как Unity рисует изображение. 

Итак, Image — это компонент, который берет данные из RectTransform с того GameObject, на котором он находится. У RectTransform заданы четыре вершины-координаты, а также четыре стандартные UV-координаты — по одной на каждую вершину: [(0, 0), (1, 0), (0, 1), (1, 1)]. В коде мы можем менять координаты, а также использовать и другие наборы UV-координат: для обычных мешей доступны восемь наборов UV-координат, но Unity UI поддерживает лишь до четырех наборов. Тогда почему бы нам не использовать другие координаты для определения формы маски? Сказано — сделано.

В первую очередь надо убедиться, что в нашем Canvas включен дополнительный UV-канал: 

Теперь, нужно расширить функционал Image так, чтобы он умел читать данные из этого канала и передавать его в меш, откуда будет читать уже шейдер: 

public class ImageWithCustomUV2 : Image { [SerializeField] private Vector2[] _uvs2;  protected override void Start() { base.Start(); if (!canvas.additionalShaderChannels.HasFlag(AdditionalCanvasShaderChannels.TexCoord1)) { canvas.additionalShaderChannels |= AdditionalCanvasShaderChannels.TexCoord1; } }  protected override void OnPopulateMesh(VertexHelper vh) { base.OnPopulateMesh(vh); if (_uvs2?.Length != 4) { return; }  var vertex = new UIVertex(); for (var i = 0; i < 4; ++i) { vh.PopulateUIVertex(ref vertex, i); vertex.uv1 = _uvs2[i]; vh.SetUIVertex(vertex, i); } }

Для упрощения настройки координат мы написали кастомный инспектор:

Код инспектора
[CustomEditor(typeof(ImageWithCustomUV2))] public class ImageWithCustomUV2Inspector : ImageEditor {     private readonly string[] _options = {"Custom", "Rectangle"};     private readonly GUIContent _blLabel = new GUIContent("Bottom left");     private readonly GUIContent _brLabel = new GUIContent("Bottom right");     private readonly GUIContent _tlLabel = new GUIContent("Top left");     private readonly GUIContent _trLabel = new GUIContent("Top right");     private bool _foldout = true;     private int _selectedOption = -1;      public override void OnInspectorGUI()     {         base.OnInspectorGUI();         var prop = serializedObject.FindProperty("_uvs2");         if (prop.arraySize != 4)         {             ResetUVs(prop);         }          _foldout = EditorDrawUtilities.DrawFoldout(_foldout, "UV2");         if (_foldout)         {             EditorGUI.indentLevel++;             DrawUVs(prop);             EditorGUI.indentLevel--;         }          serializedObject.ApplyModifiedProperties();     }      private void DrawUVs(SerializedProperty prop)     {         if (_selectedOption < 0)         {             CheckSelectedOption(prop);         }          _selectedOption = GUILayout.Toolbar(_selectedOption, _options);         switch (_selectedOption)         {             case 1: // rect                 DrawRectOption(prop);                 break;             default: // custom                 DrawCustomOption(prop);                 break;         }     }      private void CheckSelectedOption(SerializedProperty prop)     {         var bl = prop.GetArrayElementAtIndex(0).vector2Value;         var br = prop.GetArrayElementAtIndex(3).vector2Value;         var tl = prop.GetArrayElementAtIndex(1).vector2Value;         var tr = prop.GetArrayElementAtIndex(2).vector2Value;         if (bl.x == tl.x && bl.y == br.y && tr.x == br.x && tr.y == tl.y)         {             _selectedOption = 1;         }         else         {             _selectedOption = 0;         }     }      private void DrawCustomOption(SerializedProperty prop)     {         var w = EditorGUIUtility.labelWidth;          EditorGUIUtility.labelWidth = 100;         EditorGUILayout.BeginHorizontal();         DrawVector2Element(prop, 1, _tlLabel);         DrawVector2Element(prop, 2, _trLabel);         EditorGUILayout.EndHorizontal();         EditorGUILayout.BeginHorizontal();         DrawVector2Element(prop, 0, _blLabel);         DrawVector2Element(prop, 3, _brLabel);         EditorGUILayout.EndHorizontal();         EditorGUIUtility.labelWidth = w;     }      private void DrawRectOption(SerializedProperty prop)     {         var w = EditorGUIUtility.labelWidth;          EditorGUIUtility.labelWidth = 100;          var bl = prop.GetArrayElementAtIndex(0).vector2Value;         var tr = prop.GetArrayElementAtIndex(2).vector2Value;          var min = bl;         var max = tr;         EditorGUILayout.BeginHorizontal();         min = EditorGUILayout.Vector2Field("min", min);         max = EditorGUILayout.Vector2Field("max", max);         EditorGUILayout.EndHorizontal();          if (min != bl || max != tr)         {             prop.ClearArray();             AddVector2(prop, min);             AddVector2(prop, new Vector2(min.x, max.y));             AddVector2(prop, max);             AddVector2(prop, new Vector2(max.x, min.y));         }          EditorGUIUtility.labelWidth = w;     }      private void DrawVector2Element(SerializedProperty array, int index, GUIContent label)     {         var prop = array.GetArrayElementAtIndex(index);         EditorGUILayout.PropertyField(prop, label);     }      private void ResetUVs(SerializedProperty prop)     {         prop.ClearArray();         AddVector2(prop, Vector2.zero);         AddVector2(prop, Vector2.up);         AddVector2(prop, Vector2.one);         AddVector2(prop, Vector2.right);     }      private void AddVector2(SerializedProperty array, Vector2 value)     {         var id = array.arraySize;         array.InsertArrayElementAtIndex(id);         var prop = array.GetArrayElementAtIndex(id);         prop.vector2Value = value;     } }

Сам код шейдера: в нем изменился только расчет UV для текстуры, по которой берется маска:

CGPROGRAM     #pragma vertex vert     #pragma fragment frag      #include "UnityCG.cginc"      #pragma multi_compile __ UNITY_UI_ALPHACLIP          struct appdata_t     {         float4 vertex   : POSITION;         float4 color    : COLOR;         float2 texcoord : TEXCOORD0;         float2 texcoord1 : TEXCOORD1;     };      struct v2f     {         float4 vertex   : SV_POSITION;         fixed4 color    : COLOR;          float2 uv : TEXCOORD0;         float2 uv1 : TEXCOORD1;     };      fixed4 _Color;     fixed4 _TextureSampleAdd;      v2f vert(appdata_t IN)     {         v2f OUT;         OUT.vertex = UnityObjectToClipPos(IN.vertex);         OUT.color = IN.color * _Color;         OUT.uv = IN.texcoord;         OUT.uv1 = IN.texcoord1;         return OUT;     }      sampler2D _MainTex;     fixed4 _MainTex_ST;     sampler2D _AlphaTex;     fixed4 _AlphaTex_ST;      fixed4 frag(v2f IN) : SV_Target     {         float4 color = (tex2D(_MainTex, IN.uv * _MainTex_ST.xy + _MainTex_ST.zw) + _TextureSampleAdd) * IN.color;         const float mask_alpha = (tex2D(_AlphaTex, IN.uv1 * _AlphaTex_ST.xy + _AlphaTex_ST.zw) + _TextureSampleAdd).a;         color.a *= mask_alpha;         return color;     } ENDCG

В качестве маски теперь можно использовать довольно простую текстуру:

Обратите внимание: непрозрачная часть здесь — квадрат, занимающий четверть площади изображения, его окружает прозрачная рамка. Такая текстура позволяет настраивать практически любые четырехугольные формы.

Теперь, после настройки форм масок получается сохранить визуал, упростив иерархию, не ломая батчинг и используя минимальные дополнительные данные: текстуру для маски в формате Alpha8 размером 256×256 занимает в памяти всего 64 КБ.

И самое главное — сохранился батчинг между разными префабами:

Итоги

После всех произведенных действий нам удалось упростить иерархию объектов в Unity и в несколько раз сократить количество батчей: с 300+ до ~70. Значение FPS в экране увеличилось примерно на 5-10%. Платой за это стала чуть более сложная настройка компонентов.

Было:

Стало:

В итоге нам удалось реализовать задуманное: производительность экрана увеличилась, при этом верстка не изменилась, а иерархия объектов упростилась и стала понятнее. Удалось создать универсальный инструмент, который можно использовать в других экранах и элементах интерфейса.

Дополнительные ресурсы оказались минимальными и, опять же, универсальными. Из неприятного — настройка конкретной маски стала немного сложнее, но когда понимаешь, как работает механизм, она уже не составляет труда.


ссылка на оригинал статьи https://habr.com/ru/company/pixonic/blog/596863/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *