В программировании очень популярен прием создания программных интерфейсов - 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 для работы с клавиатурой)

Заключение

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

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

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

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

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

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

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

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

P.S.

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

Комментарии (17)


  1. loginmen
    14.12.2021 11:54

    В Unreal Engine OpenGL под десктоп остался только на линуксе, под винду на выбор DirectX 11, DirectX 12 и Vulkan, а OpenGL deprecated, как устаревший вот и все.

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

    В гитхабе на который ведет ссылка вообще ересь какая-то. С каких пор SFML графический апи? И зачем каждый тик отправлять данные для отрисовки?

    Если что у анреала открытые исходники и можно посмотреть что там как реализовано


    1. GoldNotch Автор
      14.12.2021 12:03
      +1

      1) SFML - не графический API, а оконный. В гитхабе просто представлено, что можно использовать различные конфигурации: GL/Vulkan + GLFW, GL/Vulkan + SFML, DirectX + WinAPI (пока не написал)

      2) Проект на гитхабе просто модель программы, без всяких оптимизаций. Понятно, что не эффективно каждый кадр собирать заново этот вершинный буфер. Естественно тут нужна оптимизация, но я посчитал, что без нее будет более наглядно.

      3) Исходники UnrealEngine это конечно хорошо, но еще поди разберись в них. Архитектура Unreal не такая прозрачная. Лично я две недели разбирался только с тем как там снять звук с Submix'а у AudioCapture, отправить этот звук в сокет, затем принять и воспроизвести. Эдакая система голосового чата. (Я не захотел использовать OnlineSubsystem, чтобы не привязываться к стиму и прочим сервисам. Возможно я сделал глупость). Понять как там устроен рендеринг это еще сложнее. Если ты в этом разбираешься, то я был бы рад прочитать объяснение как устроен рендеринг (поддержка всех API) в UnrealEngine.


      1. loginmen
        14.12.2021 12:26

        >SFML - не графический API, а оконный

        Тоже не верно, SFML это мультимедийная библиотека (поверх OpenGl с фиксированным конвеером), в ней не только окна, но и сеть, и звук, и текст, и графика, и ввод.

        А вот GLFW это чисто окно и ввод.


      1. 4eyes
        15.12.2021 18:48

        Исходники UnrealEngine это конечно хорошо, но еще поди разберись в них.

        В кратце про UE: https://docs.unrealengine.com/4.27/en-US/ProgrammingAndScripting/Rendering/ParallelRendering/

        Побольше про id Tech: https://fabiensanglard.net/doom3_bfg/


  1. SadOcean
    14.12.2021 12:43
    +2

    Ну самим подходом вы кажется вскрыли разницу между библиотекой и фреймворком (библиотека - код, который встраивается в ваш, а фреймворк - система, в которую вы встраиваете свой код)
    Но мне не понятно, как это решит заявленную проблему обобщения апи

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

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

    Все равно есть некоторое апи - либо игровые библиотеки знают, что им нужно установить, материал и заполнить специальный массив информацией о том, какие елки нужно рисовать в этом кадре (тогда эти структуры с мешами, материалами и списком объектов станут универсальным апи, просто не через вызов функций, а через структуры данных)
    Либо игра оставляет свои данные и уже общий движок должен знать, как ему нужно отрисовать вот этот набор координат - как елки или как меш со шрифтовой текстурой буквочек - и получается что никакой универсальности нет - все равно кто-то дописывает эту имплементацию под каждое целевое апи
    Так или иначе - либо общее апи, содержащее минимальный сабсет, либо нет универсальности

    В общем статья, как мне кажется, содержит здравое зерно по организации архитектуры, но на поставленный вопрос не отвечает


    1. GoldNotch Автор
      14.12.2021 13:04

      Спасибо за комментарий)

      >Но мне не понятно, как это решит заявленную проблему обобщения апи

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


      1. Mizofix
        15.12.2021 09:12
        +1

        Интересно, но именно такой подход (абстрагирование API вулкана) я выбрал примерно полгода назад и очень тогда пожалел об этом, т.к потратил пару недель там, где, возможно, можно было бы обойтись описаным здесь подходом.

        Статья неплохая, вроде ничего нового, но как-то иначе взглянул на архитектуру. Пишите ещё ????


  1. JKot
    14.12.2021 13:33
    +3

    dx12/Vulcan работают с команд буфферами, которые ещё и генерировать по хорошему нужно в многопотоке. По сути команд буфера и есть та абстракция которую вы ищите. Пишете универсальный интерфейс для команд, в dx12/Vulcan прокидываете все в api, а для OpenGL в софтварно эмулируете.

    Подход с перекидыванием треугольников в gpu каждый кадр работал во времена q1, но в современном мире шина между cpu/gpu самое узкое место, не надо так.

    Небольшие движки на которые стоит ориентироваться nvidia/falcor, GameFoundry/bsf


    1. GoldNotch Автор
      14.12.2021 14:50

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

      >dx12/Vulcan работают с команд буфферами, которые ещё и генерировать по хорошему нужно в многопотоке.

      Почему комманд буферы надо генерировать в нескольких потоках? Я если честно вообще не понимаю преимуществ многопоточного рендеринга: окно для вывода одно, шина gpu/cpu одна. Везде будут сплошные критические секции, а отправлять на видеокарту все равно что-то нужно, пусть и не каждый кадр. Дк вот в чем преимущество, если там все равно записывает только один поток?


      1. JKot
        14.12.2021 15:23
        +1

         Гпу стали быстрыми и с instancing'ом могут рисовать огромное количество объектов в кадре. CPU просто уже не успевает подготавливать кадр в один поток, чтобы не задерживать GPU, собственно vulkan/Dx12 это результат решения именно этих проблем. CPU по сути делает сильный Reduce данных, собирая гигабайты разбросанных по памяти данных в десяток другой небольших (~10mb) команд буферов данных.

        Да, по итогу один поток отправит данные на гпу и этот поток будет постоянно ждать другие потоки, которые будут эти данные формировать.

        Суть в том, что opengl,Dx11 были вообще заточены под работу с апи из одного потока, сейчас формировать команд буфера можно неограниченным числом потоков.


      1. holydel
        14.12.2021 16:29
        +1

        Советую посмотреть ещё DiligentEngine, как пример удачно реализованой абстракции над рендерилкой: https://github.com/DiligentGraphics/DiligentEngine


      1. 4eyes
        15.12.2021 18:38
        +1

        Почему комманд буферы надо генерировать в нескольких потоках?

        Потому, что пока вы выводите картинку на экран, CPU простаивает - почему бы не загрузить его просчетом следующего кадра?

        Везде будут сплошные критические секции

        Сплошные критические секции появляются тогда, когда пытаются организовать наивную многопоточную обработку данных, владение которыми разделено между несколькими потоками.

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

        Поток игры:

        • Создаем пустой "мир" из примитивов для рендеринга в потоке логики. А лучше два: мир для звука и графики.

        • Поток логики, он единственный, кому нужны игровые сущности по типу ADoomGuy. Делаем "тик" часов. Обрабатываем каждую игровую сущность по одной, в результате получая примитивы по типу CMesh+CMaterial+CTransform+CSound, которые засовываются в нужный "мир" по одному без блокировки.

        • По окончанию обработки кадра игровой логикой, берем весь мир (отдельно звук и картинку) и засовываем в список готовых к рендерингу кадров. Здесь в момент push_back (и только для него) нужна синхронизация, чтоб рендерер не сделал pop_front пока список в разобранном состоянии.

        • Если слишком сильно опередили потоки рендеринга - ждём.

        Поток рендеринга:

        • берем самый старый готовый "мир" из своего списка (world = frames.pop_front();) в этот момент, и только в него нужна блокировка.

        • отпустив блокировку, рендерим мир: или настраиваем графический API, или смешиваем звуки и применяем к ним эффекты.

        По аналогии решает проблема ввода-вывода. Так же, по аналогии можно выделить физический примитивы в отдельную сущность и "рендерить" физику (т.е. скорости и положение сущностей игры на момент следующего кадра) в отдельном потоке - разница в том, что запросы на просчет физики будут отправляться после тика, а до тика придется ждать завершения просчета физики.

        Итого, получаем минимум блокировок и максимум параллелизма. Из минусов - небольшой лаг отрисовки в пару кадров, но де-факто это стандарт, и при плавных 60-120 fps это не критично. Особенно, когда игра выдает 120 fps, монитор рисует 60 fрs, а тактильный отклик на клик кнопки доходит до мозга на всех 30 fps.


  1. Tujh
    14.12.2021 23:51
    +1

    отдельная dll не переносима с одного компьютера на другой

    Простите, что?


    1. GoldNotch Автор
      15.12.2021 09:20
      -1

      Возможно я не прав, у меня не много опыта работы с dll. Но гугл выдал мне ряд нюансов:

      1) у C++ нет стандартизованного ABI (бинарный интерфейс), следовательно dll на С++ могут не встать в других проектах (наверно получите undefined reference, но это не точно), и наверно там надо приводить все к стандарту С.

      2) И возможно возникнут проблемы при переносе dll на другую платформу. Ну я никогда не видел чтобы dll использовали на Linux (нативно).

      Хотя если прикладная библиотека полностью кроссплатформенна, то можно просто собрать несколько конфигураций - под каждую платформу.


      1. Tujh
        15.12.2021 11:50
        +2

        <зануда on>перенос с одного компьютера на другой != перенос с одной платформы на другую<зануда off>

        Да, для С++ нет стандартного ABI, отчасти потому, что каждый компилятор даже vtbl класса реализует немного по разному, всё в угоду производительности. Поэтому dll собранную в MSVC и экспортирующую классы не удастся изпользовать в, скажем, проекте, собирающимся mingw, или в проекте, собирающимся просто с другой версией той же студии.

        Но...если проект уже собран, копирование dll с одного компьютера на другой не сломает его функционал (собственно что делают все инсталяторы - копируют массу dll на компьютер пользователя).

        И да, проблема решаема, есть переусложнённое решение в виде СОМ, которое позволяет использовать dll написанную хоть на VisualBasic в проекте на С++, не говоря уже о разных версиях компилятора С++. Использовать именно СОМ не обязательно, но если точно также экспортировать не классы, а интерфейсы к классам - проблем нет, это достаточно стандартное решение.

        Dll на Linux естественно не заработает - совершенно другой бинарный формат. Динамические библиотеки в Linux имеют расширение .so - shared object, но это не перенос с одного компьютера на другой :)


  1. 4eyes
    15.12.2021 17:16
    +2

    И решения тут два: либо делай IRenderer так, чтобы он отвечал и за создание окна (дополнительная ответственность у классов), либо привязывай IRenderer к какому-то окну уже созданному (но ведь можно же рендерить без окон!!! Не универсально!!)

    ...

    UDP: Для эффективности приложения не стоит возвращать из Tick OutputData по значению.

    Напомнило анекдот

    Каждый раз, когда профессор приходил в буфет, его очень раздражало, что студенты просят "одно кофе". Но однажды он услышал:
    - Мне, пожалуйста, один кофе.
    "Ну наконец-то", - с облегчением подумал профессор.
    - И один булочка...

    Для эффективности приложения следует возвращать OutputData по значению. При этом OutpupData должен иметь перемещающий конструктор. Если вы будете хранить его в классе, то мало того, что ответственность размажется, так оно еще и масштабироваться на потоки не будет.

    Я не настоящий сварщик, но я бы для эффективности приложения все сущности складывал в OutputData, перемещая туда временные объекты.

    Примерно так:

    • один потом обрабатывает пользовательский ввод

    • один поток занимается миром игры согласно последнему полученному вводу от игрока, по сути, двигает коробки и выполняет триггеры. Если нужно, таким же образом складывает команды и очередь.

    • один поток выводит примитивы отвечающие предыдущему отрендеренному состоянию игры через графическое API.

    • один поток создает (рендерит) примитивы из сущностей игры для вывода на экран и в звуковую карту в следующем кадре, пока предыдущий кадр рендерится на экран.

    • в конце кадра предыдущий список примитивов и состояния мира заменяется текущим.

    Насколько я понимаю, Unreal Engine примерно так и работает. При этом "примитив" это скорее не треугольник, а mesh, источник света или еще какая-нибудь относительно высокоуровневая сущность.

    При это слой рендеринта получает команду "отрендерить мир", и дальше делит это на части: отрендерить mesh-ы, отрендерить свет, отрендерить тени. Слой рендеринга мешей в свою очередь знает, как отрендерить их максимально эффективно (отсортировать по материалу, установить материал, отправить пачку треугольников). И еще ниже идет графическое API, которое знает как перевести ваш материал в шейдеры и как настроить видеокарту на дальнейший рендеринг треугольников с ним.


    1. GoldNotch Автор
      16.12.2021 12:47

      >Если вы будете хранить его в классе, то мало того, что ответственность размажется, так оно еще и масштабироваться на потоки не будет.

      Да, тут вы правы. Так будет лучше.
      Спасибо за объяснение)