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

Введение

В Godot есть узлы для рисования спрайтов, полигонов, частиц, текста и многих других распространенных задач разработки игр. Однако, если вам нужно что-то конкретное и не представленное стандартными узлами, вы можете создать любой 2D-узел (например, на основе Control или Node2D), отображаемый на экране с помощью пользовательских команд.

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

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

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

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

Отрисовка

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

extends Node2D

func _draw():
    pass  # Your draw commands here.

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

Обновление

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

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

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

extends Node2D

@export var texture : Texture2D:
    set(value):
        texture = value
        queue_redraw()

func _draw():
    draw_texture(texture, Vector2())

Чтобы увидеть это в действии, вы можете установить текстуру в качестве значка Godot в редакторе, перетащив и отпустив icon.svg по умолчанию из вкладки FileSystem в свойство Texture на вкладке Inspector. При изменении значения свойства Texture во время выполнения предыдущего скрипта текстура также автоматически изменится.

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

extends Node2D

func _draw():
    pass  # Your draw commands here.

func _process(_delta):
    queue_redraw()

Выравнивание координат и ширины линии

API рисования использует систему координат CanvasItem, а не обязательно пиксельные координаты. Это означает, что _draw() использует координатное пространство, созданное после применения преобразования CanvasItem. Кроме того, вы можете применить пользовательское преобразование поверх него, используя draw_set_transform или draw_set_transform_matrix.

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

../../_images/draw_line.png
func _draw():
    draw_line(Vector2(1.5, 1.0), Vector2(1.5, 4.0), Color.GREEN, 1.0)
    draw_line(Vector2(4.0, 1.0), Vector2(4.0, 4.0), Color.GREEN, 2.0)
    draw_line(Vector2(7.5, 1.0), Vector2(7.5, 4.0), Color.GREEN, 3.0)

То же самое относится к методу draw_rect с filled = false.

../../_images/draw_rect.png
func _draw():
    draw_rect(Rect2(1.0, 1.0, 3.0, 3.0), Color.GREEN)
    draw_rect(Rect2(5.5, 1.5, 2.0, 2.0), Color.GREEN, false, 1.0)
    draw_rect(Rect2(9.0, 1.0, 5.0, 5.0), Color.GREEN)
    draw_rect(Rect2(16.0, 2.0, 3.0, 3.0), Color.GREEN, false, 2.0)

Сглаживание

Godot предлагает параметры метода в draw_line, чтобы включить сглаживание, но не все пользовательские методы рисования поддерживают antialiased параметр.

Для пользовательских методов рисования, которые не предоставляют параметр antialiased, вы можете вместо этого включить 2D MSAA, который влияет на рендеринг во всем окне просмотра. Это обеспечивает высококачественное сглаживание, но более высокую стоимость производительности и только для определенных элементов. См. 2D сглаживание для получения дополнительной информации.

Ниже приведено сравнение линии минимальной ширины (width=-1), нарисованной с antialiased=false, antialiased=true и antialiased=false при включенном 2D MSAA 2x, 4x и 8x.

../../_images/draw_antialiasing_options.webp

Инструменты

Также может потребоваться нарисовать собственные узлы при их запуске в редакторе. Это можно использовать для предпросмотра или визуализации какой-либо функции или поведения.

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

Вот простой пример того, как это работает

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

Вам придется закодировать функцию для ее выполнения и нарисовать ее самостоятельно.

Примечание

В следующих инструкциях используется фиксированный набор координат, который может быть слишком мал для экранов с высоким разрешением (больше 1080p). Если это ваш случай, и рисунок слишком мал, рассмотрите возможность увеличения масштаба окна в настройках проекта Display > Window > Stretch > Scale, чтобы настроить проект на более высокое разрешение (масштаб 2 или 4 обычно работает хорошо).

Рисование пользовательского polygon shape (формы полигона)

Хотя для рисования пользовательских полигонов существует специальный узел ( Polygon2D), в данном случае мы будем использовать исключительно функции рисования более низкого уровня, чтобы объединить их в одном узле и иметь возможность создавать более сложные фигуры в дальнейшем.

Сначала мы определим набор точек (или координат X и Y), которые лягут в основу нашей фигуры:

extends Node2D

