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

А сегодня, мы будем создавать кастомные элементы для интерфейсов, но уже используя VectorApi в UI Toolkit'е движка Unity.

Прочитав эту статью вы узнаете:

  1. Что такое painter2D

  2. Как создавать элементы используя его

  3. Как удивить коллег своими знаниями в области создания интерфейсов

Глава 1: Линии!

Также как и в предыдущей части, мы будем писать свои классы, наследуюсь от класса VisualElement (является базовым классом для создания кастомного элемента интерфейса).

Давайте будем переходить от простого к сложному, на примере кода:

using UnityEngine;
using UnityEngine.UIElements;

namespace CustomElements
{
    public class EmojiIconElement : VisualElement
    {
        public new class UxmlFactory : UxmlFactory<EmojiIconElement> { }

        public EmojiIconElement()
        {
            generateVisualContent += GenerateVisualContent;
        }
        
        private void GenerateVisualContent(MeshGenerationContext mgc)
        {
            var top = 0;
            var left = 0f;
            var right = contentRect.width;
            var bottom = contentRect.height;
            
            var painter2D = mgc.painter2D;
            painter2D.lineWidth = 10.0f;
            painter2D.strokeColor = Color.white;
            painter2D.lineJoin = LineJoin.Bevel;
            painter2D.lineCap = LineCap.Round;
            

            painter2D.BeginPath();
            
            painter2D.MoveTo(new Vector2((float)(left + (contentRect.width * 0.2)), (float)(top + contentRect.height * 0.1)));
            painter2D.LineTo(new Vector2((float)(left + (contentRect.width * 0.2)), (float)(bottom * 0.7)));
            
            painter2D.MoveTo(new Vector2((float)(right - (contentRect.width * 0.2)), (float)(top + contentRect.height * 0.1)));
            painter2D.LineTo(new Vector2((float)(right - (contentRect.width * 0.2)), (float)(bottom * 0.7)));
            
            painter2D.MoveTo(new Vector2((float)(left + (contentRect.width * 0.3)), (float)(bottom * 0.8)));
            painter2D.LineTo(new Vector2((float)(right - (contentRect.width * 0.3)), (float)(bottom * 0.8)));
            
            painter2D.Stroke();

       }
    }
}

В данном случае, мы рассмотрим класс EmojiIconElement.

Да, это интерпретация вот этого смайла – |_|

В рамках этой главы, будет рассмотрен метод GenerateVisualContent(...) и его внутренности, а про конструктор, базовый класс и UxmlFactory я уже рассказывал в предыдущей статье, в главе Mesh и треугольник!

Не буду тянуть больше со вступлением, рассмотрим наш код.

private void GenerateVisualContent(MeshGenerationContext mgc)
{
    var top = 0;
    var left = 0f;
    var right = contentRect.width;
    var bottom = contentRect.height;
            
    var painter2D = mgc.painter2D;
    painter2D.lineWidth = 10.0f;
    painter2D.strokeColor = Color.white;
    painter2D.lineJoin = LineJoin.Bevel;
    painter2D.lineCap = LineCap.Round;
            

    painter2D.BeginPath();
            
    painter2D.MoveTo(new Vector2((float)(left + (contentRect.width * 0.2)), (float)(top + contentRect.height * 0.1)));
    painter2D.LineTo(new Vector2((float)(left + (contentRect.width * 0.2)), (float)(bottom * 0.7)));
            
    painter2D.MoveTo(new Vector2((float)(right - (contentRect.width * 0.2)), (float)(top + contentRect.height * 0.1)));
    painter2D.LineTo(new Vector2((float)(right - (contentRect.width * 0.2)), (float)(bottom * 0.7)));
            
    painter2D.MoveTo(new Vector2((float)(left + (contentRect.width * 0.3)), (float)(bottom * 0.8)));
    painter2D.LineTo(new Vector2((float)(right - (contentRect.width * 0.3)), (float)(bottom * 0.8)));
            
    painter2D.Stroke();
}

Как я и говорил ранее, нас интересует метод GenerateVisualContent.

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

Четыре переменные top, left, right, bottom нужны для упрощения работы с позициями в рамках нашего UI элемента.

Также важно отметить, что эти значения переменных будет меняться в зависимости от размеров UI элемента (в UI Builder / в билде), и благодаря этому наш UI элемент будет масштабироваться относительно размера экрана и самого элемента.

Именно на этом самом contentRect'e и будет происходить наша генерация.

Дальше у нас идет описание нашего painter2D и здесь мы остановимся подробнее.

Если говорить официальным языком, то это класс, который позволяет рисовать векторную графику.

– А как именно рисовать векторую графику?

– Хороший вопрос!

