Разделяем и властвуем над интерфейсами в Unity

Рассказываю как создавать кастомные UI элементы через создание Mesh’а, используя Unity и UI Toolkit.

Кастомный градиентный ромб
Кастомный градиентный ромб

Что делаем?

В рамках статьи будем разбирать возможности генерации кастомных элементов интерфейса с использованию UI Toolkit.

Сперва будет скурпулезный разбор создание треугольника на основе генерации мэша, а вторым – градиентный ромб!

Глава 1: Mesh и треугольник!

В рамках данной главы, рассмотрим как сделать кастомный элемент треугольника, так же расскажу в целом, как работает эта система.

Поэтому, читаем код и комментарии к нему, чтобы потом смогли дома повторить: ).

Наш треугольник выглядит так: слева на сцене, справа в UI Builder.
Наш треугольник выглядит так: слева на сцене, справа в UI Builder.

Сперва будет приложен код (для самых умных и затейливых), а потом будет подробный разбор большинства моментов, поехали!

using UnityEngine; using UnityEngine.UIElements; namespace CustomUI { public class TriangleElement : VisualElement { public new class UxmlFactory : UxmlFactory<TriangleElement> { }; public TriangleElement() { generateVisualContent += GenerateVisualContent; } Vertex[] vertices = new Vertex[3]; ushort[] indices = { 0, 1, 2 }; void GenerateVisualContent(MeshGenerationContext mgc) { vertices[0].tint = Color.red; vertices[1].tint = Color.red; vertices[2].tint = Color.red; var leftCorner = 0f; var rightCorner = contentRect.width; var top = 0; var bottom = contentRect.height; var middleX = contentRect.width / 2; vertices[0].position = new Vector3(leftCorner, bottom, Vertex.nearZ); vertices[1].position = new Vector3(middleX, top, Vertex.nearZ); vertices[2].position = new Vector3(rightCorner, bottom, Vertex.nearZ); MeshWriteData mwd = mgc.Allocate(vertices.Length, indices.Length); mwd.SetAllVertices(vertices); mwd.SetAllIndices(indices); } } }

Так выглядит весь код, теперь будем разбираться, что к чему!

Начнем с базы, объявления нашего класса – TriangleElement

public class TriangleElement : VisualElement { .... }

Для создания кастомного UI элемента используется наследование класса VisualElement. (Есть и другие способы, но сейчас я не буду углубляться в детали).

Мы будем использовать некоторые методы и данные из родительского класса, такие как contentRect и generateVisualElement;

Идем дальше – UxmlFactory и отображение элемента в иерархии:

public new class UxmlFactory : UxmlFactory<TriangleElement> { };

Это относится к особенностям UI Toolkit, она нужна для того чтобы наш TriangleElement добавился в общий список UI элементов.

После этого, мы сможем его легко перетаскивать на окно верстки.

Наш элемент отобразился в секции Custom Controls
Наш элемент отобразился в секции Custom Controls

Более подробно про создание кастомых элементов, можно почитать в документации:

С этим разобрались, идем дальше:

public TriangleElement() { generateVisualContent += GenerateVisualContent; }

Как вы многие знаете, одноименно классу называется метод который мы называем — конструктором и он вызывается когда создается наш объект.

Конкретно у нас, он подписывается на Action родительского класса (тот самый который упоминался выше)

(Как чеховское ружье, однажды подписавшись, оно обязательно вызовется)

Какой Action родительского класса?

/// <summary> /// <para> /// Called when the VisualElement visual contents need to be (re)generated. /// </para> /// </summary> public Action<MeshGenerationContext> generateVisualContent { get; set; }

Если кратко, он активируется когда нашему VisualElement'у будет необходимо перегенерировать себя, это как правило происходит, если были изменения в UI элементе (изменение свойств, размеров) или был вызван метод MarkDirtyRepaint()

Также оставлю ссылку на документацию:

Когда мы ловим этот Aciton, мы также получаем MeshGenerationContext и благодаря нему, можем дополнительно рендерить геометрию на элементе.

Mетод возвращает нам значение типа – MeshGenerationContext, его разберем чуть позднее.

Сделаем небольшое отступление и сделаем введение в игровые движки – поговорим про Mesh.

Мэш представляет собой набор вершин, рёбер и полигонов, которые определяют форму и структуру объекта.

Для создания кастомного элемента, мы как раз таки и создаем этот самый мэш, но только на уровне интерфейса и в 2D пространстве.

Дальше будет разбор минимальных данных которые нужны для создания такого объекта:

Vertex[] vertices = new Vertex[3]; ushort[] indices = { 0, 1, 2 };

Это наши бравые слоны, господа «Vertices» и «Indices», далее будем называть их вершинами и индексами.

