Привет, Хабр!

Я разрабатываю собственный графический движок Shuttle Engine на базе Vulkan API (1.4) и C++. Этот проект я делаю для того, чтобы на практике изучать, как устроена 3D‑графика и разработка движков. Лучший способ детально разобраться в работе графических API и железа — спроектировать систему с нуля. Ссылка на репозиторий.

Как формировалась архитектура

Архитектурные решения в Shuttle Engine складывались как из сугубо практических задач, так и из желания заложить современный стек технологий:

  • Asset Pipeline: Загрузка даже простеньких моделей через стандартные библиотеки (вроде Assimp) занимает ощутимое время. И дело не только в геометрии: нужно раскодировать изображения текстур, сгенерировать мип‑уровни, правильно укомплектовать данные и загрузить их в память. Чтобы уйти от этих задержек, я создал свой компилятор ассетов, который запекает всё в готовый бинарный формат.

  • GPU‑Driven Рендеринг: Я изначально решил строить архитектуру на базе GPU‑driven подхода, чтобы ориентироваться на возможности современных видеокарт и свести участие CPU в процессе отрисовки к минимуму.

  • Bindless и BDA (Buffer Device Address): Мне хотелось соответствовать актуальным трендам графического программирования. Bindless и BDA привлекательны тем, что кардинально уменьшают количество дескрипторов и дескрипторных наборов, а также сильно упрощают код создания и передачи ресурсов.

  • Инфраструктура отладки: Чтобы не перекомпилировать проект и шейдеры ради проверки отдельных проходов (нормали, альбедо, глубина), я сделал мульти‑вьюпорт (Quad View), который позволяет наглядно контролировать состояние G‑буферов.

Мне хочется поделиться своими наработками, получить конструктивную критику и найти единомышленников. Если какой‑то из аспектов проекта (будь то Asset Pipeline, Bindless‑рендерер, Win32 PAL или система отладки) вызовет у вас особый интерес или вопросы — пишите в комментариях. Если будет спрос, я с удовольствием оформлю отдельную, глубокую и предметную статью по конкретной теме.

Сборка ассетов в единый бинарный формат

В самом начале разработки я использовал прямой импорт .gltf / .fbx через Assimp с последующим декодированием PNG/JPG прямо при запуске приложения. На практике этот подход быстро выявил катастрофические просадки по времени старта.

Прямой импорт сырых форматов (.gltf, .fbx, .png) в процессе работы приложения — это тупиковый путь для производительности. Загрузка геометрии, генерация мип‑уровней и декомпрессия текстур на CPU во время запуска приводят к долгим задержкам.

Чтобы уйти от этой проблемы, я вынес всю подготовку ресурсов в офлайн‑компилятор Shuttle Asset Compiler, а результаты запекаются в единый бинарный контейнер .sblb (Shuttle Binary Blob).

Рис. 1. Общая схема трансформации ассетов от исходных файлов до видеокарты.
Рис. 1. Общая схема трансформации ассетов от исходных файлов до видеокарты.

Ключевые особенности пайплайна:

  • Оптимизация геометрии: На этапе сборки с помощью meshoptimizer происходят дедупликация вершин, оптимизация порядка индексов под кэш GPU (Vertex Cache) и генерация до 4 уровней детализации (LOD) с расчетом bounding‑сфер и AABB для Culling'а.

  • Сжатие текстур: Текстуры параллельно сжимаются в нативные Vulkan‑форматы (BC1–BC7) с помощью OpenMP и ispc_texcomp. Нормали упаковываются в BC5, а PBR‑карты (Occlusion, Roughness, Metallic) запекаются в единую ORM‑текстуру.

  • Запекание IBL: HDRI‑панорамы на CPU конвертируются в кубмапы с генерацией Irradiance и Prefiltered Radiance (сжатие BC6H в FP16) для честного PBR‑освещения.

  • Zero‑Copy загрузка: Файл .sblb выровнен по границе 16 байт (alignas(16)). В runtime он просто отображается в память через Memory‑Mapped I/O (mio::mmap_source) и передается напрямую в буферы GPU без лишнего парсинга.

