Pull to refresh

Оптимизируем PropertyDrawer под Unity3d

Reading time8 min
Views4.8K

В своей предыдущей статье я описал OneLine — PropertyDrawer, позволяющий рисовать объект любой вложенности в одну строку.


В этот раз я расскажу, каким образом мне пришлось оптимизировать код, чтобы в инспекторе можно было свободно редактировать базы данных, состоящих из сотен строк.



Внимание, под катом много гифок и картинок!


Суть проблемы


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


Пример

default


Если посмотрим в профайлер, увидим 4.3 мс на отрисовку 100 элементов массива.


default


OneLine же придумывалась, чтобы избавить разработчика от постоянных кликов мышкой в инспекторе, и все вложенные поля рисуются сразу же. При этом, во время отрисовки производятся достаточно затратные вычисления позиций элементов.


Товарищ Шипилев в своем докладе "Перформанс: что в имени тебе моем?" предлагает график зависимости производительности кода от его сложности (время движется по кривой от A к E):



Описание графика
Это параметрический график: время тут течет от точки «A» до точки «B», «C», «D», «E». По оси ординат у нас производительность, по оси абсцисс — некоторая абстрактная сложность кода.

Обычно всё начинается с того, что люди велосипедят прототип, который медленно, но работает. Он достаточно сложен, потому что мы навелосипедили просто так, чтобы он не разваливался под собственным весом.

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

В точке «B» проект достигает некоторого субъективного пика «красоты», когда у нас вроде и перформанс хороший, и в продукте всё неплохо.

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

Если хочется ещё дальше, то проект приходит в некоторую красную зону, когда разработчики начинают корежить свой продукт, чтобы получить последние проценты производительности. Что делать в этой зоне — не очень понятно. Есть рецепт, по крайней мере, для этой конференции — идёте на JPoint/JokerConf/JBreak и пытаете разработчиков продуктов, как писать код, повторяющий кривизну нижних слоёв. Потому что, как правило, в красной зоне возникают штуки, которые повторяют проблемы, возникающие на нижних слоях.

График редкостно хорош, как и вся статья, очень рекомендую к прочтению.


Движение от A к B — штука довольно скучная и тривиальная. Наша статья охватывает сначала движение от B к С, затем — блуждания вокруг D в поисках правильного костыля/противовеса особенностям Unity.


Кое-кому в голову обязательно придет мысль: "Что за детский сад? И это вы называете блужданиями вокруг D? Где использование особенностей CLR и IL2CPP? Где бенчмарки по всем правилам? Где переусложнение кода ради выигрыша скорости в 0.05%?".


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


Как-то раз я видел, как мой коллега писал PropertyDrawer, отображающий сетку с футпринтом игрового объекта. Он генерировал изображения на лету, попиксельно менял их цвета и делал еще много разных страшных штук. В конечном итоге, отображение одного такого поля могло значительно просадить FPS в редакторе.


Конечно, это не значит: "Если кто-то делает глупости, значит и мне можно". Просто некоторые глупости мы делать вынуждены, а после — оптимизировать.


Как обстояло дело до оптимизаций.

pure


Те же 100 элементов потребовали 104 мс, то есть в ~24 раза больше. Такую скорость можно охарактеризовать одним словом: отвратительно.


pure


Кешируем что можем


OneLine сама вычисляет позиции полей в зависимости от их количества, типа и висящих на них атрибутов (например, [Width] или [Weight]). И каждый вызов OnGUI эти вычисления происходят заново.


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


Решено: будем кешировать!


Для продолжения нам нужно знать следующее: для одного окна инспектора для каждого типа создается лишь один объект PropertyDrawer, который рисует все поля этого типа, а после смены объекта отбрасывается.


А это значит, если у нас на экране два поля одного типа, их рисует один и тот же объект PropertyDrawer. Это несколько усложняет кеширование.


С другой стороны, если у нас один PropertyDrower на один инспектор, значит мы можем сохранять любые данные в Dictionary<string, YourData>, где ключом будет property.propertyPath.


