Кастомные элементы в редакторе Unity

Tulenber 24 April, 2020 ⸱ Intermediate ⸱ 10 min ⸱ 2019.3.10f1 ⸱

Предыдущий пост о процедурной генерации предоставляет нам прекрасный повод посмотреть на возможности кастомизации элементов в редакторе Unity.

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

Пятое поколение

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

На мой взгляд, именно геймдев подошёл к созданию таких систем наиболее близко. Самым ярким примером, конечно же, являются blueprints(блюпринты) в UnrealEngine. Unity имеет большой набор сторонних ассетов для тех же целей и уже несколько лет создаёт свои движок визуального программирования(Visual Scripting). Версия 2020 обещает его предварительную версию, хотя явно у этой задачи не самый большой приоритет.

Безусловно, основным плюсом подобного подхода является сильное снижение порога входа. Если брать тот же пример с блюпринтами, то с их помощью вполне можно сделать полноценную игру без использования кода.

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

Если же отбросить визуальное программирование и дополнительные ассеты в Unity, то базовые механизмы по изменению отображения параметров объектов или добавление кастомных редакторов тоже может сильно облегчить жизнь. Для примера воспользуемся проектом, который был реализован в предыдущем посте. Реализуем 2 улучшения, которые сразу бросаются в глаза:

  • Добавим возможность генерации карты высот из редактора, что позволит тестировать результат без перехода в Playmode
  • Создадим кастомный редактора привязки цвета к высоте, что сильно облегчит настройку параметров

CustomEditor

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
using UnityEditor;
using UnityEngine;

[CustomEditor(typeof(NoiseMap))]
public class NoiseMapEditorGenerate : Editor
{
    public override void OnInspectorGUI()
    {
        // Получаем компонент
        NoiseMap noiseMap = (NoiseMap)target;

        // Рисуем стандартный компонент, а также при изменении пересоздаём карту
        if (DrawDefaultInspector())
        {
            noiseMap.GenerateMap();
        }

        // Добавляем кнопку для генерации карты
        if (GUILayout.Button("Generate"))
        {
            noiseMap.GenerateMap();
        }
    }
}

В результате компонент NoiseMap получит дополнительную кнопку, клик по которой создаст и выставит в Renderer карту без запуска сцены.
Noise map

CustomPropertyDrawer и EditorWindow

Напомню, что в предыдущей статье мы генерировали карту высот на основании Шума Перлина.
Previous map

Настройка цвета в зависимости от высоты происходила через объект типа List и выглядела таким образом:
Terrain levels

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

  1. Класс, отвечающий за линейку цветов
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
using System;
using System.Collections.Generic;
using UnityEngine;

[Serializable]
public class TerrainLevels
{
    // Структура, отвечающая за цвет
    [Serializable]
    public struct ColorKey
    {
        [SerializeField] private string name;
        [SerializeField] private Color color;
        [SerializeField] private float height;

        public ColorKey(string name, Color color, float height)
        {
            this.name = name;
            this.color = color;
            this.height = height;
        }

        public string Name => name;
        public Color Color => color;
        public float Height => height;
    }

    [SerializeField] private List<ColorKey> levels = new List<ColorKey>();

    // Количество доступных цветов
    public int LevelsCount => levels.Count;

    // Базовый конструктор
    public TerrainLevels()
    {
        levels.Add(new ColorKey("White", Color.white, 1));
    }

    // Соответствие цвета высоте
    public Color HeightToColor(float height)
    {
        // Базовым является самый высокий цвет
        Color retColor = levels[levels.Count - 1].Color; 
        foreach (var level in levels)
        {
            // Если находим цвет ниже, то выбираем его
            if (height < level.Height)
            {
                retColor = level.Color;
                break;
            }
        }

        return retColor;
    }

    // Текстура для вертикальной линейки
    public Texture2D GenerateTextureVertical(int height)
    {
        Texture2D texture = new Texture2D(1, height);
        return FillTexture(texture, height);
    }

    // Текстура для горизонтальной линейки
    public Texture2D GenerateTextureHorizontal(int width)
    {
        Texture2D texture = new Texture2D(width, 1);
        return FillTexture(texture, width);
    }

    // Заполнение текстуры цветом высот
    private Texture2D FillTexture(Texture2D texture, int size)
    {
        texture.wrapMode = TextureWrapMode.Clamp;
        texture.filterMode = FilterMode.Point;

        // Проще задать текстуре весь массив цветов разом
        Color[] colors = new Color[size];
        for (int i = 0; i < size; i++)
        {
            // Заполняем линейку цветов доступными
            colors[i] = HeightToColor((float) i / (size - 1));
        }

        texture.SetPixels(colors);
        texture.Apply();

        return texture;
    }

    // Получить данные о цвете
    public ColorKey GetKey(int i)
    {
        return levels[i];
    }