Сборка отработавших ресурсов рендеринга

В современных графических API (Vulkan / DirectX 12) GPU работает асинхронно относительно CPU, обрабатывая несколько кадров одновременно (Frames in Flight). Из‑за этого возникает критическая проблема: нельзя просто так взять и удалить VkSwapchainKHR, VkImageView или VkDescriptorSet, как только пользователь изменил размер окна или закрыл ассет в редакторе. Ресурс всё еще может использоваться видеокартой в текущих командных буферах.

Классическое «наивное» решение — вызвать vkDeviceWaitIdle(). Однако остановка всего конвейера GPU при каждом изменении размера вьюпорта или окна редактора приводит к неприятным микрофризам и катастрофическому падению плавности UI.

Для решения этой проблемы я реализовал двухкомпонентную систему отложенного уничтожения ресурсов (Garbage Collection) без синхронных блокировок GPU: RetireController и ResourceBin.

RetireController: Отслеживание Swapchain через битовые маски

При ресайзе окна старый Swapchain отправляется «на пенсию» в контейнер retiredSwapchains. Чтобы не заставлять CPU ждать завершения всех команд GPU, безопасность уничтожения отслеживается с помощью минималистичных битовых масок:

struct RetiredSwapchain {
    vk::UniqueSwapchainKHR swapchain;
    FrameManager retiredFrameManager;

    uint32_t renderMask = 0;   // Завершенные кадры GPU (битовая маска)
    uint32_t presentMask = 0;  // Завершенные кадры презентации ОС

    uint32_t targetRenderMask;  // Ожидаемая маска (например, 0b11 для 2 кадров в полете)
    uint32_t targetPresentMask; // Ожидаемая маска всех картинок свопчейна
};

Как это работает:

  1. При продвижении кадра в рендер‑петле вызывется renderRetireUpdate(frameIndex) и presentRetireUpdate(imageIndex), которые «поджигают» соответствующие биты в маске: retired.renderMask |= (1U << frameIndex).

  2. В функции cleanupExpired() проверяется условие: когда все биты рендеринга и презентации совпали с целевыми (targetRenderMask и targetPresentMask), это гарантирует, что ни GPU, ни операционная система больше не обращаются к старому свопчейну.

  3. Ресурс автоматически и безопасно уничтожается на CPU через RAII‑обертки std::erase_if.

void RetireController::cleanupExpired() {
    std::erase_if(retiredSwapchains, [](const RetiredSwapchain& retired) {
        const bool isSafeToDelete = 
            (retired.renderMask == retired.targetRenderMask) && 
            (retired.presentMask == retired.targetPresentMask);

        if (isSafeToDelete) {
            std::cout << "[System] Retired swapchain successfully destroyed on GPU & CPU!\n";
        }
        return isSafeToDelete;
    });
}

ResourceBin: Управление временными ресурсами вьюпорта

При интерактивном изменении размеров вьюпорта редактора или переключении отладочных буферов (Albedo, Normals, Depth) постоянно пересоздаются VkImageView и дескрипторы ImGui (UniqueViewportDescriptorSet).

Класс ResourceBin накапливает устаревшие UniqueAllocatedImage и дескрипторы, связывая их с индексом кадра (frameIndex).

Освобождение памяти происходит в начале каждого кадра в Application::drawFrame():

m_retireController.renderRetireUpdate(m_currentFrameIndex);
m_resourceBin.release(m_currentFrameIndex);

Так как в начале кадра FrameManager уже дождался фенса (vkWaitForFences) для слота m_currentFrameIndex, движок со 100% гарантией знает: GPU закончил работу с ресурсами этого слота, и их деструкторы можно безопасно вызвать прямо во время отрисовки текущего кадра.

