Кастомный движок, UI, Immediate-mode библиотеки

Всем привет.

5 месяцев назад я написал очередной девлог, в котором обещал постепенно выкладывать куски движка в открытый доступ, параллельно с разработкой основного проекта - Gobby Island. Ну как, обещал ))0))0)

Кастомный движок, UI, Immediate-mode библиотеки

Я очень надеялся, что выложу все до Steam Next Fest, но вышло как вышло. Теперь же новая демка готова, она доступна на самом фестивале, а мне пожалуй пора начать рассказ про кастомный движок! Ответ на наиболее животрепещущий вопрос ("а зачем вы это сделали???") оставлю в конце статьи, в постскриптуме.

Вступление.

И так, мы собрались писать "свой" движок. Что нам будет нужно в первую очередь? Окно, которое обрабатывает ввод-вывод? Окей. Что ещё нужно в этом окне? Оконный лог! Мы же не хотим все время бегать в консоль за проверками аутпута событий, хотелось бы наблюдать их в самом окне. Кроме того, нужны элементы управления - тыкать в строго назначенную кнопку на клавиатуре, чтоб что-то запусить, тоже не очень хотелось бы, а значит нужно встроить GUI.

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

Начало начал

Как основной язык был выбран C++ (потому што я с ним знаком), как бэкенд для будущей графики - OpenGL, так как он уже достаточно зрелый, поддерживается и предустановлен везде, где можно и нельзя, и по нему написано неимоверно огромное количество гайдов.

Исходя из таких вводных, открываем гугл и ищем библиотеки:

GLFW, для функций ввода-вывода (кроссплатформенная прослойка между нами и ОС):

glm, математика

Обе сделаны с оглядкой на Opengl, так что отлично подойдут.

Назад к GUI! Гуй должен быть бесплатным (а ещё достаточно простым, чтоб не перетягивать на себя внимание, отвлекая от нашей заветной цели - окна, в которое этот самый GUI встроен). На момент начала работы автору этой статьи был известен Qt, но он сразу был отметён, так как это не только UI, но и много чего ещё. К тому же, для работы с кнопками (которые сами по себе являются вспомогательным элементом) там нужно наследовать классы и писать коллбэки. А чего бы хотелось:

if (моя_кнопка.нажата) { сделать, чего нужно }

Довольно притягательным поначалу казался RmlUI - описываем юай в HTML/CSS, потом используем. Но на практике тоже выходит не то, чтоб очень тривиально. После некоторого ресерча я наткнулся на это:

Реализация того, о чем вещает товарищ - ImGui.

Immediate-Mode библиотеки.

Модель выполнения Immediate-mode простая до безобразия - есть куча разных вызовов для UI элементов. Элемент рисуется во время вызова и в том же вызове проверяется его состояние (нажата кнопка или нет). Выходит очень похоже на желаемое:

