Пользовательская отрисовка в 2D

Почему?

В Godot есть узлы для рисования спрайтов, многоугольников, частиц и прочего. В большинстве случаев этого достаточно; но не всегда. Прежде чем плакать от страха, тоски и ярости из-за того, что узла для рисования этого конкретного чего-то не существует... было бы хорошо знать, что можно легко заставить любой 2D-узел (на основе Control < class_Control> `или :ref: Node2D <class_Node2D> `) рисовать пользовательские команды. Также это действительно легко сделать.

Но...

Пользовательское рисование вручную в узле действительно полезно. Вот несколько примеров, почему:

  • Рисование фигур или логики, которые не обрабатываются узлами (пример: создание узла, который рисует круг, изображение со следами, особый вид анимированного многоугольника и т. д.).

  • Визуализации, несовместимые с узлами: (пример: доска тетриса). В примере с тетрисом для рисования блоков используется пользовательская функция рисования.

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

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

Хорошо, как?

Добавьте сценарий к любому производному узлу CanvasItem, например Control или Node2D. Затем переопределите функцию _draw ().

extends Node2D

func _draw():
    # Your draw commands here
    pass
public override void _Draw()
{
    // Your draw commands here
}

Команды рисования описаны в справочнике по классу CanvasItem. Их много.

Обновление

Функция _draw () вызывается только один раз, а затем команды рисования кэшируются и запоминаются, поэтому дальнейшие вызовы не нужны.

Если требуется повторная отрисовка из-за того, что состояние или что-то еще изменилось, просто вызовите CanvasItem.update () в том же узле, и произойдет новый вызов _draw ().

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

extends Node2D

export (Texture) var texture setget _set_texture

func _set_texture(value):
    # If the texture variable is modified externally,
    # this callback is called.
    texture = value  # Texture was changed.
    update()  # Update the node's visual representation.

func _draw():
    draw_texture(texture, Vector2())
public class CustomNode2D : Node2D
{
    private Texture _texture;
    public Texture Texture
    {
        get
        {
            return _texture;
        }

        set
        {
            _texture = value;
            Update();
        }
    }

    public override void _Draw()
    {
        DrawTexture(_texture, new Vector2());
    }
}

В некоторых случаях может потребоваться отрисовка каждого кадра. Для этого просто вызовите update () из обратного вызова _process (), например:

extends Node2D

func _draw():
    # Your draw commands here
    pass

func _process(delta):
    update()
public class CustomNode2D : Node2D
{
    public override void _Draw()
    {
        // Your draw commands here
    }

    public override void _Process(float delta)
    {
        Update();
    }
}

Пример: рисование дуг окружности

Теперь мы будем использовать пользовательские функции рисования Godot Engine, чтобы рисовать то, для чего Godot не предоставляет функций. В качестве примера Godot предоставляет функцию draw_circle (), которая рисует весь круг. Однако как насчет рисования части круга? Вам нужно будет написать функцию для этого и нарисовать ее самостоятельно.

Функция дуги

Дуга определяется параметрами опорной окружности, то есть положением центра и радиусом. Сама дуга затем определяется углом, с которого она начинается, и углом, под которым она заканчивается. Это 4 аргумента, которые мы должны предоставить нашей функции рисования. Мы также предоставим значение цвета, чтобы при желании можно было нарисовать дугу разными цветами.

По сути, рисование фигуры на экране требует, чтобы она была разложена на определенное количество точек, связанных между собой. Как вы понимаете, чем из большего количества точек состоит ваша фигура, тем более гладкой она будет выглядеть, но тем тяжелее она будет также с точки зрения затрат на обработку. В общем, если ваша фигура огромна (или в 3D, близко к камере), для ее рисования потребуется больше точек, но она не будет угловатой. Напротив, если ваша фигура маленькая (или в 3D, вдали от камеры), вы можете уменьшить количество точек, чтобы сократить затраты на обработку; это известно как Уровень Детализации (LOD). В нашем примере мы просто будем использовать фиксированное количество точек независимо от радиуса.

func draw_circle_arc(center, radius, angle_from, angle_to, color):
    var nb_points = 32
    var points_arc = PoolVector2Array()

    for i in range(nb_points + 1):
        var angle_point = deg2rad(angle_from + i * (angle_to-angle_from) / nb_points - 90)
        points_arc.push_back(center + Vector2(cos(angle_point), sin(angle_point)) * radius)

    for index_point in range(nb_points):
        draw_line(points_arc[index_point], points_arc[index_point + 1], color)
