Надоело писать PropertyDrawer в Unity? Есть способ лучше

в 16:51, , рубрики: unity3d, unity3d editor extension, unity3d property drawer, разработка игр

Не так давно я участвовал в разработке игры на Unity. Много времени уделял инструментам для коллег: редактор уровней для геймдизайнера, удобные базы данных для художников.

По понятным причинам, в программировании интерфейсов под Unity мы не всегда можем использовать автоматическую разметку (удобные средства GUILayout), и нередко приходится вручную нарезать прямоугольники и рисовать интерфейсы средствами класса GUI. Эта работа утомительна, связана с большим количеством ошибок, а код получается сложным в поддержке. Со временем, возникла привычная каждому программисту мысль: напишу свой велосипед! "Должен быть способ лучше!". За подробностями приглашаю под кат.

Надоело писать PropertyDrawer в Unity? Есть способ лучше - 1
Картинка для привлечения внимания взята отсюда.

Первым делом, те самые понятные причины

GUILayout чтобы рассчитать размеры рисуемых полей, вызывает метод OnGUI дважды:

  • В первый проход с event.type == EventType.Layout ничего не рисует, а лишь собирает данные обо всех рисуемых компонентах, чтобы рассчитать размеры прямоугольника для каждого из них;
  • Во время второго прохода все элементы рисуются по-настоящему.

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

Благодаря такой логике, разработчика ждут взрывающие мозг артефакты отрисовки и баги, когда в самописных редакторах (чаще всего это относится к наследникам EditorWindow) он смешивает использование методов как GUILayout, так и GUI. Например, если где-то используется GUILayoutUtility.GetLastRect() или GUILayoutUtility.GetRect() и результат сохраняется в поле, либо логика завязана на делегатах и колбеках.

Проблемы хорошо лечатся проверкой в критических местах:

if (Event.current.type != EventType.Layout) {
    ...
}

О моем отношении к нарезанию прямоугольников

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

// Calculate rects
var amountRect = new Rect(position.x, position.y, 30, position.height);
var unitRect = new Rect(position.x + 35, position.y, 50, position.height);
var nameRect = new Rect(position.x + 90, position.y, position.width - 90, position.height);

// Draw fields - passs GUIContent.none to each so they are drawn without labels
EditorGUI.PropertyField(amountRect, property.FindPropertyRelative("amount"), GUIContent.none);
EditorGUI.PropertyField(unitRect, property.FindPropertyRelative("unit"), GUIContent.none);
EditorGUI.PropertyField(nameRect, property.FindPropertyRelative("name"), GUIContent.none);

Нередко в туториалах делают вот так (я немного изменил код, чтобы подчеркнуть особенности подхода к работе с прямоугольниками):

// Draw first field
position.width -=200;
EditorGUI.PropertyField (position, property.FindPropertyRelative("level"), GUIContent.none);

// Shift rect and draw second field
position.x = position.width + 20;
position.width = position.width - position.width - 10;
EditorGUI.PropertyField (position, property.FindPropertyRelative("type"), GUIContent.none );

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

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

Договоримся о терминах

  • В статье я использую выражение стандартный инспектор, имея в виду стандартный механизм отрисовки полей в инспекторе (стандартный Editor);
  • я также использую выражение базы данных, имея в виду ScriptableObject, хотя весь последующий текст также применим и к MonoBehaviour;
  • говоря GUI, я не делаю различия между классами GUI и EditorGUI; с GUILayout та же история.

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

Когда новичок в Unity хочет вынести в инспектор те или иные настройки, он просто делает поле публичным добавляет к полю [SerializeField] и радуется простоте и удобству редактора. Но когда его база данных разрастется, его ждет суровая реальность: стандартный инспектор ужасен.

Плюсы:

  • Просто добавь воды атрибут (в лучших традициях POJO);
  • Программист больше не тратит драгоценные человеко-часы в попытках убедить ГД в том, что ему не нужно выносить в базу данных ту или иную фичу (знаю на собственном опыте с json базами данных на самописных движках).

Минусы:

  • необходимо постоянно разворачивать вложенные блоки в Инспекторе;
  • невозможно окинуть взглядом всю базу данных, что создает множество проблем в моменты "А не забыл ли я чего?" и "Нужно в последний раз все перепроверить!";
  • информация в таком виде воспринимается как сплошной текст, глазу не за что зацепиться, иногда сложно отделить все свойства одного объекта от свойств другого (частично лечится использованием декораторов вроде [Header], [Range] и т.д.);
  • пространство используется нерационально: под булево значение отводится столько же места, сколько и под строковое.

