Недавно наши ребята из движковой команды выпустили статью о том, как мы навели, «красивостей» в нашем внутреннем движке. Речь шла о концепциях и том, что было важно для нас при улучшении визуальной составляющей. В этой статье я хочу рассказать, как все работает с технической стороны.
Итак, у нас есть нативный С++ движок, который работает на десктопных и мобильных платформах. Игры мы разрабатываем на десктопе, соответственно на десктопе есть редактор. Весь редактор построен на открытой библиотеке ImGui.
ImGUI
Эта библиотека работает по принципу immediate graphic user interface. Собственно, это заключено в ее названии. Суть в том, что мы одновременно описываем интерфейс и одновременно обрабатываем реакции на него. Например:
if (ImGUI::Button("Press to log"))
Log("Button has pressed!");
Этот код одновременно и рисует кнопку, и проверяет нажатие на кнопку, и возвращает true, если она была нажата. Довольно удобно.
Но, как говорится, есть нюанс. Он заключается в парадигме immediate ui (мгновенный интерфейс). Дело в том, что, когда мы рисуем элемент один за другим, мы банально не знаем, что будет дальше. Будет ли он перекрыт кем-то, сколько еще кнопок в этой строке и т. д.
Отсюда возникает проблема с версткой. Адаптивную верстку делать весьма сложно. Это когда мы задаем пропорциональные размеры кнопок, например. А ведь бывают ситуации гораздо сложнее нескольких кнопок в строке. И в случае ImGui нужно все рассчитывать заранее. Сама библиотека дает некоторый функционал для упрощения адаптивной верстки, но, скажу честно, это довольно простые функции.
Casket
Мы решили сделать свой фреймворк поверх ImGui и назвали его Casket. Этот фреймворк оборачивает immediate режим в ООП. Все элементы — кнопки, поля ввода, текст, чекбоксы и т. п. — были обернуты в классы. Элементы выстраиваются в дерево. Так мы описываем вложенность и пропорции. И так как теперь мы знаем, что «будет дальше» при отрисовке, можно заранее посчитать все размеры. Выглядит это примерно так:
Casket::HBox()
.Children({
Casket::Button("Hello").OnPressed([] { Log("Hello"); }).PercentWidth(30).GetPtr(),
Casket::Button("Kitty").OnPressed([] { Log("kitty"); }).PercentWidth(70).GetPtr()
})
.PercentWidth(100)
.Show();
Внутри ООП-классов вызов все той же отрисовки ImGui, но с жестко заданной позицией и размером, рассчитанными ранее.
Важный аспект этого фреймворка в том, что он может встраиваться в Immediate код. То есть вывели пару элементов по классике, затем создали Casket-контейнер, вывели его, продолжили в immediate-режиме.
if (ImGUI::Button("Old button"))
Log("Hello");
Casket::HBox()
.Children({
Casket::Button("Hello").OnPressed([] { Log("Hello"); }).PercentWidth(30).GetPtr(),
Casket::Button("Kitty").OnPressed([] { Log("kitty"); }).PercentWidth(70).GetPtr()
})
.PercentWidth(100)
.Show();
if (ImGUI::Button("Old button too"))
Log("Button has pressed!");
Стили
У нас уже есть «обертка» над ImGui, есть возможность перед отрисовкой менять цвета, и этим мы воспользовались. Мы сделали простые классы-структуры, описывающие стили разных элементов. Отдельно для кнопки, отдельно для поля ввода, чекбокса и т. п. И сделали общий класс со всеми существующими стилями, задали стили по умолчанию.
Затем, когда Casket-элемент создается, он берет стиль по умолчанию и просто копирует себе внутрь. Если нам нужен другой стиль — просто передаем новую копию в элемент или же меняем параметры прямо в копии элемента. Так получается работать со стилями гибко и удобно.
Casket::Button("Hello")
.Style(GetTheme().RoundedButton)
.BackgroundColor(ImColor::Red)
.Show();
Однако у этой системы есть один минус — цвета в стилях часто дублируются. На этот счет у нас в планах есть доработка: сделаем палитру цветов, а в стилях уже будет использоваться палитра.
Общее устройство
Окей, у нас есть все нужные инструменты для построения редактора. Давайте теперь посмотрим, как он устроен в общем.
Внутри у нас есть один базовый класс редактора, который объединяет в себе все. В нем создаются и регистрируются окна. Каждое окно — это отдельный класс. Здесь мы не занимались архитектурными виражами и сделали все просто: для каждого окна есть один класс, который отвечает и за отрисовку редактора, и за обработку действий в нем. Так сделано не потому, что мы глупые и не понимаем значения архитектуры, это было сделано осознанно. Мы не хотели лишнего усложнения кода, мы хотели быстрой разработки редактора. Можно было бы разделять по-классике на MVC, но это было бы разбиение ради разбиения.
В каждом окне есть вызов функции отрисовки интерфейса. До Casket мы все делали там. С приходом Casket мы стали инициализировать большинство элементов в инициализации окна.
В принципе, описывать каждое окно будет не очень интересно, проще уж выложить исходники. На самом деле редактор — это много рутины. Далее я попробую рассказать о каких-то интересных моментах, с которыми мы столкнулись.
Инспектор
Так мы называем окно, где показываем свойства объектов на сцене. Пожалуй, самое интересное место в редакторе. Мы передаем ему ссылку на объект, который необходимо отобразить, его задача показать список полей соответствующих полям классов сущностей, которые прицеплены к объекту.
Здесь не обойтись без рефлексии. В прошлой статье мы уже писали, что использовали библиотеку rttr для рефлексии, но в итоге заменили ее своей. Суть не поменялась: мы можем взять тип у класса, а у типа взять список полей. Есть простые типы для всяких чисел, строк и т. д., есть тип объекта, который содержит в себе список полей класса, есть тип массива с доступом по элементам и есть тип ассоциативного типа с доступом по ключу.
Построение полей инспектора опирается на данные рефлексии. Для передачи типа, ссылки на объект и дополнительных параметров мы используем агрегирующую структуру InspectorContext. Чуть ниже будет поподробнее про нее.
Для описания инспектора отдельного класса мы используем класс-интерфейс с одной основной функцией:
virtual bool OnInspector(const InspectorContext& context);
В параметры мы передаем контекст, через него мы можем получить объект, с которым работаем, установить новое значение объекту. Возвращаем из функции true или false в зависимости от того, был ли объект изменен внутри.
Есть дефолтный класс, который реализует эту функцию, выстраивая поля ввода по данным из рефлексии. Затем, в зависимости от типа поля, он вызывает соответствующую функцию.
virtual bool OnFloatGUI(float value, const InspectorContext& context) const;
virtual bool OnIntGUI(int value, const InspectorContext& context) const;
virtual bool OnInt64GUI(int64_t value, const InspectorContext& context) const;
virtual bool OnUInt64GUI(uint64_t value, const InspectorContext& context) const;
virtual bool OnUIntGUI(unsigned int value, const InspectorContext& context) const;
virtual bool OnBoolGUI(bool value, const InspectorContext& context) const;
virtual bool OnEnumGUI(const InspectorContext& context) const;
virtual bool OnStringGUI(const std::string& value, const InspectorContext& context) const;
virtual bool OnObjectPointerGUI(const InspectorContext& context) const;
virtual bool OnArrayGUI(const InspectorContext& context) const;
virtual bool OnAssociativeGUI(const InspectorContext& context) const;
Интерфейс OnInspector можно перегрузить и сделать свой уникальный инспектор, с помощью Casket и ImGui вывести элементы как нужно.
InspectorContext
Теперь чуть подробнее об InspectorContext. Как я уже написал выше, он хранит в себе редактируемый объект. Если мы редактируем указатель на объект, мы храним указатель. Если редактируем значение — храним копию значения. Из контекста, хранящего в себе объект, можно сделать дочерний контекст на поле этого объекта. Аналогично с массивами и элементами.
Далее, когда значение в контексте изменено, происходит изменение значения и во всех родительских контекстах. Это нужно для ситуаций, когда в иерархии контекстов есть копия объекта. Его нужно скопировать целиком обратно.
Также по ссылкам на родительский контекст можно получить путь до редактируемого поля, из которого сформировать команду Ctrl+Z для системы.
Помимо ссылки/копии редактируемого объекта, контекст содержит в себе много вспомогательной информации. Нужно ли ограничивать ширину поля, является ли поле readonly и т. п.
Эту систему отображения полей редактируемой сущности мы используем во многих местах редактора. Фактически ей можно «скормить» ссылку на любой объект, и редактор автоматически построит необходимый интерфейс для ее редактирования.
ImGuiID
Говоря об immediate ui, обычно думают, что элементы не хранят свое состояние. Ведь он мгновенный, значит, ничего хранить не должен. На самом деле это не так. Когда вы нажимаете кнопку, она приобретает состояние нажатой и забирает на себя фокус. Когда редактируете текстовое поле, то промежуточный текст не сразу передается обратно.
Так же и в нашем редакторе. Есть поля, которые хранят свое промежуточное состояние. Например, поле массива позволяет выбрать тип создаваемого элемента (если в массиве хранятся поинтеры). И выбранный тип фактически записать некуда. Структура данных предполагает только сам массив, ничего более.
Здесь используется механизм сохранения состояния в статичном хранилище по уникальному идентификатору — ImGuiID. Этот идентификатор генерируется в зависимости от идентификатора текущего элемента и идентификаторов группы, в которой находится элемент. Своеобразный хеш, сгенеренный из данных, в каком именно месте мы рисуем кнопку или что-то другое. И так как место отрисовки всегда одно и то же, то и хеш всегда генерируется один и тот же. Так можно сохранять состояния отдельных элементов.
static std::map<ImGuiID, MyData> elementsData;
....
auto id = ImGUI::GetID("my element id");
auto myData = elementsData[id];
myData.xxx = yyy;
....
Внутри ImGui есть своя хеш-таблица таких данных, можно использовать и ее. Можно хранить свои локальные таблицы и использовать сгенеренный ImGuiID для своих целей.
Иерархия
Наш движок имеет в себе граф сцены. Это значит что все игровые сущности представлены объектами и нодами, объединенными в иерархию. Эту иерархию мы и показываем в специальном окне.
Сама по себе задача показать дерево нод не очень сложная. Даже наивная реализация довольно быстро делается. Но проблема в том, что таких нод могут быть тысячи, и наивная реализация, конечно же, тормозит.
Здесь стоит отметить еще тот факт, что мы разрабатываем игры в режиме Debug. А те, кто знаком с MSVS (коим пользуются многие наши коллеги), знают, что Debug на порядок-два медленнее Release. При этом все так же нужно показывать тысячи нод в одном окне.
Мы решили эту проблему классически: показываем только то, что видно в данный момент, а при прокрутке дополняем/обрезаем видимые строки.
Также необходимо быстро отображать изменения в дереве, если что-то включилось/выключилось, удалилось или добавилось. Перестраивать постоянно дерево — дорого. Поэтому мы добавили специальные сообщения, которые рассылают сами ноды, если с ними что-то случилось. Дерево подписывается на эти сообщения и реагирует мгновенно.
Обозреватель ассетов
В этом окне мы показываем ассеты и ресурсы, с которыми работает игра и которые может использовать разработчик. Слева дерево папок, справа ассеты в текущей выбранной папке.
Движок предоставляет нам иерархию папок и ассетов в ней, нам необходимо лишь отобразить это. С деревом все просто — мы уже делали дерево в иерархии. Здесь мы просто фильтруем только папки и показываем их. Ничего сложного.
А вот с отображением ассетов уже интереснее. У нас есть три режима отображения: плитка, список и таблица. Каждый из трех способов — это три разных алгоритма отрисовки ассетов. Отображение плиткой и списком — просто рисуем один элемент за другим. Здесь можно рассмотреть, как ImGUI работает с зонами прокрутки. Начинается все, конечно же, с ImGUI::BeginChild, который обозначает группу элементов в зоне прокрутки. Далее мы просто рисуем элементы, обозначая их в системе — ImGUI::ItemAdd. Чтобы прокрутить список до нужного элемента, в момент его отрисовки вызываем ImGUI::SetScrollHereY();.
С отображением таблицей немного сложнее. Сначала нам нужно пройтись по всем ассетам и собрать информацию о том, какие есть редактируемые поля. Эти поля будут столбцами в таблице. Далее используем эту информацию, чтобы отрисовать элементы и их поля в таблице по очереди.
Еще у нас рисуются тамбнейлы — это маленькие превью содержимого ассетов. Если с текстурами все просто, то с моделями, префабами, частицами приходится рендерить их налету. Мы используем job-system на корутинах, чтобы немного нагружать основной поток этой задачей. Все, что отрендерили, кешируем на диск, ну а потом уже просто отображаем как текстуру. В будущем хотим еще добавить динамики к превью анимаций и частиц.
Нодовый редактор
У нас есть несколько нодовых редакторов в движке. Они используются для разных фич: стейтграф анимаций, визуального скриптинга, отображения взаимосвязей ивентов и квестов.
Их все объединяет общий интерфейс нодового редактора. Он предоставляет простое шаблонное API, через которое передаются данные о блоках и их связях. Внутри он работает со своей структурой данных.
Здесь изобилует кастомный код отрисовки и обработки ввода в ImGUI. Для отрисовки используются стандартные примитивы ImGUI, такие как прямоугольник, линия, кружки. Но для отображения кривых мы сделали свою реализацию кривой безье. Она умеет постепенно заполняться как прогресс-бар для отображения переходов между блоками. А еще умеет понимать клики по ней.
С обработкой ввода поначалу складывалось все сложно. Так как блоки могут свободно двигаться во все стороны и умеют разворачиваться, неизбежна ситуация перекрытия блоков. Если обрабатывать ввод наивно — попали в прямоугольник и нажали кнопку мыши — то неизбежны ложные прокликивания. Например, работая с одним блоком, можно случайно что-то поменять в другом.
Поэтому мы стали применять невидимые кнопки. ImGUI считает их полноценными элементами управления, поэтому понимает, кто из них выше и куда в итоге попадает клик. Чтобы нарисовать такую кнопку, нужно вызвать функцию InvisibleButton. Далее мы можем накладывать такие кнопки сколь угодно много друг на друга, не получая ложных кликов.
***
Мы в Playrix постоянно развиваем свой движок, и есть еще немало вещей, над которыми наша команда работает прямо сейчас. Если у вас есть вопросы о VSO, пишите комментарии, и мы с командой обязательно ответим на них здесь или в следующих статьях.