Pull to refresh
0
Pixonic
Developing and publishing games since 2009

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

Reading time8 min
Views5.8K

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

В какой-то момент мы в 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%. Платой за это стала чуть более сложная настройка компонентов.

Было:

Стало:

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

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

Tags:
Hubs:
+21
Comments2

Articles

Information

Website
pixonic.com
Registered
Founded
Employees
201–500 employees
Location
Кипр