Vulkan Learning — 3. Swapchain. Depth Buffer. Render Pass. Framebuffers

Речь пойдет об инфраструктуре предназначенной сугубо для отрисовки и отображения графики на экране. Напомним, что отрисовка изображения суть присваивание значений всем его пикселям. Изображение хранится в оперативной памяти, а, будучи отрисованным, отображается на экране монитора. Участок памяти, в котором хранится изображение, часто называется фреймбуфером. Традиционно используется как минимум два фреймбуфера — один из них отображается на экране а то время как GPU рисует изображение в другом, после чего фреймбуферы меняются местами (swap). Это называется двойная (потому что буферов два) буферизация. Однако буферизация может быть не только двойная, но и тройная, и четверная и т. д. В Vulkan весь описанный выше процесс «кручения» фреймбуферов на карусели «отрисовка-отображение» обобщен в понятии Swapchain.

Swapchain

Swapchain представляет собой набор изображений или, другими словами, буферов цвета (color buffers), а также метод (presentation mode), при помощи которого эти изображения крутятся на «карусели». Методов этих существует несколько, о них хорошо написал в своей брошюре Pavel Lapinski. Наиболее практичными являются два метода: FIFO и MAILBOX. Причем, только FIFO гарантированно поддерживается во всех реализациях Vulkan, но MAILBOX предпочтительнее, если только он поддерживается. На рис. 1 показан метод FIFO. MAILBOX очень похож, поэтому я не стал его рисовать.

Рис. 1 — Swapchain, FIFO

Таким образом, при создании Swapchain’а, мы должны указать следующее:

  • формат изображений (формат пикселя)
  • длину и ширину каждого изображения
  • количество изображений
  • метод «кручения на карусели» (presentation mode)

Все эти параметры зависят от нашей поверхности (Surface). Впрочем, значения последних двух мы вольны выбирать из потенциально нескольких вариантов, которые поддерживаются поверхностью.

// THE FOLLOWING IS PSEUDOCODE!

// Field
vk::SwapchainKHR m_Swapchain;

// Constructor
m_Swapchain(CreateSwapchain(width, height))