Немного боли

Наверное, каждый программист встречался c проблемой: добавил [SerializeField], а в инспекторе ничего не появилось? Вся магия в том, что класс поля обязательно должен иметь атрибут [Serializable], что далеко не всегда приходит в голову.

Для наглядности рассмотрим простую базу данных героев популярной саги (содержит наиболее важные характеристики персонажей):

Этот ужасный стандартный инспектор

using System;
using UnityEngine;

[CreateAssetMenu]
public class SimplePeopleDatabase : ScriptableObject {
    [SerializeField]
    private Human[] people;

    [Serializable]
    public class Human {
        [SerializeField]
        private string name;
        [SerializeField]
        private Gender gender;
        [SerializeField, Tooltip("Общее количество ушей")]
        private int earedness;
    }

    public enum Gender {
        Undefined = 0,
        Male = 1,
        Female = 2,
    }
}

Надоело писать PropertyDrawer в Unity? Есть способ лучше - 2

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

Благо, разработчики Unity делают нам предложение, от которого мы просто не можем отказаться: написать свой редактор. Целый CustomEditor писать не будем, достаточно изменить отрисовку класса Human: пишем HumanPropertyDrawer. Помещаю его в том же файле для простоты изложения, однако в реальных проектах так делать не советую: не раз встречал ситуации, когда при достаточном уровне усталости вечером в пятницу в гит попадает using UnityEditor;, не обернутый в директивы препроцессора.

Красивый вариант базы данных

using System;
using UnityEngine;
#if UNITY_EDITOR
using UnityEditor;
#endif

[CreateAssetMenu]
public class SimplePeopleDatabaseWithCustomEditor : ScriptableObject {
    [SerializeField]
    private Human[] people;

    [Serializable]
    public class Human {
        [SerializeField]
        private string name;
        [SerializeField]
        private Gender gender;
        [SerializeField, Tooltip("Общее количество ушей")]
        private int earedness;
    }

    public enum Gender {
        Undefined = 0,
        Male = 1,
        Female = 2,
    }

#if UNITY_EDITOR
    [CustomPropertyDrawer(typeof(Human))]
    public class HumanPropertyDrawer : PropertyDrawer {

        private const float space = 5;

        public override void OnGUI(Rect rect, 
                                   SerializedProperty property, 
                                   GUIContent label) {
            int indent = EditorGUI.indentLevel;
            EditorGUI.indentLevel = 0;

            var firstLineRect = new Rect(
                x: rect.x,
                y: rect.y,
                width: rect.width,
                height: EditorGUIUtility.singleLineHeight
            );
            DrawMainProperties(firstLineRect, property);

            EditorGUI.indentLevel = indent;
        }

        private void DrawMainProperties(Rect rect,
                                        SerializedProperty human){
            rect.width = (rect.width - 2*space) / 3; 
            DrawProperty(rect, human.FindPropertyRelative("name"));
            rect.x += rect.width + space;
            DrawProperty(rect, human.FindPropertyRelative("gender"));
            rect.x += rect.width + space;
            DrawProperty(rect, human.FindPropertyRelative("earedness"));
        }

        private void DrawProperty(Rect rect, 
                                  SerializedProperty property){
            EditorGUI.PropertyField(rect, property, GUIContent.none);
        }
    }
#endif
}

Надоело писать PropertyDrawer в Unity? Есть способ лучше - 3

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

Сложность поддержки кода приводит к его инертности: каждый раз, когда базу данных нужно будет "немножко" изменить, возникнет множество проблем с разъехавшимися позициями, изменившимися именами полей и т.д. Когда в коде появляется рефлексия, о рефакторинге кода можно просто забыть: стандартные средства IDE уже не справляются. Появившиеся ошибки не выявляются на этапе компиляции, и чтобы заметить их, необходимо, чтобы кто-то заглянул в эту базу данных в инспекторе.

Плюсы:

  • никаких вложенных блоков и лишних кликов;
  • размер базы данных существенно сократился, пространство используется рационально;
  • каждый объект в базе данных имеет смысловое отделение от остальных;
  • невероятная гибкость подхода — программист может подстроиться к любым изменениям и сделать базу сколь угодно удобной.

Минусы:

  • небходимость писать дополнительный код;
  • много проблем с поддержкой кода редактора;
  • из-за сложностей с поддержкой редактора рабочий код загнивает;
  • пропали подсказки с полей (те, которые с атрибута [Tootip]), чего никто не ожидал.

Все минусы первого варианта полностью разрешены, однако от его плюсов не осталось и следа.

