SDL3 GPU?

Мир эксплицитных графических API переживает бурный период фрагментации. Это похоже на 90-е, когда одновременно были Glide, Direct3D 5-6, OpenGL 1.x и программные рендеры, только сейчас все куда сложнее, потому что и железо намного сложнее.

Есть Vulkan, который де-юре является современной заменой OpenGL — в том смысле, что работает на тех же платформах. Де-факто, я считаю, не является, так как далеко не каждое существующее приложение OpenGL реально перенести на Vulkan без тотальной переделки. На macOS его нет, как, собственно, и актуальных версий OpenGL. Я считаю, что на чистом Vulkan писать опасно для ментального здоровья — поверх него обязательно нужна абстракция.

Есть Direct3D 12, который работает только под Windows и на XBox. Я его традиционно в расчет не беру, поскольку писать windows-онли приложения в наши дни уже моветон. Как минимум, поддержка Linux нужна обязательно.

Есть Metal, который аналогично существует только в экосистеме Apple. Писать на нем напрямую тоже не надо, если только вы не создаете мак-эксклюзивы.

Есть WebGPU, который, казалось, должен был исправить ситуацию, объединив предыдущую троицу под единым API. С точки зрения дизайна эта задача с грехом пополам продвигается, хотя и медленно, но вот реализации пока оставляют желать лучшего. wgpu от Mozilla написан на Rust и работает как-то не очень стабильно — до сих пор утечки памяти в простейших приложениях. Ну и WGSL поделил сообщество графических разработчиков на два лагеря — одним ок, другие не признают ничего, кроме SPIR-V. Язык и правда слишком специфический, с сильным уклоном в Rust, да и в целом от WebGPU ощущение такое, что это чисто растоманский проект, не учитывающий другие языки и парадигмы программирования. И мне лично жутко не нравится его модель биндинга ресурсов. Все эти bind group layout’ы неудобные… API пилят уже пять лет, а на нем все еще страшно писать реальный код — то и дело что-то ломается непредсказуемым образом.

Наконец, появился GPU API в SDL3. Надо сказать, библиотека вообще замечательная, она уже много лет делает возможной поддержку единой кодовой базы в играх (да и не только в играх) под Windows и Linux. То, что в третьей версии появился аж целый графический API — это нехилый такой аргумент перейти на нее как можно быстрее. Мне, конечно, было очень интересно сравнить этот API с WebGPU, и данный пост я пишу как раз с этой целью.

Но прежде — небольшое резюме. Что вся эта ситуация означает для инди-разработчика? К сожалению, ничего хорошего. Сегодня уже как минимум пять мейнстримных графических API, а завтра будет сколько? Изобретут еще пять, или в результате конкуренции останутся два, как в нулевые? Кто кому уступит? Непонятно, что выбрать, потому что не хочется вкладываться в технологию, которая может сгинуть. Корпорации, разрабатывающие AAA-продукты, обладают ресурсами для поддержки всех технологий, с них не убудет, но для пет-проектов и альтернативных движков, таких, как мой, это недостижимо — приходится выбирать что-то одно.

SDL3 GPU vs WebGPU

Главное, чем подкупает SDL3 GPU — минималистичность. Инициалирующие структуры не выглядят, как монстры а ля объекты JavaScript. Привязка рендера к окну — это всего лишь один вызов функции SDL_ClaimWindowForGPUDevice, а не целая эпопея с платформоспецифичными функциями, как в WebGPU. Сказывается, что SDL полностью берет управление окном и контекстом на себя. Также тут нет управления очередью и почти не нужно беспокоиться о синхронизации: вы просто создаете командные буферы под ваши задачи и отправляете их на исполнение функцией SDL_SubmitGPUCommandBuffer. Командный буфер, как и в других низкоуровневых API, — это список команд видеопроцессору выполнить какую-либо операцию (создать ресурс, скопировать данные, нарисовать примитив).

