Как стать автором
Обновить

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

Время на прочтение5 мин
Количество просмотров8.3K
Всего голосов 6: ↑4 и ↓2+3
Комментарии17

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Потому, что пока вы выводите картинку на экран, 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.

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

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

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

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

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

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

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

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

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

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

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

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

...

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Зарегистрируйтесь на Хабре, чтобы оставить комментарий

Публикации