Расширение редактора Unity через Editor Window, Scriptable Object и Custom Editor

    Всем привет! Меня зовут Гриша, и я основатель CGDevs. Сегодня хочется поговорить про расширения редактора и рассказать про один из моих проектов, который я решил выложить в OpenSource.

    Юнити — прекрасный инструмент, но в нём есть небольшая проблема. Новичку, чтобы сделать простую комнату (коробку с окнами), необходимо либо осваивать 3д моделирование, либо пытаться что-то собрать из квадов. Недавно стал полностью бесплатным ProBuilder, но это так же упрощённый пакет 3д моделирования. Хотелось простой инструмент, который позволит быстро создавать окружения вроде комнат со окнами и правильными UV при этом. Достаточно давно я разработал один плагин для Unity, который позволяет быстро прототипировать окружения вроде квартир и комнат с помощью 2д чертежа, и сейчас решил выложить его в OpenSource. На его примере мы разберём, каким образом можно расширять редактор и какие инструменты для этого существуют. Если вам интересно – добро пожаловать под кат. Ссылка на проект в конце, как всегда, прилагается.



    Unity3d обладает достаточно широким инструментарием для расширения возможностей редактора. Благодаря таким классам, как EditorWindow, а также функционалу Custom Inspector, Property Drawer и TreeView (+ скоро должны появиться UIElements) поверх юнити легко надстраивать свои фреймворки разной степени сложности.

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



    В основе решения лежит использование трёх классов, таких как EditorWindow (все дополнительные окна), ScriptableObject (хранение данных) и CustomEditor (дополнительный функционал инспектора для Scriptable Object).

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

    Поговорим про интересные задачи.

    Для того, чтобы нам прототипировать что-то, в первую очередь нам надо научиться рисовать чертежи, из которых мы будем генерировать наше окружение. Для этого нам необходимо специальное окно EditorWindow, в котором мы будем отображать все чертежи. В принципе можно было бы рисовать и в SceneView, но изначальная идея заключалось в том, что при доработке решения может захотеться открывать несколько чертежей одновременно. В целом в юнити создать отдельное окно — это достаточно простая задача. Об этом можно почитать в мануалах Unity. А вот чертёжная сетка – задача поинтереснее. На эту тему есть несколько проблем.

    В Юнити несколько стилей, которые влияют на расцветку окон

    Дело в том, что большинство использующих Pro версию Unity используют тёмную тему, а во бесплатной версии доступна только светлая. Тем не менее, цвета, которые используются в редакторе чертежей, не должны сливаться с фоном. Тут можно придумать два решения. Сложное – сделать свою версию стилей, проверять её и изменять палитру под версию юнити. И простое — залить фон окна определённым цветом. При разработке было решено использовать простой путь. Пример того, как это можно сделать — вызвать в OnGUI методе такой код.

    Закраска определённым цветом
       GUI.color = BgColor;
          GUI.DrawTexture(new Rect(Vector2.zero, maxSize), EditorGUIUtility.whiteTexture);
          GUI.color = Color.white;



    В сущности мы просто отрисовали текстуру цвета BgColor во всё окно.



    Отрисовка и перемещение сетки

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

    Пересчёт координат окна в координаты экрана
    
          public Vector2 GUIToGrid(Vector3 vec)
            {
                Vector2 newVec = (
                    new Vector2(vec.x, -vec.y) - new Vector2(_ParentWindow.position.width / 2, -_ParentWindow.position.height / 2)) 
                    * _Zoom + new Vector2(_Offset.x, -_Offset.y);
                return newVec.RoundCoordsToInt();
            }
            public Vector2 GridToGUI(Vector3 vec)
            {
                return (new Vector2(vec.x - _Offset.x, -vec.y - _Offset.y) ) / _Zoom 
                    + new Vector2(_ParentWindow.position.width / 2, _ParentWindow.position.height / 2);
            }
    



    где _ParentWindow — это окно в котором мы собираемся рисовать сетку, _Offset — текущая позиция грида, а _Zoom — степень приближения.

    Во-вторых, для отрисовки линий нам потребуется метод Handles.DrawLine. Класс Handles имеет внутри себя много полезных методов для отрисовки простой графики в окнах редактора, инспекторе или SceneView. На момент разработки плагина (Unity 5.5) Handles.DrawLine – аллоцировало память и в целом работало достаточно медленно. По этой причине количество возможных линий для отрисовки было ограничено константой CELLS_IN_LINE_COUNT , а также сделан “LOD level” при зуме, чтобы добиться приемлемого fps в редакторе.

    Отрисовка сетки
    
        void DrawLODLines(int level)
            {
                var gridColor = SkinManager.Instance.CurrentSkin.GridColor;
                var step0 = (int) Mathf.Pow(10, level);
                int halfCount = step0 * CELLS_IN_LINE_COUNT / 2 * 10;
                var length = halfCount * DEFAULT_CELL_SIZE;
                int offsetX = ((int) (_Offset.x / DEFAULT_CELL_SIZE)) / (step0 * step0) * step0;
                int offsetY = ((int) (_Offset.y / DEFAULT_CELL_SIZE)) / (step0 * step0) * step0;
                for (int i = -halfCount; i <= halfCount; i += step0)
                {
                    Handles.color = new Color(gridColor.r, gridColor.g, gridColor.b,  0.3f);
                        
                    Handles.DrawLine(
                        GridToGUI(new Vector2(-length + offsetX * DEFAULT_CELL_SIZE, (i + offsetY) * DEFAULT_CELL_SIZE)),
                        GridToGUI(new Vector2(length  + offsetX * DEFAULT_CELL_SIZE, (i + offsetY) * DEFAULT_CELL_SIZE))
                    );
                    Handles.DrawLine(
                        GridToGUI(new Vector2((i + offsetX) * DEFAULT_CELL_SIZE, -length + offsetY * DEFAULT_CELL_SIZE)),
                        GridToGUI(new Vector2((i + offsetX) * DEFAULT_CELL_SIZE, length + offsetY * DEFAULT_CELL_SIZE))
                    );
                }
                offsetX = (offsetX / (10 * step0)) * 10 * step0;
                offsetY = (offsetY / (10 * step0)) * 10 * step0; ;
                for (int i = -halfCount; i <= halfCount; i += step0 * 10)
                {
                    Handles.color = new Color(gridColor.r, gridColor.g, gridColor.b,  1);
                    Handles.DrawLine(
                        GridToGUI(new Vector2(-length + offsetX * DEFAULT_CELL_SIZE, (i + offsetY) * DEFAULT_CELL_SIZE)),
                        GridToGUI(new Vector2(length + offsetX * DEFAULT_CELL_SIZE, (i + offsetY) * DEFAULT_CELL_SIZE))
                    );
                    Handles.DrawLine(
                        GridToGUI(new Vector2((i + offsetX) * DEFAULT_CELL_SIZE, -length + offsetY * DEFAULT_CELL_SIZE)),
                        GridToGUI(new Vector2((i + offsetX) * DEFAULT_CELL_SIZE, length + offsetY * DEFAULT_CELL_SIZE))
                    );
                }
            }
    



    Для грида почти всё готово. Его движение описывается очень просто. _Offset – это в сущности нынешняя позиция «камеры».

    Движение грида
     public void Move(Vector3 dv)
            {
                var x = _Offset.x + dv.x * _Zoom;
                var y = _Offset.y + dv.y * _Zoom;
                _Offset.x = x;
                _Offset.y = y;
            }



    В самом проекте можно ознакомиться с кодом окна в общем и посмотреть, каким образом на окно можно добавить кнопки.

    Едем дальше. Помимо отдельного окна для отрисовки чертежей нам надо как-то хранить сами чертежи. Для этого отлично подходит внутренний механизм сериализации Unity – Scriptable Object. По сути, он позволяет хранить описанные классы в виде ассетов в проекте, что очень удобно и нативно для многих юнити разработчиков. Для примера, часть класса Apartment, которая отвечает за хранение информации о планировке в целом

    Часть класса Apartment
        public class Apartment : ScriptableObject
        {
            #region fields
    
            public float Height;
    
            public bool IsGenerateOutside;
    
            public Material OutsideMaterial;
    
            public Texture PlanImage;
    
            [SerializeField] private List<Room> _Rooms;
            [SerializeField] private Rect _Dimensions;
    
            private Vector2[] _DimensionsPoints = new Vector2[4];
    
            #endregion



    В редакторе он выглядит в текущей версии так:



    Тут, конечно, уже применён CustomEditor, но тем не менее можно заметить, что такие параметры, как _Dimensions, Height, IsGenerateOutside, OutsideMaterial и PlanImage отображаются в редакторе.

    Все публичные поля и поля, помеченные [SerializeField] – сериализуются (то есть сохраняются в файле в данном случае). Это сильно помогает при необходимости сохранять чертежи, но при работе со ScriptableObject, да и всеми ресурсами редактора необходимо помнить, что лучше для сохранения состояния файлов вызывать метод AssetDatabase.SaveAssets(). Иначе изменения не сохранятся. Если вы только руками не сохраните проект.

    Теперь частично разберём класс ApartmentCustomInspector, и то как он работает.

    Класс ApartmentCustomInspector
       [CustomEditor(typeof(Apartment))]
        public class ApartmentCustomInspector : Editor
        {
            private Apartment _ThisApartment;
    
            private Rect _Dimensions;
    
            private void OnEnable()
            {
                _ThisApartment = (Apartment) target;
                _Dimensions = _ThisApartment.Dimensions;
    
            }
    
            public override void OnInspectorGUI()
            {
                TopButtons();
                _ThisApartment.Height = EditorGUILayout.FloatField("Height (cm)", _ThisApartment.Height);
    
                var dimensions = EditorGUILayout.Vector2Field("Dimensions (cm)", _Dimensions.size).RoundCoordsToInt();
                _ThisApartment.PlanImage = (Texture) EditorGUILayout.ObjectField(_ThisApartment.PlanImage, typeof(Texture), false);
    
                _ThisApartment.IsGenerateOutside = EditorGUILayout.Toggle("Generate outside (Directional Light)", _ThisApartment.IsGenerateOutside);
                if (_ThisApartment.IsGenerateOutside)
                    _ThisApartment.OutsideMaterial = (Material) EditorGUILayout.ObjectField(
                        "Outside Material",
                        _ThisApartment.OutsideMaterial,
                        typeof(Material),
                        false);
                GenerateButton();
    
                var dimensionsRect = new Rect(-dimensions.x / 2, -dimensions.y / 2, dimensions.x, dimensions.y);
    
                _Dimensions = dimensionsRect;
    
                _ThisApartment.Dimensions = _Dimensions;
            }
    
    
            private void TopButtons()
            {
                GUILayout.BeginHorizontal();
                CreateNewBlueprint();
                OpenBlueprint();
                GUILayout.EndHorizontal();
            }
    
            private void CreateNewBlueprint()
            {
                if (GUILayout.Button(
                    "Create new"
                ))
                {
                    var manager = ApartmentsManager.Instance;
                    manager.SelectApartment(manager.CreateOrGetApartment("New Apartment" + GUID.Generate()));
                }
            }
            private void OpenBlueprint()
            {
                if (GUILayout.Button(
                    "Open in Builder"
                ))
                {
                    ApartmentsManager.Instance.SelectApartment(_ThisApartment);
                    ApartmentBuilderWindow.Create();
                }
            }
    
            private void GenerateButton()
            {
                if (GUILayout.Button(
                    "Generate Mesh"
                ))
                {
                    MeshBuilder.GenerateApartmentMesh(_ThisApartment);
                }
            }
        }


    CustomEditor – это очень мощный инструмент, позволяющий решать элегантно множество типовых задач по расширению редактора. В паре с ScriptableObject он позволяет делать простые, удобные и понятные расширения редактора. Этот класс немного сложнее простого добавления кнопок, так как в исходном классе можно заметить, что сериализуется поле [SerializeField] private List _Rooms. Отображение его в инспекторе, во-первых, ни к чему, во-вторых – это может вести к непредвиденным багам и состояниям чертежа. За отрисовку инспектора отвечает метод OnInspectorGUI, и, если вам необходимо просто добавить кнопки, то вы можете вызвать в нём метод DrawDefaultInspector() и все поля будут отрисованы.

    Тут же вручную отрисовываются необходимые поля и кнопки. Класс EditorGUILayout в себе имеет много реализаций для самых разных видов полей, поддерживаемых юнити. Но отрисовка кнопок в Unity реализована в классе GUILayout. Как в данном случае работает обработка нажатия кнопок. OnInspectorGUI – отрабатывает на каждое событие пользовательского ввода мышью (перемещение мыши, нажатие клавиш мыши внутри окна редактора и т.п.) Если пользователь сделал клик мышью в баундинг боксе кнопки, то метод возвращает true и отрабатывают методы, которые находятся внутри описанного вами if’a. Для примера:

    Кнопка генерации меша
    private void GenerateButton()
            {
                if (GUILayout.Button(
                    "Generate Mesh"
                ))
                {
                    MeshBuilder.GenerateApartmentMesh(_ThisApartment);
                }
            }


    При нажатии на кнопку Generate Mesh вызывается статический метод, отвечающий за генерацию меша конкретной планировки.

    Кроме этих базовых механизмов, используемых при расширении редактора Unity, хотелось бы отдельно отметить очень простой и очень удобный инструмент, про который почему-то многие забывают – Selection. Selection – это статический класс, позволяющий вам выделять в инспекторе и ProjectView необходимые объекты.

    Для того, чтобы выбрать какой-то объект, вам просто необходимо написать Selection.activeObject = MyAwesomeUnityObject. И самое прекрасное, что он работает со ScriptableObject. В данном проекте он отвечает за выбор чертежа и комнат в окне с чертежами.

    Спасибо за внимание! Надеюсь, статья и проект будут полезны вам, и вы почерпнёте для себя что-то новое в одном из подходов расширения редактора Unity. И как всегда – ссылка на GitHub проект, где можно посмотреть проект целиком. Он пока немного сыроват, но тем не менее уже позволяет делать планировки в 2д просто и быстро.
    • +20
    • 5,7k
    • 2
    Поделиться публикацией

    Комментарии 2

      0
      А я думал будет текст о генерации комнаты. :(

      Но сам ассет натолкнул на идею сделать генератор по мотивам, только для 2D. Что бы клепать уровни как горячие пирожки.
        0
        Исходники открытые, так что можно изучить и посмотреть как реализовано, чтобы потом использовать уже в чём-то своём. Сейчас я готовлю статью по аффинным преобразованиям. Потом может руки написать про триангуляции и генерацию процедурных мешей в целом.

        Генерация меша комнаты не сказать, что что-то супер сложное. Там чуть-чуть пришлось покопаться, чтобы была возможность задавать толщину стен, а в остальном всё +- просто. Вот сам класс отвечающий за генерацию меша (всего 253 строки) (для триангуляции используется Poly2Mesh)

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

      Самое читаемое