В конце концов, получаем несложный кэш:


public delegate <T> T CalculateValue (SerializedProperty property);

public class PropertyDrawerCache<T> {
    private Dictionary<string, T> cache;
    private CalculateValue<T> calculate;

    public PropertyDrawerCache(CalculateValue<T> calculate){
        cache = new Dictionary<string, T>();
        this.calculate = calculate;
    }

    public T this[SerializedProperty property] {
        get {
            T result = null;
            if (cache.TryGetValue(string, out result)){
                result = calculate(property);
                cache.Add(property.propertyPath, result);
            }
            return result;
        }
    }
}

Что мы получили с кешем.

cache


Первый вызов отрабатывается так же медленно, но все последующие вдвое быстрей. Неплохо, но ощущается все еще неприятно.


При первом вызове:
cache-1


При последующих вызовах:
cache-2


Проблемы с OneLine: Вложенные Массивы


Главная беда кеша: его нужно поддерживать в актуальном состоянии. Мы кешируем вычисленные позиции для полей классов и считаем их неизменными (не будет же структура классов меняться в рантайме). Однако мы не учли одного момента: OneLine умещает в строку также и все элементы дочерних массивов.


Вот так это выглядит


К счастью, проблема решается сбросом кешированных позиций элемента.


public void DrawPlusButton(Rect rect, SerializedProperty array) {
    if (GUI.Button(rect, "+")) {
        array.InsertArrayElementAtIndex(array.arraySize);

        ResetCurrentElementCache();
    }
}

Проблемы с OneLine: Корневой массив


Главная беда кеша: его нужно поддерживать в актуальном состоянии.

В ходе использования OneLine я наткнулся на интересный баг кеширования:


Как это выглядит



То же самое словами:


  • берем массив массивов;
  • добавляем элемент В (копируя элемент А — последний элемент массива);
  • изменяем длину массива в элементе В;
  • удаляем элемент В;
  • добавляем элемент С (копируя элемент А);
  • получаем ситуацию: элемент С имеет длину отличную от элемента В, в то же время в кеше уже лежат рассчитанные позиции для элемента В.

Решение: запоминаем размер для каждого массива и при следующей отрисовке проверяем, не изменился ли массив. Привожу решение только для случая, когда OneLine висит на массиве в корне ScriptableObject, чтобы упростить чтение.


Заодно обращаю внимание читателя на замечательную функцию IsReallyArray в коде.


private Dictionary<string, int> arraysSizes = new Dictionary<string, int>();

public bool IsArraySizeChanged(SerializedProperty arrayElement){
    var arrayName = arrayElement.propertyPath.Split('.')[0];
    var array = arrayElement.serializedObject.FindProperty(arrayName);

    return IsReallyArray(array) 
              && IsRealArraySizeChanged(arrayName, array.arraySize);
}

private bool IsReallyArray(this SerializedProperty property){
    return property.isArray && property.propertyType != SerializedPropertyType.String;
}

private bool IsRealArraySizeChanged(string arrayName, int currentArraySize){
    if (! arraysSizes.ContainsKey(arrayName)){
        arraysSizes[arrayName] = currentArraySize;
    }
    else if (arraysSizes[arrayName] != currentArraySize){
        arraysSizes[arrayName] = currentArraySize;
        return true;
    }

    return false;
}

Не рисуем что можем


В нашем массиве 100 элементов, а на экране видны лишь 20-25 (на гифках), что приводит нас к еще одной стандартной оптимизации: Culling: просто не будем рисовать то, что не влазит в экран!


Для этого нам необходимо знать размеры окна, в котором мы находимся, а также позицию ScrollView. Предлагаю вам решение на грани фола (привет, Unity-Decompiled).


internal class InspectorUtil {

    private const string INSPECTOR_WINDOW_ASSEMBLY_QUALIFIED_NAME =
        "UnityEditor.InspectorWindow, UnityEditor, Version=0.0.0.0, Culture=neutral, PublicKeyToken=null";

