Кроссплатформенный движок игровой графики – 1
Добро пожаловать в серию статей про кроссплатформенный движок игровой графики!
Желание написать эту серию было вызвано проектом, над которым я тружусь в свободное время вот уже пару лет. Часть этого проекта содержит графический движок, который я создал с нуля. Он настроен для поддержки современного конвейера по визуализации игр AAA. Во время разработки я старался придерживаться пары идей, легших в основу, и это сильно повлияло на архитектуру. В ходе данной серии я хотел бы поделиться идеями, а также догадками, как создать API, который остается верным целям.
Поскольку здесь есть что рассказать, я хотел бы разбить тему на несколько частей. При этом каждая часть продолжает предыдущую, чтобы последовательно показать вам мое понимание, каким должен быть графический движок сегодня. Вот разбивка:
- Основные цели в дизайне движка (сегодняшний материал)
- Эффективная многопоточная запись и подача команд
- Эффективная работа с концепциями D3D12 (или Vulkan)
- Случайные мысли, которые не соответствуют ни одному из вышеуказанных пунктов (фактическое название еще не утверждено)
Часть 1 – Кроссплатформенный движок. Основные цели в дизайне
Выбрать цели, вокруг которых будет строиться дизайн программного обеспечения, не так-то просто. Они обычно представляют собой набор идей, которые формируются из опыта работы с различными кодовыми базами. Из того, что работает или не работает в какой-либо конкретной ситуации. Это также попытка определить, что вы хотите, чтобы ваша программа делала. Всегда существует какая-нибудь библиотека, которая выполняет вещи лучше вас. Поэтому не старайтесь с самого начала достичь идеала по всем пунктам! Правильно выбирайте свои битвы и убедитесь, что сможете их выиграть. Любую вещь можно улучшить в последующие разы.
Движок, который я хотел создать, должен быть простым и легким, чтобы его могло поддерживать всего несколько человек (желательно даже один!). Он должен работать на современных десктопах и API, и должен максимально эффективно использовать эти платформы и API. Я знаю, что хотел многого, но я чувствовал, что такое можно сделать. Во время проектировки и написания кода я пришел к следующим целям.
«Сложные» концепции в современных графических API
В основном я знаком с DirectX семейством API, поэтому объясню, что подразумеваю под «сложными» концепциями в терминах DX.
Мне кажется, что во всех кодах DX12, над которыми я работал, существует борьба с корневыми подписями, дескрипторами, объектами состояния конвейера, барьерами, заборами, распределителями команд и эффективным использованием парадигмы списка команд (и я перечислил далеко не все!). Это часто связано с наличием уже существующей и зачастую сложной архитектуры, построенной вокруг DX9 или DX11, которую как-то надо совместить с DX12. Приходится тянуть за собой бесполезное наследие! Чтобы получить реальную выгоду от последних версий, я думаю, надо сделать серьезные изменения в архитектуре. И это может быть невероятно тяжело выполнить, если вы работаете с принятым в обращение кодом. Вы не просто строите самолет прямо в воздухе, вы берете старый самолет прямо посередине полета, и собираете новый из его частей.
Движок без наследия позволил бы реально использовать все «сложные» концепции и даже принять их как основные в вашем API. Это может означать, например, что мы будем говорить не об отдельных шейдерных программах, а о состояниях конвейера. Теперь в данных можно было бы явно указывать корневую подпись, а не привязываться к шейдерным регистрам.
Суть в том, что я хотел не просто использовать вышеуказанные концепции, а использовать их на полную мощь. Они задумывались создателями как улучшения более старых API, так зачем бороться с этим?
В 3 части мы будем копаться в некоторых тонкостях данной темы.
Многопоточное исполнение задач должно стать во главе
Наличие какого-нибудь масштабируемого многопоточного решения в наши дни необходимо, если вы хотите получить максимальную отдачу от своего процессора. Поскольку мы начинаем с чистого листа, почему бы не построить движок полностью вокруг этой концепции? К счастью, современные графические API позволят вам выполнять некоторое подобие записи списка команд по нескольким потокам. (Отложенные контексты в DX11 тоже могли, но мы все знаем, как это закончилось). Воплотив концепцию многопоточности в архитектуре с самого начала, написание масштабируемого многопоточного графического кода можно было бы сделать таким же прямолинейным, как традиционный однопоточный код (если в коде рендеринга высокого уровня вам нужно явно использовать критические разделы, вы делаете что-то неправильно!). Это по существу главная цель авторского многопоточного кода, причем абсолютно достижимая при тщательном обдумывании и планировании. К счастью, мне также оказали помощь несколько талантливых людей, которые серьезно подошли к написанию быстрой и аккуратной системы задач для другой части проекта.
Для меня важность заключается в полностью независимой от потока записи и регистрации команд на низком уровне (мы обсудим это во 2 части) и наличии на высоком уровне конструкции, в которой мы можем писать модульный графический код, легко распределяемый среди разных потоков/задач и с легким пониманием зависимости между этими задачами. Все это было вдохновлено прекрасным выступлением Юрия О’Доннелла о кадровых графах, прозвучавшем на GDC 2017. В небольшом разделе 3 части я также коснусь, как подошел к простой и точной реализации графа в множестве потоков.
Определите простой и небольшой API, но открытый для доработок
Я серьезно верю, что надо сосредоточиться на минимально жизнеспособном API и убедиться, что этот минимальный набор функций хорошо продуман и хорошо протестирован. Большинство моих решений основаны исключительно на работе с достойным разнообразием кодовых баз разного размера и изучением того, что они делали хорошо или где меня расстраивали. Как и любая другая сложная библиотека программного обеспечения, графические движки, как правило, усложняются довольно быстро. Придерживаться архитектуры, которая следует принципу «закрытая к изменению, открытая к расширению», похоже, хороший шаг чтобы не переусердствовать.
Когда я разговариваю с коллегами о дизайне графического движка, мне всегда нравится использовать аналогию с видеоплеером. Плеер не может и не должен заботиться о фактическом содержимом видео, которое вы смотрите. Все, о чем он должен заботиться, заключается в быстром создании ряда кадров из данных, которые ему дают. Дополнительно он может выполнять некоторую фильтрацию, масштабирование, интерполяцию и т.д., основываясь только на тех базовых данных, которые вы даете.
То же самое можно сказать и о ключевых аспектах движка визуализации. Конечно, иногда нужно иметь некоторые функции, в которых вы считываете данные, сгенерированные движком для последующего использования (например, обновление частиц или анимаций в вычислительном шейдере, использование буфера HDR последнего кадра для отражения экрана и т.д.). Но основная функция по-прежнему заключается в визуализации и отображении последовательности изображений на основе данных, которые вы подаете в него. На самом низком уровне графический движок никогда не должен знать о том, что вы делаете с ним на более высоком уровне. Поэтому старайтесь избегать утечки этих вещей в дизайн движка. Для меня абсолютно важно, чтобы движок был небольшим, поддерживаемым и эффективным.
Другим аспектом этой составляющей дизайна является разработка решения, которое позволяет интегрировать новые платформы как можно проще. Не создавая компромиссов в виде тяжелых слоев абстракции или чрезмерных обобщений. Важно принять, что защита практически никогда не является жизнеспособным вариантом, и что платформа A всегда будет отличаться от платформы B. Заманчиво разработать один общий API, который пытается охватить функциональность для всех ваших целевых платформ или графических API. Но по моему опыту это, как правило, приводит к чрезмерно сложной системе, которая пытается принимать решения о вещах вне своей компетенции. Это означает, что я могу предоставить только небольшой набор функциональных возможностей, которые, как я знаю, совместно используют все целевые платформы в дополнение к расширению функциональности для каждой из них.
Совершенно нормально забросить #ifdef (PLATFORM) или #ifdef (GRAPHICS_API) в графический код более высокого уровня, если это означает, что вы оставите базовый движок маленьким, простым и эффективным. Небольшой и простой движок в конечном итоге будет намного проще реорганизовать в будущем, как только ваша основная функциональность неизбежно изменится. Большим вдохновением для этого стал знаменитый пост Майка Актона «Три больших обмана». Если вы еще не прочитали его, я настоятельно рекомендую это сделать.
Не бойтесь проиграть, а старайтесь сделать это побыстрее
Я знаю. Это клише, но это правда! Если вы приложите все усилия, чтобы ваш код был простым, понятным и легко проверяемым, вы сможете быстро адаптировать проекты. Если какая-то система не работает так, как вы этого хотите, это не является огромной проблемой для реорганизации ее во что-то, что работает. Это справедливо для всех типов программного обеспечения.
Считать что-либо успехом или неудачей зависит только от вас. Для меня приходят на ум производительность и удобство использования. Я часто пытаюсь заставить других людей побыстрее использовать придуманные мной системы, чтобы узнать, получились они или нет. Легко стать слепым к недостаткам системы, которую вы создавали и реализовывали, поэтому как можно скорее отдавайте ее критикам. То же самое касается производительности. (Хотя в этом случае критический взгляд может быть вашим инструментом профилирования).