// Function
vk::SwapchainKHR CreateSwapchain(
    uint32_t width,
    uint32_t height)
{
    // 1. Choose the best surface format
    auto surfaceFormats = m_PhysicalDevice.getSurfaceFormatsKHR(m_Surface);
    if (surfaceFormats.empty())
        throw VkException(FUNCTION_INFO);
    vk::SurfaceFormatKHR surfaceFormat = surfaceFormats[0];
    if (surfaceFormats.size() == 1 && surfaceFormats[0].format == vk::Format::eUndefined)
    {   // This means that all formats are available
        surfaceFormat =
        {
            // Format specifies the bit layout of a color value in memory
            // (eR8G8B8A8Unorm: 32-bit unsigned normalized format that has
            // an 8-bit R component in byte 0, an 8-bit G component in byte 1,
            // an 8-bit B component in byte 2, and an 8-bit A component in byte 3).
            // Unsigned normalized format maps [0...255] integer to [0.0 ... 1.0] float.
            vk::Format::eR8G8B8A8Unorm,

            // Color space specifies the mapping between the RGB component values
            // and the human-perceived color (SRGB color space is commonly used in computer displays)
            vk::ColorSpaceKHR::eSrgbNonlinear
        };
    }
    else
    {   // Not all formats are available
        auto searchFmt = std::find_if(
            surfaceFormats.cbegin(),
            surfaceFormats.cend(),
            [](const vk::SurfaceFormatKHR& fmt)
            {
                return (fmt.format == vk::Format::eR8G8B8A8Unorm
                    || fmt.format == vk::Format::eB8G8R8A8Unorm)
                    && fmt.colorSpace == vk::ColorSpaceKHR::eSrgbNonlinear;
            });
        if (searchFmt != surfaceFormats.cend())
            surfaceFormat = *searchFmt;
    }

    // 2. Choose the best presentation mode
    auto presentModes = m_PhysicalDevice.getSurfacePresentModesKHR(m_Surface);
    if (presentModes.empty())
        throw VkException(FUNCTION_INFO);
    vk::PresentModeKHR presentMode = vk::PresentModeKHR::eFifo; // FIFO presentation mode (no tearing, possible input lag) is the only one that is guaranteed to be available
    auto searchPmd = std::find(
        presentModes.cbegin(),
        presentModes.cend(),
        vk::PresentModeKHR::eMailbox); // Another good option is MAILBOX: no tearing and input lag is minimized. Makes sense only if there are at least 3 images in the swap chain.
    if (searchPmd != presentModes.cend())
        presentMode = *searchPmd;

    // Acquired capabilities contain important information about ranges (limits) that
    // are supported by the swap chain, that is, minimal and maximal number of images,
    // minimal and maximal dimensions of images, or supported transforms (some platforms
    // may require transformations applied to images before these images may be presented).
    // https://software.intel.com/content/www/us/en/develop/articles/api-without-secrets-introduction-to-vulkan-part-2.html
    auto surfaceCapabilities = m_PhysicalDevice.getSurfaceCapabilitiesKHR(m_Surface);

    // 3. Choose the best swap chain image resolution
    // If currentExtent.(width|height) == uint32_t::max this means that window
    // subsystem allows us to set any width and height for our swapchain within
    // [minImageExtent...maxImageExtent] boundaries
    vk::Extent2D swapExtent =
        surfaceCapabilities.currentExtent.width != (std::numeric_limits<uint32_t>::max)() ?
        surfaceCapabilities.currentExtent : vk::Extent2D{ width, height };
    // Surface defines max and min size, so make sure within boundaries by clamping value
    swapExtent.width = util::constraint(swapExtent.width, surfaceCapabilities.minImageExtent.width, surfaceCapabilities.maxImageExtent.width);
    swapExtent.height = util::constraint(swapExtent.width, surfaceCapabilities.minImageExtent.height, surfaceCapabilities.maxImageExtent.height);

    // 4. Create swap chain createinfo
    vk::SwapchainCreateInfoKHR createInfo
    {
        .surface = m_Surface,

        // An application may request more images. If it wants to use multiple images at
        // once it may do so, for example, when encoding a video stream where every fourth
        // image is a key frame and the application needs it to prepare the remaining three
        // frames. Such usage will determine the number of images that will be automatically
        // created in a swap chain: how many images the application requires at once for
        // processing and how many images the presentation engine requires to function properly.
        // https://software.intel.com/content/www/us/en/develop/articles/api-without-secrets-introduction-to-vulkan-part-2.html
        .minImageCount = surfaceCapabilities.maxImageCount > 0 ? // maxImageCount = 0 means that there's no maximum
            (std::min)(surfaceCapabilities.minImageCount + 1, surfaceCapabilities.maxImageCount) :
            surfaceCapabilities.minImageCount + 1,

        .imageFormat = surfaceFormat.format,
        .imageColorSpace = surfaceFormat.colorSpace,
        .imageExtent = swapExtent,
        .imageArrayLayers = 1, // specifies the amount of layers each image consists of. This is always 1 unless you are developing a stereoscopic 3D application.

        // Usage flags define how a given image may be used in Vulkan. If we want an image to be
        // sampled (used inside shaders) it must be created with "sampled" usage. If the image
        // should be used as a depth render target, it must be created with "depth and stencil" usage.
        // An image without proper usage "enabled" cannot be used for a given purpose or the results
        // of such operations will be undefined.
        // For a swap chain we want to render(in most cases) into the image(use it as a render target),
        // so we must specify “color attachment” usage with VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT enum.
        // In Vulkan this usage is always available for swap chains, so we can always set it without
        // any additional checking. But for any other usage we must ensure it is supported – we can do
        // this through a "supportedUsageFlags" member of surface capabilities structure.
        // https://software.intel.com/content/www/us/en/develop/articles/api-without-secrets-introduction-to-vulkan-part-2.html
        .imageUsage = vk::ImageUsageFlagBits::eColorAttachment | vk::ImageUsageFlagBits::eTransferDst, // just color, no depth; "transfer destination" usage is required for image clear operation

        .preTransform = surfaceCapabilities.currentTransform, // we can specify that a certain transform should be applied to images in the swap chain if it is supported
        .compositeAlpha = vk::CompositeAlphaFlagBitsKHR::eOpaque, // no alpha blending (specifies if the alpha channel should be used for blending with other windows in the window system)
        .presentMode = presentMode,
        .clipped = VK_TRUE // If set to VK_TRUE then that means that we don't care about the color of pixels that are obscured, for example because another window is in front of them.
    };

    // 5. If graphics and presentation families are different, then swapchain must let images be shared between queue families
    uint32_t queuFamilyIndices[]
    {
        m_GraphicsQueueFamilyIndex,
        m_PresentationQueueFamilyIndex
    };
    if (m_GraphicsQueueFamilyIndex != m_PresentationQueueFamilyIndex)
    {
        // If we want to reference images from many different queue families at a time we can do so.
        // In this case we must provide "concurrent" sharing mode. But this (probably) requires us to
        // manage image data coherency by ourselves, that is, we must synchronize different queues in
        // such a way that data in the images is proper and no hazards occur - some queues are reading
        // data from images, but other queues haven't finished writing to them yet.
        // We may not specify these queue families and just tell Vulkan that only one queue family
        // (queues from one family) will be referencing image at a time. This doesn't mean other queues
        // can't reference these images. It just means they can't do it all at once, at the same time.
        // So if we want to reference images from one family and then from another we must specifically
        // tell Vulkan: "My image was used inside this queue family, but from now on another family,
        // this one, will be referencing it." Such a transition is done using image memory barrier.
        // https://software.intel.com/content/www/us/en/develop/articles/api-without-secrets-introduction-to-vulkan-part-2.html

        // Images can be used across multiple queue families without explicit ownership transfers.
        createInfo.imageSharingMode = vk::SharingMode::eConcurrent;
        // Concurrent mode requires you to specify in advance between which queue families ownership will be shared.
        createInfo.queueFamilyIndexCount = sizeof(queuFamilyIndices) / sizeof(queuFamilyIndices[0]);
        createInfo.pQueueFamilyIndices = queuFamilyIndices;
    }
    else
    {
        // An image is owned by one queue family at a time and ownership must be explicitly transferred
        // before using it in another queue family. This option offers the best performance.
        createInfo.imageSharingMode = vk::SharingMode::eExclusive;
        createInfo.queueFamilyIndexCount = 0;
        createInfo.pQueueFamilyIndices = nullptr;
    }

    // 6. With Vulkan it's possible that your swap chain becomes invalid or unoptimized while your application
    // is running, for example because the window was resized. In that case the swap chain actually needs to be
    // recreated from scratch and a reference to the old one must be specified in this field.
    createInfo.oldSwapchain = vk::SwapchainKHR(); // default vk::SwapchainKHR() plays a role of nullptr

    // 7. Create swapchain
    m_SwapChainImageFormat = createInfo.imageFormat;
    m_SwapChainExtent = swapExtent;
    return m_LogicalDevice.createSwapchainKHR(createInfo);
}