Передача данных в видеопамять осуществляется при помощи так называемых трансфер-буферов (SDL_GPUTransferBuffer). Они отображаются в системную память, так что вы можете копировать в них данные вызовом memcpy или любым другим удобным вам способом. Когда трансфер-буфер готов, он используется как источник данных для прохода копирования (SDL_GPUCopyPass), который заносится в командный буфер. В общем, основную идею эксплицитных API (сократить частоту синхронизаций CPU и GPU до необходимого минимума) тут постарались сохранить, и это чувствуется.

Отдельная головная боль WebGPU — управление свопчейном, и тут SDL3 GPU снова устраняет все сложности. Вы просто получаете текущий задний буфер функцией SDL_WaitAndAcquireGPUSwapchainTexture, обновляете ваш color target и используете его для вызова SDL_BeginGPURenderPass — и все!

Единого шейдерного языка в SDL3 GPU нет — он зависит от выбранного бэкенда. Если вы выбрали Vulkan, то это SPIR-V, если Direct3D 12 — DXIL, если Metal — MSL. Так что для мультитаргетного движка вам обязательно понадобится тулчейн трансляции шейдеров, например SPIRV-Cross. Это, пожалуй, единственная сложность, в особенности если вы не используете C/C++. Но для D я уже решил эту проблему, написав биндинги bindbc-glslang и bindbc-spirvcross. Процесс ручной компиляции шейдеров может показаться немного мудреным, но на самом деле это вещь из разряда «написал и забыл». Использование SPIR-V в качестве внутреннего промежуточного представления дает невиданную свободу — можно писать на привычном GLSL, а можно попробовать что-то новое.

Отдельная песня — привязка ресурсов к шейдерам. В SDL эту задачу решили элегантно, все 4 дескриптор-сета жестко распределены:

  • Сет 0 — текстуры/сэмплеры и SSBO вершинной стадии
  • Сет 1 — UBO вершинной стадии
  • Сет 2 — текстуры/сэмплеры и SSBO фрагментной стадии
  • Сет 3 — UBO фрагментной стадии

Биндинг-поинты вы задаете сами (в шейдере и программном коде). Единственное, что важно знать перед созданием шейдера — сколько именно сэмплеров, SSBO и UBO в нем используется. Это может поставить в тупик, так как усложняет абстракцию в движке, но даю лайфхак: эту информацию предоставляет все тот же SPIRV-Cross (см. spvc_resources_get_resource_list_for_type). Я считаю, по сравнению с WebGPU это просто рай.

Для рендеринга используются объекты SDL_GPUGraphicsPipeline (которые задают параметры растеризатора) и SDL_GPURenderPass (к которому биндятся ресурсы). В deferred-движке их удобно объединить в одну абстракцию RenderPass. Пайплайны традиционно неизменяемые, так что после OpenGL придется привыкать к новым реалиям и оптимизировать рендер.

Вот, вроде бы, и все. Я пока не затронул compute, это тема для отдельной статьи. Разработчики SDL приятно удивили, выкатив отличный API за сравнительно короткие сроки — уже сейчас на нем вполне можно делать серьезные проекты. Хочется верить, что он не заглохнет и будет развиваться.

Jolt Physics

Как я уже не раз отмечал, игровая физика — это крайне недооцененный вид софта. Люди много говорят о графике, обыватель в играх вообще ничего, кроме графона, не замечает. А между тем, проверка столкновений и физика — вот что по-настоящему важно. Устаревший визуал и любительский уровень моделей игре можно простить, но проваливание персонажа сквозь пол и стены — нет. Я видел множество инди-проектов, потенциал которых был загублен именно плохой физикой (и, как следствие, неудобным управлением), а не графической или геймплейной составляющими. В мире инди и СПО вообще редко связываются с «физичными» жанрами типа экшнов, гонок, симуляторов и т.д. Свободные игры — это в основном стратегии, песочницы, 2D-аркады, рогалики. Физика довольно устойчиво ассоциируется с AAA и колоссальными бюджетами крупных издателей. Причина, конечно, в том, что она непредсказуема и плохо программируется. Она хороша в «чистом» виде — скажем, в симуляторе бильярда, где простая геометрия и нет вычислительно сложных ситуаций. Но если физика должна быть подчинена игровой логике, а не наоборот, то решения «по учебнику» резко перестают работать, приходится изобретать невиданные хаки и решать дичайшие корнер-кейсы. Это и отличает AAA от любительского геймдева — там коммерческие секреты, ноу-хау, огромный опыт. Я вот давно уже пишу контроллеры персонажа и не понаслышке знаю, как это непросто. А ведь хочется получить этот священный Грааль — чтобы персонаж свободно двигался по карте, не застревал, правильно сталкивался с препятствиями, поднимался по склонам и лестницам. Сегодня это необходимая база для 3D-игр любого жанра, кроме совсем уж простеньких.

