
Часто мы сталкиваемся с задачей оптимизации интерфейсов, и приходится отлаживать то, что давно работает, но периодически усложняется. На таких экранах проблемы могут нарастать как снежный ком — до тех пор, пока не станут заметны невооруженным глазом. И когда придет время улучшить производительность, придется выбирать: либо переделывать все заново и сразу хорошо, либо решать проблемы по очереди.
В какой-то момент мы в 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 размером 256x256 занимает в памяти всего 64 КБ.


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

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

Стало:

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