Всем привет! Меня зовут Гриша, и я основатель 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д просто и быстро.

Комментарии (0)