Заметка о преимуществах и недостатках эксплицитных API

Индустрия графической разработки окончательно повернулась в сторону эксплицитных («явных») API — то есть, таких, которые не содержат в своей реализации скрытых эвристик и не пытаются что-либо оптимизировать, строго следуя инструкциям программиста. Человек, плохо знакомый с принципами работы видеокарт, может удивиться: неужели классические OpenGL или Direct3D не подчиняются вашим инструкциям? Подчиняются, но фишка в том, что абстрактный графический конвейер, реализуемый OpenGL, на стороне пользователя не соответствует тому, что на самом деле происходит на низком уровне. К примеру, когда вы вызываете скромную, на первый взгляд, glEnable, видеодрайвер делает много вещей — сначала проверяет, что переданная константа действительна, и что вызов не происходит в недопустимом режиме, затем обновляет CPU-копию состояния, проверяя, не является ли вызов избыточным. Вместо того чтобы немедленно сообщать видеокарте о необходимости изменения, драйвер помечает состояние как измененное — фактическая аппаратная конфигурация конвейера меняется только при следующем вызове отрисовки (glDraw*). Некоторые изменения состояния — например, переключение шейдеров — приводят к значительным задержкам; кроме того, glEnable может заставить драйвер перекомпилировать внутренние скрытые шейдеры для обработки новой конфигурации, что крайне медленно (почитайте о таком явлении, как комбинаторный взрыв).

Эту проблему, вкупе с потоковой небезопасностью конвейера, новые API (Vulkan, Direct3D 12, Metal) решают путем использования неизменяемых PSO (pipeline state objects). Программист создает нужные ему конвейеры при инициализации приложения, заранее предоставляя всю необходимую конфигурацию — шейдеры, формат выводного буфера, настройки растеризации — которые в дальнейшем уже не меняются от кадра к кадру. Это позволяет драйверу производить валидацию состояния, компиляцию шейдеров в машинный код GPU и прочую тяжелую работу лишь один раз. PSO хранятся в видеопамяти, поэтому переключение с одного состояния на другое — дешевая операция, это всего лишь запись инструкции в командный буфер и никакого скрытого оверхеда. В оптимизированном рендере можно сократить переключения PSO на кадр до 15-20 и даже меньше, в зависимости от логики — для современных видеокарт это практически ничто. Вот это и есть главное, на мой взгляд, преимущество Vulkan и его родственников.

В чем же главный недостаток? А в том же самом — в неизменяемости конвейера! Если вы создаете классический Forward, вы уже не можете дать пользователям вашего движка возможность менять шейдер на уровне отдельного объекта внутри прохода. Новый шейдер — новый PSO. Создавать их на лету нельзя, нужно иметь всю информацию заранее и предусмотреть специальные проходы для всех кастомных шейдеров, если они есть. Как бы вы ни любили Forward за его гибкость, для Vulkan намного эффективнее строгий Deferred с его жестко заданными шейдерами и полным отстутствием вариативности состояния в маштабах прохода (либо Clustered Forward, но это уже другая история). Мир эксплицитных API — это мир строгих правил, где исключения обходятся слишком дорого. Если у вас есть талант придумывать хорошие правила и следовать им, то вы выиграете от этого, в противном случае вам будет тяжело и больно.

Temporal SSAO

Реализовал в Dagon 2.0 улучшенный SSAO с темпоральной аккумуляцией: сэмплы накапливаются во времени, смешиваясь с данными с предыдущего кадра, что позволяет значительно снизить количество сэмплов на кадр. Эта фича лучше всего себя показывает в статичных сценах, где камера не движется. Вес смешивания между текущими данными и предыдущими пропорционален скорости пикселя в экранном пространстве, поэтому при быстром движении аккумуляция сбрасывается и возрастает зашумленность, а при медленном происходит репроекция, и качество картинки не страдает. Дополнительным преимуществом является то, что можно не использовать билатеральную фильтрацию, что еще сильнее снижает покадровую нагрузку и избавляет от неприятных светлых ореолов по краям объектов.

На скриншоте ниже используется всего 5 сэмплов на кадр:

Dagon vs Eevee

Рендер в Dagon 2.0 и Blender 5.0 / Eevee. Как говорится, найдите десять отличий 😀

Dagon 2.0 на SDL3. Долгосрочные планы

Я пишу графику на OpenGL много лет (с 2009 года) и считаю, что этот классический API — до сих пор самый очевидный выбор, если вам нужно кроссплатформенно вывести что-то на экран. В сочетании с SDL это еще и очень просто — код приложения со всеми его ключевыми компонентами (окно, графика, ввод) получается на 99% независимый от операционной системы. Для 2D-проектов этой связки более чем достаточно, но в играх с продвинутой 3D-графикой, которая выжимает максимум из видеокарты, теперь, к сожалению, все сложнее.

