Проблема несовместимых API или как легко поддерживать совместимость с OpenGL, DirectX и Vulkan

Моя цель - предложение широкого ассортимента товаров и услуг на постоянно высоком качестве обслуживания по самым выгодным ценам.

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

Дальше надо написать функцию 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 для работы с клавиатурой)

Заключение

Что это все дает?

  1. Переносимость кода под любые API с минимальным переписыванием кода,

  2. Ускорение темпа разработки, потому что можно очень много времени потратить на проектирование интерфейса IRenderer, а затем на дописывание/переписывание,

  3. Спорный плюс: если оформить вашу прикладную библиотеку в виде dll, то можно подключить эту dll в собственный main и выполнить портирование программы на API, которые использует ваша система. Но к сожалению формат dll не стандартизирован и отдельная dll не переносима с одного компьютера на другой.

И это все можно применять не только к играм, потому что на сегодняшний день почти любое приложение использует графику, звуки и т.д. В статье я сделал упор именно на сферу графики и геймдева, потому что в данный момент занимаюсь этим.

Важно: данный подход стоит применять только в случае несовместимых API. Когда API совместимы (сокеты, потоки, менеджеры памяти и все что работает примерно одинаково), то лучше сделать интерфейс для API, а не приложения.

Честно, я не знаю каким решением пользуются крупные компании вроде EpicGames. Возможно с их количеством экспертов по всем областям программирования они могут продумать и спроектировать интерфейс IRenderer. Но я не думаю что это под силу небольшой группе программистов, которые далеко не эксперты в применении низкоуровневых API. Если у кого-то есть информация о том, как это делается в крупных и не очень компаниях, то я буду рад, если вы скинете это в комментариях.

Мне известно, что Dear ImGui и Nuklear используют аналогичный подход - основная логика выделена в отдельный модуль и есть ряд бэкендов (под каждую конфигурацию и без единого интерфейса), которые отвечают за вызов функций графического API или оконного API. Но их код не такой прозрачный и понятный, как я показал выше.

P.S.

Дабы продемонстрировать работоспособность решения, я собрал небольшой проект на GitHub - приложение которое создает окно и выводит разноцветный треугольник.

Источник: https://habr.com/ru/post/595407/


Интересные статьи

Интересные статьи

«Корутины - это легковесные потоки», сколько раз вы слышали эту формулировку? Она что-нибудь вам говорит? Скорее всего не очень много. Если вы хотите узнать больше о том,...
На работе я занимаюсь поддержкой пользователей и обслуживанием коробочной версии CRM Битрикс24, в том числе и написанием бизнес-процессов. Нужно отметить, что на самом деле я не «чист...
С момента выпуска первой спецификации EFI в двухтысячном году прошло около девятнадцати лет. Десять лет понадобилось интерфейсу, чтобы выйти на пользовательский рынок и закрепиться на нем. На тек...
Летом мы публиковали подборку книг, в которой не было справочников или руководств по алгоритмам. Она состояла из литературы для чтения в свободное время — для расширения кругозора. В качестве про...
Программисты, кажется, забыли реальную цель программного обеспечения — это решать реальные проблемы. 50 лет назад, в 1968 году, была организована рабочая конференция по программной инженер...