Просто для примера добавим каждому персонажу еще одно поле 'Питомцы'

using System;
using UnityEngine;
#if UNITY_EDITOR
using UnityEditor;
#endif

[CreateAssetMenu]
public class PeopleDatabaseWithCustomEditor : ScriptableObject {
    [SerializeField]
    private Human[] people;

    [Serializable]
    public class Human {
        [SerializeField]
        private string name;
        [SerializeField]
        private Gender gender;
        [SerializeField, Tooltip("Общее количество ушей")]
        private int earedness;
        [SerializeField]
        private Pet[] pets;
    }

    public enum Gender {
        Undefined = 0,
        Male = 1,
        Female = 2,
    }

    [Serializable]
    public class Pet {
        [SerializeField]
        private PetType type;
        [SerializeField]
        private string name;
        [SerializeField, Tooltip("Может ли быть использован в бою?")]
        private bool combat;
    }

    public enum PetType {
        Undefined = 0,
        Wolf = 1,
        Dragon = 2,
        Raven = 3,
    }

#if UNITY_EDITOR
    [CustomPropertyDrawer(typeof(Human))]
    public class HumanPropertyDrawer : PropertyDrawer {

        private const float space = 5;

        public override float GetPropertyHeight(SerializedProperty property, GUIContent label){
            return EditorGUIUtility.singleLineHeight + 
                   EditorGUI.GetPropertyHeight(property.FindPropertyRelative("pets"), null, true);
        }

        public override void OnGUI(Rect rect, 
                                   SerializedProperty property, 
                                   GUIContent label) {
            int indent = EditorGUI.indentLevel;
            EditorGUI.indentLevel = 0;

            var firstLineRect = new Rect(
                x: rect.x,
                y: rect.y,
                width: rect.width,
                height: EditorGUIUtility.singleLineHeight
            );
            DrawMainProperties(firstLineRect, property);

            var petsRect = new Rect(
                x: rect.x,
                y: rect.y + firstLineRect.height,
                height: rect.height - firstLineRect.height,
                width: rect.width
            );
            DrawPets(petsRect, property.FindPropertyRelative("pets"));

            // Separator
            var lastLine = new Rect(
                x: rect.x,
                y: rect.y + rect.height - EditorGUIUtility.singleLineHeight / 2,
                width: rect.width,
                height: 1
            );
            EditorGUI.DrawRect(lastLine, Color.gray);

            EditorGUI.indentLevel = indent;
        }

        private void DrawMainProperties(Rect rect,
                                        SerializedProperty human){
            rect.width = (rect.width - 2*space) / 3; 
            DrawProperty(rect, human.FindPropertyRelative("name"));
            rect.x += rect.width + space;
            DrawProperty(rect, human.FindPropertyRelative("gender"));
            rect.x += rect.width + space;
            DrawProperty(rect, human.FindPropertyRelative("earedness"));
        }

        private void DrawProperty(Rect rect, 
                                  SerializedProperty property){
            EditorGUI.PropertyField(rect, property, GUIContent.none);
        }

        private void DrawPets(Rect rect, SerializedProperty petsArray){
            var countRect = new Rect(
                x: rect.x,
                y: rect.y,
                height: EditorGUIUtility.singleLineHeight,
                width: rect.width
            );
            var label = new GUIContent("Pets");
            petsArray.arraySize = EditorGUI.IntField(countRect, label, petsArray.arraySize);

            var petsRect = new Rect(
                x: rect.x + EditorGUIUtility.labelWidth,
                y: rect.y,
                width: rect.width - EditorGUIUtility.labelWidth,
                height: 18
            );
            petsArray.isExpanded = true;

            for (int i = 0; i < petsArray.arraySize; i++){
                petsRect.y += petsRect.height;
                EditorGUI.PropertyField(petsRect, petsArray.GetArrayElementAtIndex(i));
            }
            petsArray.isExpanded = true;
        }
    }

    [CustomPropertyDrawer(typeof(Pet))]
    public class PetPropertyDrawer : PropertyDrawer {

        private const float space = 2;

        public override void OnGUI(Rect rect, 
                                   SerializedProperty property, 
                                   GUIContent label) {
            int indent = EditorGUI.indentLevel;
            EditorGUI.indentLevel = 0;

            float combatFlagWidth = 20;
            rect.width = (rect.width - 2*space - combatFlagWidth) / 2; 
            DrawProperty(rect, property.FindPropertyRelative("type"));
            rect.x += rect.width + space;
            DrawProperty(rect, property.FindPropertyRelative("name"));
            rect.x += rect.width + space;
            rect.width = combatFlagWidth;
            DrawProperty(rect, property.FindPropertyRelative("combat"));

            EditorGUI.indentLevel = indent;
        }