if (ImGui::Button("Button")) { // Do stuff }

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

IndieGo UI

Нужен компромисс между здоровыми retained-mode библиотеками вроде Qt, которые позволяют запариваться с дизайном и легковесными и удобными для встраивания Immediate-mode библиотеками, дизайн для которых рисовать куда менее удобно.

Дизайн в Im-mode определяется последовательностью вызовов? Штош, мы вполне можем закодировать последовательность различных вызовов, сделав её параметром!

std::vector<int> call_ids = { 0, 2, 3, 2, 1, 3 }; void callUIfunction(int call_id) { if (call_id == 0) { ImGui::Button("Button"); } <.....> if (call_id == 3) { ImGui::Text("hello"); } }

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

struct UI_element { union _data { bool b; float f; int i; std::string * strPtr; } UI_ELEMENT_TYPE type = UI_BUTTON; void callUIfunction(); }

В момент вызова функций бэкенда (Im-библиотеки) мы можем запомнить состояние элемента:

void UI_element::callUIfunction() { if (type == UI_BUTTON) { _data.b = ImGui::Button("Button"); } <.....> if (type == UI_TEXT) { ImGui::Text(*_data.strPtr); } }

Теперь можно создать HashMap из таких элементов, и проверять их состояние в любом месте программы, где этого захочется:

std::map<std::string, UI_element> GUI; <....> if (GUI["my button"]._data.b) { std::cout << "My button was pressed! Hooray!" << std::endl; }

Рисование и апдейт состояния UI элементов же будет происходить при итерации по этому HashMap'у, один раз за "цикл рисования":

while (!windowShouldClose()) { // Program do stuff for (auto elt = GUI.begin(); elt != GUI.end(); elf++) { elt->second.callUIfunction(); } }

В реальности все немного сложнее, и кнопки должны быть расположены на виджетах, начало и конец которых так же обозначаются Immediate-mode вызовами (для ImGUI это ImGui::Begin() и ImGui::End()). Так что вводим понятие виджета, который будет хранить имена элементов, расположенных на нём. Итерироваться будем уже по элементам виджета, встраивая вызовы callUIfunction() между Begin() и End():

std::map<std::string, UI_element> GUI; struct WIDGET { std::vector<std::string> widget_elements; void callImmediateBackend(); void processElements(); } void WIDGET::callImmediateBackend() { ImGui::Begin(); processElements(); ImGui::End(); }; void WIDGET::processElements() { for (auto elt : widget_elements) { GUI[elt].callUIfunction(); } }; // widget_ID : WIDGET std::map<std::string, WIDGET> widgets; <...> for (auto widget : widgets) { widget.second.callImmediateBackend(); };

Есть ещё размер виджета, его цвет, возможная текстура, строки и столбцы - но логика для них обрабатывается примерно тем же образом (Immediate-mode вызовы в нужный момент - кому интересно, может глянуть в репозитории, ссылка в конце :))

Таким образом задача создания "оконного лога" сводится к описанию прозрачного виджета, на который можно добавлять строки текста (ImGui::Text()). Но само окно я буду описывать в другой статье, а пока....

UI editor

И так, для того, чтобы создать кнопки нам надо заполнить HashMap'ы: GUI (с UI элементами) и widgets (с виджетами). Можем ли мы заполнить их в рантайме? Можем. Можем ли мы заполнить их, тыкая другие кнопки, описанные на этапе компиляции? Почему бы и нет! Так появился UI editor:

Наблюдательный и искушенный читатель может заметить, что это ни разу не ImGui, а Nuklear. Это тоже Immediate-Mode библиотека, но она чуть новее, потому и привлекла.

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

GUI.deserialize(winID, path); UI_elements_map & UIMap = GUI.UIMaps[winID]; if (UIMap["press me"]._data.b) { // Do stuff std::cout << "[INFO] button pressed!" << std::endl; }

Сам UI editor вряд ли можно использовать как дизайнерский тул, тк кнопки на виджеты все ещё должны добавляться последовательно, да и мышкой их таскать нельзя:

Но вот рабочий интерфейс для приложения в нем создать можно - поддерживаются все основные элементы, а так же можно загружать изображения для скиннинга. Основаня задача UIMap - позволить добавлять элементы управления в окна быстро, особо не запариваясь на их счёт в архитектуре. Редактор же используется для создания UI в играх (кроме Gobby Island из начала статьи делаем ещё Elven City Simulator).

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

Все зависимости лежат в собранном виде в репозитории. Сам репозиторий:

Вам потребуется CMake для сборки и Visual Studio минимум 19й версии. При наличии Cmake собирается просто (powershell):

.\env.ps1 cd ui_editor cmake -Bbuild cmake --build build

На этом у меня всё! В следующий раз выложу и напишу про окно. Постараюсь сделать это побыстрее, честно

Кастомный движок, UI, Immediate-mode библиотеки

P.S.

Почему мы решили делать свой движок? За себя я отвечу картинкой:

И мне это жутко интересно.
И мне это жутко интересно.
1111
4 комментария

Я уж грешным делом подумал, что тут будет про immediate mode для ОпенГЛ.

Ответить

Про опенжл будет дальше

Ответить

Красавчик! Тоже пилю свой движок, с редактором уровней и свой GUI. Несмотря на увлекательность понял через года полтора разработки игры: что все таки лучше запилить картинку на весь экран - сразу с GUI, чем долго дебажить код своего GUI в рамках каждого меню. Но тебе желаю терпения и успехов!

Ответить

Спасибо! У меня именно решение для "описал кнопки и пошёл дальше" - их внутренняя логика как раз вынесена в отдельный модуль

Ответить