a-simple-triangle / Part 23 - Vulkan create frame buffers

Vulkan - Create Frame Buffers

During our Vulkan render loop we will use our swapchain to cycle between its images - one of which will be presented while one or more are being prepared for the next time a frame is ready to be presented.

Our render pass will be the vehicle which will perform the rendering through the subpasses it contains. In order for a render pass to interact with the presentation it needs to use a collection of frame buffer attachments - one to complement each swapchain image.

Each time our render loop runs we will need to find out which swapchain image position to target, then associate the frame buffer at the same position from our collection of frame buffers with the render pass that will be invoked. This lets our render pass know where the output from its subpasses should go or be read from, syncing it to the correct swapchain image.

Warning: This will be a fairly complicated article - we will need to introduce a number of new Vulkan components which will require us to jump in and out of different parts of our code base as we go.


Creating a command pool

We will discover later in this article that to perform some of the steps to create a new Vulkan image, we will be required to issue Vulkan commands which is done through command buffers. A good description of command buffers can be found here: https://vulkan-tutorial.com/Drawing_a_triangle/Drawing/Command_buffers. We will need to setup a command pool as part of our Vulkan context. The command pool itself will be used when we need to begin and end command buffers while performing Vulkan operations on the graphics queue.

Before implementing the command pool class itself, we need to revisit our existing ast::VulkanDevice class to introduce a graphics queue which we haven’t needed before. We will need the graphics queue to correctly end a command buffer as it needs to know upon which queue to perform its commands. Edit vulkan-device.hpp and add a new function signature to give access to its Vulkan graphics queue:

namespace ast
{
    struct VulkanDevice
    {
        ...

        const vk::Queue& getGraphicsQueue() const;

Open vulkan-device.cpp and add a new free function to get the queue from the logical device with the specific queue index. We will hold the resulting queue in the Internal struct:

namespace
{
    ...

    vk::Queue getQueue(const vk::Device& device, const uint32_t& queueIndex)
    {
        return device.getQueue(queueIndex, 0);
    }
} // namespace

We are using the existing queueConfig object to tell the logical device which queue we are looking for - in this case the graphicsQueueIndex property of it.

Add a new member field to hold the graphics queue and initialise it:

struct VulkanDevice::Internal
{
    const vk::UniqueDevice device;
    const vk::Queue graphicsQueue;

    Internal(const ast::VulkanPhysicalDevice& physicalDevice)
        : device(::createDevice(physicalDevice)),
          graphicsQueue(::getQueue(device.get(), queueConfig.graphicsQueueIndex)) {}

    ...

Then add the public function implementation to the bottom of the file:

const vk::Queue& VulkanDevice::getGraphicsQueue() const
{
    return internal->graphicsQueue;
}

We can now access the graphics queue through our Vulkan device class.

Command pool wrapper class

Next up is the command pool wrapper class - create vulkan-command-pool.hpp and vulkan-command-pool.cpp in application/vulkan. Edit the header file with the following:

#pragma once

#include "../../core/internal-ptr.hpp"
#include "vulkan-device.hpp"

namespace ast
{
    struct VulkanCommandPool
    {
        VulkanCommandPool(const ast::VulkanDevice& device);

        vk::UniqueCommandBuffer beginCommandBuffer(const ast::VulkanDevice& device) const;

        void endCommandBuffer(const vk::CommandBuffer& commandBuffer,
                              const ast::VulkanDevice& device) const;

    private:
        struct Internal;
        ast::internal_ptr<Internal> internal;
    };
} // namespace ast

The constructor needs a device to create a command pool from. We will be creating the command pool against the graphics queue index so the commands are aligned with the graphics queue.

There are two special functions that need some description:

Enter the following implementation:

#include "vulkan-command-pool.hpp"

using ast::VulkanCommandPool;

namespace
{
    vk::UniqueCommandPool createCommandPool(const ast::VulkanDevice& device)
    {
        vk::CommandPoolCreateInfo info{
            vk::CommandPoolCreateFlagBits::eResetCommandBuffer,
            device.getGraphicsQueueIndex()};

        return device.getDevice().createCommandPoolUnique(info);
    }

    vk::UniqueCommandBuffer beginCommandBuffer(const vk::CommandPool& commandPool,
                                               const ast::VulkanDevice& device)
    {
        // Describe how to create our command buffer - we only create 1.
        vk::CommandBufferAllocateInfo allocateInfo{
            commandPool,                      // Command pool
            vk::CommandBufferLevel::ePrimary, // Level
            1};                               // Command buffer count

        // Create and move the first (and only) command buffer that gets created by the device.
        vk::UniqueCommandBuffer commandBuffer{
            std::move(device.getDevice().allocateCommandBuffersUnique(allocateInfo)[0])};

        // Define how this command buffer should begin.
        vk::CommandBufferBeginInfo beginInfo{
            vk::CommandBufferUsageFlagBits::eOneTimeSubmit, // Flags
            nullptr                                         // Inheritance info
        };

        // Request the command buffer to begin itself.
        commandBuffer->begin(beginInfo);

        // The caller will now take ownership of the command buffer and is
        // responsible for invoking the 'endCommandBuffer' upon it.
        return commandBuffer;
    }

