В программировании очень популярен прием создания программных интерфейсов - API. Этот прием очень полезен, чтобы скрыть все тонкости реализации и не нагружать ими обывателя. Но бывают случаи, когда хотелось бы поддерживать в коде несколько API, которые выполняют одну и ту же задачу, причем с минимальным переписыванием кода. Например: поддерживать работу игры (движка) на различных графических API: DirectX, OpenGL, Vulkan. В данной статье представлены мысли о том как это сделать.

Описание проблемы
Рассмотрим пример: Вы хотите написать кроссплатформенный игровой движок. Допустим вы знаете С/С++, OpenGL, DirectX, Vulkan. Поскольку движок кроссплатформенный, то вы сначала думаете "а сделаю ка я его на OpenGL", и все то у вас хорошо получается, пока к вам не закрадывается мысль, что может OpenGL не идеально подходит для windows? Почему то же крупные компании делают поддержку сразу всех API, и UnrealEngine под windows собирает с использованием DirectX (по умолчанию), а не OpenGL/Vulkan. И вот перед вами стоит задача - как-то обобщить все API. Вы пытаетесь написать интерфейс - IRenderer и классы потомки, которые бы сами инициализировали требуемый API и отвечали за рисование. Но вот не задача, OpenGL не может работать без созданного окна (скрытое окно тоже окно), а DirectX и vulkan могут. И решения тут два: либо делай IRenderer так, чтобы он отвечал и за создание окна (дополнительная ответственность у классов), либо привязывай IRenderer к какому-то окну уже созданному (но ведь можно же рендерить без окон!!! Не универсально!!). В общем сходу так и не продумать IRenderer, слишком API не похожи друг на друга, хотя казалось бы решают одну и ту же задачу - доступ к видеокарте и обеспечение рисования. Но написать игру все же хочется и хочется чтобы она работала под разными платформами, с разными несовместимыми API, а еще хочется чтобы код был читабельным, минимальным и не переписывался по 10 раз. На этот вопрос я и постараюсь ответить.
Таким образом, несовместимые API - это API, которые решают одну и ту же задачу, но имеют совершенно разные подходы к решению и следовательно разный набор функций. Подходы настолько разные что не получается обобщить работу под одним интерфейсом. А совместимые API - это API, которые состоят из похожих функций (сигнатуры похожи). Примеры совместимых API: сокеты, потоки, кучи памяти, файлы. Их работу легко обобщить под одним интерфейсом и поэтому так много библиотек для работы с ними написано.
Предлагаемое решение
А решение простое: обобщать не API, а приложение. Прикладной код приложения (игровая логика, физика, ИИ, GUI, и пр.) - оформляется в виде отдельной библиотеки с определенным интерфейсом. Пример объявления библиотеки:
template<typename InputData, typename OutputData> class IGame { public: virtual ~IGame(){} //функция обработки одного такта игры virtual OutputData Tick(const InputData& input) = 0; };
#include "IGame.h" // Коды клавиш для внутриигровой обработки enum KeyCode { KEY_UNKNOWN, KEY_ESCAPE, KEY_W, KEY_A, KEY_S, KEY_D, //... KEYS_TOTAL }; //Возможные коды ошибок игры enum ErrorCode { ERROR_UNKNOWN, //.. ERRORS_TOTAL }; //Управляющие команды игры enum CommandCode { COMMAND_UNKNOWN, COMMAND_CLOSE_GAME, //... COMMANDS_TOTAL }; //Входные данные по которым игра обрабатывает один свой такт struct InputData { float ProcessTimeInSec; float CursorPos_x; float CursorPos_y; bool PressedKeys[KeyCode::KEYS_TOTAL]; //... }; //Выходные данные такта игры struct OutputData { std::vector<CommandCode> Сommands;//команды такта игры std::vector<ErrorCode> Уrrors;//ошибки такта игры std::vector<float> VertexBuffer; //вершинный буфер, который заполняется //при просчете такта игры int VerticesCount; //кол-во вершин //... }; // реализация интерфейса прикладной библиотеки class Game : public IGame<InputData, OutputData> { public: //Инициализация игры Game(/*Сюда можно вставить параметры игры*/); //Деструктор освободит ресурсы игры virtual ~Game(); //функция обработки одного такта игры virtual OutputData Tick(const InputData& input) override; };
Итак вы реализуете класс Game, пишите игровую логику, делаете это максимально кроссплатформенно и не привязываясь к определенным низкоуровневым API. Через структуру OutputData возвращаете данные геометрии, данные оцифрованного звука, желательно управляющие команды и ошибки.
UDP: Код представленный в статье лишь пример и модель, его цель объяснить идею, а не эффективно выполнится, поэтому я опустил различные оптимизации.
Дальше надо написать функцию main. Именно функция main и вызывает конкретные функции OpenGL/DirectX/Vulkan попутно вызывая код вашего приложения через функцию Tick. Из функции Tick возвращаются данные, которые конкретное API может использовать для рендеринга, воспроизведения звука и т.д. Общий код функции main может быть таким:
//#include <OpenGL>/<DirectX>/<Vulkan>/... //#include <windows.h>/<GLFW.h>/<SFML.h>/<SDL.h>/... #include "Game.h" int main() { //код по открытию окна Window wnd = openWindow(...); //назначить обработчики событий нажатия клавиш и движения мыши в wnd wnd.SetCallback(...); //инициализация графического API InitializeGraphicAPI(...); //также нужно сделать отображение кодов клавиш низкоуровневого API //в коды клавиш вашей прикладной библиотеки. Game::KeyCode KeyMap[Api.KeysCount()]; KeyMap[Key_Escape] = Game::KEY_ESCAPE; //... //создание игры Game game; //входные данные для просчета одного такта игры Game::InputData input;//не забудьте их заполнить начальными значениями //главный цикл приложения while(wnd.IsOpen()) { //вызываем оконные события wnd.pollEvents(); //заполняем вводные данные для такта игры input.Time = ...; input.CursorPos = ...; input.PressedKeys = ....; //просчитываем один такт игры //можно поиграть с многопоточностью и запускать просчет такта в новом потоке Game::OutputData output = game.tick(input) //вывод renderingWithApi(output); PlaySoundsWithApi(output); //... } return 0; }
Под каждую конфигурацию нужно написать свой main, который связывается с кодом вашей прикладной библиотеки.
Важно не забыть сделать отображения кодов нажатых клавиш, потому что у стороннего API и у вашей прикладной библиотеки коды клавиш могут быть разные. (Вы же определяете коды клавиш в прикладной библиотеке не привязываясь к конкретным API для работы с клавиатурой)
Заключение
Что это все дает?
Переносимость кода под любые API с минимальным переписыванием кода,
Ускорение темпа разработки, потому что можно очень много времени потратить на проектирование интерфейса IRenderer, а затем на дописывание/переписывание,
Спорный плюс: если оформить вашу прикладную библиотеку в виде dll, то можно подключить эту dll в собственный main и выполнить портирование программы на API, которые использует ваша система. Но к сожалению формат dll не стандартизирован и не переносим с одной платформы на другую.
И это все можно применять не только к играм, потому что на сегодняшний день почти любое приложение использует графику, звуки и т.д. В статье я сделал упор именно на сферу графики и г��ймдева, потому что в данный момент занимаюсь этим.
Важно: данный подход стоит применять только в случае несовместимых API. Когда API совместимы (сокеты, потоки, менеджеры памяти и все что работает примерно одинаково), то лучше сделать интерфейс для API, а не приложения.
Честно, я не знаю каким решением пользуются крупные компании вроде EpicGames. Возможно с их количеством экспертов по всем областям программирования они могут продумать и спроектировать интерфейс IRenderer. Но я не думаю что это под силу небольшой группе программистов, которые далеко не эксперты в применении низкоуровневых API. Если у кого-то есть информация о том, как это делается в крупных и не очень компаниях, то я буду рад, если вы скинете это в комментариях.
Мне известно, что Dear ImGui и Nuklear используют аналогичный подход - основная логика выделена в отдельный модуль и есть ряд бэкендов (под каждую конфигурацию и без единого интерфейса), которые отвечают за вызов функций графического API или оконного API. Но их код не такой прозрачный и понятный, как я показал выше.
P.S.
Дабы продемонстрировать работоспособность решения, я собрал небольшой проект на GitHub - приложение которое создает окно и выводит разноцветный треугольник. Опять же цель проекта - объяснить идею, а не создать эффективную программу, поэтому код не везде оптимален.