Я пробовал писать собственный физдвижок (dmech). Это был очень полезный опыт, но, взвесив все «за» и «против», я все-таки решил использовать то, что сделано профессионалами — я ведь не физик, а графический разработчик. Пусть каждый занимается тем, что лучше всего получается. Я выбрал Newton Game Dynamics, который долгое время был, пожалуй, самым оптимизированным и точным среди всех, что имеют C-интерфейс. Это отличный физический движок, и я очень благодарен Хулио Хересу за этот проект, Newton много лет был прекрасным дополнением к Dagon. Я долго не хотел его менять, но технологии не стоят на месте. Newton 3 заметно устаревает, а Newton 4 — это уже чистый C++ без C-враппера, поэтому настало время переходить на что-то альтернативное. Есть серьезные основания полагать, что новым стандартом индустрии станет Jolt Physics от Guerrilla Games. Jolt используется в их движке Decima, лежащем в основе серий Horizon и Death Stranding — редкий случай, когда технология AAA-уровня сразу переходит в Open Source! Jolt — это реальный конкурент PhysX и Bullet, многие бесплатные и свободные игровые движки уже переходят на него один за другим: Godot, NeoAxis, Dagor. И, что самое замечательное, к Jolt есть полноценный C-враппер, позволяющий использовать его не только в C++.

Итак, что же там такого уникального? Во-первых, отличная оптимизация под SIMD и поддержка многопоточности (правда, это имеет значение лишь при каких-то редко встречающихся высоких CPU-нагрузках). Во-вторых, изначальная ориентация на игры — дизайн и набор фич показывают, что Jolt разработан игроделами для игроделов, для реального продакшна, а не академических публикаций. Движок ощущается, как некий «бесплатный Havok» из закромов энтерпрайза. У него много архитектурных особенностей, но в целом с ним очень приятно работать. Например, очень понравилось, что структуры векторов и кватернионов в Jolt бинарно совместимы с моими родными Vector3f и Quaternionf. Да и сама синхронизация через позиции и кватернионы вместо матриц 4×4 — это мудро, так как матрицы требуют декомпозиции.

Я, пока писал этот пост, уже успел написать для Dagon базовую обертку над Jolt (расширение dagon:jolt), концептуально весьма близкую с dagon:newton. Движки во многом похожи — используется классическая схема с разделением на тела и полиморфные шейпы. Многие функции имеют более удобный API, чем в Newton — например, рейкастинг. В данный момент обертка поддерживает создание статических и динамических тел, шейпов всех видов, включая меши, а также базовый контроллер персонажа, который я планирую довести, как минимум, до уровня NewtonCharacterController. К релизу Dagon 0.41 будут добавлены сочленения.

Контроллер персонажа CharacterVirtual особенно порадовал: я решил сразу попробовать его в деле. Шикарно, что управление контроллером (включая гравитацию и прыжок) осуществляется путем произвольного изменения вектора скорости — можно реализовать любую механику. Контроллер стабильный и точный, встроенный ground-тест работает надежно — основа для игры от первого лица пишется буквально за один вечер.

В Jolt есть поддержка мягких тел и даже встроенный симулятор автомобиля! Очень надеюсь, что когда-нибудь все это тоже получится перенести в dagon:jolt.

Использовать расширение не сложнее, чем dagon:newton, а в чем-то даже проще. Сначала нужно вызвать joltInit для инициализации:

class MyGame: Game
{
    this(uint w, uint h, bool fullscreen, string title, string[] args)
    {
        super(w, h, fullscreen, title, args);

        if (!joltInit())
            exit();

        currentScene = New!MyScene(this);
    }

    ~this()
    {
        joltShutdown();
    }
}

Затем создать JoltPhysicsWorld:

import dagon.ext.jolt;

class MyScene: Scene
{
    JoltPhysicsWorld physicsWorld;

    this(MyGame game)
    {
        super(game);
        this.game = game;
        physicsWorld = New!JoltPhysicsWorld(eventManager, this);
    }
}

Пример создания статического тела для геометрии уровня:

Entity eLevel = addEntity();
eLevel.drawable = aLevel.mesh;
JoltMeshShape levelShape = New!JoltMeshShape(aLevel.mesh, physicsWorld);
JoltRigidBody levelBody = physicsWorld.addStaticBody(eLevel, levelShape);

Динамическое тело:

Entity eCube = addEntity();
Vector3f cubeHalfExtents = Vector3f(0.5f, 0.5f, 0.5f);
eCube.drawable = New!ShapeBox(cubeHalfExtents, assetManager);
JoltBoxShape boxShape = New!JoltBoxShape(cubeHalfExtents, physicsWorld);
auto cubeBody = physicsWorld.addDynamicBody(eCube, boxShape, 10.0f);

В onUpdate сцены, как обычно, шаг интегрирования:

override void onUpdate(Time t)
{
    physicsWorld.update(t);
}

Sponza — обновленная технодемка

Скриншоты демки с моделью Crytek Sponza на основе Dagon 0.40.0 со учетом всех недавних улучшений: тонмаппер AgX, анизотропная фильтрация, CAS, автоэкспозиция, линейный воркфлоу постпроцессинга.

Исходники и сборка для Windows.

Dagon 0.40.0

Этот релиз ознаменовался серьезным рефакторингом постпроцессинга в движке: внесено множество оптимизаций и новых возможностей, а также появилась поддержка пользовательских фильтров (PostProcRenderer.addFilterPass). Возвращена поддержка автоэкспозиции, которая когда-то уже была реализована, но впоследствии удалена из-за архитектурных изменений в движке. Управляется параметрами hdr.autoexposure, hdr.keyValue, hdr.exposureAdaptationSpeed в render.conf. Добавлены эффекты виньетирования (vignette.enabled, vignette.strength, vignette.size, vignette.roundness, vignette.feathering) и film grain (filmGrain.enabled, filmGrain.colored). Реализован высококачественный фильтр повышения резкости с алгоритмом на основе FidelityFX CAS (sharpening.enabled, sharpening.strength). Добавлены новый тонмаппер Lottes (hdr.tonemapper: "Lottes"), новые параметры фильтра Depth of Field (dof.circleOfConfusion, dof.pentagonBokeh, dof.pentagonBokehFeather), улучшен фильтр шумоподавления SSAO — добавлена поддержка взвешивания на основе глубины, что устраняет гало-артефакты на близких расстояниях. Фильтр цветокоррекции теперь поддерживает изменение яркости, контраста и насыщенности (cc.brightness, cc.contrast, cc.saturation). Также можно напрямую задать 4×4 матрицу цветокоррекции (cc.colorMatrix). Если используется LUT, эти параметры игнорируются.

Улучшен воркфлоу таблиц цветокоррекции (LUT). Добавлено новое свойство TextureAsset.lutFormat, посредством которого загрузчик текстур понимает, что нужно конвертировать двумерную таблицу в 3D-текстуру. Поддерживаются два формата — LUTFormat.Hald и LUTFormat.GPUImage. Свойство TextureAsset.loadAs3D удалено, вместо него теперь надо задавать TextureAsset.lutFormat = LUTFormat.Hald;. Дефолтная таблица формата GPUImage (загружаемая опцией lut.file) теперь автоматически конвертируется в 3D-текстуру. Если вы загружаете GPUImage LUT вручную, то можно ее не конвертировать, но отныне рекомендуется делать это для более эффективного сэмплинга в шейдере.