public void DrawCircleArc(Vector2 center, float radius, float angleFrom, float angleTo, Color color)
{
    int nbPoints = 32;
    var pointsArc = new Vector2[nbPoints];

    for (int i = 0; i < nbPoints; ++i)
    {
        float anglePoint = Mathf.Deg2Rad(angleFrom + i * (angleTo - angleFrom) / nbPoints - 90f);
        pointsArc[i] = center + new Vector2(Mathf.Cos(anglePoint), Mathf.Sin(anglePoint)) * radius;
    }

    for (int i = 0; i < nbPoints - 1; ++i)
        DrawLine(pointsArc[i], pointsArc[i + 1], color);
}

Помните, на сколько точек нужно разложить нашу фигуру? Мы зафиксировали это число в переменной nb_points на значение 32. Затем мы инициализируем пустой PoolVector2Array, который представляет собой просто массив ``Vector2 ``s.

Следующий шаг состоит в вычислении фактического положения этих 32 точек, составляющих дугу. Это делается в первом цикле for: мы перебираем количество точек, для которых мы хотим вычислить позиции, плюс одна, чтобы включить последнюю точку. Сначала мы определяем угол каждой точки между начальным и конечным углами.

Причина, по которой каждый угол уменьшается на 90°, заключается в том, что мы будем вычислять 2D-положения для каждого угла, используя тригонометрию (вы знаете, косинус и синус...). Однако для простоты cos () и sin () используют радианы, а не градусы. Угол 0° (0 радиан) начинается с 3 часов, хотя мы хотим начать отсчет с 12 часов. Таким образом, мы уменьшаем каждый угол на 90 °, чтобы начать отсчет с 12 часов.

Фактическое положение точки, расположенной на окружности под углом angle (в радианах), определяется как Vector2(cos (angle), sin (angle)). Так как cos () и sin () возвращают значения от -1 до 1, позиция находится на окружности с радиусом 1. Чтобы это положение было на нашей опорной окружности, которая имеет радиус radius, нам просто нужно умножить позицию на radius. Наконец, нам нужно расположить наш опорный круг в центральном положении, что выполняется путем добавления его к нашему значению Vector2. Наконец, мы вставляем точку в массив PoolVector2Array, который был определен ранее.

Теперь нам нужно на самом нарисовать наши точки. Как вы можете себе представить, мы не просто нарисуем наши 32 точки: нам нужно нарисовать все, что находится между каждой из них. Мы могли бы сами вычислить каждую точку, используя предыдущий метод, и нарисовать ее одну за другой. Но это слишком сложно и неэффективно (за исключением случаев, когда это явно необходимо), поэтому мы просто проводим линии между каждой парой точек. Если радиус нашего круга поддержки не велик, длина каждой линии между парой точек никогда не будет достаточной, чтобы их увидеть. Если бы это произошло, нам просто нужно было бы увеличить количество точек.

Draw the arc on the screen

Теперь у нас есть функция, которая рисует что-то на экране; пришло время вызвать ее внутри функции _draw ():

func _draw():
    var center = Vector2(200, 200)
    var radius = 80
    var angle_from = 75
    var angle_to = 195
    var color = Color(1.0, 0.0, 0.0)
    draw_circle_arc(center, radius, angle_from, angle_to, color)
public override void _Draw()
{
    var center = new Vector2(200, 200);
    float radius = 80;
    float angleFrom = 75;
    float angleTo = 195;
    var color = new Color(1, 0, 0);
    DrawCircleArc(center, radius, angleFrom, angleTo, color);
}

Результат:

../../_images/result_drawarc.png

Функция сегмента

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

func draw_circle_arc_poly(center, radius, angle_from, angle_to, color):
    var nb_points = 32
    var points_arc = PoolVector2Array()
    points_arc.push_back(center)
    var colors = PoolColorArray([color])

    for i in range(nb_points + 1):
        var angle_point = deg2rad(angle_from + i * (angle_to - angle_from) / nb_points - 90)
        points_arc.push_back(center + Vector2(cos(angle_point), sin(angle_point)) * radius)
    draw_polygon(points_arc, colors)
public void DrawCircleArcPoly(Vector2 center, float radius, float angleFrom, float angleTo, Color color)
{
    int nbPoints = 32;
    var pointsArc = new Vector2[nbPoints + 1];
    pointsArc[0] = center;
    var colors = new Color[] { color };

    for (int i = 0; i < nbPoints; ++i)
    {
        float anglePoint = Mathf.Deg2Rad(angleFrom + i * (angleTo - angleFrom) / nbPoints - 90);
        pointsArc[i + 1] = center + new Vector2(Mathf.Cos(anglePoint), Mathf.Sin(anglePoint)) * radius;
    }

    DrawPolygon(pointsArc, colors);
}
../../_images/result_drawarc_poly.png

Динамическое пользовательское рисование