Итог

  • Zero‑Stuttering: Изменение размеров окна редактора и вьюпорта происходит абсолютно плавно, без вызовов vkDeviceWaitIdle.

  • Память в безопасности: Полное отсутствие VK_ERROR_DEVICE_LOST и утечек VRAM / Descriptor Set в Vulkan Validation Layers.

  • RAII‑дизайн: Все отслужившие объекты очищаются автоматически C++ деструкторами, как только битовые маски подтверждают завершение работы GPU.

Современный конвейер без Descriptor‑ада: Bindless текстуры и Buffer Device Address (BDA)

В классическом Vulkan управление ресурсами быстро превращается в ад: нужно создавать дескрипторные сеты под каждый материал или объект, постоянно обновлять их через vkUpdateDescriptorSets и перепривязывать перед каждым вызовом отрисовки через vkCmdBindDescriptorSets. Это приводит к высокому оверхеду на CPU, фрагментации памяти и сложному коду управления барьерами.

В своём движке я полностью отказался от классических пер‑объектных дескрипторов в пользу двух современных технологий Vulkan 1.3:

  1. Bindless Descriptor Heap (не путать с VK_EXT_descriptor_heap) — единый глобальный дескрипторный сет для всех текстур и сэмплеров.

  2. Buffer Device Address (BDA) — прямое обращение к структурам данных в VRAM по 64-битным GPU‑указателям вместо SSBO/UBO‑дескрипторов.

Bindless Текстуры: Один сет, чтобы править всеми

Вместо связывания отдельных текстур с материалами движок выделяет один глобальный DescriptorHeapSet, содержащий массивы типа texture2D textures[] и sampler samplers[] большого объема (например, до 4096 текстур).

Конфигурация Descriptor Set Layout

Для работы Bindless используются флаги eUpdateAfterBind (возможность обновлять дескрипторы во время работы командного буфера) и ePartiallyBound (разрешение иметь незаполненные элементы в массиве):

std::array bindingFlags{
    vk::DescriptorBindingFlags{
        vk::DescriptorBindingFlagBits::ePartiallyBound |
        vk::DescriptorBindingFlagBits::eUpdateAfterBind
    },
    // ... для всех биндингов (Texture, Sampler, StorageImage, etc.)
};

vk::DescriptorSetLayoutBindingFlagsCreateInfo bindingFlagsInfo{
    .bindingCount = static_cast<uint32_t>(bindingFlags.size()),
    .pBindingFlags = bindingFlags.data()
};

Менеджер индексов на базе Virtual Allocation

Чтобы эффективно выделять и освобождать слоты внутри массивов дескрипторов без фрагментации, класс DescriptorHeapSet использует менеджер виртуальных блоков (resources::UniqueVirtualBlock):

vk::ResultValue<uint32_t> DescriptorHeapSet::allocateSlot(BindingStorage& storage) noexcept {
    auto [allocateResult, allocation] = storage.allocator->allocate(1, 1);
    if (allocateResult != vk::Result::eSuccess) return {allocateResult, 0};

    auto index = static_cast<uint32_t>(allocation.offset);
    storage.slots[index] = Slot{ .allocation = allocation, .occupied = true };
    return {vk::Result::eSuccess, index};
}

А автоматическое освобождение слота при уничтожении ресурса обеспечивается RAII‑оберткой UniqueDescriptorSlot:

UniqueDescriptorSlot::~UniqueDescriptorSlot() {
    reset(); // Автоматически вызывает descriptorHeap->free(binding, index)
}

Как это выглядит в шейдере и CPU‑структурах?

На стороне CPU материал больше не ссылается на VkImageView или VkDescriptorSet. Он хранит обычные 32-битные индексы (uint32_t):

struct alignas(16) MaterialGpuInfo {
    glm::vec4 baseColorFactor{1.0f};
    