Я выделяю три главные архитектурные проблемы OpenGL: однопоточность, частая синхронизация CPU и GPU и сложность управления глобальным состоянием конвейера. Новые низкоуровневые API решают все это, особенно последнее, жертвуя удобством написания приложений и вообще входным порогом в профессию графического разработчика (я не представляю, как можно изучить концепции и идиомы Vulkan с полного нуля — я бы рвал на себе волосы, если бы пришлось объяснять начинающим, что такое дескриптор-сеты, PSO и барьеры памяти). Долгое время между OpenGL и Vulkan была совершенно непреодолимая стена концептуальной несовместимости, мешающая портировать игры. OpenGL, между тем, хоть никуда и не делся, развиваться перестал. Под macOS — старая версия, под Windows — довольно серьезная проблема с вертикальной синхронизацией в оконном режиме, приводящая к статтерингу (это полноценно решается только путем работы поверх свопчейна DXGI, что требует нетривиального бойлерплейта в приложении, либо поддержки со стороны видеодрайвера). Ну и до кучи в OpenGL нет поддержки HDR-режима Windows, что некритично, но не круто.

Все это подводит к мысли, что OpenGL, каким бы он комфортным ни был, уже отжил свое. Но и на Vulkan переходить совершенно не хочется. Познакомившись с SDL GPU, я решил попробовать портировать на него некоторые ключевые части Dagon и быстро понял, что этот API — именно то, чего мне и не хватало в последние годы. WebGPU стал разочарованием из-за громоздкости интерфейсов и спорного синтаксиса WGSL, а здесь с этим проблем нет.

Исходники Dagon 2.0 доступны в отдельном репозитории на GitHub: https://github.com/gecko0307/dagon2. На данный момент перенесен dagon.core, реализованы G-буфер, базовый deferred-рендеринг, тонмаппинг и анти-алиасинг. Движок загружает модели OBJ и текстуры в стандартных форматах, поддерживает кубические карты и DDS. Судя по всему, большая часть возможностей Dagon будет перенесена без серьезных изменений, картинка движка останется прежней, но не исключены архитектурные улучшения и CPU-оптимизации. Также изменится шейдерный API — на днях напишу отдельный пост об этом. Переход на Vulkan-бэкенд должен заметно ускорить рендер, а также позволит использовать HDR-свопчейн, если дисплей позволяет выводить в extended linear.

Я не уверен, что процесс портирования будет быстрым, но впечатления от работы пока весьма положительные, код для SDL GPU компактный и читаемый. Самая нетривиальная часть — воркфлоу компиляции шейдеров, но я уже смирился с тем, что от GLSLang в современных условиях никуда не денешься. Есть вероятность, что Dagon 1.0 я выпущу уже в ближайшее время, чтобы полностью сосредоточиться на порте.

Новая техно-демка Dagon

Параллельно с Dagon 0.42 работаю над новой демкой движка, в которой наконец-то будут не бесплатные тестовые сцены из Интернета, а оригинальный контент — хочу сделать фрагмент города со зданиями, в которые можно заходить. Не исключено, что она перерастет в полноценную игру, что-нибудь в духе незабываемых творений Chinese Room.

Стриминг уровней (продолжение)

Несколько лет назад я проектировал для Dagon систему больших миров на основе динамической загрузки/выгрузки ресурсов в ячейках прямоугольной сетки. До практической реализации тогда дело на дошло, но недавно я решил вернуться к этой теме. Итак, встречайте — dagon.openworld.

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

Есть класс OpenWorldManager, который управляет массивом чанков — объектов WorldChunk. Чанки полиморфны; предполагается, что пользователь напишет специализированную версию этого класса под свои нужды. Каждый чанк занимает известную заранее квадратную область в глобальном пространстве мира. Общий размер мира фиксирован и задается при создании OpenWorldManager.

Мир адаптируется к текущей позиции так называемого путешественника (traveler) — это Entity, который представляет игрока. В каждый момент времени активны до 9 чанков вокруг путешественника — окно 3×3. Когда путешественник пересекает границу между чанками, система перемещает окно по карте, активируя новые чанки и деактивируя старые по необходимости.

Активация и деактивация — это виртуальные вызовы, семантика которых полностью определяется реализацией чанка. Типовой сценарий — загрузить/выгрузить статическую геометрию и другие ресурсы, включить/выключить логику динамических объектов. Я предусмотрел небольшой гистерезис на случай, если игрок часто пересекает границу — можно отложить активацию/деактивацию на несколько секунд, до момента, когда текущий чанк перестанет меняться. Таким образом, оверхед на управление системой получается минимальный — весь тяжелый код будет в пользовательских onActivate и onDeactivate.

