Вступление
Приветствую вас, дорогие читатели! В этой статье мы разберёмся в том, как нам добраться от самого нуля до первого треугольника. Мы будем использовать Vulkan API 1.0, а затем будем переходить к всё более и более новым версиям этого API. Цель данной статьи - разъяснить процесс отрисовки треугольника так, чтобы любой мог не просто нарисовать треугольник, а понять как устроен простейший рендеринг на Vulkan API на примере отрисовки треугольника. Это моя первая авторская статья, поэтому прошу вашей поддержки. Лучшая ваша помощь - это спокойная конструктивная критика.
Пример кода можно посмотреть на сайте GitHub: https://github.com/shagunov/VulkanTutorials.
Обзор Vulkan API
Прежде, чем приступить к написанию программы, нужно разобраться с тем что такое Vulkan API. Это API для низкоуровневой работы с GPU. В отличие от OpenGL, где имеется глобальное состояние и команды выполняются немедленно, в Vulkan API состояние инкапсулировано в объекты, а все операции с GPU должны быть запланированы на очереди.
Состав API. Слои и расширения
Спецификация Vulkan API состоит из главного функционала (ядро API) и расширений.
Расширение - это функционал vulkan API, который не является обязательной частью API.
Также вызовы API Vulkan могут включать в себя несколько слоёв.
Слой - это дополнительный вызов между вызовом функции API и вызовом самого драйвера, который реализует спецификацию. Слои могут иметь свой собственный набор расширений.
Инициализация: экземпляр и устройство
Инициализация Vulkan API рендеринга состоит из нескольких этапов.
Загружаем библиотеку Vulkan API. В Windows данная библиотека находится в файле vulkan-1.dll. Если мы используем библиотеку из Vulkan SDK, она сделает это за нас.
Из данной библиотеки нам нужно получить функцию
vkGetInstanceProcAddr.В системе Windows мы делаем это при помощи функцииGetProcAddress.Данную функцию мы используем для загрузки глобальных функций Vulkan API.
Создаём экземпляр (
vk::Instance). Для этого нужно передать списки запрашиваемых расширений и слоёв.Загружаем все нужные функции из экземпляра для дальнейшей работы.
Выбираем подходящее физическое устройство (
vk::PhysicalDevice). Основные критерии выбора: свойства расширений, свойства семейств очередей, свойства памяти, свойства устройства и его возможности.Создаём из выбранного физического устройства логическое устройство (
vk::Device). Указываем расширения, возможности, свойства очередей.Получаем нужные нам очереди (
vk::Queue).
Очереди и командные буферы
Основная часть работы с GPU выполняется через очереди. Это фундаментальное отличие от OpenGL, в котором обращение к GPU происходит немедленными вызовами команд. В Vulkan API работа GPU и CPU асинхронная. Для синхронизации используются объекты синхронизации.
Очереди сгруппированы по семействам очередей. Главный признак, по которому они объединены - это поддерживаемые операции. К ним относят: рендеринг, вычисления, трансфер, показ и привязка разрежённых ресурсов.
Некоторые операции выполняются через командные буферы. Командный буфер - это объект, который используют для записи команд и отправкой одной группой. Обычно, это трансфер или рендеринг. Командные буферы выделяются в командных пулах, которые создаются на устройствах.
Память и ресурсы
В отличие от OpenGL, Vulkan API требует того, чтобы мы сами выделяли память для ресурсов. Память Vulkan API может иметь несколько куч, а те, в свою очередь имеют различные типы памяти.
Память устройства
Все ресурсы Vulkan API делятся на два типа: буферы и изображения. Каждый ресурс после создания необходимо привязывать к памяти. Чтобы сделать это корректно, нужно получить требования ресурса к памяти. К ним относятся размер блока памяти, выравнивания и флаги для типа памяти.
Если мы используем память с флагом vk::MemoryPropertiesFlagBits::eHostVisible, мы можем отобразить память в виртуальную память процесса. Для этого мы используем вызов vk::Device::mapMemory, где указывается блок памяти, размер и смещение от начала. Отображение используется для копирования данных в память GPU.
Если у типа памяти нет флага vk::MemoryPropertiesFlagBits::eHostCoherent, то для фиксации операций с памятью нужно вызывать функцию vk::Device::flushMappedMemoryRanges.
Все ресурсы, которые используются в шейдерах, рекомендуется размещать в памяти с флагом eDeviceLocal для эффективного доступа к ресурсам со стороны GPU.
Буферы
Буферы - это объекты, представляющие собой массивы данных. Они могут быть разнообразных типов. Uniform, vertex, index, storage и так далее. Буферы используются для работы GPU с данными. Они описывают данные, которые используются, например в шейдерах для отрисовки сцены или для расчёта физики.
Изображения
Изображения представляют собой более сложный тип. Они могут использоваться, например, для хранения текстур, цветовых прикреплений, показа. Параметров для создания изображения несколько больше, чем буферов. Это связано с тем, что изображения имеют больше различных состояний. Количество сэмплов, слоёв, тип изображения, его layout, формат, размер в трёх измерениях, количество мип уровней (каждый мип уровень - это изображения вдвое меньшее исходного), тайлинг (в каком порядке и как сгруппированы тексели) и т.д.
Поверхности и цепочки обмена
Поверхность (vk::SurfaceKHR) в Vulkan API - это ключевое звено, связывающее окно платформы и рендеринг. Цепочка обмена (vk::SwapchainKHR) используется для управления сменой буферов. Первый мы создаём в экземпляре Vulkan API, а второй на устройстве.
Цепочка обмена имеет две главные функции: получить все созданные изображения, получить индекс доступного изображения и отобразить отрендеренное изображение в окне. Операция показа выполняется отправкой команды на очередь. Необходимо убедиться, что соответствующее ей семейство очередей поддерживает показ.
Если по какой-то причине изменилась поверхность, то цепочка обмена устаревает и нужно её обновить и все связанные с ней ресурсы
Проходы рендеринга и фреймбуферы
Объект прохода рендеринга (vk::RenderPass) - это описание процедуры рендеринга. Рендеринг состоит из нескольких подпроходов. Основные понятия рендеринга:
Цветовые прикрепления. Это описание всех изображений, которые используются для процесса рендеринга. Здесь нет указаний на конкретные изображения: лишь описания того, какие они должны быть (формат, layout).
Описание подпроходов. Это описание этапа рендеринга, где указывается используемые прикрепления, в какой layout переходят изображения на данном этапе.
Описание зависимостей между подпроходами. Это описания того какая последовательность должна быть установлена между подпроходами. Драйвер на основе данной информации сам расставляет барьеры памяти.
Объект фреймбуфера (vk::Framebuffer) - это объект, который связывает конкретные изображения (vk::ImageView) и цветовые прикрепления.
Графический конвейер
Для рендеринга изображения необходимо передать в командный буфер состояние графического конвейера. Дело в том, что рендеринг на современном GPU состоит из нескольких стадий. Для отрисовки треугольника нам нужны вершинный и фрагментный шейдеры. Таким образом, мы пропускаем половину стадий графического конвейера. Также нужно будет настроить непрограммируемые части конвейера.
Vertex Assembly. Описание привязок и атрибутов вершинного буфера и буфера инстансинга. Вершина - это звено примитива, как угол примитива. Вершина описывается при помощи атрибутов. Каждый атрибут содержит данные о привязке, формате и смещении относительно начала привязки.
Vertex Shader. Программируемая разработчиком стадия конвейера, где одни вершины преобразуются в другие. Очень важным в данном шейдере является преобразование вершин в пространстве модели в пространство NDC-координат.
Primitive Assembly. После обработки геометрии происходит сборка примитивов.
Scissors and View Ports. Это этап, на котором NDC-пространство преобразуется в пространство ViewPort, а также обрезается часть фрагментов за пределами Scissors. Scissor - это прямоугольная область, в которой записываются фрагменты. ViewPort - это преобразование NDC-координат в координаты фреймбуфера.
Rasterization. На данном этапе происходит преобразование примитивов в набор фрагментов, которые обрабатываются фрагментным шейдером.
Depth and Stencil test. На данном этапе фрагменты, которые совпадают по экранным координатам, тестируются на глубину. Тот фрагмент, который "выиграл" соревнование, отправляется на следующий этап.
Тест трафарета отбрасывает те фрагменты, которые его "провалили".Color Blending. На данном этапе происходит смешивание цветов. Если фрагменты используют четырёхкомпонентный цвет, итоговый пиксель выходного прикрепления получается из смешения цветов нескольких слоёв изображений.
Синхронизация
Синхронизация в Vulkan API представлена, в основном, тремя примитивами синхронизации.
Memory Barriers. Эти примитивы синхронизации обеспечивают порядок доступа и операций. Все операции и доступ, указанные в src, должны завершиться перед операциями, указанными в dst. Соответственно, операции, указанные в dst, должны завершиться до операций, указанных в src.
Semaphores. Они нужны для синхронизации между командными буферами, отправленными на очередь.
Fences. Используются для синхронизации между GPU и CPU. Например, процессор в вызов submit может передать
vk::Fenceдля того, чтобы дождаться когда командный буфер завершит свою работу.
Проход рендеринга расставляет барьеры внутри себя автоматически, исходя из описания прохода рендеринга.
Подготовка проекта
Для написания проектов на Vulkan API нам потребуется пакет VulkanSDK. Скачать его можно тут. Этот пакет включает нужную нам библиотеку SDL2. Также там находятся инструменты для настройки VulkanAPI, компиляции шейдеров. В пакете есть тестовое приложение, рисующее вращающийся куб. Если вы хотите проверить поддержку Vulkan API на вашем устройстве, можете запустить эту программу. По идее, если она запустится и вы увидите куб, Vulkan API поддерживается в вашей системе.
В нашем проекте мы будем использовать систему сборки Cmake, а также будем использовать библиотеку SDL2 для создания окна и поверхности.
Мы будем использовать C++-заголовочник для vulkan API. Он предоставляет удобные RAII-обёртки для объектов Vulkan API.
Чтобы в cmake получить заголовочные файлы Vulkan API, а также загрузить библиотеку SDL2, я пишу в CmakeLists.txt следующее.
find_package(Vulkan REQUIRED) include_directories(SYSTEM ${Vulkan_INCLUDE_DIRS}) set(SDL2Libraries ${Vulkan_INCLUDE_DIRS}/../Lib/SDL2.lib ${Vulkan_INCLUDE_DIRS}/../Lib/SDL2main.lib)
Также нам нужна кастомная цель для компиляции шейдеров.
add_executable(HelloTriangle src/HelloTriangle.cpp ) # add shader compilation step here if needed add_custom_command( OUTPUT ${CMAKE_CURRENT_BINARY_DIR}/shaders/triangle.vert.spv ${CMAKE_CURRENT_BINARY_DIR}/shaders/triangle.frag.spv COMMAND ${CMAKE_COMMAND} -E make_directory ${CMAKE_CURRENT_BINARY_DIR}/shaders COMMAND glslc ${CMAKE_CURRENT_SOURCE_DIR}/shaders/triangle.vert -o ${CMAKE_CURRENT_BINARY_DIR}/shaders/triangle.vert.spv COMMAND glslc ${CMAKE_CURRENT_SOURCE_DIR}/shaders/triangle.frag -o ${CMAKE_CURRENT_BINARY_DIR}/shaders/triangle.frag.spv DEPENDS ${CMAKE_CURRENT_SOURCE_DIR}/shaders/triangle.vert ${CMAKE_CURRENT_SOURCE_DIR}/shaders/triangle.frag COMMENT "Compiling shaders..." ) add_custom_target(CompileShaders ALL DEPENDS ${CMAKE_CURRENT_BINARY_DIR}/shaders/triangle.vert.spv ${CMAKE_CURRENT_BINARY_DIR}/shaders/triangle.frag.spv ) add_dependencies(HelloTriangle CompileShaders) target_link_libraries(HelloTriangle PRIVATE ${SDL2Libraries})
Перейдём к созданию окна SDL2.
Создаём окно SDL2
Прежде, чем приступать к самому Vulkan API, нужно создать окно SDL2 и получить список требуемых для создания поверхности расширений. Это C-API, поэтому было бы удобно написать RAII-обёртки для используемых компонентов. Перед тем, как создать окно SDL2, нужно инициализировать библиотеку. Сделаем небольшую RAII обёртку для библиотеки.
class SdlLibrary { public: SdlLibrary() { if (SDL_Init(SDL_INIT_VIDEO | SDL_INIT_AUDIO) != 0) { throw std::runtime_error("Failed to initialize SDL"); } } [[nodiscard]] static std::vector<char const*> getSurfaceRequiredExtensions() { uint32_t sdlExtensionCount = 0; if (!SDL_Vulkan_GetInstanceExtensions(nullptr, &sdlExtensionCount, nullptr)) { throw std::runtime_error("Failed to get SDL Vulkan extension count"); } std::vector<char const*> extensions(sdlExtensionCount); if (!SDL_Vulkan_GetInstanceExtensions(nullptr, &sdlExtensionCount, extensions.data())) { throw std::runtime_error("Failed to get SDL Vulkan extensions"); } return extensions; } SdlLibrary(const SdlLibrary&) = delete; SdlLibrary& operator=(const SdlLibrary&) = delete; SdlLibrary(SdlLibrary&&) = delete; SdlLibrary& operator=(SdlLibrary&&) = delete; ~SdlLibrary() { SDL_Quit(); } };
Метод getSurfaceRequiredExtensions() позволяет получить нужные расширения для поверхности, специфичные для текущей платформы.
Создадим также обёртку для окна.
class SdlWindow { public: SdlWindow(char const* title, int width, int height) { window = SDL_CreateWindow( title, SDL_WINDOWPOS_CENTERED, SDL_WINDOWPOS_CENTERED, width, height, SDL_WINDOW_VULKAN | SDL_WINDOW_RESIZABLE ); if (!window) { throw std::runtime_error("Failed to create SDL window"); } } SdlWindow(const SdlWindow&) = delete; SdlWindow& operator=(const SdlWindow&) = delete; SdlWindow(SdlWindow&&) = delete; SdlWindow& operator=(SdlWindow&&) = delete; ~SdlWindow() { SDL_DestroyWindow(window); } [[nodiscard]] SDL_Window* get() const { return window; } private: SDL_Window* window; };
Также добавим в наш код, в конец, цикл обработки сообщений. Когда мы закрываем окно или мы нажимаем Escape, приложение заканчивает свою работу.
// Main loop // In the main loop SDL_Event event; bool running = true; while (running){ while (SDL_PollEvent(&event)) { switch (event.type) { case SDL_QUIT: running = false; break; case SDL_KEYDOWN: if (event.key.keysym.sym == SDLK_ESCAPE) { running = false; } default: break; } } }
Инициализация
Создание экземпляра устройства. VkInstance
Для создания экземпляра устройства нам нужно запросить поддержку нужных слоёв и расширений, заполнить структуры VkInstanceCreateInfo, VkApplicationInfo, VkDebugUtilsMessengerCreateInfoEXT, написать небольшую функцию для отображения сообщений о валидации.
VkApplicationInfo
Начнём со структуры VkApplicationInfo. Эта структура нужна для предоставления метаданных драйверу. По факту, я до сих пор не знаю зачем она понадобится нам. Ниже я предоставлю ссылки на все статьи в спецификации, чтобы вы могли подробнее рассмотреть описания структур и функций Vulkan API. Что ж, давайте заполним эту структуру.
vk::ApplicationInfo applicationInfo{ .pApplicationName = "Hello Triangle", .applicationVersion = vk::makeVersion(1, 0, 0), .pEngineName = "No Engine", .engineVersion = vk::makeVersion(1, 0, 0), .apiVersion = vk::ApiVersion10 };
Для работы эта структура не имеет значения, но мы укажем её для приличия.
Проверяем поддержку расширений и слоёв
Для создания экземпляра устройства нам понадобятся некоторые расширения, а также слои валидации. Расширение - это функционал, который не входит в базовую спецификацию VulkanAPI. Слои - это прослойка между вызовом API функции и вызовом функции драйвера видеокарты.
Нам понадобятся следующие расширения.
VK_EXT_debug_utils.VK_KHR_surface.VK_KHR_win32_surface(Или другое, в зависимости от расширения). Название данного расширения мы получаем из функцииgetSurfaceRequiredExtensions.
Также нам потребуется слой VK_LAYER_KHRONOS_validation.
Для того, чтобы проверить поддержку требуемых расширений, определим функцию checkInstanceExtensionsSupport.
bool checkInstanceExtensionsSupport(std::vector<char const*> const& requiredExtensions) { auto [result, availableExtensions] = vk::enumerateInstanceExtensionProperties(); if (result != vk::Result::eSuccess) { throw std::runtime_error("Failed to enumerate instance extension properties"); } for (char const* requiredExtension : requiredExtensions) { bool found = false; for (auto const& [extensionName, _] : availableExtensions) { if (strcmp(requiredExtension, extensionName) == 0) { found = true; break; } } if (!found) { return false; } } return true; }
Напишем аналогичную функцию checkInstanceLayersSupport.
bool checkInstanceLayersSupport(std::vector<char const*> const& requiredLayers) { auto [result, availableLayers] = vk::enumerateInstanceLayerProperties(); if (result != vk::Result::eSuccess) { throw std::runtime_error("Failed to enumerate instance layer properties"); } for (char const* requiredLayer : requiredLayers) { bool found = false; for (auto const& [layerName, _, __, ___] : availableLayers) { if (strcmp(requiredLayer, layerName) == 0) { found = true; break; } } if (!found) { return false; } } return true; }
Слои валидации
Теперь нам нужно настроить отладку. В данном случае мы будем перехватывать сообщения слоя валидации. Для этого определим экземпляр vk::DebugUtilsMessengerCreateInfoEXT.
vk::DebugUtilsMessengerCreateInfoEXT{ .messageSeverity = vk::DebugUtilsMessageSeverityFlagBitsEXT::eError, .messageType = vk::DebugUtilsMessageTypeFlagBitsEXT::eValidation, .pfnUserCallback = [](vk::DebugUtilsMessageSeverityFlagBitsEXT, vk::DebugUtilsMessageTypeFlagsEXT, vk::DebugUtilsMessengerCallbackDataEXT const* pCallbackData, void*) -> vk::Bool32 { std::cerr << "Validation layer: " << pCallbackData->pMessage << '\n'; return VK_FALSE; }, .pUserData = nullptr }
В этой структуре указано, что мы будем перехватывать только сообщения валидации и только ошибки. Ошибки валидации - это ошибки, связанные с нарушением спецификации Vulkan API. Например, передача изображения для отрисовки с неправильным vk::ImageLayout является ошибкой валидации. Параметр pUserData позволяет нам создать обёртку для нашего логгера.
Заполняем vk::InstanceCreateInfo и создаём экземпляр Vulkan API
Для создания экземпляра Vulkan API нам потребуется передать запрашиваемые расширения, а также дополнительные структуры. В нашем случае, мы передаём параметры для создания логгера.
requiredInstanceExtensions.push_back("VK_EXT_debug_utils"); auto const validationLayer = "VK_LAYER_KHRONOS_validation"; if (!checkInstanceExtensionSupport(requiredInstanceExtensions)) { throw std::runtime_error("Required instance extensions are not supported"); } if (!checkInstanceLayersSupport(std::vector{validationLayer} )) { throw std::runtime_error("Validation layer is not supported"); } constexpr vk::ApplicationInfo applicationInfo{ .pApplicationName = "Hello Triangle", .applicationVersion = vk::makeVersion(1, 0, 0), .pEngineName = "No Engine", .engineVersion = vk::makeVersion(1, 0, 0), .apiVersion = vk::ApiVersion10 }; vk::StructureChain instanceCreateInfoStructureChain{ vk::InstanceCreateInfo{ .pApplicationInfo = &applicationInfo, .enabledLayerCount = 1, .ppEnabledLayerNames = &validationLayer, .enabledExtensionCount = static_cast<uint32_t>(requiredInstanceExtensions.size()), .ppEnabledExtensionNames = requiredInstanceExtensions.data(), }, vk::DebugUtilsMessengerCreateInfoEXT{ .messageSeverity = vk::DebugUtilsMessageSeverityFlagBitsEXT::eError, .messageType = vk::DebugUtilsMessageTypeFlagBitsEXT::eValidation, .pfnUserCallback = [](vk::DebugUtilsMessageSeverityFlagBitsEXT, vk::DebugUtilsMessageTypeFlagsEXT, vk::DebugUtilsMessengerCallbackDataEXT const* pCallbackData, void*) -> vk::Bool32 { std::cerr << "Validation layer: " << pCallbackData->pMessage << '\n'; return VK_FALSE; }, .pUserData = nullptr } }; auto [result, uniqueInstance] = vk::createInstanceUnique(instanceCreateInfoStructureChain.get()); if (result != vk::Result::eSuccess) { throw std::runtime_error("Failed to create Vulkan instance"); }
Здесь мы создаём объект vk::StructureChain: он позволяет передавать в функцию создания экземпляра цепочку структур. Он сам пишет в поле pNext указатели на дополнительные параметры. Приступим к созданию поверхности. Определим в классе SdlWindow методы. Они позволяют создавать поверхность.
[[nodiscard]] vk::SurfaceKHR createVulkanSurface(vk::Instance const& instance) const { VkSurfaceKHR surface = VK_NULL_HANDLE; if (!SDL_Vulkan_CreateSurface(window, instance, &surface)) { throw std::runtime_error("Failed to create Vulkan surface"); } return surface; } [[nodiscard]] vk::UniqueSurfaceKHR createVulkanSurfaceUnique(vk::Instance const& instance) const { return vk::UniqueSurfaceKHR(createVulkanSurface(instance), instance); }
Теперь одной строчкой создадим нашу поверхность.
auto const uniqueSurface = window.createVulkanSurfaceUnique(*uniqueInstance);
Создание логического устройства
Для начала нам нужно получить список физических устройств, затем запросить его свойства (семейства очередей, свойства поверхностей, свойства памяти). Для простоты выберем первое устройство в списке, которое поддерживает нужное расширение, возможности, а также нужные семейства очередей.
Для начала мы проверим поддержку необходимых расширений. Нам нужно только одно расширение: VK_KHR_swapchain.
std::vector requiredDeviceExtensions = { vk::KHRSwapchainExtensionName };
Добавим функцию checkDeviceExtensionsSupport для проверки поддержки устройством расширений.
bool checkDeviceExtensionsSupport(vk::PhysicalDevice const& physicalDevice, std::vector<char const*> const& requiredExtensions) { auto [result, availableExtensions] = physicalDevice.enumerateDeviceExtensionProperties(); if (result != vk::Result::eSuccess) { throw std::runtime_error("Failed to enumerate device extension properties"); } for (char const* requiredExtension : requiredExtensions) { bool found = false; for (auto const& [extensionName, _] : availableExtensions) { if (strcmp(requiredExtension, extensionName) == 0) { found = true; break; } } if (!found) { return false; } } return true; }
Пройдёмся теперь по каждому физическому устройству и выберем первое из тех, что соответствует нашим требованиям: устройство должно иметь семейство очередей, которое поддерживает показ и графику, а также должно поддерживаться расширение VK_KHR_swapchain. Вот код для поиска такого устройства
auto [enumeratePhysicalDevicesResult, physicalDevices] = uniqueInstance.get().enumeratePhysicalDevices(); if (enumeratePhysicalDevicesResult != vk::Result::eSuccess || physicalDevices.empty()) { throw std::runtime_error("Failed to find a suitable GPU"); } std::vector requiredDeviceExtensions = { vk::KHRSwapchainExtensionName }; std::vector<uint32_t> graphicFamilyIndices{}; std::vector<uint32_t> presentationFamilyIndices{}; vk::PhysicalDevice physicalDevice = VK_NULL_HANDLE; for (auto physicalDevice1: physicalDevices) { bool requiredExtensionsSupported = checkDeviceExtensionsSupport(physicalDevice1, requiredDeviceExtensions); std::vector<uint32_t> physicalDevicePresentationFamilyIndices; std::vector<uint32_t> physicalDeviceGraphicFamilyIndices; auto physicalDeviceQueueFamilyProperties = physicalDevice1.getQueueFamilyProperties(); for (int queueFamilyIndex = 0U; auto queueFamilyProperties: physicalDeviceQueueFamilyProperties) { if (queueFamilyProperties.queueFlags & vk::QueueFlagBits::eGraphics) physicalDeviceGraphicFamilyIndices.push_back(queueFamilyIndex); if (physicalDevice1.getSurfaceSupportKHR(queueFamilyIndex, *uniqueSurface).value) physicalDevicePresentationFamilyIndices.push_back(queueFamilyIndex); queueFamilyIndex++; } if (requiredExtensionsSupported && !physicalDeviceGraphicFamilyIndices.empty() && !physicalDevicePresentationFamilyIndices.empty()) { physicalDevice = physicalDevice1; graphicFamilyIndices = physicalDeviceGraphicFamilyIndices; presentationFamilyIndices = physicalDevicePresentationFamilyIndices; break; } } if (physicalDevice == VK_NULL_HANDLE) { throw std::runtime_error("Failed to find a suitable GPU with required extensions and queue families"); }
Мы проверяем устройства на поддержку расширения VK_KHR_swapchain и наличие семейств очередей для показа и рендеринга. Интересующие нас индексы семейств очередей мы добавляем в массивы индексов очередей, поддерживающих показ и рендеринг.
Если мы не найдём нужное нам устройство, мы бросим исключение, поскольку дальше продолжать работу нет смысла.
Далее выбираем индексы для наших семейств очередей. Если одно из семейств очередей поддерживает обе операции, выбираем его. Ниже код для выбора очередей.
std::vector<vk::DeviceQueueCreateInfo> deviceQueueCreateInfos{}; std::array queuePriorities = {1.0f, 1.0f}; std::pair<uint32_t, uint32_t> graphicQueueIndex; std::pair<uint32_t, uint32_t> presentationQueueIndex; bool queueConfigured = false; for (auto graphicFamilyIndex: graphicFamilyIndices) { for (auto presentationFamilyIndex: presentationFamilyIndices) { if (graphicFamilyIndex == presentationFamilyIndex) { deviceQueueCreateInfos.push_back( vk::DeviceQueueCreateInfo{ .queueFamilyIndex = graphicFamilyIndex, .queueCount = 1, .pQueuePriorities = queuePriorities.data() } ); graphicQueueIndex = { graphicFamilyIndex, 0 }; presentationQueueIndex = { presentationFamilyIndex, 0 }; queueConfigured = true; break; } } if (queueConfigured) break; } if (!queueConfigured) { graphicQueueIndex = { graphicFamilyIndices[0], 0 }; deviceQueueCreateInfos.push_back( vk::DeviceQueueCreateInfo{ .queueFamilyIndex = graphicFamilyIndices[0], .queueCount = 1, .pQueuePriorities = queuePriorities.data() } ); presentationQueueIndex = { presentationFamilyIndices[0], 0 }; deviceQueueCreateInfos.push_back( vk::DeviceQueueCreateInfo{ .queueFamilyIndex = presentationFamilyIndices[0], .queueCount = 1, .pQueuePriorities = queuePriorities.data() } ); }
В цикле мы перебираем семейства очередей. Если находим семейство очередей, которое поддерживает и рендеринг, и показ, берём его. Если не находим такое, то мы создаём две очереди из разных семейств очередей.
Теперь вызываем функцию для создания устройства и получаем наши очереди.
auto [createDeviceResult, uniqueDevice] = physicalDevice.createDeviceUnique( vk::DeviceCreateInfo{ .queueCreateInfoCount = static_cast<uint32_t>(deviceQueueCreateInfos.size()), .pQueueCreateInfos = deviceQueueCreateInfos.data(), .enabledExtensionCount = static_cast<uint32_t>(requiredDeviceExtensions.size()), .ppEnabledExtensionNames = requiredDeviceExtensions.data(), }); if (createDeviceResult != vk::Result::eSuccess) { throw std::runtime_error("Failed to create logical device"); } vk::Queue graphicQueue = uniqueDevice->getQueue(graphicQueueIndex.first, graphicQueueIndex.second); vk::Queue presentationQueue = uniqueDevice->getQueue(presentationQueueIndex.first, presentationQueueIndex.second);
Очередь для графики мы будем использовать для команд рендеринга, а презентации для показа изображения в окне.
Настраиваем цепочки обмена, проходы рендеринга и фреймбуферы
Теперь настал этап, когда мы настроим цели для нашего рендеринга. Этот этап можно разбить на следующие шаги.
Получаем возможности нашего физического устройства для созданной поверхности, а также доступные форматы.
Создаём цепочку обмена (
vk::SwapchainKHR) и из неё получаем изображения.Создаём из этих изображений вид этих изображений (
vk::ImageView).Создаём объект прохода рендеринга (
vk::RenderPass).Из наших видов изображения и прохода рендеринга мы создаём фреймбуферы (
vk::Framebuffer).
Создаём цепочку обмена
Для начала получим возможности поверхности для выбранного физического устройства, а также доступные форматы и режимы показа.
auto [getSurfaceCapabilitiesResult, physicalDeviceSurfaceCapabilities] = physicalDevice.getSurfaceCapabilitiesKHR(uniqueSurface.get()); if (getSurfaceCapabilitiesResult != vk::Result::eSuccess) { throw std::runtime_error("Failed to get surface capabilities"); } auto [getSurfaceFormatsResult, surfaceFormats] = physicalDevice.getSurfaceFormatsKHR(uniqueSurface.get()); if (getSurfaceFormatsResult != vk::Result::eSuccess) { throw std::runtime_error("Failed to get surface formats"); } auto [getSurfacePresentModesResult, surfacePresentModes] = physicalDevice.getSurfacePresentModesKHR(uniqueSurface.get()); if (getSurfacePresentModesResult != vk::Result::eSuccess) { throw std::runtime_error("Failed to get surface present modes"); }
Для простоты будем использовать первый из доступных форматов, а в качестве режима показа vk::PresentModeKHR::eFifo. Заполним структуру vk::SwapchainCreateInfoKHR. Большую часть параметров будем брать из объекта physicalDeviceSurfaceCapabilities.
Для удобство сохраним некоторые параметры в отдельные переменные.
auto swapchainImageFormat = surfaceFormats.front().format; auto swapchainColorSpace = surfaceFormats.front().colorSpace; auto swapchainExtent = physicalDeviceSurfaceCapabilities.currentExtent; auto swapchainPresentMode = std::ranges::find(surfacePresentModes, vk::PresentModeKHR::eFifo) != surfacePresentModes.end() ? vk::PresentModeKHR::eFifo : surfacePresentModes.front();
swapchainImageFormat будем использовать для создания vk::SwapchainKHR, vk::ImageView, vk::RenderPass. То же касается и swapchainPresentMode.
А теперь создадим нашу цепочку обмена и получим из неё изображения.
auto [createSwapchainResult, uniqueSwapchain] = uniqueDevice->createSwapchainKHRUnique( vk::SwapchainCreateInfoKHR{ .surface = *uniqueSurface, .minImageCount = physicalDeviceSurfaceCapabilities.minImageCount, .imageFormat = swapchainImageFormat, .imageColorSpace = swapchainColorSpace, .imageExtent = swapchainExtent, .imageArrayLayers = 1, .imageUsage = vk::ImageUsageFlagBits::eColorAttachment, .imageSharingMode = vk::SharingMode::eExclusive, .queueFamilyIndexCount = 0, .pQueueFamilyIndices = nullptr, .preTransform = physicalDeviceSurfaceCapabilities.currentTransform, .compositeAlpha = vk::CompositeAlphaFlagBitsKHR::eOpaque, .presentMode = swapchainPresentMode, .clipped = vk::True, .oldSwapchain = nullptr } ); if (createSwapchainResult != vk::Result::eSuccess) { throw std::runtime_error("Failed to create swapchain"); } auto [getSwapchainImagesResult, swapchainImages] = uniqueDevice->getSwapchainImagesKHR(*uniqueSwapchain); if (getSwapchainImagesResult != vk::Result::eSuccess) { throw std::runtime_error("Failed to get swapchain images"); }
Из каждого изображения цепочки обмена мы создаём его вид, чтобы создать фреймбуферы.
std::vector<vk::UniqueImageView> uniqueImageViews{}; uniqueImageViews.reserve(swapchainImages.size()); for (auto image: swapchainImages) { auto [createImageViewResult, uniqueImageView] = uniqueDevice->createImageViewUnique( vk::ImageViewCreateInfo{ .image = image, .viewType = vk::ImageViewType::e2D, .format = swapchainImageFormat, .components = vk::ComponentMapping{ .r = vk::ComponentSwizzle::eIdentity, .g = vk::ComponentSwizzle::eIdentity, .b = vk::ComponentSwizzle::eIdentity, .a = vk::ComponentSwizzle::eIdentity }, .subresourceRange = vk::ImageSubresourceRange{ .aspectMask = vk::ImageAspectFlagBits::eColor, .baseMipLevel = 0, .levelCount = 1, .baseArrayLayer = 0, .layerCount = 1 } }); if (createImageViewResult != vk::Result::eSuccess) { throw std::runtime_error("Failed to create image view"); } uniqueImageViews.push_back( std::move(uniqueImageView)); }
Создаём объект прохода рендеринга и фреймбуферы
Теперь нужно настроить проход рендеринга. Этот объект содержит данные о всех прикреплениях, подпроходах, а также о зависимостях между подпроходами, для того, чтобы в них не было гонок за данные. Например, нужно, чтобы одно и то же изображение не могло записываться и прочитываться одновременно.
Начнём с конца.
Чтобы создать проход рендеринга для отрисовки треугольника, нужно вызвать
auto [renderPassCreateResult, uniqueRenderPass] = uniqueDevice->createRenderPassUnique( vk::RenderPassCreateInfo{ .attachmentCount = 1, .pAttachments = &swapchainAttachmentDescription, .subpassCount = 1, .pSubpasses = &swapchainSubpassDescription, .dependencyCount = static_cast<uint32_t>(swapchainSubpassDependencies.size()), .pDependencies = swapchainSubpassDependencies.data() } ); if (renderPassCreateResult != vk::Result::eSuccess) { throw std::runtime_error("Failed to create render pass"); }
Теперь разберёмся со всем этим.
Итак, первое, что у нас имеется, -- это описание прикреплений. Прикрепления или аттачменты - это изображения, которые будут использоваться в рендеринге. У нас будет только одно изображение участвовать в рендеринге - его и опишем.
vk::AttachmentDescription swapchainAttachmentDescription{ .format = swapchainImageFormat, .samples = vk::SampleCountFlagBits::e1, .loadOp = vk::AttachmentLoadOp::eClear, .storeOp = vk::AttachmentStoreOp::eStore, .stencilLoadOp = vk::AttachmentLoadOp::eDontCare, .stencilStoreOp = vk::AttachmentStoreOp::eDontCare, .initialLayout = vk::ImageLayout::eUndefined, .finalLayout = vk::ImageLayout::ePresentSrcKHR };
Здесь мы не указываем на фактическое изображение, а лишь описываем то, каким оно должно быть: формат, как мы из него читаем, как в него записываем и как меняется layout изображения. Здесь указывается, что при чтении пикселей мы используем цвет очистки, а при записи мы сохраняем изображение из прохода рендеринга. Параметры для трафарета (stencilXXXOp) мы не используем. А в качестве конечного layout мы указываем на то, что после рендеринга мы будем показывать его в окне.
Второе, что у нас имеется, - это описание подпроходов рендеринга. Поскольку у нас самый простой рендер треугольника, будем использовать только один подпроход.
vk::AttachmentReference swapchainAttachmentReference{ .attachment = 0, .layout = vk::ImageLayout::eColorAttachmentOptimal }; vk::SubpassDescription swapchainSubpassDescription{ .pipelineBindPoint = vk::PipelineBindPoint::eGraphics, .colorAttachmentCount = 1, .pColorAttachments = &swapchainAttachmentReference };
Здесь мы указываем, что в данном подпроходе мы будем использовать графический конвейер, одно изображение. В данном подпроходе мы будем использовать только выходное цветовое прикрепление, поэтому остальные параметры в vk::SubpassDescription мы просто проигнорируем. Очень важно указать правильный layout, иначе можно словить ошибку валидации и неопределённое поведение.
Последнее, что здесь нужно указать - это зависимости между подпроходами. Они нужны для того, чтобы защитить ресурсы от гонок данных, а также выстроить зависимости между подпроходами.
std::array swapchainSubpassDependencies{ vk::SubpassDependency{ .srcSubpass = vk::SubpassExternal, .dstSubpass = 0, .srcStageMask = vk::PipelineStageFlagBits::eTopOfPipe, .dstStageMask = vk::PipelineStageFlagBits::eColorAttachmentOutput, .srcAccessMask = {}, .dstAccessMask = vk::AccessFlagBits::eColorAttachmentWrite, .dependencyFlags = vk::DependencyFlagBits::eByRegion }, vk::SubpassDependency{ .srcSubpass = 0, .dstSubpass = vk::SubpassExternal, .srcStageMask = vk::PipelineStageFlagBits::eColorAttachmentOutput, .dstStageMask = vk::PipelineStageFlagBits::eBottomOfPipe, .srcAccessMask = vk::AccessFlagBits::eColorAttachmentWrite, .dstAccessMask = {}, .dependencyFlags = vk::DependencyFlagBits::eByRegion } };
В данном случае мы ограничиваем доступ к изображению вне рендеринга. Вначале мы его захватываем, а затем освобождаем. В динамическом рендеринге это делается при помощи барьеров.
Создаём vk::Framebuffer
Теперь соединим изображения нашей цепочки обмена и проход рендеринга во фреймбуфер. Это набор изображений, который участвует в рендеринге. Каждому прикреплению из прохода рендеринга мы подбираем соответствующее формату изображение (его вид).
Итак, создадим фреймбуферы по числу изображений.
std::vector<vk::UniqueFramebuffer> uniqueFramebuffers{}; uniqueFramebuffers.reserve(imageCount); for (auto& imageView: uniqueImageViews) { auto [createFramebufferResult, uniqueFramebuffer] = uniqueDevice->createFramebufferUnique( vk::FramebufferCreateInfo{ .renderPass = *uniqueRenderPass, .attachmentCount = 1, .pAttachments = &imageView.get(), .width = swapchainExtent.width, .height = swapchainExtent.height, .layers = 1 } ); if (createFramebufferResult != vk::Result::eSuccess) { throw std::runtime_error("Failed to create framebuffer"); } uniqueFramebuffers.push_back(std::move(uniqueFramebuffer)); }
Я думаю, что здесь всё понятно. Только убедитесь, что layout и формат вашего изображения совпадает с initialLayout, который был указан в vk::AttachmentDescription.
Создание вершинного буфера и конвейера
Итак, нам нужно для начала создать буфер и привязать его к памяти устройства, затем нужно будет написать шейдер, скомпилировать его и создать шейдерные модули, в конце мы создадим объект графического конвейера.
Вершинный буфер
Итак, прежде, чем мы будем настраивать конвейер, нам нужно создать буфер для вершин. Он привязывается перед вызовом отрисовки объекта. Вершинный буфер может содержать самую разную информацию о вершине.
Вершина - это опорная точка примитива для отрисовки. Любой объект, который мы отрисовываем, состоит из примитивов, а примитивы строятся из вершин. Вершины могут содержать несколько атрибутов: позиция, цвет, текстурные координаты, нормали и т.д. Когда мы эти данные передаём далее, растеризатор интерполирует вершинные данные, заполняя весь примитив.
Будем использовать два атрибута: позицию и цвет. Позиция будет 2D, а цвет формата RGB.
std::array triangleVertices{ // positions|colors 0.0f, -0.5f, 1.0f, 0.0f, 0.0f, 0.5f, 0.5f, 0.0f, 1.0f, 0.0f, -0.5f, 0.5f, 0.0f, 0.0f, 1.0f };
Теперь этот буфер нужно скопировать в память устройства. Для этого сначала создадим буфер и получим требования к памяти.
auto [createBufferResult, uniqueVertexBuffer] = uniqueDevice->createBufferUnique( vk::BufferCreateInfo{ .size = sizeof(triangleVertices), .usage = vk::BufferUsageFlagBits::eVertexBuffer, .sharingMode = vk::SharingMode::eExclusive }); if (createBufferResult != vk::Result::eSuccess) { throw std::runtime_error("Failed to create vertex buffer"); } auto vertexBufferMemoryRequirements = uniqueDevice->getBufferMemoryRequirements(*uniqueVertexBuffer);
Здесь мы указываем размер, использование, режим разделения между семействами очередей. Для буфера нужно выделить память и привязать его к конкретному участку памяти устройства, а также отобразить этот участок в виртуальную память хоста для копирования в него наших вершинных данных.
Найдём индекс нужного типа памяти. Для этого мы запросим у физического устройства свойства памяти и выберем тот тип памяти, который указан в поле memoryTypeBits объекта vertexBufferMemoryRequirements.
auto memoryProperties = physicalDevice.getMemoryProperties(); std::optional<uint32_t> vertexBufferMemoryTypeIndex{0}; for (uint32_t memoryTypeIndex = 0; memoryTypeIndex < memoryProperties.memoryTypeCount; memoryTypeIndex++) { if (vertexBufferMemoryRequirements.memoryTypeBits & 1 << memoryTypeIndex && memoryProperties.memoryTypes[memoryTypeIndex].propertyFlags & vk::MemoryPropertyFlagBits::eHostVisible && memoryProperties.memoryTypes[memoryTypeIndex].propertyFlags & vk::MemoryPropertyFlagBits::eHostCoherent) { vertexBufferMemoryTypeIndex = memoryTypeIndex; break; } } if (!vertexBufferMemoryTypeIndex.has_value()) { throw std::runtime_error("Failed to find suitable memory type for vertex buffer"); }
Нам нужна память, которая имеет те же флаги свойства типа памяти, а также она должна быть видимой для хоста. Если нужный индекс типа памяти не найден, бросаем исключение.
Теперь выделяем память и отображаем, привязываем буфер, а также отображаем в виртуальное адресное пространство и копируем вершинные данные. После копирования убираем отображение из виртуального адресного пространства процесса.
auto [allocateMemoryResult, uniqueVertexBufferMemory] = uniqueDevice->allocateMemoryUnique( vk::MemoryAllocateInfo{ .allocationSize = vertexBufferMemoryRequirements.size, .memoryTypeIndex = vertexBufferMemoryTypeIndex.value() } ); if (allocateMemoryResult != vk::Result::eSuccess) { throw std::runtime_error("Failed to allocate vertex buffer memory"); } if (auto allocateVertexBufferMemoryResult = uniqueDevice->bindBufferMemory(*uniqueVertexBuffer, *uniqueVertexBufferMemory, 0); allocateVertexBufferMemoryResult != vk::Result::eSuccess) { throw std::runtime_error("Failed to bind vertex buffer memory"); } // Map memory and copy vertex data auto [mapMemoryResult, vertexBufferData] = uniqueDevice->mapMemory(uniqueVertexBufferMemory.get(), 0, vertexBufferMemoryRequirements.size); if (mapMemoryResult != vk::Result::eSuccess) { throw std::runtime_error("Failed to map vertex buffer memory"); } std::memcpy(vertexBufferData, triangleVertices.data(), sizeof(triangleVertices)); uniqueDevice->unmapMemory(uniqueVertexBufferMemory.get());
Теперь вершинные данные готовы.
Подготавливаем шейдеры
Напишем наши шейдеры для отрисовки треугольника.
// shader/triangle.vert #version 460 core layout (location = 0) in vec2 aPos; layout (location = 1) in vec3 aColor; layout (location = 0) out vec3 ourColor; void main() { gl_Position = vec4(aPos, 0.0, 1.0); ourColor = aColor; } // shader/triangle.frag #version 460 core layout (location = 0) in vec3 ourColor; layout (location = 0) out vec4 FragColor; void main(){ FragColor = vec4(ourColor, 1.0f); }
location = <номер> - это расположение атрибута вершины. Мы указали, что номер ноль - это позиция, а номер один - это цвет. В вершинном шейдере мы обязательно должны проинициализировать переменную gl_Position. Во фрагментном шейдере мы принимаем переменную ourColor, которая передаётся при генерации фрагментов во время этапа растеризации. Дело в том, что мы выводим значение ourColor из вершинного шейдера, которое при генерации фрагментов интерполируются и передаются во фрагментный шейдер по location = 0. Фрагментный шейдер выводит значение фрагмента для выходных аттачментов. У нас используется один аттачмент, который в нулевом подпроходе имеет location = 0.
Эти шейдеры нужно скомпилировать в промежуточный байт-код, который драйверы превращают в код, который исполняется на GPU. Для этого мы используем команду
glslc.exe shader/triangle.vert -o shader/triangle.vert.spv glslc.exe shader/triangle.frag -o shader/triangle.frag.spv
Вернёмся теперь к нашему основному коду и создадим шейдерные модули.
std::fstream vertexShaderFile("shaders/triangle.vert.spv", std::ios::binary | std::ios::ate | std::ios::in); if (!vertexShaderFile.is_open()) { throw std::runtime_error("Failed to open vertex shader file"); } auto vertexShaderFileSize = vertexShaderFile.tellg(); std::vector<char> vertexShaderCode(vertexShaderFileSize); vertexShaderFile.seekg(0); vertexShaderFile.read(vertexShaderCode.data(), vertexShaderFileSize); auto [vertexShaderModuleCreateResult, uniqueVertexShaderModule] = uniqueDevice->createShaderModuleUnique( vk::ShaderModuleCreateInfo{ .codeSize = vertexShaderCode.size(), .pCode = reinterpret_cast<uint32_t const*>(vertexShaderCode.data()) } ); if (vertexShaderModuleCreateResult != vk::Result::eSuccess) { throw std::runtime_error("Failed to create vertex shader module"); } std::fstream fragmentShaderModule("shaders/triangle.frag.spv", std::ios::binary | std::ios::ate | std::ios::in); if (!fragmentShaderModule.is_open()) { throw std::runtime_error("Failed to open fragment shader file"); } auto fragmentShaderFileSize = fragmentShaderModule.tellg(); std::vector<char> fragmentShaderCode(fragmentShaderFileSize); fragmentShaderModule.seekg(0); fragmentShaderModule.read(fragmentShaderCode.data(), fragmentShaderFileSize); auto [fragmentShaderModuleCreateResult, uniqueFragmentShaderModule] = uniqueDevice->createShaderModuleUnique( vk::ShaderModuleCreateInfo{ .codeSize = fragmentShaderCode.size(), .pCode = reinterpret_cast<uint32_t const*>(fragmentShaderCode.data()) } ); if (fragmentShaderModuleCreateResult != vk::Result::eSuccess) { throw std::runtime_error("Failed to create fragment shader module"); }
Здесь мы открываем файлы с шейдерами, копируем данные в буфер и создаём шейдерные модули.
Создаём графический конвейер
Для начала надо создать vk::PipelineLayout. Здесь он нам не нужен, поэтому создим его пустым.
auto [createPipelineLayoutResult, uniquePipelineLayout] = uniqueDevice->createPipelineLayoutUnique( vk::PipelineLayoutCreateInfo{} ); if (createPipelineLayoutResult != vk::Result::eSuccess) { throw std::runtime_error("Failed to create pipeline layout"); }
А теперь рассмотрим создание графического конвейера.
auto [uniqueGraphicsPipelineResult, uniqueGraphicsPipeline] = uniqueDevice->createGraphicsPipelineUnique( nullptr, vk::GraphicsPipelineCreateInfo{ .stageCount = static_cast<uint32_t>(shaderStages.size()), .pStages = shaderStages.data(), .pVertexInputState = &vertexInputStateCreateInfo, .pInputAssemblyState = &inputAssemblyStateCreateInfo, .pViewportState = &viewPortStateCreateInfo, .pRasterizationState = &rasterizationStateCreateInfo, .pMultisampleState = &multisampleStateCreateInfo, .pDepthStencilState = &depthStencilStateCreateInfo, .pColorBlendState = &colorBlendStateCreateInfo, .pDynamicState = &dynamicStateCreateInfo, .layout = uniquePipelineLayout.get(), .renderPass = uniqueRenderPass.get(), .subpass = 0 } ); if (uniqueGraphicsPipelineResult != vk::Result::eSuccess) { throw std::runtime_error("Failed to create graphics pipeline."); }
Как мы видим, это огромная структура, которая состоит из множества других структур. Это связано с тем, что графический конвейер содержит множество стадий и каждая стадия имеет свои настройки.
Первая настройка - это шейдерные стадии. Мы используем две стадии: вершинный и фрагментный шейдеры.
std::vector shaderStages = { vk::PipelineShaderStageCreateInfo { .stage = vk::ShaderStageFlagBits::eVertex, .module = *uniqueVertexShaderModule, .pName = "main" }, vk::PipelineShaderStageCreateInfo { .stage = vk::ShaderStageFlagBits::eFragment, .module = *uniqueFragmentShaderModule, .pName = "main" // Это имя главной функции } };
Теперь мы настроим атрибуты вершин.
vk::VertexInputBindingDescription vertexInputBinding{ .binding = 0, .stride = sizeof(float) * 5, .inputRate = vk::VertexInputRate::eVertex }; vk::VertexInputAttributeDescription vertexInputAttributes[2]{ { .location = 0, .binding = 0, .format = vk::Format::eR32G32Sfloat, .offset = 0 }, { .location = 1, .binding = 0, .format = vk::Format::eR32G32B32Sfloat, .offset = 2 * sizeof(float) } }; vk::PipelineVertexInputStateCreateInfo vertexInputStateCreateInfo{ .vertexBindingDescriptionCount = 1, .pVertexBindingDescriptions = &vertexInputBinding, .vertexAttributeDescriptionCount = 2, .pVertexAttributeDescriptions = vertexInputAttributes };
Поле binding обозначает привязку к конкретному вершинному буферу. Поле attribute означает атрибуты, которые передаются в шейдеры через location = <номер атрибута>, а привязка назначается через командный буфер.
Следующая настройка - это сборка вершин в примитивы. Мы выбираем какие примитивы мы будем собирать, а также состояние сброса примитивов (для triangle strip, line strip, triangle fan).
vk::PipelineInputAssemblyStateCreateInfo inputAssemblyStateCreateInfo{ .topology = vk::PrimitiveTopology::eTriangleList, .primitiveRestartEnable = vk::False };
Здесь указываем, что мы рисуем отдельные треугольники.
Следующая настройка - это настройка тесселяции. Её мы игнорируем.
Передаём дальше настройку viewPort. Это то, куда будет отрисовано наше изображение.
vk::Viewport viewport{ .x = 0.0f, .y = 0.0f, .width = static_cast<float>(swapchainExtent.width), .height = static_cast<float>(swapchainExtent.height), .minDepth = 0.0f, .maxDepth = 1.0f }; vk::Rect2D scissor{ .offset = vk::Offset2D{0, 0}, .extent = swapchainExtent }; vk::PipelineViewportStateCreateInfo viewPortStateCreateInfo{ .viewportCount = 1, .pViewports = &viewport, .scissorCount = 1, .pScissors = &scissor };
Далее - настройка растеризации.
vk::PipelineRasterizationStateCreateInfo rasterizationStateCreateInfo{ .depthClampEnable = vk::False , .rasterizerDiscardEnable = vk::False, .polygonMode = vk::PolygonMode::eFill, .cullMode = vk::CullModeFlagBits::eBack, .frontFace = vk::FrontFace::eClockwise, .depthBiasEnable = vk::False, .lineWidth = 1.0f };
Первое поле мы отключаем. Эта настройка отключает отсечение по глубине вершин, z которых <0 и >1. Второе поле выключает растеризацию. Мы также его не используем. Следующая настройка определяет режим заполнения фрагментами. Мы используем полную заливку примитивов. далее режим отсечения. Мы не будем отрисовывать обратную сторону примитива. Далее направление вершин. Мы выбираем по часовой стрелке. Смещение глубины отключаем. Ширина линии один фрагмент.
Следующий этап - это настройка мультисэмплинга.
vk::PipelineMultisampleStateCreateInfo multisampleStateCreateInfo{ .rasterizationSamples = vk::SampleCountFlagBits::e1, .sampleShadingEnable = vk::False };
Мы здесь используем только один сэмпл на фрагмент и мы отключаем многократный вызов шейдера на каждый сэмпл
Далее отключаем тест глубины и трафарета.
vk::PipelineDepthStencilStateCreateInfo depthStencilStateCreateInfo{ .depthTestEnable = vk::False, .stencilTestEnable = vk::False };
Мы не используем смешивание цветов.
vk::PipelineColorBlendAttachmentState colorBlendAttachmentState{ .blendEnable = vk::False, .colorWriteMask = vk::ColorComponentFlagBits::eR | vk::ColorComponentFlagBits::eG | vk::ColorComponentFlagBits::eB | vk::ColorComponentFlagBits::eA }; vk::PipelineColorBlendStateCreateInfo colorBlendStateCreateInfo{ .logicOpEnable = vk::False, .attachmentCount = 1, .pAttachments = &colorBlendAttachmentState };
И, наконец, динамическое состояние конвейера. Его мы не используем.
В конце мы передаём наш vk::PipelineLayout и vk::RenderPass с номером подпрохода.
Запись команд. Рендеринг. Презентация
Заключительный этап - это запись команд в командный буфер и показ полученного изображения. Но для начала нам надо создать командные буферы.
Командные буферы
Командный буфер - это объект, который используется для записи команд в буфер. Все операции на GPU мы должны запланировать на очереди.
Для начала создадим пулл команд. Он используется для размещения командных буферов. В данном случае за выделение памяти для командных буферов отвечает драйвер устройства.
auto [createCommandPoolResult, uniqueCommandPool] = uniqueDevice->createCommandPoolUnique( vk::CommandPoolCreateInfo{ .queueFamilyIndex = graphicQueueIndex.first } ); if (createCommandPoolResult != vk::Result::eSuccess) { throw std::runtime_error("Failed to create command pool"); }
Для каждого семейства очередей мы создаём отдельный пулл команд.
Теперь из пулла выделим командные буферы.
auto [allocateCommandBuffersResult, uniqueCommandBuffer] = uniqueDevice->allocateCommandBuffersUnique( vk::CommandBufferAllocateInfo{ .commandPool = *uniqueCommandPool, .level = vk::CommandBufferLevel::ePrimary, .commandBufferCount = imageCount } ); if (allocateCommandBuffersResult != vk::Result::eSuccess) { throw std::runtime_error("Failed to allocate command buffers"); }
Мы выделяем столько командных буферов, сколько всего изображений цепочки обмена. Мы выделяем только первичные командные буферы.
А теперь заполним наши буферы.
vk::ClearValue clearValue{ .color = vk::ClearColorValue{std::array{0.0f, 0.0f, 0.0f, 1.0f}} }; for (uint32_t frameIndex = 0U; auto& commandBuffer : uniqueCommandBuffer) { if (auto beginCommandBufferResult = commandBuffer->begin(vk::CommandBufferBeginInfo{ .flags = vk::CommandBufferUsageFlagBits::eSimultaneousUse }); beginCommandBufferResult != vk::Result::eSuccess) { throw std::runtime_error("Failed to begin recording command buffer"); } commandBuffer->beginRenderPass( vk::RenderPassBeginInfo{ .renderPass = *uniqueRenderPass, .framebuffer = uniqueFramebuffers[frameIndex].get(), .renderArea = vk::Rect2D{ .offset = vk::Offset2D{ .x = 0, .y = 0}, .extent = swapchainExtent }, .clearValueCount = 1, .pClearValues = &clearValue }, vk::SubpassContents::eInline ); commandBuffer->bindPipeline(vk::PipelineBindPoint::eGraphics, *uniqueGraphicsPipeline); commandBuffer->bindVertexBuffers(0, {*uniqueVertexBuffer}, {0}); commandBuffer->draw(triangleVertices.size() / 5, 1, 0, 0); commandBuffer->endRenderPass(); if (auto endCommandBufferResult = commandBuffer->end(); endCommandBufferResult != vk::Result::eSuccess) { throw std::runtime_error("Failed to end recording command buffer"); } frameIndex++; }
Вверху мы объявляем значение для очистки аттачментов. Сначала мы "открываем" командный буфер. Флаг, который мы передаём в команду, означает то, что буфер может переиспользоваться.
Далее команда, начинающая проход рендеринга. В структуре vk::RenderPassBeginInfo мы передаём объект прохода рендеринга, текущий фреймбуфер и область для отрисовки. В конце мы передаём все значения для очистки аттачментов. Ещё один параметр вызова vk::SubpassContents указывает на то, где поставляются команды. В данном случае, они записаны в первичном командном буфере.
Следующая команда - это привязка графического конвейера.
Далее привязка вершинного буфера. Первый параметр - это номер первой привязки, далее - массив вершинных буферов. В конце смещения относительно начала буфера.
Затем следует команда рисования. Мы передаём количество вершин, количество инстансов, номер первой вершины и номер первого инстанса. В данного случае мы не используем инстансинг.
Последние две команды - это завершение рендеринга и "закрытие" командного буфера.
Делаем цикл смены кадров
Сначала создадим все нужные примитивы синхронизации
uint32_t currentFrame = 0U; std::vector<vk::UniqueSemaphore> imageAvailableSemaphores{}; std::vector<vk::UniqueSemaphore> renderFinishedSemaphores{}; std::vector<vk::UniqueFence> inFlightFences{}; imageAvailableSemaphores.reserve(imageCount); renderFinishedSemaphores.reserve(imageCount); inFlightFences.reserve(imageCount); for (uint32_t i = 0; i < imageCount; i++) { auto [createImageAvailableSemaphoreResult, imageAvailableSemaphore] = uniqueDevice->createSemaphoreUnique({}); if (createImageAvailableSemaphoreResult != vk::Result::eSuccess) { throw std::runtime_error("Failed to create image available semaphore"); } imageAvailableSemaphores.push_back(std::move(imageAvailableSemaphore)); auto [createRenderFinishedSemaphoreResult, renderFinishedSemaphore] = uniqueDevice->createSemaphoreUnique({}); if (createRenderFinishedSemaphoreResult != vk::Result::eSuccess) { throw std::runtime_error("Failed to create render finished semaphore"); } renderFinishedSemaphores.push_back(std::move(renderFinishedSemaphore)); auto [createInFlightFenceResult, inFlightFence] = uniqueDevice->createFenceUnique(vk::FenceCreateInfo{ .flags = vk::FenceCreateFlagBits::eSignaled }); if (createInFlightFenceResult != vk::Result::eSuccess) { throw std::runtime_error("Failed to create in-flight fence"); } inFlightFences.push_back(std::move(inFlightFence)); }
Примитивы синхронизации позволяют организовать правильный порядок операций на GPU. Мы используем заграждение, чтобы дождаться окончания рендеринга. Мы используем один семафор для ожидания доступного изображения и другой для ожидания окончания рендеринга. Их количество равно количеству кадров.
Объявим также вспомогательные переменные
uint32_t currentFrame = 0U; vk::PipelineStageFlags pipelineStageFlags = vk::PipelineStageFlagBits::eColorAttachmentOutput;
Первая переменная счётчик для текущего номера кадра. Вторая используется для отправки командного буфера в очередь.
Теперь добавим в цикл операции рендеринга треугольника.
if (auto waitFenceResult = uniqueDevice->waitForFences(*inFlightFences[currentFrame], vk::True, UINT64_MAX); waitFenceResult != vk::Result::eSuccess) { throw std::runtime_error("Failed to wait for fence"); } if (auto resetFenceResult = uniqueDevice->resetFences(*inFlightFences[currentFrame]); resetFenceResult != vk::Result::eSuccess) { throw std::runtime_error("Failed to reset fence"); } auto [acquireNextImageResult, currentImageIndex] = uniqueDevice->acquireNextImageKHR(*uniqueSwapchain, UINT64_MAX, *imageAvailableSemaphores[currentFrame], nullptr); if (acquireNextImageResult != vk::Result::eSuccess) { throw std::runtime_error("Failed to acquire next image"); } auto submitResult = graphicQueue.submit( { vk::SubmitInfo{ .waitSemaphoreCount = 1, .pWaitSemaphores = &imageAvailableSemaphores[currentFrame].get(), .pWaitDstStageMask = new vk::PipelineStageFlags{vk::PipelineStageFlagBits::eColorAttachmentOutput}, .commandBufferCount = 1, .pCommandBuffers = &uniqueCommandBuffer[currentImageIndex].get(), .signalSemaphoreCount = 1, .pSignalSemaphores = &renderFinishedSemaphores[currentFrame].get() } }, inFlightFences[currentFrame].get()); if (submitResult != vk::Result::eSuccess) { throw std::runtime_error("Failed to submit draw command buffer"); } auto presentResult = presentationQueue.presentKHR( vk::PresentInfoKHR{ .waitSemaphoreCount = 1, .pWaitSemaphores = &renderFinishedSemaphores[currentFrame].get(), .swapchainCount = 1, .pSwapchains = &uniqueSwapchain.get(), .pImageIndices = ¤tImageIndex } ); if (presentResult != vk::Result::eSuccess) { throw std::runtime_error("Failed to present swapchain image"); } currentFrame = currentFrame + 1 < imageCount ? currentFrame + 1 : 0;
При отрисовке и отображении треугольника происходит здесь следующее. Ожидаем когда будет просигнален fence для начала рендеринга графики. Затем мы тут же его сбрасываем. Следующий вызов - получение индекса следующего изображения. Мы передаём семафор imageAvailableSemaphore для того, чтобы обеспечить строгую последовательность: сначала получи изображение, а затем рисуй туда. Далее вызов submit. Мы передаём семафор для ожидания изображения и семафор renderFinishedSemaphore для того, чтобы просигналить следующую операцию о том, что изображение отрисовано и готово для показа. Для синхронизации GPU и CPU мы передаём в submit fence для того, чтобы повторить отрисовку кадра.
Момент истины. Мы компилируем и вуаля! Наш треугольник готов!!!

Заключение
Как мы видим, vulkan API довольно-таки непрост для так называемого "hello, world!". Статья вышла довольно большой, но это не удивительно. В мире Vulkan мы должны произвести явную доскональную настройку, начиная от создания vk::Instance, заканчивая отправкой в командный буфер. Также стоит обратить внимание на то, сколько тем было задействовано при описании простейшей программы aka "Hello, Triangle!". В следующей статье мы попытаемся нарисовать текстурированный куб. Мы познакомимся с таким понятием, как дескрипторы, изображения, сэмплеры и операции перемещения. Также мы немного изменим цикл рендеринга.