// Destructor
m_LogicalDevice.destroySwapchainKHR(m_Swapchain);

Где используется объект Swapchain. Во-первых, он используется при создании т. н. ImageViews, о которых речь пойдет далее. Во-вторых и в главных, Swapchain используется в методе отрисовки — в двух местах (см. рис. 1): Acquire Image — получить изображение, дабы нарисовать в нем что-то и Present Image — передать изображение для отображения на экране.

Swapchain Images & ImageViews

Достаточно внятное объяснение того, что такое ImageView, приведено в Vulkan Guide. Там сказано следующее:

Изображения — это еще один тип «хранилища», который есть в Vulkan, помимо буферов. В отличие от буферов, изображения сложнее из-за всей логики, которой они управляют, и их настроек. VkImage — это объект, который содержит фактические данные изображения. Он содержит пиксели и основную память изображения, но не содержит информации о том, как ее читать. VkImageView — это объект-обёртка вокруг VkImage. Он содержит информацию о том, как интерпретировать данные изображения, например, если вы хотите получить доступ только к участку изображения или слою (если изображение 3-хмерное), или если вы хотите перетасовать цветовые каналы определенным образом.

А на форуме reddit сравнивают ImageView с типом string_view из стандартной библиотеки языка C++ (про string_view можно почитать тут тут).

// THE FOLLOWING IS PSEUDOCODE!

// Field
std::vector<vk::ImageView> m_SwapchainImages;

// Constructor
m_SwapchainImages(CreateImageViews())

// Functions
std::vector<vk::ImageView> CreateImageViews()
{
    std::vector<vk::ImageView> imageViews;
    for (auto image : m_LogicalDevice.getSwapchainImagesKHR(m_Swapchain))
    {
        imageViews.push_back(
            {
                CreateImageView(
                    m_LogicalDevice,
                    image,
                    m_SwapChainImageFormat,
                    vk::ImageAspectFlagBits::eColor)
            });
    }
    return imageViews;
}

vk::ImageView CreateImageView(
    const vk::Device& logicalDevice,
    vk::Image image,
    vk::Format format,
    vk::ImageAspectFlags aspectFlags)
{
    vk::ImageViewCreateInfo viewCreateInfo
    {
        .image = image,
        .viewType = vk::ImageViewType::e2D, // Type of image (1D, 2D, 3D, Cube map, etc)
        .format = format,
        // The components field allows remapping of rgba components to other rgba values the
        // color channels around. For example, you can map all of the channels to the red channel
        // for a monochrome texture. You can also map constant values of 0 and 1 to a channel.
        .components = vk::ComponentMapping
        {
            .r = vk::ComponentSwizzle::eIdentity,
            .g = vk::ComponentSwizzle::eIdentity,
            .b = vk::ComponentSwizzle::eIdentity,
            .a = vk::ComponentSwizzle::eIdentity,
        },
        // The subresourceRange field describes what the image's purpose is and which part of the image should be accessed.
        .subresourceRange = vk::ImageSubresourceRange
        {
            .aspectMask = aspectFlags, // Which aspect of image to view (e.g. COLOR_BIT for viewing color)
            .baseMipLevel = 0,         // Start mipmap level to view from
            .levelCount = 1,           // Number of mipmap levels to view

            // For cube and cube array image views, the layers of the image view starting
            // at baseArrayLayer correspond to faces in the order +X, -X, +Y, -Y, +Z, -Z.
            .baseArrayLayer = 0,       // Start array layer to view from
            .layerCount = 1            // Number of array levels to view
        }
    };
    return logicalDevice.createImageView(viewCreateInfo);
}

// Destructor
for (auto& imageView : m_SwapchainImages)
    m_LogicalDevice.destroyImageView(imageView);

Кто ссылается на SwapchainImages. Только фреймбуферы (Framebuffers) — см. далее.

Depth Buffer (буфер глубины)

Мы создали Swapchain с его набором буферов цвета, но для создания трехмерной реальности нам понадобится как минимум еще один вид буфера — буфер глубины. Вообще, для представления чего-либо двумерного, будь то буфер цвета, текстура или буфер глубины — в Vulkan существует тип VkImage (а для ссылки на VkImage, как мы говорили существует тип VkImageView). Создание изображения распадается на несколько этапов:

  1. Создание объекта VkImage, имеющего определенный формат
  2. Выделения памяти для хранения изображения
  3. Связывание объекта VkImage с выделенной памятью
  4. Создание объекта VkImageView

Эти вещи я решил поместить в отдельный класс, который назвал VkImage2d:

class VkImage2d
{
private:
    vk::Image m_Image;
    vk::DeviceMemory m_ImageMemory;
    vk::ImageView m_ImageView;

public:
    VkImage2d(
        uint32_t width,
        uint32_t height,
        vk::Format format,
        vk::ImageTiling tiling,
        vk::ImageUsageFlags useFlags,
        vk::MemoryPropertyFlags memoryPropFlags,
        vk::ImageAspectFlags aspectFlags)
    {
        // CREATE IMAGE STRUCTRE
        m_Image = logicalDevice.createImage(
            vk::ImageCreateInfo
            {
                ...
                .format = format,
                .extent = vk::Extent3D {width, height, 1u},
                .tiling = tiling,
                .usage = useFlags,
                ...
            });

        // ALLOCATE MEMORY
        m_ImageMemory = logicalDevice.allocateMemory(...);

        // BIND IMAGE AND MEMORY
        logicalDevice.bindImageMemory(m_Image, m_ImageMemory, ...);

        // CREATE IMAGE VIEW
        m_ImageView = CreateImageView(..., m_Image, format, aspectFlags);
    }

