
Привет, Хабр!
Я разрабатываю собственный графический движок 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; // Ожидаемая маска всех картинок свопчейна };
Как это работает:
При продвижении кадра в рендер‑петле вызывется
renderRetireUpdate(frameIndex)иpresentRetireUpdate(imageIndex), которые «поджигают» соответствующие биты в маске:retired.renderMask |= (1U << frameIndex).В функции
cleanupExpired()проверяется условие: когда все биты рендеринга и презентации совпали с целевыми (targetRenderMaskиtargetPresentMask), это гарантирует, что ни GPU, ни операционная система больше не обращаются к старому свопчейну.Ресурс автоматически и безопасно уничтожается на 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:
Bindless Descriptor Heap (не путать с VK_EXT_descriptor_heap) — единый глобальный дескрипторный сет для всех текстур и сэмплеров.
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 в архитектуре движка
Zero Bind Overhead: Для отрисовки сцены из тысячи объектов не нужно делать ни одного
vkCmdBindDescriptorSetsна объект/материал. Постоянный пайплайн биндится один раз за кадр.Максимальная кэш‑локальность: Данные сцены (
SceneNode,MeshGpuInfo,MaterialGpuInfo) упаковываются в плоские GPU‑массивы при загрузке кадра (uploadScene), после чего шейдеры обращаются к ним по смещениям за O(1).Упрощение 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) бок о бок.