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

Я разрабатываю собственный графический движок 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).

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

  • Оптимизация геометрии: На этапе сборки с помощью 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.

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

Динамическая смена пайплайнов (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) бок о бок.