    // Текстуры — это просто индексы в глобальном массиве!
    uint32_t albedoTexture   = UINT32_MAX;
    uint32_t normalTexture   = UINT32_MAX;
    uint32_t ormTexture      = UINT32_MAX;
    uint32_t emissiveTexture = UINT32_MAX;
};

В GLSL шейдере обращение к текстуре происходит в одну строчку через переданный индекс:

// Массив объявлен глобально в сетке Bindless
layout(set = 0, binding = 1) uniform texture2D globalTextures[];

vec4 albedo = texture(sampler2D(globalTextures[material.albedoTexture], globalSampler), uv);

Buffer Device Address (BDA): Забудьте про SSBO/UBO

Вместо того чтобы пробрасывать буферы кадров, матрицы камер, списки мешей и материалов через VkDescriptorBufferInfo, движок использует Buffer Device Address. Каждый буфер создается с флагом vk::BufferUsageFlagBits::eShaderDeviceAddress:

vk::ResultValue<resources::UniqueAllocatedBuffer> createDeviceAddressBuffer(
    resources::DeviceAllocator const& allocator,
    vk::DeviceSize size) 
{
    return allocator.createAndAllocateBufferUnique(
        vk::BufferCreateInfo{
            .size = size,
            .usage = vk::BufferUsageFlagBits::eShaderDeviceAddress | 
                     vk::BufferUsageFlagBits::eTransferDst,
            .sharingMode = vk::SharingMode::eExclusive
        },
        resources::MemoryUsage::eGpuOnly);
}

Получение GPU‑указателя (64-bit Address)

Сразу после выделения памяти мы запрашиваем физический 64-битный адрес буфера в видеопамяти через device.getBufferAddress():

vk::DeviceAddress cameraAddress = device.getBufferAddress({
    .buffer = *cameraDataBuffer
});

Передача через Push Constants

Все адреса буферов для пасса объединяются в одну компактную структуру и передаются в командный буфер напрямую через Push Constants:

struct alignas(16) MainPassData {
    vk::DeviceAddress mainPassSettingsAddress;
    vk::DeviceAddress instanceRemapAddress;
    vk::DeviceAddress worldTransformBufferAddress;
    uint64_t reserved;
};

// Запись в командный буфер
cmd.pushConstants(
    pipelineLayout,
    vk::ShaderStageFlagBits::eVertex | vk::ShaderStageFlagBits::eFragment | vk::ShaderStageFlagBits::eCompute,
    PassSpecificDataOffset, 
    sizeof(MainPassData),
    &mainPassData
);

Как это работает в Compute и Vertex шейдерах?

В шейдерах адрес кастится к buffer_reference, превращаясь в привычный C++ подобный указатель:

layout(buffer_reference, std430, buffer_reference_align = 16) readonly buffer MaterialBuffer {
    MaterialGpuInfo materials[];
};

// Чтение структуры напрямую из VRAM по 64-битному адресу:
MaterialBuffer materialsBuf = MaterialBuffer(pushConstants.materialBufferAddress);
MaterialGpuInfo mat = materialsBuf.materials[materialIndex];

Преимущества связки Bindless + BDA в архитектуре движка

  1. Zero Bind Overhead: Для отрисовки сцены из тысячи объектов не нужно делать ни одного vkCmdBindDescriptorSets на объект/материал. Постоянный пайплайн биндится один раз за кадр.

  2. Максимальная кэш‑локальность: Данные сцены (SceneNode, MeshGpuInfo, MaterialGpuInfo) упаковываются в плоские GPU‑массивы при загрузке кадра (uploadScene), после чего шейдеры обращаются к ним по смещениям за O(1).

  3. Упрощение Compute‑пассов: Вся цепочка Compute‑шейдеров GPU‑Culling, Compute Instance Remap (InstanceRemapPass), Prefix Sum (PrefixSumPass) и World Transform Update (WorldTransformUpdatePass) обменивается промежуточными данными исключительно через 64-битные vk::DeviceAddress, передаваемые в Push Constants.