Vertex это внутрняя структура Unity UI Toolkit, который используется в отрисовки UI элементов, главное для нас это:

public struct Vertex { ... public Vector3 position; // Позиция на которой находится вершина public Color32 tint; // Соотвествующая цветовой оттенок ... }

Под капотом у нее Vector3 — позиция вершины и Color32 (32 битное представление RGBA) — цветовой оттенок.

Вывод – вершины описывают свое местоположение и какого цвета они будут.

Дальше у нас идут индексы, они отвечают за соединение вершин мэш.

Большинство игровых движков и инструментов 3D моделирования используют подход треугольной сеткой (triangular mesh), когда все объекты состоят из треугольников.

Также и у нас, когда мы говорим, что массив indices заполнен – { 0, 1, 2 },
мы подразумеваем, что мы на выходе получим треугольник который состоит из этих вершины.

Поэтому, минимальное описание фигуры состоит из трех чисел для описания одного треугольника.

Наш треугольник
Наш треугольник

Про анатомию мэша и его строение, можно более подробно прочитать здесь:

Теперь спешу вас успокоить, дальше все будет намного проще, это были самые душные и тяжелые в понимание моменты :)

Дальше мы по коду добрались до тела нашего метода, который будет вызван при перегенерации нашего UI элемента:

void GenerateVisualContent(MeshGenerationContext mgc) { vertices[0].tint = Color.red; vertices[1].tint = Color.red; vertices[2].tint = Color.red; }

Держим в голове, что у наших вершин есть поле tint (цвета вершины), мы как раз здесь их и определили, задав всем красный;)

А дальше определим их позиции (кто и где находится):

var leftCorner = 0f; var rightCorner = contentRect.width; var top = 0; var bottom = contentRect.height; var middleX = contentRect.width / 2; vertices[0].position = new Vector3(leftCorner, bottom, Vertex.nearZ); vertices[1].position = new Vector3(middleX, top, Vertex.nearZ); vertices[2].position = new Vector3(rightCorner, bottom, Vertex.nearZ);

Начнем с того, что описание позиции вершины происходит через тип Vector3 (где 3, это количество измерений, т. е в нашем случае X, Y, Z).

Для указания позиций, нам нужна теоретческая поддержка:

Любой VisualElement имеет область отрисовки он называется – contentRect.
У него есть свойства высоты и ширины.

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

Именно поэтому я делаю привязку к размеру области отрисовки. Если оно изменится, например произойдет уменьшение/увеличение размера экрана, тогда это вызовет перегенерацию всего UI элемента и соответственно этот метод вызовется еще раз, но уже с обновленными размерами contentRect'а.

Так расположились наши вершины(vX вершина с индексом X)
Так расположились наши вершины(vX вершина с индексом X)

Тут начало координат, как вы могли заметить, лежит в левом верхнем углу – позиция { x = 0, y = 0 };

А область нашего contentRect'a, кончается на значениях { x = width, y = height },
что в целом логично, самая краяняя точка по X – равна ширине элемента и Y – высоте.

Что в итоге получилось по вершинам?

  • Вершина с индексом 0, находится в координатах { x = 0, y = height }; (левый нижний угол)
  • Вершина с индексом 1, находится в координатах { x = width / 2, y = 0 }; (сверху по середине)
  • Вершина с индексом 2, находится в координатах { x = width, y = height }; (правый нижний угол)

После всех подготовок значений позиций и цветов, теперь наконец-то нужно зарендерить все это дело!

MeshWriteData mwd = mgc.Allocate(vertices.Length, indices.Length); mwd.SetAllVertices(vertices); mwd.SetAllIndices(indices);

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

Потом через метод SetAllVertices(), обозначаем наши вершины и через SetAllIndices() назначаем наши индексы.

И все, вуаля! Получили треугольник:

Наш треугольник скейлится от размера contentRect'a (который скейлится из-за изменения Canvas Size).

Глава 2: Ромб и градиент!

Теперь после простенького треугольника, можем перейти к более интересному ромбу.

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

using UnityEngine; using UnityEngine.UIElements; namespace CustomUI { public class RombElement : VisualElement { public new class UxmlFactory : UxmlFactory<RombElement> { } public RombElement() { generateVisualContent += GenerateVisualContent; } Vertex[] vertices = new Vertex[4]; ushort[] indices = { 0, 1, 2, 2, 3, 0}; void GenerateVisualContent(MeshGenerationContext mgc) { vertices[0].tint = new Color32(255, 0, 0, 255); vertices[1].tint = new Color32(0, 255, 0, 255); vertices[2].tint = new Color32(0, 0, 255, 255); vertices[3].tint = new Color32(17, 55, 55, 255); var top = 0; var left = 0f; var middleX = contentRect.width / 2; var middleY = contentRect.height / 2; var right = contentRect.width; var bottom = contentRect.height; vertices[0].position = new Vector3(left, middleY, Vertex.nearZ); vertices[1].position = new Vector3(middleX, top, Vertex.nearZ); vertices[2].position = new Vector3(right, middleY, Vertex.nearZ); vertices[3].position = new Vector3(middleX, bottom, Vertex.nearZ); MeshWriteData mwd = mgc.Allocate(vertices.Length, indices.Length); mwd.SetAllVertices(vertices); mwd.SetAllIndices(indices); } } }