    void endCommandBuffer(const vk::CommandBuffer& commandBuffer,
                          const ast::VulkanDevice& device)
    {
        // Ask the command buffer to end itself.
        commandBuffer.end();

        // Configure a submission object to send to the graphics queue to wait
        // for the command buffer to have been completed.
        vk::SubmitInfo submitInfo{
            0,              // Wait semaphore count
            nullptr,        // Wait semaphores
            nullptr,        // Wait destination stage mask
            1,              // Command buffer count
            &commandBuffer, // Command buffers,
            0,              // Signal semaphore count
            nullptr         // Signal semaphores
        };

        // Ask the graphics queue to take the submission object which will declare
        // the command buffer to wait on, then wait until the graphics queue is
        // idle, indicating that the command buffer is complete.
        device.getGraphicsQueue().submit(1, &submitInfo, vk::Fence());
        device.getGraphicsQueue().waitIdle();
    }
} // namespace

struct VulkanCommandPool::Internal
{
    const vk::UniqueCommandPool commandPool;

    Internal(const ast::VulkanDevice& device)
        : commandPool(::createCommandPool(device)) {}
};

VulkanCommandPool::VulkanCommandPool(const ast::VulkanDevice& device)
    : internal(ast::make_internal_ptr<Internal>(device)) {}

vk::UniqueCommandBuffer VulkanCommandPool::beginCommandBuffer(const ast::VulkanDevice& device) const
{
    return ::beginCommandBuffer(internal->commandPool.get(), device);
}

void VulkanCommandPool::endCommandBuffer(const vk::CommandBuffer& commandBuffer, const ast::VulkanDevice& device) const
{
    ::endCommandBuffer(commandBuffer, device);
}

We create and hold an instance of a vk::UniqueCommandPool via the createCommandPool free function. The createCommandPool function produces a command pool using the eResetCommandBuffer mode and which targets the graphics queue index.

The beginCommandBuffer function does the following:

The endCommandBuffer function does the following:

Note: It probably isn’t clear yet why we are even writing the beginCommandBuffer and endCommandBuffer functions. Just trust me that we will need them later in this article when we are trying to create some other Vulkan components.

Update the Vulkan context

We will now add a new member field to hold our command pool and initialise it in the Internal constructor of our Vulkan context class. Edit vulkan-context.cpp and add the following header:

#include "vulkan-command-pool.hpp"

Then hop down to the Internal struct and add a new field to hold an instance of our command pool class - be sure to create it before we create the render context, as we need to pass it into the construction of the render context:

struct VulkanContext::Internal
{
    ...
    const ast::VulkanCommandPool commandPool;
    const ast::VulkanRenderContext renderContext;

    Internal() : ...
                 commandPool(ast::VulkanCommandPool(device)),
                 renderContext(ast::VulkanRenderContext(window, physicalDevice, device, surface, commandPool))

The command pool will be supplied to the render context via its constructor so it can be used in its internal implementation to run Vulkan commands during initialisation - we will work through that later in this article. Update the constructor in vulkan-render-context.hpp to include the command pool:

#include "vulkan-command-pool.hpp"

...

VulkanRenderContext(...
               const ast::VulkanCommandPool& commandPool);

Follow up by passing the command pool through the implementation in vulkan-render-context.cpp as well, including into the Internal constructor so it becomes available to its internal implementation.


Creating the frame buffers

The steps we’ll need to implement to create a collection of frame buffers follows like this:

create and keep an image to be used for multi sampling
create and keep an image view to wrap the image used for multi sampling
create and keep an image to be used for depth testing
create and keep an image view to wrap the image used for depth testing

create a list of frame buffers of the same length as the number of swapchain image views

for each image view in swapchain
    create a Vulkan frame buffer with the following attributes:
        - a reference to the multi sampling image view
        - a reference to the depth testing image view
        - a reference to the swapchain image view

    add the created frame buffer to the list of frame buffers
end for

return the list of frame buffers

Remember: A Vulkan image view is the outward facing container used to wrap a Vulkan image to allow interaction with it. You will notice that each frame buffer created in the psuedo code above includes a reference to an image view which wraps the appropriate image.

Recall that we already implemented the image views for the swapchain in an earlier article, exposing them through our ast::VulkanSwapchain via the getImageViews function so accessing them should be easy.

We do however need to create the images and image views for multi sampling and depth testing - meaning we will need to invent a way to construct a Vulkan image - not to be confused with an image view which we can already do from our previous work.

Create the multi sample image

Let’s begin with the multi sample image. Note that although we previously added the ast::VulkanImageView class to act as a wrapper on each swapchain image, we didn’t have to create the actual swapchain images themselves - they were created and owned automatically by the swapchain. This time we need to create and own the images ourselves from scratch so we’ll need a way to represent a Vulkan image. As usual we will introduce a new wrapper class to help us with this task.

Create the files vulkan-image.hpp and vulkan-image.cpp in the applications/vulkan folder. Edit the header with the following:

#pragma once

#include "../../core/graphics-wrapper.hpp"
#include "../../core/internal-ptr.hpp"
#include "vulkan-device.hpp"
#include "vulkan-physical-device.hpp"

namespace ast
{
    struct VulkanImage
    {
        VulkanImage(const ast::VulkanPhysicalDevice& physicalDevice,
                    const ast::VulkanDevice& device,
                    const uint32_t& height,
                    const uint32_t& mipLevels,
                    const vk::SampleCountFlagBits& sampleCount,
                    const vk::Format& format,
                    const vk::ImageTiling& tiling,
                    const vk::ImageUsageFlags& usageFlags,
                    const vk::MemoryPropertyFlags& memoryFlags);

        uint32_t getWidth() const;

        uint32_t getHeight() const;

        uint32_t getMipLevels() const;

        const vk::Format& getFormat() const;

        const vk::Image& getImage() const;