Нативный Platform Abstraction Layer: Кастомные Win32-окна

Стандартные библиотеки вроде GLFW или SDL2 накладывают ограничения, когда дело доходит до создания гибкого UI редактора: кастомные заголовочные панели (Borderless Windows), подгонка под нетипичные системы декораций, поддержка Drag‑and‑Drop и тонкая обработка DPI.

Я реализовал модуль PAL (Platform Abstraction Layer). Под Windows он работает нативно с Win32 API / User32 без лишних прослоек, обеспечивая полный контроль над HWND, но сохраняет возможность переключения на SDL2 (SHUTTLE_FORCE_SDL) для кроссплатформенности.

Zero‑Cost связка HWND и C++ через cbWndExtra

Вместо медленных глобальных std::unordered_map<HWND, WindowBase*> с мьютексами для поиска С++ объектов в оконных процедурах (WndProc), при регистрации класса окна выделяется память прямо в системной структуре HWND:

wc.cbWndExtra = sizeof(void*) * 2; // [0] = WindowBase*, [1] = Win32Platform*

При создании окна указатели на C++ класс и платформу записываются напрямую в память HWND через SetWindowLongPtrW. Это позволяет получать C++ объект окна за $O(1)$ без единого аллокатора или блокировки:

shuttle::pal::WindowBase* getWindow(HWND hwnd) noexcept {
    return reinterpret_cast<shuttle::pal::WindowBase*>(
        GetWindowLongPtrW(hwnd, 0));
}

Frameless UI без артефактов (WM_NCCALCSIZE & WM_NCHITTEST)

Для создания современного бесшовного окна редактора с кастомным заголовком и кнопками управления:

  • WM_NCCALCSIZE: Подавляет стандартные рамки ОС. При разворачивании окна на весь экран автоматически привязывает границы к rcWork монитора, убирая классический баг Win32 — вылет окна за пределы экрана на 8 пикселей.

  • WM_NCHITTEST: Экранные координаты мыши передаются в собственный кроссплатформенный алгоритм WindowDecorations::evaluate(). Он определяет тип зоны под курсором и транслирует его в системные ответы Windows (HTCAPTION, HTCLOSE, HTTOPLEFT и так далее):

LRESULT doHittest(shuttle::pal::WindowBase* window, int32_t x, int32_t y) {
    // Преобразуем координаты и прогоняем через движковый оценщик декораций
    auto result = window->getDecorations().evaluate(pt.x, pt.y, winWidth, winHeight, isMaximized);
    
    switch (result) {
        case WindowHitTestResult::Caption:      return HTCAPTION;
        case WindowHitTestResult::CloseButton:  return HTCLOSE;
        case WindowHitTestResult::ResizeTop:    return HTTOP;
        /* ... */
    }
}

Нативная интеграция с Vulkan, ImGui и ОС

  • Vulkan Surface: Поверхность создается напрямую через vkCreateWin32SurfaceKHR без сторонних хелперов.

  • DPI & Events: Встроенная поддержка WM_DPICHANGED с автоматическим масштабированием окон, перехват WM_DROPFILES для перетаскивания ассетов из проводника прямо во вьюпорт и бесшовная трансляция ввода в ImGui (ImGui_ImplWin32_WndProcHandler).

Вывод отладки

При отладке PBR‑материалов, касательного пространства (Tangent Space) и IBL‑освещения критически важно видеть не только итоговую картинку, но и промежуточные G‑буферы и геометрию в реальном времени.

В движке я реализовал систему визуальной отладки на базе Multiple Render Targets (MRT) и Vulkan Dynamic Rendering.

Рис. 2: Графический интерфейс отладочных буферов в Shuttle Editor
Рис. 2: Графический интерфейс отладочных буферов в Shuttle Editor

Архитектурные особенности отладочного конвейера

Динамическая смена пайплайнов (Zero Overhead в релизе):

