В программировании очень популярен прием создания программных интерфейсов - 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 - приложение которое создает окно и выводит разноцветный треугольник. Опять же цель проекта - объяснить идею, а не создать эффективную программу, поэтому код не везде оптимален.