var coords_head : Array = [
    [ 22.952, 83.271 ],  [ 28.385, 98.623 ],
    [ 53.168, 107.647 ], [ 72.998, 107.647 ],
    [ 99.546, 98.623 ],  [ 105.048, 83.271 ],
    [ 105.029, 55.237 ], [ 110.740, 47.082 ],
    [ 102.364, 36.104 ], [ 94.050, 40.940 ],
    [ 85.189, 34.445 ],  [ 85.963, 24.194 ],
    [ 73.507, 19.930 ],  [ 68.883, 28.936 ],
    [ 59.118, 28.936 ],  [ 54.494, 19.930 ],
    [ 42.039, 24.194 ],  [ 42.814, 34.445 ],
    [ 33.951, 40.940 ],  [ 25.637, 36.104 ],
    [ 17.262, 47.082 ],  [ 22.973, 55.237 ]
]

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

Чтобы преобразовать эти координаты в правильный формат, мы создадим новый метод float_array_to_Vector2Array(). Затем мы переопределим функцию _ready(), которую Godot вызовет только один раз - в начале выполнения - для загрузки этих координат в переменную:

var head : PackedVector2Array

func float_array_to_Vector2Array(coords : Array) -> PackedVector2Array:
    # Convert the array of floats into a PackedVector2Array.
    var array : PackedVector2Array = []
    for coord in coords:
        array.append(Vector2(coord[0], coord[1]))
    return array

func _ready():
    head = float_array_to_Vector2Array(coords_head);

Чтобы наконец нарисовать нашу первую фигуру, мы воспользуемся методом draw_polygon и передадим точки (как массив координат Vector2) и ее цвет, вот так:

func _draw():
    # We are going to paint with this color.
    var godot_blue : Color = Color("478cbf")
    # We pass the PackedVector2Array to draw the shape.
    draw_polygon(head, [ godot_blue ])

Запустив, вы увидите что-то вроде этого:

../../_images/draw_godot_logo_polygon.webp

Обратите внимание, что нижняя часть логотипа выглядит сегментированной — это потому, что для определения этой части было использовано малое количество точек. Чтобы смоделировать плавную кривую, мы могли бы добавить больше точек в наш массив или, может быть, использовать математическую функцию для интерполяции кривой и создания плавной формы из кода (см. example 2).

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

Отрисовка соединенных линий

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

Сначала мы определим список координат, формирующих форму рта, например:

var coords_mouth = [
    [ 22.817, 81.100 ], [ 38.522, 82.740 ],
    [ 39.001, 90.887 ], [ 54.465, 92.204 ],
    [ 55.641, 84.260 ], [ 72.418, 84.177 ],
    [ 73.629, 92.158 ], [ 88.895, 90.923 ],
    [ 89.556, 82.673 ], [ 105.005, 81.100 ]
]

Мы загрузим эти координаты в переменную и определим дополнительную переменную с настраиваемой толщиной линии:

var mouth : PackedVector2Array
var _mouth_width : float = 4.4

func _ready():
    head = float_array_to_Vector2Array(coords_head);
    mouth = float_array_to_Vector2Array(coords_mouth);

И наконец, мы воспользуемся методом draw_polyline, чтобы нарисовать линию, вот так:

func _draw():
    # We will use white to draw the line.
    var white : Color = Color.WHITE
    var godot_blue : Color = Color("478cbf")

    draw_polygon(head, [ godot_blue ])

    # We draw the while line on top of the previous shape.
    draw_polyline(mouth, white, _mouth_width)

Вы должны получить следующий вывод:

../../_images/draw_godot_logo_polyline.webp

В отличие от draw_polygon(), полилинии могут иметь только один уникальный цвет для всех своих точек (второй аргумент). Этот метод имеет 2 дополнительных аргумента: ширину линии (которая по умолчанию минимально возможная) и включение или отключение сглаживания (по умолчанию оно отключено).

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

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

Отрисовка кругов

Чтобы создать глаза, мы добавим 4 дополнительных вызова для рисования форм глаз, с различными размерами, цветами и позициями.

Чтобы отрисовать круг, вы располагаете его от центра используя метод draw_circle. Первый параметр это Vector2 координат его центра, второй - его радиус, а третий - его цвет:

func _draw():
    var white : Color = Color.WHITE
    var godot_blue : Color = Color("478cbf")
    var grey : Color = Color("414042")

    draw_polygon(head, [ godot_blue ])
    draw_polyline(mouth, white, _mouth_width)

    # Four circles for the 2 eyes: 2 white, 2 grey.
    draw_circle(Vector2(42.479, 65.4825), 9.3905, white)
    draw_circle(Vector2(85.524, 65.4825), 9.3905, white)
    draw_circle(Vector2(43.423, 65.92), 6.246, grey)
    draw_circle(Vector2(84.626, 66.008), 6.246, grey)

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

../../_images/draw_godot_logo_circle.webp

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

Отрисовка линий

Что отрисовать последнюю часть (нос) мы используем линию.

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

func _draw():
    var white : Color = Color.WHITE
    var godot_blue : Color = Color("478cbf")
    var grey : Color = Color("414042")

    draw_polygon(head, [ godot_blue ])
    draw_polyline(mouth, white, _mouth_width)
    draw_circle(Vector2(42.479, 65.4825), 9.3905, white)
    draw_circle(Vector2(85.524, 65.4825), 9.3905, white)
    draw_circle(Vector2(43.423, 65.92), 6.246, grey)
    draw_circle(Vector2(84.626, 66.008), 6.246, grey)

    # Draw a short but thick white vertical line for the nose.
    draw_line(Vector2(64.273, 60.564), Vector2(64.273, 74.349), white, 5.8)

Вы должны сейчас увидеть следующую форму на экране:

../../_images/draw_godot_logo_line.webp

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

Отрисовка текста

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

Мы будем использовать draw_string метод, для того чтобы сделать это, вот так:

var default_font : Font = ThemeDB.fallback_font;

func _draw():
    var white : Color = Color.WHITE
    var godot_blue : Color = Color("478cbf")
    var grey : Color = Color("414042")

    draw_polygon(head, [ godot_blue ])
    draw_polyline(mouth, white, _mouth_width)
    draw_circle(Vector2(42.479, 65.4825), 9.3905, white)
    draw_circle(Vector2(85.524, 65.4825), 9.3905, white)
    draw_circle(Vector2(43.423, 65.92), 6.246, grey)
    draw_circle(Vector2(84.626, 66.008), 6.246, grey)
    draw_line(Vector2(64.273, 60.564), Vector2(64.273, 74.349), white, 5.8)

    # Draw GODOT text below the logo with the default font, size 22.
    draw_string(default_font, Vector2(20, 130), "GODOT",
                HORIZONTAL_ALIGNMENT_CENTER, 90, 22)

Здесь мы сначала загружаем в переменную defaultFont настроенный шрифт темы по умолчанию (вместо него можно установить пользовательский шрифт), а затем передаем следующие параметры: шрифт, положение, текст, горизонтальное выравнивание, ширину и размер шрифта.

Вот, что вы увидите на экране:

../../_images/draw_godot_logo_text.webp

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

Показывать рисунок во время редактирования

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

Чтобы отобразить логотип непосредственно в редакторе (не запуская его), можно использовать аннотацию @tool, чтобы запросить отображение пользовательского рисунка узла во время редактирования, например:

@tool
extends Node2D

