Вступление
Это первая из цикла статей о создании компактной кросс‑платформенной библиотеки для разработки приложений с графическим интерфейсом на языке C++ — Frenchie. Для тех, кто привык изучать исходный код самостоятельно, репозиторий с исходным кодом библиотеки на Гитхабе. В данном материале я расскажу, как пришёл к созданию этой библиотеки, и постараюсь максимально понятно и просто описать общие принципы её работы и архитектуру.
Цель всего цикла материалов — дать представление о том, как устроены подобные проекты, начиная от открытия контекстного окна и управления его состоянием, заканчивая рисованием графических примитивов. Надеюсь, что весь цикл материалов окажется полезным как для тех, кто начинает свой путь в разработке приложений с графическим интерфейсом, так и для опытных разработчиков.
Зачем делать свою библиотеку, да еще и на C++?
Практически в любой современной программе есть графический интерфейс. Даже утилиты командной строки постепенно обзаводятся графическими оболочками. Графический интерфейс нужен для упрощения использования программы и предотвращения ошибок, которые пользователь может допустить, не имея визуализации того, что он делает.
Самый частый вопрос, который многие программисты задают, когда слышат про разработку приложений с графическим интерфейсом: зачем использовать C++, когда есть другие языки программирования, предоставляющие нативные средства для разработки приложений с графическим интерфейсом?
C++ — объекто‑ориентированный язык программирования, который даёт разработчику полный контроль над используемыми программой ресурсами. По этой причине С++ фактически стал стандартным языком программирования для разработки высокопроизводительных приложений с низким потреблением ресурсов: игровые движки, инженерные системы автоматического проектирования (САПР) и так далее. По этой причине я и решил делать библиотеку именно на C++. Однако до того, как я решил ее делать, реализуя различные проекты, я часто задавал себе вопрос: а стоит ли вообще что‑то делать, когда решения этой задачи уже существуют?
Почему не подошли уже существующие популярные библиотеки и framework‑и?
Чем не устроил Qt и WxWidgets?
Достаточно популярными современными C++ framework‑ами для разработки приложений с графическим интерфейсом являются Qt и WxWidgets. Данные framework‑и относительно просты в освоении, очень хорошо и подробно задокументированы, а также предоставляют широкие возможности для разработки приложений с графическим интерфейсом.
Основной проблемой этих двух framework‑ов (и похожих на них) является то, что в них достаточно сложно делать приложения, в которых требуется отображать одни и те же данные в разных графических представлениях. Что имеется в виду? Допустим, у нас есть сцена с графическими объектами, которую нужно уметь рисовать на 2D плоскости и при этом уметь отображать ее в виде дерева элементов. Чтобы это сделать в Qt или WxWidgets нужно представить сцену c объектами в виде модели данных, для неё потребуется сделать контроллер, который будет пробрасывать модель в конкретное графическое представление.
Таким образом, для решения относительно простой задачи, потребуется реализовать как минимум два слоя абстракции — модель данных и контроллер, а также по одному слою абстракции для каждого вида графического представления сцены: дерево элементов и 2D плоскость. Такой подход к решению поставленной задачи известен как MVC — model/view/controller и он достаточно распространен и популярен (например, в разработке компьютерных игр).
Проблема MVC кроется не в количестве нужных слоев абстракции, а в синхронизации всех графических представлений с моделью данных через контроллер. Именно по этой причине я и начал искать решения, которые позволили бы рисовать интерфейс по любым данным и моделям «на лету». И да, такие решения есть.
Парадигма Immediate mode GUI и почему не подошла библиотека ImGUI?
Рисование графического интерфейса «на лету» по любой модели данных принято называть immediate mode graphical user interface (IMGUI). Суть IMGUI в том, чтобы рисовать интерфейс процедурно по любой имеющейся модели данных и при этом хранить как можно меньшее количество информации о состоянии самого интерфейса. При таком подходе к разработке приложения с интерфейсом, задача синхронизации графического представления данных и модели данных отпадает сама собой, поскольку элементы интерфейса рисуются процедурно «на лету».
Одной из самых популярных библиотек, используемых для разработки приложений с рисованием интерфейса «на лету» по любым данным является библиотека ImGUI. ImGUI — прекрасный инструмент, закрывающий примерно 99% всех возможных задач. Основная проблема, с которой я столкнулся при работе с ImGUI — это отсутствие возможности полноценно рисовать 2D графику даже несмотря на то, что в библиотеке есть встроенный 2D renderer. Почему так?
Дело в том что, 2D renderer ImGUI не поддерживает геометрические преобразования (поворот, масштабирование, перенос и.т.д), а также ручную сортировку рисуемых объектов в глубину для корректного отображения полупрозрачных объектов при наложении друг на друга.
2D renderer ImGUI рисует графические объекты в порядке вызова команд рисовки и манипулировать глубиной их расположения нельзя, а матрицы геометрических преобразований вообще не были поддержаны с целью экономии оперативной памяти, так как при рисовке элементов графического интерфейса геометрические преобразования в общем‑то и не нужны.
Плюс к этому, в ImGUI нет встроенной функции для загрузки текстур, библиотека лишь дает возможность рисовать текстуры, которые уже каким‑то образом были загружены в видеопамять. По этим причинам, если хочется использовать библиотеку ImGUI для графического интерфейса и при этом полноценно рисовать в 2D, то рисование в 2D придется реализовывать самостоятельно с использованием конкретного графического API типа OpenGL, Vulkan, Metal и.т.д.
В таком случае, возникает логичный вопрос: если мне нужно самому реализовывать полноценный 2D renderer для решения своих задач, так почему бы уже не сделать на его основе пользовательский интерфейс? Собственно, с этого вопроса и началась разработка моей библиотеки. Теперь перейдем к обсуждению архитектуры библиотеки и принципов, по которым она работает, и начнем с того, как работает цикл приложения.
Архитектура библиотеки
Что такое приложение с графическим интерфейсом?
Приложение с графическим интерфейсом по сути является бесконечным циклом, который начинается при запуске приложения и работает до тех пор, пока оно не закроется. На C++ самый базовый вариант класса цикла работы приложения будет выглядеть так:
namespace Frenchie{ namespace Application { class Application { Application() = delete; Application(const Application&) = delete; Application& operator=(const Application&) = delete; static int execute() { if(!awake()) return 1; while(!is_closed()) { // логика работы приложения ... } return 0; } // проверяет, не закрыто ли приложение static bool is_closed() const; // запускает приложение static bool awake(); }; }}
Класс выше является статическим потому что экземпляр цикла работы приложения должен быть один на один экземпляр приложения. На самом деле, использовать статические классы для реализации описанного поведения не совсем хорошая практика и в таких случаях лучше использовать паттерн singletone, но ввиду того, что разработка велась итерационно и изначально класс приложения в моей библиотеке имел только функции, был использован статический класс. Каркас у нас есть, дальше, вопрос в том, что же приложение делает при запуске, внутри цикла работы и при закрытии?
Как устроен жизненный цикл приложения с графическим интерфейсом?
Запуск
Базовая вещь любого приложения, имеющего графический интерфейс — это контекстное окно. Контекстное окно — это окно операционной системы, в котором приложение рисует свой графический интерфейс. Рисование элементов графического интерфейса реализуется при помощи наборов функций драйверов видеокарты, называемых графическим API. Графический API помимо функций рисования имеет состояние, определяющее параметры рисовки. Чтобы начать что‑то рисовать, нам нужно открыть контекстное окно, создать состояние графического API и привязать его к контекстному окну. Именно это и происходит при запуске приложения. После запуска идет сам цикл работы приложения.
Цикл работы
Итерацию цикла работы приложения с графическим интерфейсом принято называть кадром. В начале кадра происходит подготовка к рисованию, связанная с выставлением нужных параметров состояния графического API (типа включения alpha blending‑а, depth testing‑а и.т.д).
После того как графический API готов к рисованию, происходит подготовка геометрии рисуемых графических элементов. Как только геометрия готова мы можем захотеть как‑то ее поменять в зависимости от того, какие действия выполняет пользователь в нашем приложении. Чтобы понять, что делает пользователь в приложении, нужно зафиксировать события, связанные с контекстным окном (нажатия кнопок клавиатуры, мыши и.т.д).
После того как мы подготовили геометрию рисуемых графических примитивов и поняли, какие геометрические преобразования нужно сделать, на основании того, что пользователь делает в приложении, мы можем смело отправить все это через функции графического API на видеокарту.
Как только мы загрузили всё, что нужно, на видеокарту, можем попросить её это нарисовать также через графический API. Далее, закончив рисовку, нам нужно сбросить состояние графического API в исходное и отобразить то, что нарисовала видеокарта, в контекстном окне.
Примерно так, максимально абстрактно, выглядит процесс рисования графических примитивов в контекстном окне. На самом деле, всё чуточку сложнее, но об этом будет отдельная статья, в которой поговорим уже непосредственно о рисовании. С логикой запуска и логикой работы кадра приложения мы разобрались, теперь разберёмся с тем, что происходит при закрытии приложения.
Закрытие
При закрытии приложения нам нужно закрыть контекстное окно и очистить используемые им ресурсы. Как я писал ранее, при открытии контекстного окна мы инициализируем и привязываем к нему состояние графического API, поэтому при закрытии приложения нам нужно деинициализировать графический API, а потом закрыть само контекстное окно.
Внутри приложения у нас могут быть какие‑то свои слои абстракции, выполняющие логику, не связанную с контекстным окном и рисованием в нем, и эти слои абстракции также нужно корректно деинициализировать и закрыть. Сделать это желательно в отдельной функции, чтобы не перемешивать логику работы самого приложения с логикой используемых слоев абстракции.
А теперь все вместе на C++
Таким образом, на основании всего, что мы узнали, можем расширить наш статический класс, реализующий логику работы приложения с графическим интерфейсом, вот так:
namespace Frenchie{ namespace Application { class Application { Application() = delete; Application(const Application&) = delete; Application& operator=(const Application&) = delete; static int execute() { if(!awake()) return 1; while(!is_closed()) { frame_start(); frame_update(); frame_input(); frame_render(); frame_finish(); } finish(); quit(); return 0; } // проверяет, не закрыто ли приложение static bool is_closed() const; // запускает приложение static bool awake(); // готовит графический API к рисованию в контекстном окне static void frame_start(); // подготавливает геометрию, загружаемую на видеокарту static void frame_update(); // отслеживает события, связанные с контекстным окном приложения static void frame_input(); // загружает подготовленную геометрию рисуемых графических примитивов на видеокарту static void frame_render(); // отображает то, что нарисовала видеокарта, в контекстном окне и выставляет состояние графического API в исходное static void frame_finish(); // деинициализирует пользовательские слои абстракции приложения static void finish(); // деинициализирует графический API и закрывает контекстное окно приложения static void quit(); }; }}
Вот мы и сделали каркас статического класса, который реализует логику работы приложения с графическим интерфейсом. Ранее я упоминал, что наше приложение может иметь слои абстракции, не связанные с контекстным окном и рисованием в нём, и эти слои абстракции могут выполнять нашу пользовательскую логику. Как же будет выглядеть класс такого слоя абстракции и как это повлияет на класс приложения?
Пользовательские слои абстракции и работа с ними внутри цикла приложения
Класс приложения будет иметь список слоев абстракции, у которых наше приложение внутри кадра будет вызывать переопределяемые функции, на каждом этапе выполнения кадра: от подготовки графического API к рисованию графических примитивов заканчивая отображением результата рисовки в контекстном окне.
Помимо списка слоев абстракции, приложение должно иметь список запускаемых слоев абстракции для обеспечения возможности добавления слоев абстракции в приложение не только перед его запуском, но, и во время выполнения. Также, следует учесть, что некоторые пользовательские слои абстракции могут существовать в приложении во множественных экземплярах, а какие‑то — нет. Ниже приведен код слоя абстракции приложения и самого приложения:
namespace Frenchie{ namespace Application { // слой абстракции приложения class Layer { public: Layer(const std::string& _Name); virtual ~Layer(); // проверяет, пора ли закрыть слой абстракции и удалить его из списка слоев абстрации приложения bool is_closed() const; // закрывает слой абстракции void close(); // запускает слой абстрации virtual bool awake(); // функции выполняемые внутри цикла работы приложения, начиная от подготовки графического API к рисованию, заканчивая отображением результата рисовки в контекстно окне virtual void frame_start(); virtual void frame_update(); virtual void frame_input(); virtual void frame_render(); virtual void frame_finish(); // функции, выполняемые на этапе закрытия приложения или самого слоя абстракции virtual void finish(); virtual void quit(); // определяет, можно ли иметь несколько слоев абстракции одного типа внутри приложения virtual bool allows_multiple_instances() const; private: std::string m_Name; // имя слоя абстракцит bool m_Opened; // определяет нужно ли закрыть слой абстракции и удалить его из списка слоев абстракции приложения }; // Приложение class Application { Application() = delete; Application(const Application&) = delete; Application& operator=(const Application&) = delete; static int execute() { if(!awake()) return 1; while(!is_closed()) { frame_start(); frame_update(); frame_input(); frame_render(); frame_finish(); } finish(); quit(); return 0; } // проверяет, не закрыто ли приложение static bool is_closed() const; // запускает приложение static bool awake() { // открываем контекстное окно и инициализиуем в нем графический API } // готовит графический API к рисованию в контекстном окне static void frame_start() { // загружаем вновь добавленные слои абстракции в приложение и запускаем их for(auto it = m_Awakes.begin(); it != m_Awakes.end(); it++) { if((*it)->awake()) m_Layers.push_back((*it)); } // чистим список запускаемых слоев абстракции m_Awakes.clear(); // выполняем логику слоев абстракции for(auto layer : m_Layers) layer->frame_start(); } // подготавливает геометрию, загружаемую на видеокарту static void frame_update() { // выполняем логику слоев абстракции for(auto layer : m_Layers) layer->frame_update(); } // отслеживает события, связанные с контекстным окном приложения static void frame_input() { // выполняем логику слоев абстракции for(auto layer : m_Layers) layer->frame_input(); } // загружает подготовленную геометрию рисуемых графических примитивов на видеокарту static void frame_render() { // выполняем логику слоев абстракции for(auto layer : m_Layers) layer->frame_render(); } // рисует то, что нарисовала видеокарта, в контекстном окне // и выставляет состояние графического API в исходное static void frame_finish() { // выполняем логику слоев абстракции for(auto layer : m_Layers) layer->frame_finish(); // удаляем закрытые слои абстракции из списка слоев абстракции приложения for(auto it = m_Layers.begin(); it != m_Layers.end(); it++) { if((*it)->is_closed()) { (*it)->finish(); (*it)->quit(); auto rm = it; it++; m_Layers.erase(rm); if(it == m_Layers.end()) break; } } } // деинициализирует пользовательские слои абстракции приложения static void finish() { // выполняем логику слоев абстракции, доживших до этого этапа for(auto layer : m_Layers) layer->finish(); } // деинициализирует графический API и закрывает контекстное окно приложения static void quit() { // выполняем логику слоев абстракции, доживших до этого этапа for(auto layer : m_Layers) layer->quit(); } // создаем слой абстракции и добавляем его в список запускаемых слоев абстракции template<typename Type, typename ... Arguments> static std::shared_ptr<Type> push_layer(Arguments... _Parameters) { // если слой абстракции заданного типа может быть только один в приложении, то возвращаем уже существующий слой std::shared_ptr<Type> found = find_layer<Type>(); if(found != nullptr && !found->allows_multiple_instances()) return found; // создаем слой абстракции и добавляем его в список слоев, которые собираемся запустить в начале следующего кадра работы приложения auto layer = std::make_shared<Type>(_Parameters...); m_Awakes.push_back(layer); return layer; } // находит слой абстракции заданного типа в списке слоев абстракции приложения template<typename Type> static std::shared_ptr<Type> find_layer() { auto layer = std::find_if( m_Layers.begin(), m_Layers.end(), [](std::shared_ptr<Layer> _Layer)->bool { return std::dynamic_pointer_cast<Type>(_Layer) != nullptr; } ); return layer != m_Layers.end() ? std::dynamic_pointer_cast<Type>(*layer) : nullptr; } // список слоев абстракции приложения static std::list<std::shared_ptr<Layer>> m_Layers; // список запускаемх слоев абстракции приложения static std::list<std::shared_ptr<Layer>> m_Awakes; }; }}
Итак, мы создали статический класс приложения с возможностью добавления в него пользовательских слоев абстракции, однако мы не затрагивали тему управления состоянием контекстного окна и рисования в нем графических примитивов.
Логика управление состоянием контекстного окна и рисование в нем графических примитивов. Platform и rendering backend.
За управление состоянием контекстного окна и отслеживания связанных с ним событий отвечает так называемый platform backend. Platform backend — это слой абстракции над функциями операционной системы, нужными для менеджмента состояния контекстного окна, отслеживания связанных с ним событий, а также для запуска процесса инициализации и привязки к контекстному окну состояния используемого графического API и отображения результата рисования в контекстном окне.
За загрузку в видеопамять текстур, геометрии графических примитивов и их рисование отвечает rendering backend. Rendering backend — это слой абстракции над используемым графическим API типа OpenGL, Vulkan, DirectX и.т.д.
На данном этапе достаточно знать, в какие моменты работает platform backend внутри цикла приложения, ибо именно через этот backend реализуется отслеживание событий контекстного окна, а также отображение результата рисовки в контекстном окне. Модифицированный класс приложения с учетом работы platform backend‑а приведен ниже:
namespace Frenchie{ namespace Application { // статический класс backend-а платфомы class ApplicationPlatformBackend { public: ApplicationPlatformBackend() = delete; ApplicationPlatformBackend(const ApplicationPlatformBackend&) = delete; ApplicationPlatformBackend& operator=(const ApplicationPlatformBackend&) = delete; bool is_closed(){} // открывает контекстное окно, создает состояние графического API и привязывает его к контестному окну bool awake(); // ловим события, связанные с контекстным окном void frame_start(); // фиксируем события, связанные с контекстным окном, т.е сохраняем, что произошло: наждатие кнопки клавиатуры, мыши и.т.д void frame_update(); // отображает результат рисования в контекстном окне и вызывает сброс состояния графического API в исходное void frame_finish(); // деинициализируем графический API и уничтожаем контекстное окно quit(); // здесь еще есть другая логика, про которую поговорим в другой статье ... } // Приложение class Application { Application() = delete; Application(const Application&) = delete; Application& operator=(const Application&) = delete; static int execute() { if(!awake()) return 1; while(!is_closed()) { frame_start(); frame_update(); frame_input(); frame_render(); frame_finish(); } finish(); quit(); return 0; } // проверяет, не закрыто ли приложение static bool is_closed() const { // спрашиваем у platform backend-а закрыто ли наше контекстное окно return ApplicationPlatformBackend::is_closed(); } // запускает приложение static bool awake() { // открываем контекстное окно и инициализиуем в нем графический API return ApplicationPlatformBackend::awake(); } // готовит графический API к рисованию в контекстном окне static void frame_start() { // выполняем логику backend-а платформы: берем графический API и выставляем параметры рисовки графических примитивов ApplicationPlatformBackend::frame_start(); // загружаем вновь добавленные слои абстракции в приложение и запускаем их for(auto it = m_Awakes.begin(); it != m_Awakes.end(); it++) { if((*it)->awake()) m_Layers.push_back((*it)); } m_Awakes.clear(); // выполняем логику слоев абстракции for(auto layer : m_Layers) layer->frame_start(); } // подготавливает геометрию, загружаемую на видеокарту static void frame_update() { // выполняем логику backend-а платформы: ловим события, связанные с контекстным окном ApplicationPlatformBackend::frame_update(); // выполняем логику слоев абстракции for(auto layer : m_Layers) layer->frame_update(); } // отслеживает события, связанные с контекстным окном приложения static void frame_input() { // выполняем логику слоев абстракции for(auto layer : m_Layers) layer->frame_input(); } // загружает подготовленную геометрию рисуемых графических примитивов на видеокарту static void frame_render() { // выполняем логику слоев абстракции for(auto layer : m_Layers) layer->frame_render(); } // рисует то, что нарисовала видеокарта, в контекстном окне // и выставляет состояние графического API в исходное static void frame_finish() { // выполняем логику backend-а платформы: берем графический API, сбрасываем его состояние в исходное и отображаем результат рисования в контекстном окне ApplicationPlatformBackend::frame_finish(); // выполняем логику слоев абстракции for(auto layer : m_Layers) layer->frame_finish(); // удаляем закрытые слои абстракции из списка слоев абстракции приложения for(auto it = m_Layers.begin(); it != m_Layers.end(); it++) { if((*it)->is_closed()) { (*it)->finish(); (*it)->quit(); auto rm = it; it++; m_Layers.erase(rm); if(it == m_Layers.end()) break; } } } // деинициализирует пользовательские слои абстракции приложения static void finish() { // выполняем логику слоев абстракции, доживших до этого этапа for(auto layer : m_Layers) layer->finish(); } // деинициализирует графический API и закрывает контекстное окно приложения static void quit() { // выполняем логику backend-а платформы: деинициализируем графический API и уничтожаем контекстное окно ApplicationPlatformBackend::quit(); // выполняем логику слоев абстракции, доживших до этого этапа for(auto layer : m_Layers) layer->quit(); } // создаем слой абстракции и добавляем его в список запускаемых слоев абстракции template<typename Type, typename ... Arguments> static std::shared_ptr<Type> push_layer(Arguments... _Parameters) { // если слой абстракции заданного типа может быть только один в приложении, то возвращаем уже существующий слой std::shared_ptr<Type> found = find_layer<Type>(); if(found != nullptr && !found->allows_multiple_instances()) return found; // создаем слой абстракции и добавляем его в список слоев, которые собираемся запустить в начале следующего кадра работы приложения auto layer = std::make_shared<Type>(_Parameters...); m_Awakes.push_back(layer); return layer; } // находит слой абстракции заданного типа в списке слоев абстракции приложения template<typename Type> static std::shared_ptr<Type> find_layer() { auto layer = std::find_if( m_Layers.begin(), m_Layers.end(), [](std::shared_ptr<Layer> _Layer)->bool { return std::dynamic_pointer_cast<Type>(_Layer) != nullptr; } ); return layer != m_Layers.end() ? std::dynamic_pointer_cast<Type>(*layer) : nullptr; } // список слоев абстракции приложения static std::list<std::shared_ptr<Layer>> m_Layers; // список запускаемх слоев абстракции приложения static std::list<std::shared_ptr<Layer>> m_Awakes; }; }}
Итак, на этапе запуска приложения, plarform backend открывает контекстное окно и инициализирует используемый графический API через rendering backend.
В начале кадра, на этапе frame update, platform backend ловит и кеширует события, связанные контекстным окном и готовит rendering backend к рисованию графических примитивов.
Далее, в конце кадра (на этапе frame_finish()) platform backend сбрасывает состояние rendering backend‑а в исходное и отображает результат рисования в контекстном окне.
Когда приложение закрывается, platform backend, на этапе quit(), запускает деинициализацию графического API через rendering backend и уничтожает контекстное окно.
Теперь, чтобы статья не раздулась до астрономических масштабов, предлагаю на этом остановиться. В следующем материале я расскажу о том, как работает platform backend, какие функции в нем есть и как он реализован в моей библиотеке. На этом пока всё, спасибо за внимание, надеюсь, что было интересно.
ссылка на оригинал статьи https://habr.com/ru/articles/1055228/