Pull to refresh

Godot — рисование без правил

Level of difficultyEasy
Reading time6 min
Views8.3K

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

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

Кто не знает, поясню: в Godot рисование примитивов осуществляется в виртуальном методе _draw объекта CanvasItem, т.е. нельзя просто так взять, и нарисовать линию, например, из метода_process. Как выход, можно создать массивы для каждого примитива, а в методе _draw прогонять каждый массив, рисуя соответствующий примитив. При обработке же клика записывать в соответствующий массив позицию мыши. Уже звучит страшно, не так ли? А если пользователь может менять размер? Создавать свои примитивы? Так массивов с объектами не напасешься.

Наверно во всех приемлемых движках есть сурфейсы – «холсты» для отрисовки всякого разного. Godot – не исключение, и хоть на первый взгляд кажется что они скрыты где-то глубоко в бэкэнде, на самом деле это не так. Все ноды в godot используют четыре основных сервера как API для различных модулей, меня же будет интересовать один – это сервер для проработки всего визуала под названием VisualServer. Подробнее про него (правда на английском) можно прочитать в официальной документации Godot. Также есть небольшая статья, описывающая работу сия волшебства на практике, причем не только про VisualServer.

Каждый CanvasItem при рисовании чего-либо на самом деле обращается к этому самому серверу, так зачем же нам эти лишние переадресации, когда мы можем сами обращаться за рисованием? Сам объект этого сервера имеет несметное количество методов для работы с графикой, я для примера буду использовать простейшие методы для отрисовки примитивов. Каждый такой метод требует RID(Resource ID), по сути просто номер сурфейса в World2D. У любого CanvasItem этот его номер можно получить через метод get_canvas_item(), и нагло рисовать на холсте этого объекта из любой точки программы, чего нельзя добиться с методом _draw. От теории перейдем к практике, создадим простейшую сцену, состоящую из трех узлов:

Задача: Нода Controller рисует на холсте ноды Drawer без ее ведомства.

Решить ее "традиционным" методом никак не выйдет, поэтому прибегнем к вышеописанному серверу. В ноду Controllerзасунем следующий код:

extends Node2D

onready var drawer = $"../Drawer"

var counter = 0

func custom_draw_line(start, goal, color, width=1.0, antialising=false):
	VisualServer.canvas_item_add_line(drawer.get_canvas_item(), start, goal, color, width, antialising)

func _process(delta):
	if Input.is_action_just_pressed("mouse_left"):
		counter += 2
		custom_draw_line(Vector2(100, 100)+Vector2(counter, counter), Vector2(300, 150)+Vector2(counter, counter), Color.green)

В методе custom_draw_line(...) по сути и происходит вся магия, мы обращаемся к VisualServer за рисованием линии через метод canvas_item_add_line(...), а в качестве холста передаем холст ноды Drawer. Особенность заключается в том, что как бы мы не влияли на Controller, изменяя его трансформацию или позицию, это никак не отражается на том что мы рисуем, потому как холст принадлежит другому узлу.

Тут можно было бы радоваться и хлопать в ладоши, однако все не так гладко. Если мы изменим размер окна или каким либо еще другим образом заставим движок отправить ноду на перерисовку, заметим, что линии пропали. Почему так происходит? Обратимся к исходникам Godot, благо его лицензия позволяет. Вот код, который перерисовывает все содержимое:

void CanvasItem::_update_callback() {
	if (!is_inside_tree()) {
		pending_update = false;
		return;
	}

	RenderingServer::get_singleton()->canvas_item_clear(get_canvas_item());
	//todo updating = true - only allow drawing here
	if (is_visible_in_tree()) { //todo optimize this!!
		if (first_draw) {
			notification(NOTIFICATION_VISIBILITY_CHANGED);
			first_draw = false;
		}
		drawing = true;
		current_item_drawn = this;
		notification(NOTIFICATION_DRAW);
		emit_signal(SceneStringNames::get_singleton()->draw);
		if (get_script_instance()) {
			get_script_instance()->call(SceneStringNames::get_singleton()->_draw);
		}
		current_item_drawn = nullptr;
		drawing = false;
	}
	//todo updating = false
	pending_update = false; // don't change to false until finished drawing (avoid recursive update)
}