    ~VkImage2d()
    {
        logicalDevice.destroyImageView(m_ImageView);
        logicalDevice.destroyImage(m_Image);
        logicalDevice.freeMemory(m_ImageMemory);
    }
}

Форматы изображений

Изображение должно иметь определенный формат пикселя. Часто для разных сценариев использования изображения существуют разные форматы: скажем, один формат предназначается только для изображения, используемого для буфера цвета, а другой — только для буфера глубины. Например в моем проекте упоминаются по крайней мере четыре формата пикселя:

eR8G8B8A8Unorm A four-component, 32-bit unsigned normalized format that has an 8-bit R component in byte 0, an 8-bit G component in byte 1, an 8-bit B component in byte 2, and an 8-bit A component in byte 3
eD32Sfloat A one-component, 32-bit signed floating-point format that has 32 bits in the depth component
eD32SfloatS8Uint A two-component format that has 32 signed float bits in the depth component and 8 unsigned integer bits in the stencil component. There are optionally 24 bits that are unused
eD24UnormS8Uint A two-component, 32-bit packed format that has 8 unsigned integer bits in the stencil component, and 24 unsigned normalized bits in the depth component

Помимо того, что не каждый формат годится для использования в определенном буфере (цвета, глубины и пр.), не каждый формат поддерживается физическим прибором (GPU). Таким образом, при создании буфера (например, буфера глубины) мы должны выбрать адекватный формат пикселя из ряда потенциальных кандидатов. Поиск формата можно записать в виде нескольких функций:

// Looks among specified candidates for am image format
// that supports specified tiling and usage.
vk::Format FindSupportedFormat(
    const vk::PhysicalDevice& physicalDevice,
    const std::vector<vk::Format>& candidates,
    vk::ImageTiling tiling,
    vk::FormatFeatureFlags features)
{
    for (vk::Format format : candidates)
    {
        auto props = physicalDevice.getFormatProperties(format);

        switch (tiling)
        {
        case vk::ImageTiling::eOptimal:
            if ((props.optimalTilingFeatures & features) == features)
                return format;
            break;

        case vk::ImageTiling::eLinear:
            if ((props.linearTilingFeatures & features) == features)
                return format;
            break;
        }
    }
    throw VkException(FUNCTION_INFO);
}

vk::Format FindDepthFormat(
    const vk::PhysicalDevice& physicalDevice)
{
    return FindSupportedFormat(
        physicalDevice,
        { vk::Format::eD32Sfloat, vk::Format::eD32SfloatS8Uint, vk::Format::eD24UnormS8Uint },
        vk::ImageTiling::eOptimal,
        vk::FormatFeatureFlagBits::eDepthStencilAttachment);
}

vk::Format FindColorBufferFormat(
    const vk::PhysicalDevice& physicalDevice)
{
    return FindSupportedFormat(
        physicalDevice,
        { vk::Format::eR8G8B8A8Unorm },
        vk::ImageTiling::eOptimal,
        vk::FormatFeatureFlagBits::eColorAttachment);
}

Выделение памяти

Вопрос выделения памяти немного нетривиален. Дело в том, что наш прибор (GPU) может поддерживать несколько видов памяти (например, на моем ПК их оказалось пять). Информацию о них можно получить вызовом physicalDevice.getMemoryProperties(). Каждый вид памяти идентифицируется целочисленным индексом от 0 до 31 и характеризуется комбинацией присущих ему свойств, представленных флагами VkMemoryPropertyFlags. Упомянем только три самые понятные из них:

VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT Доступ GPU к памяти с таким свойством наиболее эффективен
VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT Память с таким свойством может быть спроецирована на адресное пространство CPU для чтения и записи
VK_MEMORY_PROPERTY_HOST_COHERENT_BIT Для памяти с таким свойством не требуются команды vkFlushMappedMemoryRanges и vkInvalidateMappedMemoryRanges для поддержки когерентности между CPU и GPU

Оказывается, что память для изображения может быть выделена лишь из определенных типов памяти, поддерживаемых GPU. Узнать из каких именно можно вызовом logicalDevice.getImageMemoryRequirements(m_Image). Помимо этого к памяти для изображения нами может предъявляться требование поддержки каких-то из упомянутых свойств в таблице выше (в коде выше они представлены параметром memoryPropFlags). Таким образом, выделение памяти превращается в следующий код:

// ALLOCATE MEMORY
auto memoryRequirements = logicalDevice.getImageMemoryRequirements(m_Image);
m_ImageMemory = logicalDevice.allocateMemory(
    vk::MemoryAllocateInfo
    {
        .allocationSize = memoryRequirements.size,
        .memoryTypeIndex = FindMemoryTypeIndex(physicalDevice, memoryRequirements, memoryPropFlags)
    });