    // Добавить цвет
    public int AddKey(Color color, float height)
    {
        ColorKey newKey = new ColorKey("New key", color, height);
        for (int i = 0; i < levels.Count; i++)
        {
            if (newKey.Height < levels[i].Height)
            {
                // Сохраняем список отсортированным по высотам
                levels.Insert(i, newKey);
                return i;
            }
        }
        
        levels.Add(newKey);

        return levels.Count - 1;
    }

    // Удаляем цвет
    public void RemoveKey(int index)
    {
        if (levels.Count > 1)
        {
            levels.RemoveAt(index);    
        }
    }

    // Обновляем цвет
    public void UpdateKeyColor(string name, int index, Color newColor)
    {
        levels[index] = new ColorKey(name, newColor, levels[index].Height);
    }

    // Обновляем положение цвета на шкале
    public int UpdateKeyHeight(int keyIndex, float newHeight)
    {
        Color col = levels[keyIndex].Color;
        RemoveKey(keyIndex);
        return AddKey(col, newHeight);
    }
}
  1. Кастомный отрисовщик свойства для нашего класса линейки цветов
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
using UnityEditor;
using UnityEngine;

[CustomPropertyDrawer(typeof(TerrainLevels))]
public class TerrainLevelsPropertyDrawer : PropertyDrawer
{
    public override void OnGUI(Rect position, SerializedProperty property, GUIContent label)
    {
        Event guiEvent = Event.current;

        // Получаем объект с линейкой цветов
        TerrainLevels terrainLevels = (TerrainLevels) fieldInfo.GetValue(property.serializedObject.targetObject);

        // Рассчитываем размер заголовка компонента и прямоугольник для отрисовки линейки
        float labelWidth = GUI.skin.label.CalcSize(label).x + 5;
        Rect textureRect = new Rect(position.x + labelWidth, position.y, position.width - labelWidth, position.height);

        // Перерисовываем линейку
        if (guiEvent.type == EventType.Repaint)
        {
            GUI.Label(position, label);
            GUI.DrawTexture(textureRect,terrainLevels.GenerateTextureHorizontal((int)position.width));
        }

        // Открываем окно редактора
        if (guiEvent.type == EventType.MouseDown 
            && guiEvent.button == 0 
            && textureRect.Contains(guiEvent.mousePosition))
        {
            TerrainLevelsEditor window = EditorWindow.GetWindow<TerrainLevelsEditor>();
            window.SetTerrainLevels(terrainLevels);
        }
    }
}
  1. Редактор линейки цветов
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
using UnityEditor;
using UnityEngine;
using Random = UnityEngine.Random;

public class TerrainLevelsEditor : EditorWindow
{
    // Линейка цветов
    private TerrainLevels _terrainLevels;

    // Прямоугольник для отрисовки линейки
    private Rect _levelRulerRect;
    // Прямоугольники конечных цветов
    private Rect[] _keyRects;

    // Ширина линейки
    private const int LevelRullerWidth = 25;

    // Ключевые размеры
    private const int BorderSize = 10;
    private const float KeyWidth = 60;
    private const float KeyHeight = 20;

    // Поддержка редактирования
    private bool _mouseDown = false;
    private int _selectedKeyIndex = 0;

    private bool _repaint = false;

    private void OnEnable()
    {
        // При открытии окна выставляем параметры заголовка и размеров
        titleContent.text = "Terrain level editor";
        position.Set(position.x, position.y, 300, 400);
        minSize = new Vector2(300, 400);
        maxSize = new Vector2(300, 1500);
    }

    // Помечаем сцену грязной при закрытии редактора
    private void OnDisable()
    {
        UnityEditor.SceneManagement.EditorSceneManager.MarkSceneDirty(UnityEngine.SceneManagement.SceneManager.GetActiveScene());
    }

    // Передаём в редактор линейку цветов
    public void SetTerrainLevels(TerrainLevels levels)
    {
        _terrainLevels = levels;
    }

    private void OnGUI()
    {
        // Базовая отрисовка редактора
        Draw();
        Input();

        if (_repaint)
        {
            _repaint = false;
            Repaint();
        }
    }