    private:
        struct Internal;
        ast::internal_ptr<Internal> internal;
    };
} // namespace ast

We will walk through the implementation next but here is some info about some of the arguments we need:

The remaining getter functions simply expose some of the image properties to a consumer. Note that I haven’t bothered with a const reference for the uint32_t types.

Edit the vulkan-image.cpp file to start building out the implementation. We will start with a basic implementation but we’ll need to add something extra after:

#include "vulkan-image.hpp"

using ast::VulkanImage;

namespace
{
    vk::UniqueImage createImage(const vk::Device& device,
                                const uint32_t& width,
                                const uint32_t& height,
                                const uint32_t& mipLevels,
                                const vk::SampleCountFlagBits& sampleCount,
                                const vk::Format& format,
                                const vk::ImageTiling& tiling,
                                const vk::ImageUsageFlags& usageFlags)
    {
        vk::Extent3D extent{
            width,  // Width
            height, // Height
            1       // Depth
        };

        vk::ImageCreateInfo imageInfo{
            vk::ImageCreateFlags(),       // Flags
            vk::ImageType::e2D,           // Image type
            format,                       // Format
            extent,                       // Extent
            mipLevels,                    // Mip levels
            1,                            // Array layers
            sampleCount,                  // Sample count
            tiling,                       // Tiling
            usageFlags,                   // Usage flags
            vk::SharingMode::eExclusive,  // Sharing mode
            0,                            // Queue family index count
            nullptr,                      // Queue family indices,
            vk::ImageLayout::eUndefined}; // Initial layout

        return device.createImageUnique(imageInfo);
    }
} // namespace

struct VulkanImage::Internal
{
    const uint32_t width;
    const uint32_t height;
    const uint32_t mipLevels;
    const vk::Format format;
    const vk::UniqueImage image;