Он предоставляет различные вызовы API используя которые можно рисовать линии, дуги, кривые.

Также, у него есть различные свойства, которые влияют на результат зарисовки:

  • lineWidth – отвечает за ширину линии

  • strokeColor – цвет обводки

  • fillColor – цвет заливки

  • lineJoin – как будет выглядеть линии при соединение

  • lineCap – как будут выглядеть концы линий

Более наглядно как это выглядит

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

В контексте графического программирования "путь" (path) представляет собой последовательность геометрических фигур, таких как линии, кривые, прямоугольники и окружности, которые определяют форму или контур объекта.

Путь может быть открытым или закрытым.

  • Открытый путь: Начинается и заканчивается без соединения конечных точек.

  • Закрытый путь: Конечные точки пути соединены, образуя замкнутую форму.

  1. Начало нового пути: Этот шаг определяет начало нового векторного пути. При вызове BeginPath() вы начинаете записывать команды рисования для нового пути.

  2. Добавление команд рисования: После начала нового пути вы добавляете команды рисования, такие как ArcTo(), LineTo(), и другие, для создания форм и геометрических объектов в вашем пути.

  3. Завершение пути: После того как вы нарисовали все необходимые фигуры и геометрические объекты для текущего пути, вы вызываете ClosePath() для завершения этого пути.
    Это указывает графическому движку на то, что вы закончили рисование этого пути, и что он должен его отрисовать.

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

painter2D.BeginPath();
            
painter2D.MoveTo(new Vector2((float)(left + (contentRect.width * 0.2)), (float)(top + contentRect.height * 0.1)));
painter2D.LineTo(new Vector2((float)(left + (contentRect.width * 0.2)), (float)(bottom * 0.7)));
            
painter2D.MoveTo(new Vector2((float)(right - (contentRect.width * 0.2)), (float)(top + contentRect.height * 0.1)));
painter2D.LineTo(new Vector2((float)(right - (contentRect.width * 0.2)), (float)(bottom * 0.7)));
            
painter2D.MoveTo(new Vector2((float)(left + (contentRect.width * 0.3)), (float)(bottom * 0.8)));
painter2D.LineTo(new Vector2((float)(right - (contentRect.width * 0.3)), (float)(bottom * 0.8)));
            
painter2D.Stroke();

Мы начинаем с команды BeginPath() , после чего вызываем метод MoveTo(Vector2 pos) – который перемещает точку рисования на новую позицию, от которой будут выполняться следующие команды.

Следом за ней идет метод LineTo(Vector2 pos) , как можно понять из названия, оно проводит прямую линию из текущей позицию painter2D до позиции заданный в аргументе метода.

Далее идет две пачки команды, которые перемещают курсор рисования и чертят линию.

В конце, мы можем заметить метод Stroke() – который непосредственно отрисовывает контур текущего пути, который мы определили ранее.

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

Поздравляю, теперь у вас есть кастомный UI элемент!

Глава 2: Кривые!

У нас есть два разных варианта возможности нарисовать кривые линии:

  1. Метод BezierCurveTo() генерирует кубическую кривую Безье по двум контрольным точкам и конечному положению кубической кривой Безье.

  2. Метод QuadraticCurveTo() генерирует квадратичную кривую Безье по контрольной точке и конечному положению квадратичной кривой Безье.

Рассмотрим их использование:

painter2D.BeginPath();
painter2D.MoveTo(new Vector2(100, 100));
painter2D.BezierCurveTo(new Vector2(150, 150), new Vector2(200, 50), new Vector2(250, 100));
painter2D.Stroke();
Кривая Безье

И также приведем код для второго примера:

painter2D.BeginPath();
painter2D.MoveTo(new Vector2(100, 100));
painter2D.QuadraticCurveTo(new Vector2(150, 150), new Vector2(250, 100));
painter2D.Stroke();
Квадратичная кривая Безье

Для более глубокого понимания кривых Безье, читаем Wikipedia.

Глава 3: Дуги!

Для рисования дуг можно использовать следующие методы:

  1. Метод Arc() создает дугу на основе предоставленного центра дуги, радиуса, а также начального и конечного углов.

  2. Метод ArcTo()создает дугу между двумя прямыми сегментами.

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

Когда в конце пути мы получаем замкнутую фигуру, тогда мы можем ее покрасить в какой-то цвет, вызвав метод painter2D.Fill().

Рассмотрим пример построения дуги используя метод Arc() .

painter2D.lineWidth = 2.0f;
painter2D.strokeColor = Color.red;
painter2D.fillColor = Color.blue;

painter2D.BeginPath();

painter2D.MoveTo(new Vector2(100, 100));


painter2D.Arc(new Vector2(100, 100), 50.0f, 10.0f, 95.0f);
painter2D.ClosePath();