    private void Draw()
    {
        // Отрисовываем линейку цветов
        _levelRulerRect = new Rect(BorderSize,BorderSize, LevelRullerWidth, position.height - BorderSize * 2);
        GUI.DrawTexture(_levelRulerRect, _terrainLevels.GenerateTextureVertical((int)_levelRulerRect.height));

        // Отрисовываем конкретные цвета
        _keyRects = new Rect[_terrainLevels.LevelsCount];
        for (int i = 0; i < _terrainLevels.LevelsCount; i++)
        {
            TerrainLevels.ColorKey key = _terrainLevels.GetKey(i);
            Rect keyRect = new Rect(_levelRulerRect.xMax + BorderSize, _levelRulerRect.height - _levelRulerRect.height * key.Height, KeyWidth, KeyHeight);

            // Для текущего выбранного цвета рисуем рамку
            if (i == _selectedKeyIndex)
            {
                EditorGUI.DrawRect(new Rect(keyRect.x - 2, keyRect.y - 2, keyRect.width + 4, keyRect.height + 4), Color.black);
            }

            // Рисуем цвет
            EditorGUI.DrawRect(keyRect, key.Color);

            // В зависимости от яркости конкретного цвета выбираем цвет шрифта, для удобства чтения
            float brightness = key.Color.r * 0.3f + key.Color.g * 0.59f + key.Color.b * 0.11f;
            GUIStyle style = new GUIStyle();
            style.normal.textColor = brightness < 0.4 ? Color.white: Color.black;

            // На прямоугольник с цветом накладываем значение высоты
            EditorGUI.LabelField(keyRect, key.Height.ToString("F"), style);
            
            _keyRects[i] = keyRect;
        }

        // Прямоугольник для отображения полей настройки цвета
        Rect settingsRect = new Rect(BorderSize*3 + LevelRullerWidth + KeyWidth, BorderSize, position.width - (BorderSize*4 + LevelRullerWidth + KeyWidth), position.height - BorderSize * 2);
        GUILayout.BeginArea(settingsRect);
        // Слушаем изменение параметров
        EditorGUI.BeginChangeCheck();

        // Поле ввода имени для цвета
        GUILayout.BeginHorizontal();
        GUILayout.Label("Name");
        string nameText = EditorGUILayout.TextField(_terrainLevels.GetKey(_selectedKeyIndex).Name);
        GUILayout.EndHorizontal();

        // Выбор самого цвета
        Color newColor = EditorGUILayout.ColorField(_terrainLevels.GetKey(_selectedKeyIndex).Color);
        // При изменении параметров обновляем цвет в линейке
        if (EditorGUI.EndChangeCheck())
        {
            _terrainLevels.UpdateKeyColor(nameText, _selectedKeyIndex, newColor);
        }

        // Кнопка удаления цвета
        if (GUILayout.Button("Remove"))
        {
            _terrainLevels.RemoveKey(_selectedKeyIndex);
            if (_selectedKeyIndex >= _terrainLevels.LevelsCount)
            {
                _selectedKeyIndex--;
                _repaint = true;
            }
        }
        GUILayout.EndArea();
    }

    private void Input()
    {
        Event guiEvent = Event.current;
        // Добавляем цвет по клику мышки на редакторе
        if (guiEvent.type == EventType.MouseDown && guiEvent.button == 0)
        {
            for (int i = 0; i < _keyRects.Length; i++)
            {
                if (_keyRects[i].Contains(guiEvent.mousePosition))
                {
                    _mouseDown = true;
                    _selectedKeyIndex = i;
                    _repaint = true;
                    break;
                }
            }

            if (!_mouseDown)
            {
                // Инициализируем случайным цветом
                Color randomColor = new Color(Random.value, Random.value, Random.value);
                // Определяем высоту для цвета в диапазоне [0,1], по координатам относительно линейки
                float keyHeight = Mathf.InverseLerp(_levelRulerRect.y, _levelRulerRect.yMax, guiEvent.mousePosition.y);
                // Шкала цвета обратно направлена координатам редактора
                _selectedKeyIndex = _terrainLevels.AddKey(randomColor, 1 - keyHeight);
                _mouseDown = true;
                _repaint = true;
            }
        }

        // Сбрасываем отслеживание клика
        if (guiEvent.type == EventType.MouseUp && guiEvent.button == 0)
        {
            _mouseDown = false;
        }

        // Перетаскивание цвета при движении мышки
        if (_mouseDown && guiEvent.type == EventType.MouseDrag && guiEvent.button == 0)
        {
            // Определяем высоту для цвета в диапазоне [0,1], по координатам относительно линейки
            float keyHeight = Mathf.InverseLerp(_levelRulerRect.y, _levelRulerRect.yMax, guiEvent.mousePosition.y);
            // Шкала цвета обратно направлена координатам редактора
            _selectedKeyIndex = _terrainLevels.UpdateKeyHeight(_selectedKeyIndex, 1 - keyHeight);
            _repaint = true;
        }
    }
}

В результате наше кастомное свойство в компоненте будет выглядеть как горизонтальная линейка
Property drawer

А редактор позволит легко её редактировать
Editor

Заключение

Unity имеет практически бесконечные возможности для кастомизации интерфейса в целях упрощения жизни, однако, не стоит забывать о стоимости разработки. В нашем примере мы заменили 10 строк изначальной версии с List, на 300 строк кода кастомного редактора. Так что принимая решение о создании кастомных редакторов не забывайте, что любая работа занимает время, причём не только время на создание, но и на поддержку, так как наверняка новая версия Unity, если и не привнесёт новых багов, то вполне может поменять Api. Пока! =)



Privacy policyCookie policyTerms of service
Tulenber 2020