    Internal(const ast::VulkanPhysicalDevice& physicalDevice,
             const ast::VulkanDevice& device,
             const uint32_t& width,
             const uint32_t& height,
             const uint32_t& mipLevels,
             const vk::SampleCountFlagBits& sampleCount,
             const vk::Format& format,
             const vk::ImageTiling& tiling,
             const vk::ImageUsageFlags& usageFlags,
             const vk::MemoryPropertyFlags& memoryFlags)
        : width(width),
          height(height),
          mipLevels(mipLevels),
          format(format),
          image(::createImage(device.getDevice(), width, height, mipLevels, sampleCount, format, tiling, usageFlags)) {}
};

VulkanImage::VulkanImage(const ast::VulkanPhysicalDevice& physicalDevice,
                         const ast::VulkanDevice& device,
                         const uint32_t& width,
                         const uint32_t& height,
                         const uint32_t& mipLevels,
                         const vk::SampleCountFlagBits& sampleCount,
                         const vk::Format& format,
                         const vk::ImageTiling& tiling,
                         const vk::ImageUsageFlags& usageFlags,
                         const vk::MemoryPropertyFlags& memoryFlags)
    : internal(ast::make_internal_ptr<Internal>(physicalDevice,
                                                device,
                                                width,
                                                height,
                                                mipLevels,
                                                sampleCount,
                                                format,
                                                tiling,
                                                usageFlags,
                                                memoryFlags)) {}

uint32_t VulkanImage::getWidth() const
{
    return internal->width;
}

uint32_t VulkanImage::getHeight() const
{
    return internal->height;
}

uint32_t VulkanImage::getMipLevels() const
{
    return internal->mipLevels;
}

const vk::Format& VulkanImage::getFormat() const
{
    return internal->format;
}

const vk::Image& VulkanImage::getImage() const
{
    return internal->image.get();
}

Quite a lot of the implementation is just boilerplate passing the arguments around into the internal structure - it’s a trade off with our internal_ptr component but nothing we haven’t grown used to by now.

The interesting part is the instance of a vk::UniqueImage in the internal struct which is populated via the createImage free function. Remember, we are preferring to use the vk::Unique* Vulkan components so they can automatically clean themselves up when going out of scope.

Let’s walk through the createImage function now. We start off by determining the extent of the image based on its width and height. The extent is used in the image creation object. Since we are only creating a two dimensional image the depth of the extent is 1. I guess it’s possible to create 3 dimensional images too which might have a depth greater than 1 but not really applicable to our application:

namespace
{
    vk::UniqueImage createImage(const vk::Device& device,
                                const uint32_t& width,
                                const uint32_t& height,
                                const uint32_t& mipLevels,
                                const vk::SampleCountFlagBits& sampleCount,
                                const vk::Format& format,
                                const vk::ImageTiling& tiling,
                                const vk::ImageUsageFlags& usageFlags)
    {
        vk::Extent3D extent{
            width,  // Width
            height, // Height
            1       // Depth
        };

Note: I could have inlined the creation of the extent object within the imageInfo object but for sake of code readability I kept it declared separately.

Next we create and populate an instance of a vk::ImageCreateInfo object which will become the configuration we supply to Vulkan when asking it to create a new image for us. You can learn about this info object here: https://www.khronos.org/registry/vulkan/specs/1.1-extensions/man/html/VkImageCreateInfo.html:

vk::ImageCreateInfo imageInfo{
    vk::ImageCreateFlags(),       // Flags
    vk::ImageType::e2D,           // Image type
    format,                       // Format
    extent,                       // Extent
    mipLevels,                    // Mip levels
    1,                            // Array layers
    sampleCount,                  // Sample count
    tiling,                       // Tiling
    usageFlags,                   // Usage flags
    vk::SharingMode::eExclusive,  // Sharing mode
    0,                            // Queue family index count
    nullptr,                      // Queue family indices,
    vk::ImageLayout::eUndefined}; // Initial layout

The properties of interest:

  • queueFamilyIndexCount is the number of entries in the pQueueFamilyIndices array.
  • pQueueFamilyIndices is a list of queue families that will access this image (ignored if sharingMode is not VK_SHARING_MODE_CONCURRENT).

VK_IMAGE_LAYOUT_UNDEFINED does not support device access. This layout must only be used as the initialLayout member of VkImageCreateInfo or VkAttachmentDescription, or as the oldLayout in an image transition. When transitioning out of this layout, the contents of the memory are not guaranteed to be preserved.

Creating the image

The configuration object is finally fed into our logical device and Vulkan will provision the appropriate image for us which we will return to the caller:

return device.createImageUnique(imageInfo);

Allocating image memory

Something a bit interesting about creating Vulkan image objects is that Vulkan does not actually allocate the memory for them - that is left to us to do. Although this seems a bit weird it is something we just have to accommodate. The memory for an image will need to be stored in a vk::UniqueDeviceMemory object and held as a field alongside our current image member field so it shares the same lifecycle.

In order to allocate the memory for our image we will need to know the type of memory that needs to be allocated along with the memory index of where it can be allocated from in the physical device. This topic is explained reasonably well in the following article where the author introduces the findMemoryType function: https://vulkan-tutorial.com/Vertex_buffers/Vertex_buffer_creation.

We will be writing the same findMemoryType function but will place it into our ast::VulkanPhysicalDevice class so it can be used in other parts of our application later too. Open up vulkan-physical-device.hpp and add a new function signature like so:

uint32_t getMemoryTypeIndex(const uint32_t& filter, const vk::MemoryPropertyFlags& flags) const;

Open vulkan-physical-device.cpp and add to the bottom the public function implementation:

uint32_t VulkanPhysicalDevice::getMemoryTypeIndex(const uint32_t& filter, const vk::MemoryPropertyFlags& flags) const
{
    return ::getMemoryTypeIndex(internal->physicalDevice, filter, flags);
}

The getMemoryTypeIndex function doesn’t yet exist, so we will add it to the anonymous namespace:

namespace
{
    ...

    uint32_t getMemoryTypeIndex(const vk::PhysicalDevice& physicalDevice,
                                const uint32_t& filter,
                                const vk::MemoryPropertyFlags& flags)
    {
        // Fetch all the memory properties of the physical device.
        vk::PhysicalDeviceMemoryProperties memoryProperties{physicalDevice.getMemoryProperties()};

        // Loop through each of the memory type fields in the properties.
        for (uint32_t index = 0; index < memoryProperties.memoryTypeCount; index++)
        {
            // If the current memory type is available and has all the property flags required, we
            // have found a position in the physical device memory indices that is compatible.
            if ((filter & (1 << index)) && (memoryProperties.memoryTypes[index].propertyFlags & flags) == flags)
            {
                return index;
            }
        }

        // If no memory type could be found that meets our criteria we can't proceed.
        throw std::runtime_error("ast::VulkanImage::getMemoryTypeIndex: Failed to find suitable memory type.");
    }
}

The comments in the code describe what is happening - we basically need to iterate all the available memory types supported by the current physical device, looking for the first one that meets the criteria of the flags that were passed in. If none were found we throw an exception. With the getMemoryTypeIndex function complete, the physical device class can now be closed.

Hop back to vulkan-image.cpp and add a new free function which we’ll use to allocate the memory for our image:

namespace
{
    ...

    vk::UniqueDeviceMemory allocateImageMemory(const ast::VulkanPhysicalDevice& physicalDevice,
                                               const vk::Device& device,
                                               const vk::Image& image,
                                               const vk::MemoryPropertyFlags& memoryFlags)
    {
        // Discover what the memory requirements are for the specified image configuration.
        vk::MemoryRequirements memoryRequirements{device.getImageMemoryRequirements(image)};

        // Query the physical device to determine where to find the memory type the image requires.
        uint32_t memoryTypeIndex{physicalDevice.getMemoryTypeIndex(memoryRequirements.memoryTypeBits, memoryFlags)};

        // Form a configuration to model what kind of memory to allocate.
        vk::MemoryAllocateInfo info{
            memoryRequirements.size, // Allocation size
            memoryTypeIndex};        // Memory type index

        // Request that the logical device allocate memory for our configuration.
        vk::UniqueDeviceMemory deviceMemory{device.allocateMemoryUnique(info)};

        // Bind the image to the allocated memory to associate them with each other.
        device.bindImageMemory(image, deviceMemory.get(), 0);

        // Give back the allocated memory.
        return deviceMemory;
    }
}

The function will do the following:

We can now allocate the image memory and store it in the internal struct. Add a new member field to hold the allocated memory object and initialise it using our new free function:

struct VulkanImage::Internal
{
    ...
    const vk::UniqueDeviceMemory imageMemory;

    Internal(...)
        : ...
          imageMemory(::allocateImageMemory(physicalDevice, device.getDevice(), image.get(), memoryFlags)) {}

We are done with the ast::VulkanImage class for now so you can close it.


Creating our first Vulkan image

We now have the ast::VulkanImage class at our disposal so we can use it to complete the first step we identified about creating an image and image view for multi sampling:

create and keep an image to be used for multi sampling

Part of creating an image is knowing its width and height. We will want to use the dimensions of the swapchain extent to obtain these values, so make a quick detour into vulkan-swapchain.hpp and add a new function definition to allow its extent to be used:

const vk::Extent2D& getExtent() const;

Then edit vulkan-swapchain.cpp and add the public implementation of that function:

const vk::Extent2D& VulkanSwapchain::getExtent() const
{
    return internal->extent;
}

With the extent made available, go back to vulkan-render-context.cpp where we will create and hold the image for multi sampling use. Add the header to give us access to our new ast::VulkanImage class:

#include "vulkan-image.hpp"

Then add a new free function which will create the multi sample image:

namespace
{
    ast::VulkanImage createMultiSampleImage(const ast::VulkanPhysicalDevice& physicalDevice,
                                            const ast::VulkanDevice& device,
                                            const ast::VulkanSwapchain& swapchain,
                                            const ast::VulkanCommandPool& commandPool)
    {
        const vk::Extent2D& extent{swapchain.getExtent()};

        return ast::VulkanImage(
            physicalDevice,
            device,
            extent.width,
            extent.height,
            1,
            physicalDevice.getMultiSamplingLevel(),
            swapchain.getColorFormat(),
            vk::ImageTiling::eOptimal,
            vk::ImageUsageFlagBits::eTransientAttachment | vk::ImageUsageFlagBits::eColorAttachment,
            vk::MemoryPropertyFlagBits::eDeviceLocal);
    }
}

We are using our new VulkanImage class to instantiate the required image. The arguments we are supplying will be appropriate for using this image during our render pass for multi sampling output. You might be wondering why we are passing in the commandPool - we will need to use it shortly to perform another initialisation aspect of our image.

Add a new member field to our Internal struct to hold the multi sample image and create it by calling our new free function:

struct VulkanRenderContext::Internal
{
    ...
    const ast::VulkanImage multiSampleImage;

    Internal(...)
        : ...
          multiSampleImage(::createMultiSampleImage(physicalDevice, device, swapchain, commandPool)) {}

Applying image layout transition

Although we we have created the image with the configuration needed, we also need to perform an operation to give Vulkan more information about how this image will be accessed by specifying access and pipeline stage flags known as an image layout transition. To do this we will construct a memory barrier configuration and feed it into a command buffer to be processed - this will be why we need the command pool.

The following article talks about this nicely I’d recommend reading it before continuing: https://vulkan-tutorial.com/Texture_mapping/Images. Also check the official docs here: https://www.khronos.org/registry/vulkan/specs/1.1-extensions/html/vkspec.html#synchronization-image-layout-transitions.

A somewhat more technical description of memory barriers can be found here: https://www.khronos.org/registry/vulkan/specs/1.1-extensions/man/html/VkImageMemoryBarrier.html.

To apply the layout transition we will update the constructor of our VulkanImage class so it takes the command pool along with old and new image layouts which will offer enough information to know how what access and pipeline flags to apply in order to transition between the two layouts.

Open vulkan-image.hpp and add the following header:

#include "vulkan-command-pool.hpp"

Then update the constructor to take in the command pool, the old layout and the new layout:

VulkanImage(const ast::VulkanCommandPool& commandPool,
            const ast::VulkanPhysicalDevice& physicalDevice,
            const ast::VulkanDevice& device,
            const uint32_t& width,
            const uint32_t& height,
            const uint32_t& mipLevels,
            const vk::SampleCountFlagBits& sampleCount,
            const vk::Format& format,
            const vk::ImageTiling& tiling,
            const vk::ImageUsageFlags& usageFlags,
            const vk::MemoryPropertyFlags& memoryFlags,
            const vk::ImageLayout& oldLayout,
            const vk::ImageLayout& newLayout);

Note: The constructor for this class is beginning to get a little unwieldy but we won’t be adding any more to it. It might be nice to model all the arguments into a configuration object but I’ll leave it like this for our application.

Add the same three arguments into the public constructor implementation and into the constructor of our Internal struct as well, and invoke the following function inside the body of our constructor (we will write the transitionLayout function next):

Internal(const ast::VulkanCommandPool& commandPool,
         ...
         const vk::ImageLayout& oldLayout,
         const vk::ImageLayout& newLayout)
    : ...
{
    ::transitionLayout(device, commandPool, image.get(), format, mipLevels, oldLayout, newLayout);
}

We are calling transitionLayout in the constructor body as it doesn’t actually return an object so is not used as an initialiser of a member field.

Add the transitionLayout free function with the following:

namespace
{
    ...

    void transitionLayout(const ast::VulkanDevice& device,
                          const ast::VulkanCommandPool& commandPool,
                          const vk::Image& image,
                          const vk::Format& format,
                          const uint32_t& mipLevels,
                          const vk::ImageLayout& oldLayout,
                          const vk::ImageLayout& newLayout)
    {
        // Create a barrier with sensible defaults - some properties will change
        // depending on the old -> new layout combinations.
        vk::ImageMemoryBarrier barrier;
        barrier.image = image;
        barrier.oldLayout = oldLayout;
        barrier.newLayout = newLayout;
        barrier.subresourceRange.aspectMask = vk::ImageAspectFlagBits::eColor;
        barrier.subresourceRange.baseMipLevel = 0;
        barrier.subresourceRange.levelCount = mipLevels;
        barrier.subresourceRange.baseArrayLayer = 0;
        barrier.subresourceRange.layerCount = 1;

        // Scenario: undefined -> color attachment optimal
        if (oldLayout == vk::ImageLayout::eUndefined && newLayout == vk::ImageLayout::eColorAttachmentOptimal)
        {
            barrier.dstAccessMask = vk::AccessFlagBits::eColorAttachmentRead | vk::AccessFlagBits::eColorAttachmentWrite;
            return ::applyTransitionLayoutCommand(device,
                                                  commandPool,
                                                  vk::PipelineStageFlagBits::eTopOfPipe,
                                                  vk::PipelineStageFlagBits::eColorAttachmentOutput,
                                                  barrier);
        }

        // An unknown combination might mean we need to add a new scenario to handle it.
        throw std::runtime_error("ast::VulkanImage::transitionLayout: Unsupported 'old' and 'new' image layout combination.");
    }
}

Let’s walk through this function. We start off by creating a vk::ImageMemoryBarrier which encapsulates properties describing the old and new aspect of the image. We assign some basic properties that are likely to be used in all of our scenarios, though some scenarios may need to tweak a few of them:

void transitionLayout(const ast::VulkanDevice& device,
                      const ast::VulkanCommandPool& commandPool,
                      const vk::Image& image,
                      const vk::Format& format,
                      const uint32_t& mipLevels,
                      const vk::ImageLayout& oldLayout,
                      const vk::ImageLayout& newLayout)
{
    // Create a barrier with sensible defaults - some properties will change
    // depending on the old -> new layout combinations.
    vk::ImageMemoryBarrier barrier;
    barrier.image = image;
    barrier.oldLayout = oldLayout;
    barrier.newLayout = newLayout;
    barrier.subresourceRange.aspectMask = vk::ImageAspectFlagBits::eColor;
    barrier.subresourceRange.baseMipLevel = 0;
    barrier.subresourceRange.levelCount = mipLevels;
    barrier.subresourceRange.baseArrayLayer = 0;
    barrier.subresourceRange.layerCount = 1;

Next we evaluate a series of conditional checks to identify what scenario we are dealing with. The oldLayout for our multi sample image will be eUndefined and the newLayout will be eColorAttachmentOptimal. Our multi sample image will not care about where it came from but it will care that it should emit a colour attachment, therefore we have the undefined -> color attachment optimal scenario.

This scenario will require us to apply the destination access mask which specifies the ability to both read and write to the colour attachment during rendering:

 barrier.dstAccessMask = vk::AccessFlagBits::eColorAttachmentRead | vk::AccessFlagBits::eColorAttachmentWrite;

We then invoke another function to take the memory barrier configuration and run it within a command buffer, along with when the command should be executed in the Vulkan pipeline. We will write the applyTransitionLayoutCommand in a moment.

Note that we are specifying the top of pipe and color attachment output as the source and destination stages of the Vulkan pipeline to run this. Check this documentation to learn about what the different Vulkan pipeline stages are: https://www.khronos.org/registry/vulkan/specs/1.1-extensions/html/vkspec.html#synchronization-pipeline-stages. Different scenarios will specify different pipeline stage flags depending on what kind of transition they represent:

return ::applyTransitionLayoutCommand(device,
                                      commandPool,
                                      vk::PipelineStageFlagBits::eTopOfPipe,
                                      vk::PipelineStageFlagBits::eColorAttachmentOutput,
                                      barrier);

The final part of this function is to deliberately throw an exception if there was no matching scenario of oldLayout -> newLayout. The idea is that as we introduce more image transition scenarios to the code base, we would update this function adding in the required implementations as needed.

throw std::runtime_error("ast::VulkanImage::transitionLayout: Unsupported 'old' and 'new' image layout combination.");

The applyTransitionLayoutCommand function

Let’s write the applyTransitionLayoutCommand now which was called to send our image memory barrier and pipeline stage flags into a command buffer. Place this function above the transitionLayout function, but still within the anonymous namespace:

namespace
{
    ...

    void applyTransitionLayoutCommand(const ast::VulkanDevice& device,
                                      const ast::VulkanCommandPool& commandPool,
                                      const vk::PipelineStageFlags& sourceStageFlags,
                                      const vk::PipelineStageFlags& destinationStageFlags,
                                      const vk::ImageMemoryBarrier& barrier)
    {
        // Obtain a new command buffer than has been started.
        vk::UniqueCommandBuffer commandBuffer{commandPool.beginCommandBuffer(device)};

        // Issue a 'pipeline barrier' command, using the image memory barrier as configuration
        // and the source / destination stage flags to determine where in the graphics pipeline
        // to apply the command.
        commandBuffer->pipelineBarrier(
            sourceStageFlags,
            destinationStageFlags,
            vk::DependencyFlags(),
            0, nullptr,
            0, nullptr,
            1, &barrier);

        // End the command buffer, causing it to be run.
        commandPool.endCommandBuffer(commandBuffer.get(), device);
    }

    ...
}

Again, let’s walk through this function. First off, we use our commandPool to acquire and start a command buffer for us. We need the command buffer as we will be issuing a pipelineBarrier command to it:

    void applyTransitionLayoutCommand(...)
    {
        vk::UniqueCommandBuffer commandBuffer{commandPool.beginCommandBuffer(device)};

With the new command buffer we can record the pipeline barrier command by invoking the pipelineBarrier function, supplying the source pipeline stage, destination pipeline stage and the barrier. The other arguments are not used in this situation.

Remember: Calling functions on a command buffer doesn’t actually execute them it simply records them for when it is executed.

Finally we end the command buffer, causing it to be processed. The ast::CommandPool class which we authored earlier will take care of the execution of the command for us and wait for it to be finished.

Update the render context

Our VulkanImage class can now transition itself correctly - at least with one scenario to begin with - so we should revisit our code where we create the multi sample image again as it will have syntax errors. Head back to vulkan-render-context.cpp and update the createMultiSampleImage function to include the extra constructor arguments when creating the image:

namespace
{
    ast::VulkanImage createMultiSampleImage(const ast::VulkanCommandPool& commandPool,
                                            const ast::VulkanPhysicalDevice& physicalDevice,
                                            const ast::VulkanDevice& device,
                                            const ast::VulkanSwapchain& swapchain)
    {
        const vk::Extent2D& extent{swapchain.getExtent()};

        return ast::VulkanImage(
            commandPool,
            physicalDevice,
            device,
            extent.width,
            extent.height,
            1,
            physicalDevice.getMultiSamplingLevel(),
            swapchain.getColorFormat(),
            vk::ImageTiling::eOptimal,
            vk::ImageUsageFlagBits::eTransientAttachment | vk::ImageUsageFlagBits::eColorAttachment,
            vk::MemoryPropertyFlagBits::eDeviceLocal,
            vk::ImageLayout::eUndefined,
            vk::ImageLayout::eColorAttachmentOptimal);
    }
}

Note that we pass in the commandPool but more importantly we pass in vk::ImageLayout::eUndefined and vk::ImageLayout::eColorAttachmentOptimal as the final two parameters - which represent the old layout and the new layout for layout transitioning.

Run your application - if all is well it should still boot, but you will now have created a Vulkan image which has transitioned its layout correctly.


Multi sample image view

Ok, that was seriously a huge pile of work to create an image, the good news is that the remaining tasks for this article are fairly straight forward. We can now tackle the second step for the multi sample image:

create and keep an image view to wrap the image used for multi sampling

We already have our ast::ImageView class designed for exactly this purpose so we should be able to whip this up easily. Return to vulkan-render-context.cpp and add the header for our image view class:

#include "vulkan-image-view.hpp"

Now add a new free function to create an image view like so:

namespace
{
    ...

    ast::VulkanImageView createImageView(const ast::VulkanDevice& device,
                                         const ast::VulkanImage& image,
                                         const vk::ImageAspectFlags& aspectFlags)
    {
        return ast::VulkanImageView(device.getDevice(),
                                    image.getImage(),
                                    image.getFormat(),
                                    aspectFlags,
                                    image.getMipLevels());
    }
}

Add a new member field to hold the multi sample image view and initialise it in the constructor:

struct VulkanRenderContext::Internal
{
    ...
    const ast::VulkanImageView multiSampleImageView;

    Internal(...)
        : ...
          multiSampleImageView(::createImageView(device, multiSampleImage, vk::ImageAspectFlagBits::eColor)) {}
};

Note that we are passing vk::ImageAspectFlagBits::eColor as the final argument, to let Vulkan know the image view will be for a colour image.


Depth test image

Next up we need to do some very similar steps to procure an image and image view for our depth testing:

create and keep an image to be used for depth testing
create and keep an image view to wrap the image used for depth testing

Start off by creating a new free function to create the depth image:

namespace
{
    ...

    ast::VulkanImage createDepthImage(const ast::VulkanCommandPool& commandPool,
                                      const ast::VulkanPhysicalDevice& physicalDevice,
                                      const ast::VulkanDevice& device,
                                      const ast::VulkanSwapchain& swapchain)
    {
        const vk::Extent2D& extent{swapchain.getExtent()};

        return ast::VulkanImage(
            commandPool,
            physicalDevice,
            device,
            extent.width,
            extent.height,
            1,
            physicalDevice.getMultiSamplingLevel(),
            physicalDevice.getDepthFormat(),
            vk::ImageTiling::eOptimal,
            vk::ImageUsageFlagBits::eDepthStencilAttachment,
            vk::MemoryPropertyFlagBits::eDeviceLocal,
            vk::ImageLayout::eUndefined,
            vk::ImageLayout::eDepthStencilAttachmentOptimal);
    }
}

At a glance this looks almost the same as the function to create the multi sample image. The key differences are:

Move to the Internal struct again and add two member fields to hold the depth image and image view, and construct them:

struct VulkanRenderContext::Internal
{
    ...
    const ast::VulkanImage depthImage;
    const ast::VulkanImageView depthImageView;

    Internal(...)
        : ...
          depthImage(::createDepthImage(commandPool, physicalDevice, device, swapchain)),
          depthImageView(::createImageView(device, depthImage, vk::ImageAspectFlagBits::eDepth)) {}
};

Notice that the depth image view has the eDepth image aspect flag unlike the multi sample which had eColor.

Run your application and it fail to initialise Vulkan (and actually fall back to OpenGL at this point) with a message like this:

ast::Engine::resolveApplication: Vulkan application failed to initialize. Exception message was: ast::VulkanImage::transitionLayout: Unsupported 'old' and 'new' image layout combination.

This is due to the undefined -> depth stencil attachment optimal scenario not being modelled yet in our ast::VulkanImage class. Open vulkan-image.cpp and update the transitionLayout function to include another scenario below the existing one to accommodate the new combination:

namespace
{
    void transitionLayout(...)
    {
        ...

        // Scenario: undefined -> depth stencil attachment optimal
        if (oldLayout == vk::ImageLayout::eUndefined && newLayout == vk::ImageLayout::eDepthStencilAttachmentOptimal)
        {
            barrier.dstAccessMask = vk::AccessFlagBits::eDepthStencilAttachmentRead | vk::AccessFlagBits::eDepthStencilAttachmentWrite;
            barrier.subresourceRange.aspectMask = vk::ImageAspectFlagBits::eDepth;

            return ::applyTransitionLayoutCommand(device,
                                                  commandPool,
                                                  vk::PipelineStageFlagBits::eTopOfPipe,
                                                  vk::PipelineStageFlagBits::eEarlyFragmentTests,
                                                  barrier);
        }

        ...

Our new scenario will map transitioning from undefined to depth stencil attachment optimal. Notice that the destination access mask is now read/write to the depth stencil which is required for depth testing. We also tweak the subresourceRange.aspectMask to declare that this is intended to be for depth testing.

The layout transition command specifies top of pipe again as the source pipeline stage, but unlike the multi sample image chooses early fragment tests as the destination pipeline stage. This is because depth testing is intended to verify whether pixels should be rendered based on whether they are in front of all other pixels and discarded if not.

Save and run again and Vulkan should happily boot up - you now have the depth test image and image view.


Creating the frame buffers

Now to create the actual frame buffers which was the entire point of this article - we took a heck of a detour though didn’t we?? Recall that the frame buffers are created like this:

create a list of frame buffers of the same length as the number of swapchain image views

for each image view in swapchain
    create a Vulkan frame buffer with the following attributes:
        - a reference to the multi sampling image view
        - a reference to the depth testing image view
        - a reference to the swapchain image view

    add the created frame buffer to the list of frame buffers
end for

return the list of frame buffers

We have our multi sample and depth test image views now, so along with the swapchain I think we might have everything we need. The Vulkan component that will represent a frame buffer for us is of the type vk::UniqueFrameBuffer. We pretty much just have to create a list of them - one for each swapchain image view.

Note: We can share the same single multi sample and depth test image views among all the frame buffers rather than creating one for each frame buffer. This is why we don’t have a vector of multi sample and depth test images.

Add the standard library header to give us vector objects:

#include <vector>

Now add a free function whose job it will be construct a std::vector of vk::UniqueFrameBuffer objects:

namespace
{
    ...

    std::vector<vk::UniqueFramebuffer> createFramebuffers(const ast::VulkanDevice& device,
                                                          const ast::VulkanSwapchain& swapchain,
                                                          const ast::VulkanRenderPass& renderPass,
                                                          const ast::VulkanImageView& multiSampleImageView,
                                                          const ast::VulkanImageView& depthImageView)
    {
        std::vector<vk::UniqueFramebuffer> framebuffers;

        const vk::Extent2D& extent{swapchain.getExtent()};

        for (const auto& swapchainImageView : swapchain.getImageViews())
        {
            std::array<vk::ImageView, 3> attachments{
                multiSampleImageView.getImageView(),
                depthImageView.getImageView(),
                swapchainImageView.getImageView()};

            vk::FramebufferCreateInfo info{
                vk::FramebufferCreateFlags(),              // Flags
                renderPass.getRenderPass(),                // Render pass
                static_cast<uint32_t>(attachments.size()), // Attachment count
                attachments.data(),                        // Attachments
                extent.width,                              // Width
                extent.height,                             // Height
                1};                                        // Layers

            framebuffers.push_back(device.getDevice().createFramebufferUnique(info));
        }

        return framebuffers;
    }
}

Let’s step through this, first up we initialise an empty list of framebuffers and grab the extent of the swapchain:

    std::vector<vk::UniqueFramebuffer> createFramebuffers(...)
    {
        std::vector<vk::UniqueFramebuffer> framebuffers;

        const vk::Extent2D& extent{swapchain.getExtent()};

Next we will loop through each of the swapchain image views, creating frame buffers as we go. To create a frame buffer, we need to have an array of attachments which includes three image views:

  1. The multi sample image view.
  2. The depth test image view.
  3. The swapchain image view.

Important: Don’t get the order wrong! Multi sample -> Depth -> Swapchain.

This array will be fed into the creation info object to construct a frame buffer.

for (const auto& swapchainImageView : swapchain.getImageViews())
{
    std::array<vk::ImageView, 3> attachments{
        multiSampleImageView.getImageView(),
        depthImageView.getImageView(),
        swapchainImageView.getImageView()};

The creation info object for a frame buffer is of the type vk::FramebufferCreateInfo. We associate the image views array attachments as well as the render pass that the frame buffer is for. You can see that we are calling renderPass.getRenderPass() though we actually haven’t written that method yet:

vk::FramebufferCreateInfo info{
    vk::FramebufferCreateFlags(),              // Flags
    renderPass.getRenderPass(),                // Render pass
    static_cast<uint32_t>(attachments.size()), // Attachment count
    attachments.data(),                        // Attachments
    extent.width,                              // Width
    extent.height,                             // Height
    1};                                        // Layers

Pop over to vulkan-render-pass.hpp and add a new function definition to allow us to access the underlying Vulkan render pass component:

const vk::RenderPass& getRenderPass() const;

The edit vulkan-render-pass.cpp and implement the function at the bottom of the file:

const vk::RenderPass& VulkanRenderPass::getRenderPass() const
{
    return internal->renderPass.get();
}

Save your changes and go back to vulkan-render-context.cpp - your syntax error should be gone now for the renderPass.getRenderPass() invocation.

The end of the loop simply pushes the new frame buffer into the list, then the end of the function just returns (moves) the list to the caller:

        ...

        framebuffers.push_back(device.getDevice().createFramebufferUnique(info));
    }

    return framebuffers;
}

Go back and add a new member field to hold the list of frame buffers and initialise them in the constructor:

struct VulkanRenderContext::Internal
{
    ...
    const std::vector<vk::UniqueFramebuffer> framebuffers;

    Internal(...)
        : ...
          framebuffers(::createFramebuffers(device, swapchain, renderPass, multiSampleImageView, depthImageView)) {}
};

Run the application, nothing new will show but you now have a set of frame buffers ready to be used in our Vulkan rendering loop!


Summary

I found this article quite difficult to write - it is one of the most complicated articles in the series. Some Vulkan components have cross cutting relationships with each other which meant we had to move in and out of different parts of the code base to implement the frame buffers concept.

In the next article we will work on our render loop.

The code for this article can be found here.

Continue to Part 24: Vulkan render loop.

End of part 23