    private const string INITIALIZATION_ERROR_MESSAGE = 
        @"OneLine can not initialize Inspector Window Utility. 
You may experience some performance issues. 
Please create an issue on https://github.com/slavniyteo/one-line/ and we will repair it.
";

    private bool enabled = true;

    private MethodInfo getWindowPositionInfo;
    private FieldInfo scrollPositionInfo;
    private object window;

    private float lastWindowWidth;

    public InspectorUtil() {
        try {
            Initialize();
            enabled = true;
        }
        catch (Exception ex){
            // Находимся не в окне инспектора 
            // Или в новой версии Unity изменилась реализация,
            // Оптимизация будет отключена.
            enabled = false;
            Debug.LogError(INITIALIZATION_ERROR_MESSAGE + ex.ToString());
        }
    }

    private void Initialize(){
        var inspectorWindowType = 
                    Type.GetType(INSPECTOR_WINDOW_ASSEMBLY_QUALIFIED_NAME);
        window = inspectorWindowType
                    .GetField("s_CurrentInspectorWindow", 
                                BindingFlags.Public | BindingFlags.Static)
                    .GetValue(null);

        scrollPositionInfo = inspectorWindowType
                                   .GetField("m_ScrollPosition");
        getWindowPositionInfo = inspectorWindowType
                                   .GetProperty("position", typeof(Rect))
                                   .GetGetMethod();
    }

    public bool IsOutOfScreen(Rect position){
        if (! enabled) { return false; }

        var scrollPosition = (Vector2) scrollPositionInfo.GetValue(window);
        var windowPosition = (Rect) getWindowPositionInfo.Invoke(window, null);

        bool above = (position.y + position.height) < scrollPosition.y;
        bool below = position.y > (scrollPosition.y + windowPosition.height);

        return above || below;
    }
}

Затем используем:


public override void OnGUI(Rect position, SerializedProperty property, GUIContent label) {
    if (inspectorUtil.IsOutOfScreen(position)) { return; }    

    <..>
}

Этот код будет работать только в случае, когда объекты отрисовываются в окне инспектора. Если вы используете OneLine (или другой PropertyDrawer) в своем кастомном окне, эта оптимизация работать не будет. Причина, конечно, в жесткой завязке на реализацию прокрутки экрана. Сделать универсальный инструмент в данном случае невозможно. Зато код достаточно прост, его всегда можно адаптировать под свои нужды.


Что мы получили с куллингом.

culling


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


culling


Проблема с куллингом


Если присмотреться к предыдущей гифке, можно заметить. что при прокрутке, фокус элемента "прилипает" к верхнему краю и не уезжает за экран.


Очевидно, Unity запоминает активный элемент на основании его порядкового номера с начала обработки ивента. А так как culling отбрасывает все невидимые элементы, мы этот порядок нарушаем.


Решить проблему можно довольно просто: сбрасывать фокус с контрола при каждом движении колесика.


if (Event.current.type == EventType.ScrollWheel){
    EditorGUI.FocusTextInControl("");
}

Но я это решение так и не интегрировал в OneLine, потому что еще не убедил себя в том, что это решение достаточно хорошим. Когда-нибудь я закопаюсь поглубже и возможно сделаю это чуть лучше.


Соберем все вместе


Что мы получили.

full


При первом вызове:
full-1


При последующих вызовах все происходит значительно быстрее:
full-2


Для сравнения все варианты в одном окне:
all


В результате работы мы получили значительное ускорение работы библиотеки. Я не пишу "десятикратное ускорение", поскольку время отрисовки элементов в значительной степени зависит от высоты окна (если возьмем окно вдвое выше, время также увеличится практически в два раза). Однако и этот результат меня устраивает.




Не буду вставлять уже привычный на Хабре блок с рекламой: кому нужно, тот найдет.


Всем добра!

Tags:
Hubs:
If this publication inspired you and you want to support the author, do not hesitate to click on the button
Total votes 8: ↑8 and ↓0+8
Comments0

Articles