Всем привет! Меня зовут Гриша, и я основатель 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, которая отвечает за хранение информации о планировке в целом
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, и то как он работает.
[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д просто и быстро.
Автор: DyadichenkoGA