Хорошо, теперь мы можем рисовать на экране пользовательские объекты. Однако они статичны; давайте заставим эту фигуру вращаться вокруг центра. Решение состоит в том, чтобы просто изменять значения angle_from и angle_to с течением времени. Для нашего примера мы просто увеличим их на 50. Это значение приращения должно оставаться постоянным, иначе скорость вращения соответственно изменится.

Во-первых, мы должны сделать переменные angle_from и angle_to глобальными в верхней части нашего скрипта. Также обратите внимание, что вы можете хранить их в других узлах и получать к ним доступ с помощью get_node().

extends Node2D

var rotation_angle = 50
var angle_from = 75
var angle_to = 195
public class CustomNode2D : Node2D
{
    private float _rotationAngle = 50;
    private float _angleFrom = 75;
    private float _angleTo = 195;
}

Мы изменяем эти значения в функции _process(delta).

Здесь мы также увеличиваем наши значения angle_from и angle_to. Однако мы не должны забывать применять wrap() в диапазоне от 0 до 360° для результирующих значений! То есть, если угол равен 361°, то на самом деле он равен 1°. Если вы не обернете эти значения, скрипт будет работать правильно, но значения угла будут со временем увеличиваться и увеличиваться, пока не достигнут максимального целочисленного значения, которым может управлять Godot (2^31 - 1). Когда это произойдет, Godot может выйти из строя или привести к неожиданному поведению.

Наконец, мы не должны забывать вызывать функцию update(), которая автоматически вызывает _draw(). Таким образом, вы можете контролировать, когда вы хотите обновить кадр.

func _process(delta):
    angle_from += rotation_angle
    angle_to += rotation_angle

    # We only wrap angles when both of them are bigger than 360.
    if angle_from > 360 and angle_to > 360:
        angle_from = wrapf(angle_from, 0, 360)
        angle_to = wrapf(angle_to, 0, 360)
    update()
private float Wrap(float value, float minVal, float maxVal)
{
    float f1 = value - minVal;
    float f2 = maxVal - minVal;
    return (f1 % f2) + minVal;
}

public override void _Process(float delta)
{
    _angleFrom += _rotationAngle;
    _angleTo += _rotationAngle;

    // We only wrap angles when both of them are bigger than 360.
    if (_angleFrom > 360 && _angleTo > 360)
    {
        _angleFrom = Wrap(_angleFrom, 0, 360);
        _angleTo = Wrap(_angleTo, 0, 360);
    }
    Update();
}

Кроме того, не забудьте изменить функцию _draw(), чтобы использовать эти переменные:

func _draw():
   var center = Vector2(200, 200)
   var radius = 80
   var color = Color(1.0, 0.0, 0.0)

   draw_circle_arc( center, radius, angle_from, angle_to, color )
public override void _Draw()
{
    var center = new Vector2(200, 200);
    float radius = 80;
    var color = new Color(1, 0, 0);

    DrawCircleArc(center, radius, _angleFrom, _angleTo, color);
}

Давайте запустим! Это работает, но дуга вращается безумно быстро! Что не так?

Причина в том, что ваш графический процессор на самом деле отображает кадры так быстро, как только может. Нам нужно "нормализовать" рисунок под эту скорость; для этого мы должны использовать параметр delta функции _process (). delta содержит время, прошедшее между двумя последними отрисованными кадрами. Как правило, он невелик (около 0,0003 секунды, но это зависит от вашего оборудования), поэтому использование delta для управления вашим рисунком гарантирует, что ваша программа будет работать с одинаковой скоростью на любом оборудовании.

В нашем случае нам просто нужно умножить нашу переменную rotation_angle на delta в функции _process(). Таким образом, наши 2 угла будут увеличены на гораздо меньшее значение, которое напрямую зависит от скорости рендеринга.

func _process(delta):
    angle_from += rotation_angle * delta
    angle_to += rotation_angle * delta

    # We only wrap angles when both of them are bigger than 360.
    if angle_from > 360 and angle_to > 360:
        angle_from = wrapf(angle_from, 0, 360)
        angle_to = wrapf(angle_to, 0, 360)
    update()
public override void _Process(float delta)
{
    _angleFrom += _rotationAngle * delta;
    _angleTo += _rotationAngle * delta;

    // We only wrap angles when both of them are bigger than 360.
    if (_angleFrom > 360 && _angleTo > 360)
    {
        _angleFrom = Wrap(_angleFrom, 0, 360);
        _angleTo = Wrap(_angleTo, 0, 360);
    }
    Update();
}

Давайте снова запустим! На этот раз вращение отображается правильно!

Инструменты

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

Не забудьте использовать ключевое слово "tool" в верхней части сценария (посмотрите Основы GDScript, если вы забыли, что оно делает).