        private void DrawProperty(Rect rect, 
                                  SerializedProperty property){
            EditorGUI.PropertyField(rect, property, GUIContent.none);
        }
    }
#endif
}

Надоело писать PropertyDrawer в Unity? Есть способ лучше - 4

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

Решение проблемы

Первое, что приходит в голову: кто-то уже сталкивался с такой проблемой, идем искать в AssetStore. И находим в нем множество ассетов, направленных на улучшение стандартного инспектора: они позволяют размещать в инспекторе кнопки (вызывающие методы объекта), превью текстур, добавляют возможность ветвления и многое другое. Но почти все они платные, да и большинство из них следуют парадигме "одно поле — одна линия", а добавляемые ими функции лишь еще сильней увеличивают вертикальные размеры базы данных.

В общем, хватит вступлений, пора рассказать о том, ради чего мы все здесь собрались. Я написал свой ассет: OneLine.

Что делает OneLine? Рисует в одну строку поле, помеченное атрибутом, вместе со всеми вложенными полями (будет понятно на примерах ниже).

Используем OneLine в первом примере

Добавляем атрибут [OneLine] для отрисовки в одну линию и [HideLabel], чтобы спрятать ненужный в нашем случае заголовок, съедающий треть длины строки.

using System;
using UnityEngine;
using OneLine;

[CreateAssetMenu]
public class SimplePeopleDatabaseWithOneLine : ScriptableObject {
    [SerializeField, OneLine, HideLabel]
    private Human[] people;

    [Serializable]
    public class Human {
        [SerializeField]
        private string name;
        [SerializeField]
        private Gender gender;
        [SerializeField, Tooltip("Общее количество ушей")]
        private int earedness;
    }

    public enum Gender {
        Undefined = 0,
        Male = 1,
        Female = 2,
    }
}

Надоело писать PropertyDrawer в Unity? Есть способ лучше - 5

Используем OneLine во втором примере с животными

Растягивание полей по ширине в OneLine выглядит странно с динамическими массивами (а список питомцев как раз такой), так что жестко задаем всем полям ширину атрибутом [Width]. В этот раз картинка кликабельна.

using System;
using UnityEngine;
using OneLine;

[CreateAssetMenu]
public class PeopleDatabaseWithOneLine : ScriptableObject {
    [SerializeField, OneLine, HideLabel]
    private Human[] people;

    [Serializable]
    public class Human {
        [SerializeField, Width(130)]
        private string name;
        [SerializeField, Width(75)]
        private Gender gender;
        [SerializeField, Tooltip("Общее количество ушей"), Width(25)]
        private int earedness;
        [SerializeField]
        private Pet[] pets;
    }

    public enum Gender {
        Undefined = 0,
        Male = 1,
        Female = 2,
    }

    [Serializable]
    public class Pet {
        [SerializeField, Width(60)]
        private PetType type;
        [SerializeField, Width(100)]
        private string name;
        [SerializeField, Tooltip("Может ли быть использован в бою?")]
        private bool combat;
    }

    public enum PetType {
        Undefined = 0,
        Wolf = 1,
        Dragon = 2,
        Raven = 3,
    }
}

Надоело писать PropertyDrawer в Unity? Есть способ лучше - 6

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

Как все это работает? В основном, благодаря возможности написать PropertyDrawer для атрибута. На чем висит атрибут, то Drawer и рисует. Плюс немного рефлексии, помощь декомпилированного кода библиотек Unity на гитхабе и чуточку воображения. Об основных трудностях на пути напишу чуть позже.

Возможности OneLine:

  • Все работает прямо из коробки: добавляем один атрибут на поле и все, что внутри него отрисуется в одну линию;
  • Если "из коробки" выглядит не очень, есть возможность кастомизации (подсветка полей, управления шириной и т.д.);
  • Нормально обрабатыват подсказки ([Tooltip]) в коде;
  • Бесплатный, отзывчивое сообщество (на гитхабе) из одного человека, готовое помочь;

Ограничения OneLine:

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

Как это работает:

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

  • для расчета прямоугольников проходим по графу детей вглубь, определяем у каждого его относительную ширину на основе веса (weight) и дополнительной ширины (width);
  • для этого разбираемся, как из SerializedProperty узнать, что за поле перед нами: лист, узел или массив (что является особым случаем узла). Кстати, вы знали, что SerializedProperty, указывающее на строку, возвращает property.isArray == true? Вот я не знал;
  • заодно разбираемся, как с помощью property.propertyPath и рефлексии добыть все атрибуты, висящие на поле (и снова костыли, связанные с массивами);
  • пробираемся через дебри особых случаев и исключений;
  • приправляем украшениями вроде [HideLabel] и [Highlight];
  • оптимизируем;
  • ...
  • наслаждаемся результатом.

