Укрощение батчинга за счет оптимизации масок 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%. Платой за это стала чуть более сложная настройка компонентов.
Было:
Стало:
В итоге нам удалось реализовать задуманное: производительность экрана увеличилась, при этом верстка не изменилась, а иерархия объектов упростилась и стала понятнее. Удалось создать универсальный инструмент, который можно использовать в других экранах и элементах интерфейса.
Дополнительные ресурсы оказались минимальными и, опять же, универсальными. Из неприятного — настройка конкретной маски стала немного сложнее, но когда понимаешь, как работает механизм, она уже не составляет труда.