Вам потребуется сохранить сцену, пересобрать проект (только для C#) и перезагрузить текущую сцену вручную с помощью пункта меню Сцена > Перезагрузить сохраненную сцену, чтобы обновить текущий узел в представлении 2D при первом добавлении или удалении аннотации @tool.

Анимация

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

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

extends Node2D

@export var rotation_speed : float = 1  # In radians per second.

func _ready():
    rotation = 0
    ...

func _process(delta: float):
    rotation -= rotation_speed * delta

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

Хотя мы могли бы переписать все координаты точек так, чтобы они были центрированы вокруг (0, 0), включая отрицательные координаты, это потребовало бы много работы.

Одним из возможных способов решения этой проблемы является использование метода нижнего уровня draw_set_transform для исправления этой проблемы, переводя все точки в собственном пространстве CanvasItem, а затем возвращая его в исходное место с помощью обычного преобразования узла либо в редакторе, либо в коде, например:

func _ready():
    rotation = 0
    position = Vector2(60, 60)
    ...

func _draw():
    draw_set_transform(Vector2(-60, -60))
    ...

Вот результат вращения вокруг точки опоры на (60, 60):

../../_images/draw_godot_rotation.webp

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

Например, вот как мы можем заставить робота открывать и закрывать рот, изменяя ширину линии его рта по синусоидальной (sin) кривой:

var _mouth_width : float = 4.4
var _max_width : float = 7
var _time : float = 0

func _process(delta : float):
    _time += delta
    _mouth_width = abs(sin(_time) * _max_width)
    queue_redraw()

func _draw():
    ...
    draw_polyline(mouth, white, _mouth_width)
    ...

Примерно так оно будет выглядеть при запуске:

../../_images/draw_godot_mouth_animation.webp

Обратите внимание, что _mouth_width - это определяемое пользователем свойство, как и любое другое, и оно или любое другое, используемое в качестве аргумента рисования, может быть анимировано с использованием более стандартных и высокоуровневых методов, таких как узел Tween или AnimationPlayer. Единственное отличие заключается в том, что для применения этих изменений, чтобы они отображались на экране, необходим вызов queue_redraw().

Пример 2: рисование линии в динамике

Предыдущий пример был полезен для изучения того, как рисовать и изменять узлы с помощью пользовательских фигур и анимаций. Это может иметь некоторые преимущества, такие как использование точных координат и векторов для рисования, а не растровых изображений, что означает, что они будут хорошо масштабироваться при трансформации на экране. В некоторых случаях похожих результатов можно достичь, составляя функциональность более высокого уровня с узлами, такими как sprites или AnimatedSprites, загружающими ресурсы SVG (которые также являются изображениями, определенными с помощью векторов), и узлом AnimationPlayer.

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

Проведем прямую линию между 2 точек

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

Мы могли бы провести динамическую линию между этими двумя точками следующим образом:

extends Node2D

var point1 : Vector2 = Vector2(0, 0)
var width : int = 10
var color : Color = Color.GREEN

var _point2 : Vector2

func _process(_delta):
    var mouse_position = get_viewport().get_mouse_position()
    if mouse_position != _point2:
        _point2 = mouse_position
        queue_redraw()

func _draw():
    draw_line(point1, _point2, color, width)

В этом примере мы получаем позицию мыши в области просмотра по умолчанию в каждом кадре с помощью метода get_mouse_position. Если позиция изменилась с момента последнего запроса на отрисовку (небольшая оптимизация, чтобы избежать перерисовки в каждом кадре) - мы запланируем перерисовку. Наш метод _draw() имеет только одну строку: запрос на отрисовку зеленой линии шириной 10 пикселей между верхним левым углом и полученной позицией.

Ширину, цвет и положение начальной точки можно настроить с помощью соответствующих свойств.

При запуске, мы увидим следующее:

../../_images/draw_line_between_2_points.webp

Рисование дуги между 2 точками

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

Давайте попробуем создать дугу (часть окружности) между двумя точками.

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

extends Node2D

@export var point1 : Vector2 = Vector2(0, 0)
@export_range(1, 1000) var segments : int = 100
@export var width : int = 10
@export var color : Color = Color.GREEN
@export var antialiasing : bool = false

var _point2 : Vector2
../../_images/draw_dynamic_exported_properties.webp

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

Вычисление этой дуги может быть более сложным чем в случае линии:

func _draw():
    # Average points to get center.
    var center : Vector2 = Vector2((_point2.x + point1.x) / 2,
                                   (_point2.y + point1.y) / 2)
    # Calculate the rest of the arc parameters.
    var radius : float = point1.distance_to(_point2) / 2
    var start_angle : float = (_point2 - point1).angle()
    var end_angle : float = (point1 - _point2).angle()
    if end_angle < 0:  # end_angle is likely negative, normalize it.
        end_angle += TAU

    # Finally, draw the arc.
    draw_arc(center, radius, start_angle, end_angle, segments, color,
             width, antialiasing)

Центр полукруга будет точкой посередине между обеими точками. Радиус будет половиной расстояния между обеими точками. Начальный и конечный углы будут углами вектора от точки1 до точки2 и наоборот. Заметьте что мы нормализуем end_angle в позитивные значения поскольку если end_angle меньше чем start_angle, дуга будет отрисована против часовой стрелки, что мы не хотим в этом случае (дуга будет смотреть сверху-вниз).

Результат будет выглядеть как-то так, с дугой смотрящей вниз и между точек:

../../_images/draw_arc_between_2_points.webp

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