Препятствия на пути

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

Такой подход приводит к хрупкости кода, ведь в следующей версии редактора OneLine может вдруг прекратить работать корректно. С одной стороны, я надеюсь, что этого не случится. С другой стороны, проблемы решать в любом случае нужно, а необходимого для этого открытого API нет и не предвидится.

Пропадающая подсказка

Когда я писал базы данных для художников, я постоянно расставлял развернутые подсказки к полям с помощью [Tooltip], заменяющие нам документацию. С этим атрибутом в инспекторе достаточно навести мышку на поле, и всплывающее облачко все тебе о нем расскажет.

Проблема: как я писал ранее, простой вызов `EditorGUI.PropertyField(rect, property, content) не отрисовывает подсказки. Что интересно, SerializedProperty содержит свойство tootlip (обратите внимание на исчерпывающую документацию), но оно всегда оказывалось пустым, когда я к нему обращался. ЧЯДНТ?

Решение: берем SerializedProperty.propertyPath и с рефлексией в руках ползем по пути с самого начала (обращаем внимание на массивы), когда доползем до конца, можем узнать все атрибуты поля. Этот метод получения атрибутов поля я использую не только для работы с подсказками.

public static T[] GetCustomAttributes<T>(this SerializedProperty property) where T : Attribute {
    string[] path = property.propertyPath.Split('.');

    bool failed = false;
    var type = property.serializedObject.targetObject.GetType();
    FieldInfo field = null;
    for (int i = 0; i < path.Length; i++) {
        field = type.GetField(path[i], BindingFlags.Public 
                                       | BindingFlags.NonPublic
                                       | BindingFlags.DeclaredOnly 
                                       | BindingFlags.FlattenHierarchy
                                       | BindingFlags.Instance);
        type = field.FieldType;

        // Обычное поле выглядит так: .fieldName
        // Элемент массива выглядит так: .fieldName.Array.data[0]
        int next = i + 1;
        if (next < path.Length && path[next] == "Array") {
            i += 2;
            if (type.IsArray) {
                type = type.GetElementType();
            }
            else {
                type = type.GetGenericArguments()[0];
            }
        }
    }

    return field.GetCustomAttributes(typeof(T), false)
                .Cast<T>()
                .ToArray();
}


Моргающий EditorGUI.DrawRect

В большой базе данных длинная линия, содержащая множество полей, может выглядеть слишком "ровной", то есть снова проявляется проблема "не за что глазу зацепиться". Передо мной встала задача обеспечить механизм выделения особо важных полей в линии.
В соответствии с политикой партии, выделение поля будет производить атрибутом [Highlight].

Реализация очень проста:

  • тем же инструментом, что и в случае с подсказкой, находим на поле атрибут [Highlight];
  • перед отрисовкой поля вызываем EditorGUI.DrawRect(rect, color); и подкрашиваем прямоугольник нужным цветом.

Проблема: в результате известного бага Unity (который помечен Won't fix) подсветка то появляется, то пропадает.

Решение: описано здесь. Выглядит просто. Интересно, по какой причине разработчики Unity отказались его чинить?


Не пропадающие декораторы

Основная фишка OneLine в том, что она должна работать из коробки: добавил один атрибут и все рисуется в одну линию. Но жизнь постоянно устраивает нам разного рода подлянки. И одна из них: декораторы. Это атрибуты [Header], [Space] (и любые другие, которые вы можете написать сами, расширяя DecoratorDrawer). Оказалось, что при обычном вызове `EditorGUI.PropertyField(rect, property, content) все декораторы тоже рисуются.

Проблема:
Надоело писать PropertyDrawer в Unity? Есть способ лучше - 7

Решение: Сначала я пытался найти обходной путь и даже спрашивал на Unity Answers, но без результата. Тогда я порылся в декомпилированных исходниках UnityEditor.dll (здесь), и получил следующее решение:

typeof(EditorGUI)
     .GetMethod("DefaultPropertyField", BindingFlags.NonPublic | BindingFlags.Static)
     .Invoke(null, new object[]{rect, property, GUIContent.none});

Где скачать

Текущая версия OneLine v0.2.0.
За ситуацией Вы можете следить на гитхабе. В ридми функционал описан более подробно.

Автор: SlavniyTeo

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js