Как видите, у нас изменились vertices и indices. Теперь у нас 4 вершины, как раз столько и нужно для создания прямоугольника, это мы помним из уроков геометрии.

Vertex[] vertices = new Vertex[4]; ushort[] indices = { 0, 1, 2, 2, 3, 0};

А вот с индексами, давайте разбираться!

Давайте разобьем индексы на триплеты, получится – { 0, 1, 2 } и { 2, 3, 0 }.

Теперь изобразим наши вершины и индексы.

Для первого триплета используется пунктир красного цвета, для второго – синий.
Для первого триплета используется пунктир красного цвета, для второго – синий.

По позициям вершин – ничего нового или сложного там нет, все просто как пять копеек.

А вот что интересно, так это градиент и цвета вершин!

У треугольника все вершины принимали значение Color. red, мы можем это записать в RGBA представление и тогда это будет выглядеть так:

var redColor = new Color32(255, 0, 0, 255);

Да, мы можем прокидывать любые цветые значения и оттенки, которые можно описать через RGBA.

А как тогда нам сделать градиент? Для этого ответим на вопрос – кто этот ваш градиент?

Градиент (англ. Gradient) — вид заливки в компьютерной графике, которая по заданным параметрам цвета в ключевых точках рассчитывает промежуточные цвета остальных точек.

Также есть разные виды градиента: круговые, угловые, отражённые.

Мы будем делать линейный градиент, он выглядит вот так:

Линейный градиент от красного к синему. 
Линейный градиент от красного к синему. 

Теперь вернемся к нашим баранам, разберем наши вершины и цвета:

vertices[0].tint = new Color32(255, 0, 0, 255); // Красный цвет vertices[1].tint = new Color32(0, 255, 0, 255); // Зеленый vertices[2].tint = new Color32(0, 0, 255, 255); // Синий vertices[3].tint = new Color32(17, 55, 55, 255); // Темно-зеленый

Вот так будет выглядеть UI элемент:

Градиентый ромб
Градиентый ромб

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

Но есть небольшой нюанс – различие цветов в UI Builder и в RunTime, сейчас покажу:

Действительно разные...
Действительно разные...

Эта проблема заключается в том, что в RunTimе и в UI Builder используются разные color space.

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

Подтверждения этому нашел на форумах Unity:

Что можно сделать или у нас будет градиент с нюансом?

По-дефолту, после создания проекта используется – Linear color space.

Можно поменять в Project Settings.
Можно поменять в Project Settings.

Если поставить Gamma, тогда все станет на свои места и заиграет новыми красками:

Лучше? Лучше.
Лучше? Лучше.

Послесловие

Если у вас остались вопросы или не поняли какую-то часть, напишите в комментариях, постараюсь объяснить, что да как:)

Надеюсь, эта статья оказалась интересной и полезной.

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

Ссылка на репозиторий с кодом:

4242
27 комментариев

Спасиб за статью!

Оставлю мини-шутку все равно
Рассказываю как создавать кастомные UI элементы через создание Mesh’а, используся Unity и UI Toolkit.Шаг 1: Наследуемся от VisualElement
Шаг 2: Unity выпускает новое соглашение с разработчиками
Шаг 3: Устанавливаем Unreal Engine
...

8
Ответить

Шаг 4: Мудохаемся с Unreal Engine
Шаг 5: Возвращаемся в Unity

12
Ответить

Хорошая шутка)

Ответить

UI Toolkit в целом выглядит очень вкусно, особенно если есть опыт работы с вебом. Но они совсем его не обновляют, печаль.

3
Ответить

Насколько знаю, они над ним работают, даже выпускают фичи:

https://forum.unity.com/threads/whats-new-in-the-ui-toolkit-documentation.1256697/

2
Ответить

Вот уж что не ожидал увидеть на ДТФ, так это гайд по Unity. Да не просто по Unity, а по UI Toolkit. Да не просто по UI Toolkit, а по UI Toolkit для рантайма, а не редактора.

3
Ответить

Насколько помню, были уже гайды по Unity, а вот по UI Toolkit, вроде не было, тут да)

1
Ответить