Для того, чтобы точность float-вычислений не падала по мере отдаления от начала координат, я, как и планировал, реализовал заворачивание позиций. Суть его в том, что при достижении игроком определенного расстояния от центра все объекты мира синхронно переносятся на противоположную сторону координатной плоскости, «из плюса в минус». Сохраняется иллюзия бесконечного пространства при ограниченном координатном охвате. Эта фича полностью опциональная — система лишь сообщает о возможности врапить координаты в те или иные моменты (через виртуальный вызов WorldChunk.onWrap и коллбек onTravelerWrap а делать это фактически или нет, решает пользователь.

Симулятор автомобиля в Jolt

Jolt Physics включает не только готовый контроллер персонажа, но и полнофункциональный raycast vehicle — причем, это не экспериментальная поделка-бонус, как часто бывает в физдвижках, а серьезный такой префаб, который с минимальными усовершенствованиями можно брать для создания симкейда коммерческого уровня. Достаточно сказать, что он безо всякой доработки напильником уже выглядит лучше, чем, скажем, печально известная физика авто в Cyberpunk 2077. А если еще подобрать реалистичные параметры подвески и инерцию шасси, заменить встроенную модель трения шин на свою, то вообще идеально!

На видео — демка 4×4, которую я накидал буквально за день, изучив VehicleController в Jolt:

Пока в Dagon нет соответствующей объектной обертки, но это не проблема, так как можно использовать любые функции joltc напрямую. Исходники и сборку планирую выпустить одновременно с Dagon 0.42, где будет несколько важных улучшений в dagon:jolt и dagon:audio.

Dagon 0.41.0

В новой версии движка дебютирует физический движок Jolt Physics, в виде расширения dagon:jolt. Он предоставляет примерно тот же набор возможностей, что и Newton, при этом он лучше оптимизирован под многоядерные процессоры, а также имеет встроенный контроллер персонажа, который работает более стабильно, чем старый NewtonCharacterController. Newton, однако, в обозримом будущем никуда не денется, оба расширения будут развиваться параллельно. Я планирую также добавить поддержку встроенного симулятора автомобиля Jolt — об экспериментах с ним напишу как-нибудь в отдельном посте.

Исправлен микростаттеринг, связанный с неверной работой главного таймера. Визуально это проявлялось в виде периодических скачков изображения, заметных при движении камеры. Свойство stepFrequency переименовано в updatesPerSecond, в классе Application и в settings.conf.

Добавлены новые цветовые профили вывода: линейный (gl.outputColorProfile = "Linear") и Gamma 2.4 (gl.outputColorProfile = "Gamma24").

Добавлено новое свойство Entity.autoUpdateTransformation. Если его отключить, то Dagon не будет автоматически обновлять матрицы трансформации Entity. Это нужно, главным образом, в компонентах, которые заменяют встроенную трансформацию своей собственной математикой.

Реализована поддержка кинематических тел в dagon:newton. С их помощью можно реализовать, например, движущиеся платформы. Также добавлены новые свойства NewtonRigidBody: bodyType, collisionShape, angularVelocity, acceleration, linearDamping, angularDamping, simulationState, collidable, sleepState, autoSleep, freezeState, gyroscopicTorque и новые методы setMassMatrix, setMassProperties. В контроллере персонажа исправлено застревание в стенах при прыжке.

В dagon:audio добавлены новый метод AudioManager.setPlaySpeed и опциональный параметр громкости в методах SoundComponent.play.

Как сделать простой автосимулятор

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

Стоит сначала оговориться, что я имею в виду под автосимом. Традиционно все гонки делятся на два поджанра — аркадные гонки и автосимуляторы. В аркадных не используется реалистичная динамика, вместо нее — различные формы кинематики и визуальные эффекты, имитирующие физику автомобиля: например, видимость управляемого заноса — машина «смотрит» в одну сторону, но едет в другую, и игрок может управлять этим поведением при помощи какого-то простого механизма ввода (например, мобильный NFS: No Limits позволяет войти в дрифт свайпом по экрану в нужную сторону). Симулятор же в обязательном порядке использует настоящий физический движок — динамику твердых тел. В нем все честно: силы, моменты, ускорение, моделирование подвесок и трения в шинах. Как следствие, управлять симулируемым транспортом заметно сложнее, но мы получаем невероятное преимущество — практически все феномены, наблюдаемые на дороге в реальности, возникают как следствие модели, и их не нужно специально имитировать. Классический пример симулятора — Assetto Corsa.

Где-то в промежутке между чистой аркадой и строгой симуляцией затесались так называемые симкейды (simcade). Этим термином называют упрощенные симуляторы, где параметров меньше, и вождение не требует понимания автомобильной механики — при этом они настолько физичны, насколько это важно для сохранения реализма. Типичные примеры — франшизы Gran Turismo и Forza Motorsport, но, по моему мнению, любые «физичные» гонки, для которых необязателен руль и достаточно контроллера с аналоговыми стиками, можно считать симкейдом. В эпоху первой PlayStation мне дико нравилась TOCA World Touring Cars — хрестоматийный симкейд, причем, по тем временам, на удивление реалистичный: у машин правдоподобно деформировались любые части кузова, отваливался бампер, можно было даже потерять колесо. Создавая свой автомобильный движок, я держал в голове стиль именно таких игр. Следуя этой статье, конкурент Assetto Corsa вы, конечно, вряд ли сделаете, но, тем не менее, физика получится очень близкая к реальности — не хуже (а может быть, где-то даже лучше), чем во многих коммерческих играх.

(далее…)