// Looks for the right memory index that fits the requirements.
// Graphics cards can offer different types of memory to allocate from. Each type
// of memory varies in terms of allowed operations and performance characteristics.
// We need to combine the requirements of the buffer and our own application requirements
// to find the right type of memory to use.
uint32_t FindMemoryTypeIndex(
    const vk::PhysicalDevice& physicalDevice,
    const vk::MemoryRequirements& bufferMemoryRequirements,
    vk::MemoryPropertyFlags appMemoryRequirements)
{
    // Get properties of physical device memory
    auto memoryProperties = physicalDevice.getMemoryProperties();

    auto allowedTypes = bufferMemoryRequirements.memoryTypeBits;

    for (uint32_t i = 0; i < memoryProperties.memoryTypeCount; i++)
    {
        if (// Index of memory type must match corresponding bit in allowedTypes
            (allowedTypes & (1 << i)) &&
            // Desired property bit flags must be part of memory type's property flags
            ((memoryProperties.memoryTypes[i].propertyFlags & appMemoryRequirements) == appMemoryRequirements))
        {
            return i;
        }
    }
    throw VkException(FUNCTION_INFO);
}

Но вернемся к буферу глубины. Он собой тоже представляет «изображение», но изображение специфическое — каждый пиксель которого содержит не цветовые каналы, а значение глубины (если вы знакомы с компьютерной графикой, то вам не нужно объяснять, что это такое). Поэтому нам осталось вызвать конструктор класса VkImage2d со специфичными для буфера глубины параметрами, для чего я создал следующую функцию:

inline VkImage2d CreateDepthBuffer(
    const vk::PhysicalDevice& physicalDevice,
    const vk::Device& logicalDevice,
    const vk::Extent2D& imageExtent,
    vk::ImageUsageFlags usage = vk::ImageUsageFlagBits{})
{
    return VkImage2d(
        physicalDevice,
        logicalDevice,
        imageExtent.width,
        imageExtent.height,
        /*format*/ FindDepthFormat(physicalDevice),
        /*tiling*/ vk::ImageTiling::eOptimal,
        /*useFlags*/ vk::ImageUsageFlagBits::eDepthStencilAttachment | usage,
        /*propFlags*/ vk::MemoryPropertyFlagBits::eDeviceLocal,
        /*aspectFlags*/ vk::ImageAspectFlagBits::eDepth);
}

Кто ссылается на DepthBuffer (посредством его ImageView). Только фреймбуферы (Framebuffers) — см. далее.

RenderPass

RenderPass — это очередная абстракция, которая, однако, является нововведением Vulkan. Объект RenderPass описывает то, что называется multipass rendering — отрисовка в несколько проходов. Multipass rendering используется, например, для отрисовки теней либо для создания т. н. эффектов постпроцессинга. Но в простейшем случае для отрисовки достаточно одного прохода. Тем не менее, и этот один проход должен быть описан в структуре RenderPass. Какую же информацию она содержит?

Multipass rendering, как уже говорилось, состоит из нескольких проходов (subpass). Каждый проход — это запуск пайплайна (pipeline), т. е. программы, состоящей из ряда фиксированных функций (vertex assembly, clipping, rasterization и пр.) и программируемых функций, то бишь шейдеров. Результатом прохода является заполнение (отрисовка) какого либо буфера или буферов (цвета, глубины и пр.). Возьмем например shadow mapping: он осуществляется в два прохода. В 1-ом проходе заполняется «теневой» буфер глубины, во 2-ом проходе выполняется чтение из этого «теневого» буфера глубины (как из текстуры) и заполняются буфер цвета и «обычный» буфер глубины. Таким образом, каждый проход (subpass) характеризуется следующей информацией:

  • Набор буферов, из которых он читает (input attachments)
  • Набор буферов, в которые он пишет (color attachments, depth/stencil attachments)
  • Pipeline, который запускается и, собственно, выполняет вышеупомянутые два пункта

Вот эта информация и описывается в структуре RenderPass. Для чего городить данный огород — описано в спецификации Vulkan:

By describing a complete set of subpasses in advance, render passes provide the implementation an opportunity to optimize the storage and transfer of attachment data between subpasses.
In practice, this means that subpasses with a simple framebuffer-space dependency may be merged into a single tiled rendering pass, keeping the attachment data on-chip for the duration of a render pass instance. However, it is also quite common for a render pass to only contain a single subpass.

Отдельные стадии различных subpass’ов могут выполняться GPU параллельно, что невозможно например в OpenGL. Но это вносит дополнительную сложность, связанную с необходимостью синхронизации subpass’ов (аналогично тому, как синхронизируются потоки в программах) — это делается путем описания т. н. зависимостей между subpass’ами (subpass dependencies). По сути это означает, что для каждой пары последовательно идущих subpass’ов вы должны описать, какие операции и на какой стадии пайплайна выполняет 1-й subpass над своими выходными attachment’ами, из которых читает 2-й subpass; и то, какие операции и на какой стадии пайплайна выполняет 2-й subpass над своими входными attachment’ами, которые предоставляет ему 1-й subpass. Зависимости между subpass’ами описываются при помощи массива структур vk::SubpassDependency. Заметим, что каждая стадия пайплайна поддерживает лишь ограниченный набор операций.

Еще одна сложность связана с тем, что каждый attachment может менять свой layout при переходе от одного subpass’а к другому. Поэтому для каждого subpass’а должен быть указан layout каждого attachment’а, который тот должен иметь во время выполнения данного subpass’а. Если взять тот же пример с shadow mapping, то в нем во время 1-ого subpass’а «теневой» буфер глубины должен иметь layout под названием DepthStencilAttachmentOptimal (оптимальный для записи глубины), а во время 2-ого subpass’а тот же самый буфер должен иметь layout ShaderReadOnlyOptimal (оптимальный для чтения в шейдере). Переходы между layout’ами выполняются автоматически.