При переключении галочки Enable Debug View движок на лету выбирает специализированный пайплайн (debugMainPipeline). В релизе графический проход пишет в 1 цветовой аттачмент. В отладочном режиме Vulkan Dynamic Rendering монтирует до 5 аттачментов одновременно (MainColor + 4 дополнительных R16G16B16A16_SFLOAT таргета):

 std::array colorFormats {
     colorFormat,
     vk::Format::eR16G16B16A16Sfloat, // Output 1
     vk::Format::eR16G16B16A16Sfloat, // Output 2
     vk::Format::eR16G16B16A16Sfloat, // Output 3
     vk::Format::eR16G16B16A16Sfloat  // Output 4
 };

Маршрутизация буферов через Push Constants:

Структура MainPassSettings передает в фрагментный шейдер режимы отображения (DebugMode) для каждого из 4 выходов:

  • Геометрия & Пространство: WorldPosition, WorldNormal, Tangent, Bitangent, UV.

  • Параметры PBR: Albedo, Metallic, Roughness, AmbientOcclusion, Emissive.

  • Глубина и Идентификаторы: LinearDepth, ViewDepth, MeshId, MaterialId, InstanceId.

Мульти‑вьюпорт макеты (Viewport Layout):

Интерфейс редактора поддерживает четыре режима компоновки окон:

  • Single — полноразмерный вывод одного выбранного отладочного канала.

  • Split Vertical / Horizontal — разделение экрана на 2 буфера по вертикали или горизонтали.

  • Quad — сетка 2×2, выводящая одновременно все 4 отладочных аттачмента.

Это позволяет наглядно сравнивать итоговый PBR‑рендеринг с нормалями, касательными и UV‑сеткой без постоянного переключения в настройках.

Заключение. Что дальше?

Перевод графического движка на современного демона в лице Vulkan 1.3 (с Dynamic Rendering, Bindless‑текстурами и Buffer Device Address) в комбинации с нативным Win32 PAL позволил заложить мощный фундамент: избавиться от CPU‑оверхеда, уйти от дескрипторного ада и получить полный контроль над окнами и вводом.

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

1. Разделение движка и редактора (Modular Decoupling)

Сейчас редактор и рендерер тесно связаны. Следующий шаг — четкая модульная декомпозиция: выделение ядрового движка (Shuttle Engine Runtime) в отдельную динамическую/статическую библиотеку, а Shuttle Editor — в самостоятельное клиентское приложение, работающее поверх движка.

2. Переход на RmlUi и нативные Win32-стили

  • Гибридный UI: Перевод основного пользовательского интерфейса приложения на RmlUi (HTML/CSS‑подобная верстка для С++), что позволит создавать гибкие, адаптируемые макеты. При этом ImGui (Immediate Mode GUI) останется исключительно как инструмент быстрого вывода внутренних отладочных панелей.

  • Оконная система: Расширение нативной поддержки стилей Windows, полноценная реализация модальных диалоговых окон с блокировкой родительских HWND, а также честный Docking для открепляемых и плавающих панелей.

3. Asset Pipeline и Инспектор ресурсов

Разработка визуального инспектора ресурсов: просмотр иерархии нод сцены, структуры мешей и параметров PBR‑материалов. Внедрение файлового пайплайна ассетов с текстовым мета‑форматом для хранения настроек импорта и связания ресурсов.

4. Многопроходная архитектура отладочного рендеринга (Multi‑Pass Debug Pipeline)

Текущий вывод MRT за один проход ограничен единым состоянием сцены. В планах — переработка отладочного конвейера на независимый 4-проходный рендеринг (по проходу на каждый квадрант вьюпорта).

Это позволит изолировать настройки кадра для каждого окна отладки индивидуально: например, визуально сравнивать сцену в реальном времени с включенным IBL (Image‑Based Lighting) в первом окне и отключенным — во втором, или сопоставлять разные алгоритмы тональной компрессии (Tone Mapping) бок о бок.

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