Добавлен опциональный вывод в честный sRGB вместо обычного Gamma 2.2. Теперь можно задать цветовой профиль вывода при помощи опции gl.outputColorProfile в settings.conf. Возможные значения (строковые): "Gamma22", "sRGB".

Реализована поддержка глобальных определений макропроцессора GLSL (функция globalShaderDefine в dagon.graphics.shader). Чтобы их использовать в шейдерах, нужно добавить виртуальный инклюд #include <dagon>.

Добавлена поддержка сохранения в DDS кубических карт, 3D-текстур и RGTC-текстур. Появилась новая функция downloadTexture для выгрузки текстур любого формата из видеопамяти.

Появился новый тип событий EventType.KeyboardLayoutChange и соответствующий метод-обработчик onKeyboardLayoutChange. Это событие возникает, когда пользователь переключает раскладку клавиатуры.

Повышение резкости

Добавил в Dagon фильтр повышения резкости на основе FidelityFX CAS. Его можно включить свойством sharpening.enabled в render.conf, свойство sharpening.strength управляет силой эффекта. Вкупе с анизотропной фильтрацией это значительно повышает качество рендера. На скриншоте разница особенно заметна на золотых узорах гобеленов и текстуре каменной кладки.

Dagon 0.39.0

Новая версия движка. Добавил возможность загружать кастомные курсоры и заменять ими системные курсоры в приложении (методы Application.loadCursor, Application.replaceCursor). Появилась поддержка многомониторных конфигураций — теперь можно задать индекс монитора для создания игрового окна (опция window.display в settings.conf). Добавлены новые свойства класса Application: displayCount, displayIndex, displayWidth, displayHeight, desktopWidth, desktopHeight, refreshRate, framebufferFormat.

В модуль dagon.graphics.shape добавлены новые геометрические тела: ShapeCapsule (капсула), ShapeTorus (тор).

Появилась Поддержка uniform-массивов — шаблонный класс ShaderParameterArray и метод Shader.createParameterArray. Методы Shader.setParameter, Shader.setParameterRef, Shader.setParameterCallback, Shader.setParameterSubroutine, Shader.getParameterValue помечены как deprecated. Рекомендуется работать с объектами параметров напрямую.

Добавлена поддержка несжатых текстур RGB8 и RGBA8 в экспортер DDS, а также несжатых RGB8 в загрузчик DDS. Реализована перегрузка функции loadImageViaSDLImage, которая возвращает SDL_Surface*.

Перлы из сабреддита /r/vulkan

К Vulkan я испытываю если не неприязнь, то во всяком случае изрядный скепсис. Это API для машин, а не для людей. Писать на нем напрямую — это ад, и веских причин пытать себя вулкановскими ужасами (во всяком случае, в инди-разработке) я за эти десять лет так и не увидел. Оптимизация CPU-bound частей рендера? Вы не Epic Games, чтобы этим заниматься — ресурсов не хватит. DLSS, трассировка лучей? В реальности все эти навороты в играх отключают первым делом, чтобы играть, а не смотреть слайдшоу, ибо видеокарты у большинства далеко не топовые.

Но вот что забавно: вулканисты сами признают, что использовать Vulkan необязательно! Если не верите, откройте Reddit. Вот что я там нашел:

pls use vk-bootstrap, dynamic rendering, VMA in big 2026. Don’t do raw vulkan. You will lose motivation sooner.

То есть, без разнообразных обвязок и прибамбасов писать на Vulkan принципиально не рекомендуется! Это прям классно — напомнило времена GLU, GLUT и тому подобного. Я и сам пробовал много всяких компромиссных вариантов — вкатывался в WebGPU, щупал LLGL и GPU API в SDL3, но везде одна и та же жопа в разных ракурсах: нужны дополнительные инструменты, как минимум для компиляции шейдеров и SPIR-V рефлексии. Оверхед по расходу памяти от всех этих обвязок немаленький, в WebGPU вообще память течет — привет растоманам. И я уж молчу про то, что обвязки систематически ломают обратную совместимость, так что писать на них — то еще удовольствие.

А вот это вообще анекдот:

Your options are basically:
— Learn Vulkan, but use AI to fill in the boilerplate.
— Use CUDA instead of Vulkan.

Без ИИ сегодня, конечно, вообще никуда 😂 А графическая разработка — такая уж область, ChatGPT ногу сломит. Ну и наконец:

OpenGL is perfectly viable in 2026 for hobby programmers and you’ll probably even get better performance out of it than not knowing how to use Vulkan and using it anyway. Hardware support is also completely fine. If you want to learn Vulkan, go do it. If you don’t, there’s better alternatives.

Занавес. Directed by Robert B. Weide.

Dagon 0.38.0

Очередной релиз. Главное нововведение — это модуль dagon.core.dxt, быстрый компрессор текстур в DXT1/DXT5 (D-порт библиотеки RygsDXTc, о котором писал ранее). Текстуру теперь можно сжать после загрузки при помощи свойства TextureAsset.compress. Также реализована функция dagon.resource.dds.saveDDS для сохранения сжатых текстур в DDS. Добавлено новое свойство TextureAsset.assetManager. Добавлена поддержка дополнительных форматов SDL_Image в структуре ImageFileFormat, а именно GIF, QOI, PNM, XCF, XPM, PCX, LBM.

Добавлены новые опции settings.conf для глобального управления анизотропной фильтрацией текстур: gl.anisotropicFiltering и gl.defaultTextureAnisotropy. По умолчанию анизотропная фильтрация отключена.

Добавлен новый оператор тональной компрессии Uchimura.

Реализована обработка SEH-исключений под Windows с трассировкой стека в отладочных сборках (dagon.core.crashhandler).

Добавлена поддержка API вибрации (Haptic API) SDL (GameInputDevice.haptic), который, впрочем, на практике является экзотикой — далеко не все устройства с ним работают. Для большинства контроллеров в SDL поддерживается только Rumble API.

Добавлена функция dagon.core.dialogs.showMessage для графического вывода сообщений.

Если в системе не найдена библиотека Wintab, движок теперь логирует предупреждение вместо ошибки.

В расширении dagon:audio исправлены сигнатуры некоторых функций SoLoud, добавлена перегрузка метода AudioManager.createSound для создания звуков из буферов в памяти.

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

window.hiDPI теперь включен в дефолтном settings.conf.

RygsDXTc и сжатие текстур на лету

Очередная часть Марлезонского балета с компрессией текстур. Я долго думал, делать или не делать в движке сжатие в DXT, и ответ пришел в виде проекта RygsDXTc, быстрого компрессора DXT1/DXT5. Причем написанного не кем-нибудь, а самим Фабианом Гизеном aka ryg из легендарного Farbrausch!

Залипнув на вечер, я портировал на D эту тысячу строк зубодробительного C-кода, под завязку набитого черной магией целочисленной арифметики, и вот результат — в Dagon теперь можно простым переключением свойства отправлять текстуры в видеопамять в сжатом виде:

aTexture = addTextureAsset("data/input.png");
aTexture.compress = true;
aTexture.generateMipmaps = true;

При включенном generateMipmaps перед сжатием будут сгенерированы мип-уровни. Полноценно поддерживаются NPOT-текстуры. При этом оверхед на все про все ничтожный — libktx при транскодировании из Basis Universal и то срабатывает дольше.

Если задать перед загрузкой aTexture.persistent = true, то ассет сохранит исходный TextureBuffer в памяти, и вы сможете сохранить сжатую текстуру в DDS:

auto outputStrm = game.vfs.stdfs.openForOutput("out.dds");
saveDDS(outputStrm, &aTexture.buffer);
Delete(outputStrm);

Ловим необработанные исключения под Windows

Обработка исключений — это всегда скучно и многословно, никому не хочется загромождать свой код вездесущими try/catch. Кроме того, в D обработка исключений на уровне языка не работает с SEH-исключениями под x86_64, что сильно усложняет отлов багов, связанных с доступом по невалидному указателю — приложение просто падает молча, что не соответствует принципу «fail loudly». Но оказалось, что есть относительно простой способ решения этой проблемы.

(далее…)