А теперь плохие новости:

… fragments for pixel (x,y,layer) in one subpass can only read attachment contents written by previous subpasses at the same (x,y,layer) location

Это значит, что shadow mapping одним RenderPass’ом мы все-таки сделать не сможем, поскольку shadow mapping’у необходимо читать произвольные фрагменты из «теневого» буфера глубины, а нам разрешено читать только фрагмент с теми же координатами, что и наш текущий фрагмент в буфере цвета. Ну да ничего, зато эффекты постпроцессинга мы реализовать сможем.

Каждый subpass идентифицируется целочисленным индексом (0, 1, 2 и т. д.). Однако существует некий «псевдоsubpass», идентифицируемый константой VK_SUBPASS_EXTERNAL. Согласно спецификации, этот псевдоsubpass includes commands that occur earlier in submission order than the vkCmdBeginRenderPass used to begin the render pass instance или includes commands that occur later in submission order than the vkCmdEndRenderPass used to end the render pass instance. Так или иначе, этот subpass должен фигурировать в 1-й и в последней зависимости массива subpass dependencies.

Итак, приведем функцию для создания простейшего RenderPass’а с двумя attachment’ами (color buffer & depth buffer) и одним subpass’ом:

// THE FOLLOWING IS PSEUDOCODE!

// Field
vk::RenderPass m_RenderPass;

// Constructor
m_RenderPass(CreateRenderPassOneSubpass(
    m_VkGraphicsContext.LogicalDevice(),
    m_SwapChainImageFormat,
    depthBufferImageFormat))