Нас конкретно интересует строчка 7:

RenderingServer::get_singleton()->canvas_item_clear(get_canvas_item())

из которой мы можем понять, что каждый раз перед перерисовкой холст очищается. Получается что рисовать на холсте самого объекта не совсем правильно, т.к. придется запоминать все предыдущие рендеры, что очень муторно и не есть хорошо. Вместо этого мы можем использовать еще один сурфейс, дочерний по отношению к холсту ноды Drawer. Создать холст мы можем командой VisualServer.canvas_item_create(), однако пока это просто сурфейс, летающий где то в памяти, что бы его увидеть, он должен наследоваться от World2D.canvas(это "главный" холст) или от его наследников и т.д. Мы же хотим наследовать все свойства ноды Drawer, значит надо унаследовать новоиспеченный холст от холста этого узла. Это также делается через VisualServer командойVisualServer.canvas_item_set_parent(item, parent). Используя такой метод мы убиваем двух зайцев сразу: теперь при перерисовке наш холст никто не трогает, а также мы можем различать холст самой ноды от нашего. Например, можно очищать этот холст, не трогая при этом основной. Делается это опять же через сервер:VisualServer.canvas_item_clear(surface). В конце концов получим следующий код:

extends Node2D

onready var drawer = $"../Drawer"

var counter = 0

var surface

func _ready():
	surface = VisualServer.canvas_item_create()
	VisualServer.canvas_item_set_parent(surface, drawer.get_canvas_item())

func custom_draw_line(start, goal, color, width=1.0, antialising=false):
	VisualServer.canvas_item_add_line(surface, start, goal, color, width, antialising)

func _process(delta):
	if Input.is_action_just_pressed("mouse_left"):
		counter += 2
		custom_draw_line(Vector2(100, 100)+Vector2(counter, counter), Vector2(300, 150)+Vector2(counter, counter), Color.green)
	elif Input.is_action_just_pressed("mouse_right"):
		VisualServer.canvas_item_clear(surface)
		counter = 0

На ЛКМ мы рисуем линии с некоторым интервалом, на ПКМ очищаем холст и сбрасываем интервалы. Подмечу, что я ни разу не тронул саму ноду Drawer, она в данном примере служит просто бездушным телом.

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

void CanvasItem::draw_line(const Point2 &p_from, const Point2 &p_to, const Color &p_color, real_t p_width) {
	ERR_FAIL_COND_MSG(!drawing, "Drawing is only allowed inside NOTIFICATION_DRAW, _draw() function or 'draw' signal.");

	RenderingServer::get_singleton()->canvas_item_add_line(canvas_item, p_from, p_to, p_color, p_width);
}

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

А где вообще это все может пригодится? Например для любителей разделения обязанностей, т.е. за отрисовку по каким-либо иным алгоритмам отвечает один объект, никак не привязанный к сцене. Например, создать наследника класса Resource со статическими методами, отвечающими за необходимую отрисовку. Данный подход поможет в целом упростить работу с графикой в силу отсутствия объекта отрисовки на сцене, а как следствие к нему не придется обращаться через дерево сцены, достаточно описать статические методы в некотором классе или воспользоваться автозагрузкой.

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

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

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

В целом, такой подход облегчает отрисовку примитивов и сложных форм, состоящих из оных. Также, он поможет сократить количество зависимостей на сцене и позволит рисовать из любого метода, не только из _draw объекта CanvasItem. Однако, я сильно сомневаюсь что это поможет при рисовании спрайтов и тем более рендеринга 3D объектов, хотя и это, разумеется, возможно.

Надеюсь столь небольшой пост имеет не столь маленькое значение. Я хочу сказать следующее: не слушайте меня, слушайте официальную документацию Godot и его исходники. Напоследок пожелаю удачи в работе со столь гибким и доступным движком, коих сейчас так мало!

Tags:
Hubs:
Total votes 5: ↑5 and ↓0+5
Comments12

Articles