painter2D.Fill();
painter2D.Stroke();

И как раз, параметр painter2D.FillColor отвечает какой цвет будет у залитой области.

Красная обводка и синяя заливка

А используя метод painter2D.ArcTo(), можно нарисовать кривую:

painter2D.BeginPath();
painter2D.MoveTo(new Vector2(100, 100));
painter2D.ArcTo(new Vector2(150, 150), new Vector2(200, 100), 20.0f);
painter2D.LineTo(new Vector2(200, 100));
painter2D.Stroke();
Кривая через дугу

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

– Ну, конечно, можно.

С ней вы можете ознакомиться из официальной документации от Unity.

Глава 4: Остальное?

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

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

Когда вы вызываете Fill() для закраски области, содержащейся внутри пути, вы также можете создать "дыры" в этой закрашенной области, используя дополнительные подпути.

Чтобы создать дыру, вы должны создать дополнительный подпуть с помощью MoveTo(), а затем использовать правило заливки (fill rule), чтобы определить, какие области будут закрашены, а какие нет.

Вот два основных правила заливки:

  1. OddEven (Нечетное/Четное): Отрисовывается луч из данной точки в бесконечность в любом направлении и подсчитываются количество пересечений сегментов пути. Если количество пересечений нечетное, то точка считается внутри пути, если четное - снаружи.

  2. NonZero (Не нуль): Отрисовывается луч из данной точки в бесконечность в любом направлении, и подсчитываются пересечения сегментов пути. При этом, когда сегменты пересекают луч справа налево, счетчик уменьшается, а когда слева направо - увеличивается. Если счетчик равен нулю, то точка считается снаружи пути, иначе - внутри.

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

В приведенном коде создается прямоугольник с дополнительным подпутем, который определяет форму ромба (бриллианта) внутри прямоугольника. Этот ромб будет являться "дырой" в заполненной области прямоугольника.

painter2D.BeginPath();
painter2D.MoveTo(new Vector2(10, 10));
painter2D.LineTo(new Vector2(300, 10));
painter2D.LineTo(new Vector2(300, 150));
painter2D.LineTo(new Vector2(10, 150));
painter2D.ClosePath();

painter2D.MoveTo(new Vector2(150, 50));
painter2D.LineTo(new Vector2(175, 75));
painter2D.LineTo(new Vector2(150, 100));
painter2D.LineTo(new Vector2(125, 75));
painter2D.ClosePath();

painter2D.Fill(FillRule.OddEven);
Прямоугольник с отверстием внутри

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

Для этого нужно использовать методы BeginPath() и ClosePath() и между ними менять значения у painter2D.

private void GenerateVisualContent(MeshGenerationContext mgc)
{
    var painter2D = mgc.painter2D;
    painter2D.lineWidth = 10.0f;

    // Начало первого подпути
    painter2D.BeginPath();
    painter2D.strokeColor = Color.red;
  
    painter2D.MoveTo(new Vector2(50, 50));
    painter2D.LineTo(new Vector2(100, 100));
  
    painter2D.Stroke();
    painter2D.ClosePath();
    // Конец первого подпутя

    // Начало второго подпути
    painter2D.BeginPath();
    painter2D.strokeColor = Color.blue;
  
    painter2D.MoveTo(new Vector2(20, 20));
    painter2D.LineTo(new Vector2(60, 60));
    
    painter2D.Stroke();
    painter2D.ClosePath();
    // Конец второго подпутя

    // Начало третьего подпути
    painter2D.BeginPath();
    painter2D.strokeGradient = new Gradient()
    {
        colorKeys = new GradientColorKey[]
        {
            new() { color = Color.red, time = 0.0f },
            new() { color = Color.blue, time = 1.0f }
        }
    };
    painter2D.fillColor = Color.green;
    
    painter2D.MoveTo(new Vector2(50, 150));
    painter2D.LineTo(new Vector2(100, 200));
    painter2D.LineTo(new Vector2(150, 150));
    
    painter2D.Fill();
    painter2D.Stroke();
            
    painter2D.ClosePath();
    // Конец третьего подпутя
}
Три разных стиля

Внимательный зритель уже заметил следующую фишку – поддержка градиента для обводки.

painter2D.strokeGradient = new Gradient()
{
    colorKeys = new GradientColorKey[]
    {
        new() { color = Color.red, time = 0.0f },
        new() { color = Color.blue, time = 1.0f }
    }
};

Используя свойство strokeGradient можно рисовать обводку через градиент.

Глава 5: Финал!

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

Сегодня мы разобрались как можно рисовать кастомные элементы в интерфейсах, какой инструментарий для этого имеется в движке Unity.

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

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

Спасибо за внимание и до скорых встреч!