// Function
inline vk::RenderPass CreateRenderPassOneSubpass(
    const vk::Device& logicalDevice,
    vk::Format colorImageFormat,
    vk::Format depthImageFormat)
{
    // --- ATTACHMENTS ---

    // Color attachment of the render pass
    vk::AttachmentDescription colorAttachment
    {
        .format = colorImageFormat, // Format to use for attachment (here we are rendering directly into a swap chain so we need to take its format)
        .samples = vk::SampleCountFlagBits::e1,   // Number of samples to write for multisampling (we are not using any multisampling here so we just use one sample)
        .loadOp = vk::AttachmentLoadOp::eClear,   // What to do with the attached framebuffer before rendering
        .storeOp = vk::AttachmentStoreOp::eStore, // What to do with the attached framebuffer after rendering

        // When an attachment has a depth format (and potentially also a stencil component) load and store ops refer
        // only to the depth component. If a stencil is present, stencil values are treated the way stencil load and
        // store ops describe. For color attachments, stencil ops are not relevant.
        //.stencilLoadOp = vk::AttachmentLoadOp::eDontCare,   // What to do with the stencil buffer before rendering
        //.stencilStoreOp = vk::AttachmentStoreOp::eDontCare, // What to do with the stencil buffer after rendering

        // Framebuffer data will be stored as an image, but images can be given different data layouts
        // to give optimal use for certain operations.
        // Layout is an internal memory arrangement of an image. Image data may be organized in such a way
        // that neighboring "image pixels" are also neighbors in memory, which can increase cache hits
        // (faster memory reading) when image is used as a source of data (that is, during texture sampling).
        // But caching is not necessary when the image is used as a target for drawing operations, and the
        // memory for that image may be organized in a totally different way. Image may have linear layout
        // (which gives the CPU ability to read or populate image's memory contents) or optimal layout
        // (which is optimized for performance but is also hardware/vendor dependent). So some hardware may
        // have special memory organization for some types of operations; other hardware may be operations-
        // agnostic. Some of the memory layouts may be better suited for some intended image "usages."
        // Or from the other side, some usages may require specific memory layouts. There is also a general
        // layout that is compatible with all types of operations. But from the performance point of view,
        // it is always best to set the layout appropriate for an intended image usage and it is application's
        // responsibility to inform the driver about transitions.
        .initialLayout = vk::ImageLayout::eUndefined,  // INITIAL layout informs the hardware about the layout the application "provides" the given attachment with.
        .finalLayout = vk::ImageLayout::ePresentSrcKHR // The final layout is the layout the given attachment will be transitioned into (automatically) AT THE END of a render pass.
    };

    // Depth attachment of the render pass
    vk::AttachmentDescription depthAttachment
    {
        .format = depthImageFormat,       // Format to use for attachment (here we are rendering directly into a swap chain so we need to take its format)
        .samples = vk::SampleCountFlagBits::e1,      // Number of samples to write for multisampling (we are not using any multisampling here so we just use one sample)
        .loadOp = vk::AttachmentLoadOp::eClear,      // What to do with the attached framebuffer before rendering
        .storeOp = vk::AttachmentStoreOp::eDontCare, // What to do with the attached framebuffer after rendering

        // When an attachment has a depth format (and potentially also a stencil component) load and store ops refer
        // only to the depth component. If a stencil is present, stencil values are treated the way stencil load and
        // store ops describe.
        .stencilLoadOp = vk::AttachmentLoadOp::eDontCare,
        .stencilStoreOp = vk::AttachmentStoreOp::eDontCare,

        .initialLayout = vk::ImageLayout::eUndefined,                   // INITIAL layout informs the hardware about the layout the application "provides" the given attachment with.
        .finalLayout = vk::ImageLayout::eDepthStencilAttachmentOptimal  // The final layout is the layout the given attachment will be transitioned into (automatically) AT THE END of a render pass.
    };

    // --- REFERENCES ---
    // Every subpass references one or more of the attachments.
    // These references are themselves AttachmentReference structs.
    // There is a separation between a whole render pass and its subpasses because each subpass may use
    // multiple attachments in a different way, that is, in one subpass we are rendering into one color
    // attachment but in the next subpass we are reading from this attachment. In this way, we can prepare
    // a list of all attachments used in the whole render pass, and at the same time we can specify how
    // each attachment will be used in each subpass. And as each subpass may use a given attachment in
    // its own way, we must also specify each image’s layout for each subpass.
    // So before we can specify a description of all subpasses(an array with elements of type
    // VkSubpassDescription) we must create references for each attachment used in each subpass.
    // And this is what the color_attachment_references variable was created for.

    vk::AttachmentReference colorAttachmentRef
    {
        // specifies which attachment to reference by its index in the attachment descriptions array
        .attachment = 0,

        // Requested (required) layout the attachment will use DURING a given subpass.
        // The hardware will perform an automatic transition into a provided layout just
        // before a given subpass.
        .layout = vk::ImageLayout::eColorAttachmentOptimal
    };

    vk::AttachmentReference depthAttachmentRef
    {
        // specifies which attachment to reference by its index in the attachment descriptions array
        .attachment = 1,

        // Requested (required) layout the attachment will use DURING a given subpass.
        // The hardware will perform an automatic transition into a provided layout just
        // before a given subpass.
        .layout = vk::ImageLayout::eDepthStencilAttachmentOptimal
    };

    // --- SUBPASSES ---

    // Information about a particular subpass the render pass is using.
    // A subpass consists of drawing operations that use (more or less) the same attachments.
    // Each of these drawing operations may read from some input attachments and render data
    // into some other (color, depth, stencil) attachments.
    // Each subpass uses its own unique pipeline (the one currently bound to the pipelineBindPoint).
    vk::SubpassDescription subpass
    {
        // Type of pipeline in which this subpass will be used (graphics
        // or compute). Our example, of course, uses a graphics pipeline.
        .pipelineBindPoint = vk::PipelineBindPoint::eGraphics,

        // Array describing (pointing to) attachments which will be used
        // as color render targets (that image will be rendered into).
        // The index of the attachment in this array is directly referenced from the
        // fragment shader with the layout(location = 0) out vec4 outColor directive.
        .colorAttachmentCount = 1,
        .pColorAttachments = &colorAttachmentRef,

        // Unlike color attachments, a subpass can only use a single depth (+stencil) attachment.
        .pDepthStencilAttachment = &depthAttachmentRef
    };

    // When multiple subpasses are in use, the driver needs to be told the relationship between them. A subpass can depend
    // on operations which were submitted outside the current render pass, or be the source on which later rendering depends.
    // Most commonly, the need is to ensure that the fragment shader from an earlier subpass has completed rendering (to the
    // current tile, on a tiler) before the next subpass starts to try to read that data.
    // https://developer.samsung.com/galaxy-gamedev/resources/articles/renderpasses.html
    std::array<vk::SubpassDependency, 2> subpassDependencies
    {
        // External --> Subpass 0
        vk::SubpassDependency
        {
            .srcSubpass = VK_SUBPASS_EXTERNAL, // The subpass index of the 1st subpass in the dependency. Subpass index (VK_SUBPASS_EXTERNAL = special value meaning outside of renderpass).
            .dstSubpass = 0, // The subpass index of the 2nd subpass in the dependency

            // The synchronization scope of the 1st set of commands. What pipeline stage must have completed for the dependency
            .srcStageMask = vk::PipelineStageFlagBits::eBottomOfPipe, // eBottomOfPipe specifies all operations performed by all commands supported on the queue it is used with
            // The synchronization scope of the 2nd set of commands. What pipeline stage is waiting on the dependency
            .dstStageMask = vk::PipelineStageFlagBits::eColorAttachmentOutput | vk::PipelineStageFlagBits::eEarlyFragmentTests,

            // Memory access scope of the 1st set of commands
            .srcAccessMask = vk::AccessFlagBits::eNoneKHR,
            // Memory access scope of the 2nd set of commands
            .dstAccessMask = vk::AccessFlagBits::eColorAttachmentWrite | vk::AccessFlagBits::eDepthStencilAttachmentWrite
        },
        // Subpass 0 --> External
        vk::SubpassDependency
        {
            .srcSubpass = 0,  // The subpass index of the 1st subpass in the dependency
            .dstSubpass = VK_SUBPASS_EXTERNAL, // The subpass index of the 2nd subpass in the dependency. Subpass index (VK_SUBPASS_EXTERNAL = special value meaning outside of renderp

                // The synchronization scope of the 1st set of commands. What pipeline stage must have completed for the dependency
            .srcStageMask = vk::PipelineStageFlagBits::eColorAttachmentOutput | vk::PipelineStageFlagBits::eEarlyFragmentTests,
            // The synchronization scope of the 2nd set of commands. What pipeline stage is waiting on the dependency
            .dstStageMask = vk::PipelineStageFlagBits::eTopOfPipe, // eTopOfPipe specifies all operations performed by all commands supported on the queue it is used with

            // Memory access scope of the 1st set of commands
            .srcAccessMask = vk::AccessFlagBits::eColorAttachmentWrite | vk::AccessFlagBits::eDepthStencilAttachmentWrite,
            // Memory access scope of the 2nd set of commands
            .dstAccessMask = vk::AccessFlagBits::eNoneKHR,
        },
    };

    // --- RENDER PASS ---

    std::array<vk::AttachmentDescription, 2> attachments
    {
        colorAttachment, // 0 (position is important, because AttachmentReferences refer to it)
        depthAttachment  // 1 (position is important, because AttachmentReferences refer to it)
    };

    vk::RenderPassCreateInfo renderPassCreateInfo
    {
        .attachmentCount = attachments.size(), // Number of all different attachments (elements in pAttachments array) used during whole render pass
        .pAttachments = attachments.data(),    // Array specifying all attachments used in a render pass
        .subpassCount = 1,      // Number of subpasses a render pass consists of
        .pSubpasses = &subpass, // Array with descriptions of all subpasses
        .dependencyCount = subpassDependencies.size(),
        .pDependencies = subpassDependencies.data()
    };

    return logicalDevice.createRenderPass(renderPassCreateInfo);
}

// Destructor
logicalDevice.destroyRenderPass(m_RenderPass);

Кто ссылается на RenderPass. Фреймбуферы (см. ниже) и пайплайн (vk::Pipeline).

Framebuffers

Итак, мы имеем Swapchain, состоящий из нескольких буферов цвета. Сколько нам потребуется буферов глубины? Столько же, сколько изображений у нас в свопчейне — потому, что каждое из этих изображений мы отрисовываем в общем случае параллельно, и в этой отрисовке задействован буфер глубины, в который в ходе отрисовки производится и чтение, и запись. Если у вас потенциально есть N параллельных потоков (в GPU), которые пишут и читают буфер глубины, то лучше бы вам иметь N отдельных буферов глубины. Сказанное относится не только к буферу глубины, но и к любым ресурсам (буфер цвета, uniform buffer), которые не являются read-only.

Мы также знаем, что наш RenderPass использует ровно два attachment’а: один из них — буфер цвета (ColorAttachmentOutput), второй — буфер глубины (DepthAttachment). Заметим, что RenderPass никоим образом не ссылается ни на изображения из Swapchain’а, ни на наши DepthBuffer’ы — он только специфицирует то, какого рода attachment’ы ему нужны и сколько их. Каким же образом GPU узнает, какие конкретно буферы надо будет «приattach’ить» к RenderPass’у. А вот как раз ссылки на эти буферы и хранят структуры под названием vk::Framebuffer (под ссылками мы понимаем объекты типа vk::ImageView). Сколько же будет таких фреймбуферов? Конечно столько же, столько изображений содержит Swapchain. Каждый фреймбуфер будет содержать ссылку на соответствующее изображение из Swapchain и соответствующий буфер глубины (Рис. 2). Просто для примера: если бы мы использовали всего один буфер глубины, то наши фреймбуферы выглядели бы так, как показано на Рис. 3.

Рис. 2 — Фреймбуферы: много буферов цвета и много буферов глубины
Рис. 3 — Фреймбуферы: много буферов цвета и один буфер глубины

Итак, вот последняя в этой заметке функция для создания фреймбуферов:

// THE FOLLOWING IS PSEUDOCODE!

// Field
std::vector<vk::Framebuffer> m_Framebuffers;

// Constructor
m_Framebuffers(CreateFramebuffers())

// Function
std::vector<vk::Framebuffer> CreateFramebuffers()
{
    std::vector<vk::Framebuffer> framebuffers;
    framebuffers.reserve(m_SwapchainImages.size());
    for (size_t i = 0; i < m_SwapchainImages.size(); i++)
    {
        std::array<vk::ImageView, 2> attachments
        {
            m_SwapchainImages[i],         // 0 (position is important - it should match that of the render pass in vk::RenderPassCreateInfo::pAttachments)
            m_DepthBuffers[i].ImageView() // 1 (position is important - it should match that of the render pass in vk::RenderPassCreateInfo::pAttachments)
        };

        vk::FramebufferCreateInfo framebufferCreateInfo
        {
            // Render Pass with which the framebuffer needs to be compatible. You can only use a framebuffer
            // with the render passes that it is compatible with, which roughly means that they use the same
            // number and type of attachments.
            // https://vulkan-tutorial.com/Drawing_a_triangle/Drawing/Framebuffers
            .renderPass = m_RenderPass,

            .attachmentCount = static_cast<uint32_t>(attachments.size()),
            .pAttachments = attachments.data(), // List of attachments (1:1 with Render Pass)
            .width = SwapChainExtent().width,   // Framebuffer width
            .height = SwapChainExtent().height, // Framebuffer height
            .layers = 1                         // Framebuffer layers
        };

        framebuffers.push_back(
            m_VkGraphicsContext.LogicalDevice().createFramebuffer(framebufferCreateInfo));
    }
    return framebuffers;
}

// Destructor
for (auto& framebuffer : m_Framebuffers)
    logicalDevice.destroyFramebuffer(framebuffer);

Кто ссылается на фреймбуфер(ы). Только структура vk::RenderPassBeginInfo, которая передается в функцию vk::CommandBuffer.beginRenderPass(), которую мы вызываем, когда записываем команды в буфер команд (об этом речь пойдет в следующей заметке). Собственно вызов этой функции и записывает в буфер команд информацию о том, какой RenderPass выполнить, и какие буферы к нему приattach’ить.

Как обычно, в конце привожу рисунок взаимозависимостей между изученными нами на данный момент объектами Vulkan:

Рис. 4 — Объектная модель

Продолжение следует…

Добавить комментарий

Ваш адрес email не будет опубликован.