a-simple-triangle / Part 22 - Vulkan create render pass

Vulkan - Create Render Pass

While the Vulkan swapchain was a difficult component to implement, it gives us the prequisites for some of the remaining components needed to form a renderer. In this article we will progress through the following topics:

Reminder: I’ll be using the word colour a lot throughout these topics - don’t forget that I use the American spelling color in code, but I use the spelling colour in editorial text. Same goes for the other kinds of words like this :)


Render context class

The main render loop for Vulkan is composed of the interactions between a number of different Vulkan components. We will go into detail about these interactions over the next few articles as we write the rest of the rendering code.

Having a render context class which is separate from our Vulkan context class allows us to encapsulate all the potentially volatile rendering components together - making it easier to recreate them in situations where they become invalid. One such scenario where the rendering components become invalid is if the size of the application window surface changes. I’ll cover the recreation topic in more depth in a subsequent article. For now we will focus on authoring a basic render context class.

Create vulkan-render-context.hpp and vulkan-render-context.cpp in application/vulkan. Enter the following into the header:

#pragma once

#include "../../core/internal-ptr.hpp"
#include "../../core/sdl-window.hpp"
#include "vulkan-device.hpp"
#include "vulkan-physical-device.hpp"
#include "vulkan-surface.hpp"

namespace ast
{
    struct VulkanRenderContext
    {
        VulkanRenderContext(const ast::SDLWindow& window,
                            const ast::VulkanPhysicalDevice& physicalDevice,
                            const ast::VulkanDevice& device,
                            const ast::VulkanSurface& surface);

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

Initially the render context will need the related components required to create a swapchain as this class will now become the owner of the swapchain.

Enter the following into the implementation:

#include "vulkan-render-context.hpp"
#include "vulkan-swapchain.hpp"

using ast::VulkanRenderContext;

struct VulkanRenderContext::Internal
{
    const ast::VulkanSwapchain swapchain;

    Internal(const ast::SDLWindow& window,
             const ast::VulkanPhysicalDevice& physicalDevice,
             const ast::VulkanDevice& device,
             const ast::VulkanSurface& surface)
        : swapchain(ast::VulkanSwapchain(window, physicalDevice, device, surface)) {}
};

VulkanRenderContext::VulkanRenderContext(const ast::SDLWindow& window,
                                         const ast::VulkanPhysicalDevice& physicalDevice,
                                         const ast::VulkanDevice& device,
                                         const ast::VulkanSurface& surface)
    : internal(ast::make_internal_ptr<Internal>(window, physicalDevice, device, surface)) {}

The implementation is quite straight forward - really it is just creating and holding a swapchain instance. We will add more components during this article.

Our Vulkan context class will hold an instance of our new render context and will no longer directly create the swapchain itself. Edit vulkan-context.cpp and exchange the swapchain header for our new render context header:

#include "vulkan-swapchain.hpp"

becomes:

#include "vulkan-render-context.hpp"

Replace the existing swapchain member field and construction code with a VulkanRenderContext instead:

struct VulkanContext::Internal
{
    ...
    const ast::VulkanSwapchain swapchain;

    Internal() : ...
                 swapchain(ast::VulkanSwapchain(window, physicalDevice, device, surface))

becomes:

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

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

Run the application to make sure it still works the same way as before.


Render pass prerequisites

I would highly recommend reading the following links about render passes - what they are for and an overview of their behaviours:

We must have a render pass otherwise we won’t really be able to render anything. There are some prerequisite bits of information we need to create our render pass - some of which we already have in our Vulkan implementation and some which we’ll need to introduce in this article. In particular we will need:

Swapchain colour format

We already have the colour format for our swapchain held privately in the VulkanSwapchainFormat format field inside vulkan-swapchain.cpp. All we need to do is add a way to get the colour format publicly. Open vulkan-swapchain.hpp and add a new public function definition:

namespace ast
{
    struct VulkanSwapchain
    {
        ...
        const vk::Format& getColorFormat() const;
        ...

In vulkan-swapchain.cpp add the implementation of this new function to return the colorFormat component of the internal format field:

const vk::Format& VulkanSwapchain::getColorFormat() const
{
    return internal->format.colorFormat;
}

Multi sampling level

The multi sampling level - or anti aliasing level as it is also known - comes from our physical device. We will choose a multi sampling level by querying the properties of the physical device we chose during initialisation. We will have to write some extra code in our physical device class to achieve this.

Open vulkan-physical-device.hpp and add a new function definition to expose the multi sampling level that can be used. Note that it is of the type vk::SampleCountFlagBits which is an enumeration of all the possible values - the multi sampling level isn’t simply a number:

vk::SampleCountFlagBits getMultiSamplingLevel() const;

Since we can compute this value when initialising the physical device, we’ll add a new field to the internal implementation - edit vulkan-physical-device.cpp and add the following field and constructor code into the Internal struct:

struct VulkanPhysicalDevice::Internal
{
    ...
    const vk::SampleCountFlagBits multiSamplingLevel;

    Internal(const vk::Instance& instance)
        : ...
          multiSamplingLevel(::getMultiSamplingLevel(physicalDevice)) {}

Add the <stack> header at the top of our file to allow us to use a std::stack component:

#include <stack>

Now jump to the anonymous namespace and write a new free function named getMultiSamplingLevel:

namespace
{
    ...

    vk::SampleCountFlagBits getMultiSamplingLevel(const vk::PhysicalDevice& physicalDevice)
    {
        static const std::string logTag{"ast::VulkanPhysicalDevice::getMultiSamplingLevel"};

        vk::PhysicalDeviceProperties properties{physicalDevice.getProperties()};
        vk::SampleCountFlags supportedSampleCounts{properties.limits.framebufferColorSampleCounts};

        std::stack<vk::SampleCountFlagBits> preferredSampleCounts;
        preferredSampleCounts.push(vk::SampleCountFlagBits::e1);
        preferredSampleCounts.push(vk::SampleCountFlagBits::e2);
        preferredSampleCounts.push(vk::SampleCountFlagBits::e4);
        preferredSampleCounts.push(vk::SampleCountFlagBits::e8);

        while (!preferredSampleCounts.empty())
        {
            // Take the sample count at the top of the stack and see if it is supported.
            vk::SampleCountFlagBits sampleCount{preferredSampleCounts.top()};

            if (supportedSampleCounts & sampleCount)
            {
                return sampleCount;
            }

            // If our preferred sample count is not found, pop the stack ready for the next iteration.
            preferredSampleCounts.pop();
        }

        // If none of our sample counts is found, multi sampling is not supported on this device ...
        throw std::runtime_error(logTag + ": Multi sampling not supported.");
    }
}

We start off by obtaining the physical device properties then querying them for the framebufferColorSampleCounts limit:

vk::SampleCountFlagBits getMultiSamplingLevel(const vk::PhysicalDevice& physicalDevice)
{
    static const std::string logTag{"ast::VulkanPhysicalDevice::getMultiSamplingLevel"};

    vk::PhysicalDeviceProperties properties{physicalDevice.getProperties()};
    vk::SampleCountFlags supportedSampleCounts{properties.limits.framebufferColorSampleCounts};

We then create a stack which holds our preferred levels of multi sampling to allow us to pop each one looking for matching support. Note that I’ve only gone as high as multi sampling level of 8 which is reasonably high on most average hardware. You could try to go higher if desired or make it configurable by a user so they could choose instead but we will simply use the highest available multi sampling level if it’s supported up to a maximum of e8:

std::stack<vk::SampleCountFlagBits> preferredSampleCounts;
preferredSampleCounts.push(vk::SampleCountFlagBits::e1);
preferredSampleCounts.push(vk::SampleCountFlagBits::e2);
preferredSampleCounts.push(vk::SampleCountFlagBits::e4);
preferredSampleCounts.push(vk::SampleCountFlagBits::e8);

Note: High multi sampling levels can impact performance. You may find that low end platforms such as mobile devices might perform better with lower multi sampling levels at the cost of image quality.

The stack is then walked, popping and checking each element until we find a supported multi sampling count or don’t find any. It would be very unlikely (not sure if it’s even possible) to not have at least e1 support, but our code will cope regardless. If we find a match we return it and we are done. You might remember we took a similar approach when writing the swapchain to choose a presentation mode:

while (!preferredSampleCounts.empty())
{
    // Take the sample count at the top of the stack and see if it is supported.
    vk::SampleCountFlagBits sampleCount{preferredSampleCounts.top()};

    if (supportedSampleCounts & sampleCount)
    {
        return sampleCount;
    }

    // If our preferred sample count is not found, pop the stack ready for the next iteration.
    preferredSampleCounts.pop();
}

Of course if our entire stack is exhausted and no match was found, then ultimately we can’t use Vulkan on whatever device this is:

throw std::runtime_error(logTag + ": Multi sampling not supported.");

Depth testing format

We need to supply the render pass with the colour format to apply when performing depth testing. The article I mentioned earlier here https://vulkan-tutorial.com/Depth_buffering explains a lot about this topic. We will be choosing to use the VK_FORMAT_D32_SFLOAT format for depth testing exposed to us via the Vulkan C++ header as vk::Format::eD32Sfloat.

All the Vulkan formats are described here: https://www.khronos.org/registry/vulkan/specs/1.1-extensions/man/html/VkFormat.html. Searching that site for VK_FORMAT_D32_SFLOAT produces the following definition:

“VK_FORMAT_D32_SFLOAT specifies a one-component, 32-bit signed floating-point format that has 32-bits in the depth component.”

We will need to check if the format is supported by our physical device before assuming it is available then keep it as a field within our vulkan-physical-device class for easy reference. Edit vulkan-physical-device.hpp and add the following function definition to allow other code to ask for the depth format:

vk::Format getDepthFormat() const;

Update vulkan-physical-device.cpp, adding a new member field named depthFormat, initialising it via the getDepthFormat function:

struct VulkanPhysicalDevice::Internal
{
    ...
    const vk::Format depthFormat;

    Internal(const vk::Instance& instance)
        : ...
          depthFormat(::getDepthFormat(physicalDevice)) {}

Next, add the getDepthFormat free function into the anonymous namespace:

namespace
{
    ...

    vk::Format getDepthFormat(const vk::PhysicalDevice& physicalDevice)
    {
        static const std::string logTag{"ast::VulkanPhysicalDevice::getDepthFormat"};

        vk::FormatProperties formatProperties{physicalDevice.getFormatProperties(vk::Format::eD32Sfloat)};

        if (formatProperties.optimalTilingFeatures & vk::FormatFeatureFlagBits::eDepthStencilAttachment)
        {
            return vk::Format::eD32Sfloat;
        }

        throw std::runtime_error(logTag + ": 32 bit signed depth stencil format not supported.");
    }
}

This function firstly fetches the formatProperties for the given physical device that are specific to the eD32Sfloat format which is the one we want to use for depth testing. It then checks if the format properties has the eDepthStencilAttachment capability which is required to use the format for depth testing. If support is not found, we throw an exception.

Lastly we need to add the public function implementation to the bottom of vulkan-physical-device.cpp:

vk::Format VulkanPhysicalDevice::getDepthFormat() const
{
    return internal->depthFormat;
}

Creating the render pass

We now have all the components needed to instantiate a render pass. A render pass is comprised of one or more vk::AttachmentDescription configurations, each having a corresponding vk::AttachmentReference. The attachment references are then fed into a subpass which is what the render pass object will execute during rendering. We will also define a vk::SubpassDependency configuration to describe the relationship between the subpasses. This is particularly important when we have more than one subpass in the same render pass instance - though we will just have one subpass for this article.

Our render pass will have the following:

If you feel lost here don’t fret - I found (still finding!!) the relationships between these attachments and subpasses to be quite challenging to sink into my brain. The following site describes a lot about the subpass system: https://www.khronos.org/registry/vulkan/specs/1.1-extensions/man/html/VkSubpassDescription.html.

The main thing is that a render pass can have one or more subpasses, and the subpasses are the actual stages of operations that will be performed during rendering. A subpass itself can have different kinds of attachments - colour, depth testing and multi sampling (syntactically this is called the resolve attachment as it resolves the final state of the output image and may not necessarily be due to multi sampling).

With all that said, we will now stand up a new class to encapsulate the render pass. Create vulkan-render-pass.hpp and vulkan-render-pass.cpp in application/vulkan. Edit the header with the following:

#pragma once

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

namespace ast
{
    struct VulkanRenderPass
    {
        VulkanRenderPass(const ast::VulkanPhysicalDevice& physicalDevice,
                         const ast::VulkanDevice& device,
                         const ast::VulkanSwapchain& swapchain);

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

Note that we need the device as it is the component that creates the render pass. We need the physical device to find out what the multi sampling level and depth formats are. We need the swapchain to find out the colour format to use when rendering to the colour attachment.

Now enter the following into vulkan-render-pass.cpp - note we will work through the createRenderPass function separately so we can spend time on its details:

#include "vulkan-render-pass.hpp"

using ast::VulkanRenderPass;

namespace
{
    vk::UniqueRenderPass createRenderPass(const ast::VulkanPhysicalDevice& physicalDevice,
                                          const ast::VulkanDevice& device,
                                          const ast::VulkanSwapchain& swapchain)
    {
    }
} // namespace

struct VulkanRenderPass::Internal
{
    const vk::UniqueRenderPass renderPass;

    Internal(const ast::VulkanPhysicalDevice& physicalDevice,
             const ast::VulkanDevice& device,
             const ast::VulkanSwapchain& swapchain)
        : renderPass(::createRenderPass(physicalDevice, device, swapchain)) {}
};

VulkanRenderPass::VulkanRenderPass(const ast::VulkanPhysicalDevice& physicalDevice,
                                   const ast::VulkanDevice& device,
                                   const ast::VulkanSwapchain& swapchain)
    : internal(ast::make_internal_ptr<Internal>(physicalDevice, device, swapchain)) {}

createRenderPass function

We will walk slowly through the createRenderPass function and I’ll try to explain the parts of it as we go.

First up, we query the swapchain for its colour format - we need to know this so our render pass can output its rendering result in a compatible format for consumption by the swapchain. If we used a different colour format to the swapchain’s colour format they would be incompatible. We also query the physical device for its multi sampling level and depth format using the functions we added earlier in this article:

namespace
{
    vk::UniqueRenderPass createRenderPass(const ast::VulkanPhysicalDevice& physicalDevice,
                                          const ast::VulkanDevice& device,
                                          const ast::VulkanSwapchain& swapchain)
    {
        vk::Format colorFormat{swapchain.getColorFormat()};
        vk::SampleCountFlagBits multiSamplingLevel{physicalDevice.getMultiSamplingLevel()};
        vk::Format depthFormat{physicalDevice.getDepthFormat()};

Colour attachment

The first attachment is the colour attachment. Like all attachments, it is defined through a vk::AttachmentDescription object:

vk::AttachmentDescription colorAttachment{
    vk::AttachmentDescriptionFlags(),          // Flags
    colorFormat,                               // Format
    multiSamplingLevel,                        // Samples
    vk::AttachmentLoadOp::eClear,              // Load operation
    vk::AttachmentStoreOp::eStore,             // Store operation
    vk::AttachmentLoadOp::eDontCare,           // Stencil load operation
    vk::AttachmentStoreOp::eDontCare,          // Stencil store operation
    vk::ImageLayout::eUndefined,               // Initial layout
    vk::ImageLayout::eColorAttachmentOptimal}; // Final layout

The configuration comprises of:

Remembering that each attachment description should be coupled with an attachment reference, we next write the attachment reference for our colour attachment:

vk::AttachmentReference colorAttachmentReference{
    0,                                         // Attachment index
    vk::ImageLayout::eColorAttachmentOptimal}; // Layout

The attachment index of 0 will inform the consumer of the reference to look at position 0 in an array of attachments to find this one. The layout reflects that this is a colour attachment.

Depth testing attachment

Next up is our attachment to describe how to do depth testing within the render pass:

vk::AttachmentDescription depthTestingAttachment{
    vk::AttachmentDescriptionFlags(),                 // Flags
    depthFormat,                                      // Format
    multiSamplingLevel,                               // Samples
    vk::AttachmentLoadOp::eClear,                     // Load operation
    vk::AttachmentStoreOp::eDontCare,                 // Store operation
    vk::AttachmentLoadOp::eDontCare,                  // Stencil load operation
    vk::AttachmentStoreOp::eDontCare,                 // Stencil store operation
    vk::ImageLayout::eUndefined,                      // Initial layout
    vk::ImageLayout::eDepthStencilAttachmentOptimal}; // Final layout

While similar to our colour attachment there are a few subtle differences:

And of course we need an accompanying attachment reference:

vk::AttachmentReference depthTestingAttachmentReference{
    1,                                                // Attachment index
    vk::ImageLayout::eDepthStencilAttachmentOptimal}; // Layout

This time you might notice the attachment index is 1 whereas our colour attachment index was 0. This specifies that the depth testing attachment description can be found at position 1 in the list of attachments (we will create the list of attachments soon).

Multi sampling attachment

Our multi sampling attachment actually goes into a property named resolve attachment because it is used to resolve the final image output for the subpass. The multi sampling would be applied when resolving the final image to suppress aliasing in the image:

vk::AttachmentDescription multiSamplingAttachment{
    vk::AttachmentDescriptionFlags(), // Flags
    colorFormat,                      // Format
    vk::SampleCountFlagBits::e1,      // Samples
    vk::AttachmentLoadOp::eDontCare,  // Load operation
    vk::AttachmentStoreOp::eStore,    // Store operation
    vk::AttachmentLoadOp::eDontCare,  // Stencil load operation
    vk::AttachmentStoreOp::eDontCare, // Stencil store operation
    vk::ImageLayout::eUndefined,      // Initial layout
    vk::ImageLayout::ePresentSrcKHR}; // Final layout

The properties are:

And for the attachment reference:

vk::AttachmentReference multiSamplingAttachmentReference{
    2,                                         // Attachment index
    vk::ImageLayout::eColorAttachmentOptimal}; // Layout

Here we are specifying 2 as the index position where the attachment description can be found and the layout is eColorAttachmentOptimal because the output of the multi sampling is still effectively a colour image.

List of all attachments

We now create a list (technically a std::array) holding our three attachments so it can be passed into another configuration object soon. Observe that the order of the attachments reflects the attachment index values we specified in each of the attachment references. If the attachment indices don’t correlate to the correct attachment descriptions in the array our render pass will not function properly:

std::array<vk::AttachmentDescription, 3> attachments{
    colorAttachment,
    depthTestingAttachment,
    multiSamplingAttachment};

Subpass

We have created all the attachments we need, but a render pass needs at least one subpass. Our subpass will stitch together all the attachments to describe how to perform its operations:

vk::SubpassDescription subpass{
    vk::SubpassDescriptionFlags(),     // Flags
    vk::PipelineBindPoint::eGraphics,  // Pipeline bind point
    0,                                 // Input attachment count
    nullptr,                           // Input attachments
    1,                                 // Color attachments count
    &colorAttachmentReference,         // Color attachments
    &multiSamplingAttachmentReference, // Resolve attachments
    &depthTestingAttachmentReference,  // Depth stencil attachments
    0,                                 // Preserve attachments count
    nullptr};                          // Preserve attachments

The properties are:

Subpass dependencies

We have a subpass and even though we only have one of them, we must define its dependencies. If we had more than one subpass, the dependencies would allow Vulkan to orchestrate the order of operations amongst them:

vk::SubpassDependency subpassDependency{
    0,                                                                                    // Source subpass index
    0,                                                                                    // Destination subpass index
    vk::PipelineStageFlagBits::eColorAttachmentOutput,                                    // Source access mask
    vk::PipelineStageFlagBits::eColorAttachmentOutput,                                    // Destination access mask
    vk::AccessFlags(),                                                                    // Source access flags
    vk::AccessFlagBits::eColorAttachmentRead | vk::AccessFlagBits::eColorAttachmentWrite, // Destination access flags
    vk::DependencyFlags()};                                                               // Dependency flags

The important properties:

“VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT specifies the stage of the pipeline after blending where the final color values are output from the pipeline. This stage also includes subpass load and store operations and multisample resolve operations for framebuffer attachments with a color or depth/stencil format.”

Render pass creation info

We can now use all the components we’ve configured to describe the actual render pass itself through a vk::RenderPassCreateInfo object.

vk::RenderPassCreateInfo renderPassCreateInfo{
    vk::RenderPassCreateFlags(),               // Flags
    static_cast<uint32_t>(attachments.size()), // Attachment count
    attachments.data(),                        // Attachments
    1,                                         // Subpass count
    &subpass,                                  // Subpasses
    1,                                         // Dependency count
    &subpassDependency};                       // Dependencies

Create render pass

Ok, finally we can create and return the render pass, by invoking the correct method on our device:

return device.getDevice().createRenderPassUnique(renderPassCreateInfo);

Update render context

We have enough of our render pass written now to instantiate one in our Vulkan render context. Pop open vulkan-render-context.cpp and add the render pass header:

#include "vulkan-render-pass.hpp"

Then go to the Internal struct and add a field to hold a render pass and create it in the constructor:

struct VulkanRenderContext::Internal
{
    const ast::VulkanSwapchain swapchain;
    const ast::VulkanRenderPass renderPass;

    Internal(const ast::SDLWindow& window,
             const ast::VulkanPhysicalDevice& physicalDevice,
             const ast::VulkanDevice& device,
             const ast::VulkanSurface& surface)
        : swapchain(ast::VulkanSwapchain(window, physicalDevice, device, surface)),
          renderPass(ast::VulkanRenderPass(physicalDevice, device, swapchain)) {}
};

Run your application and once again you won’t see anything different but if it boots up with Vulkan successfully initialised you know that our render pass was created correctly.

Phew … you might want to stop here for a minute and absorb all that - maybe have a coffee or a sleep. There is still a fair bit more to go but we are chipping away at it!!


Where are we?

It is worth a quick note that the code we are writing at the moment is still executed during the constructor of the Vulkan application class. This means that any exceptions that propagate out will cause our main engine to fall back to the OpenGL implementation. We have a few more things to initialise during construction but eventually we will reach a point where we stop running the code during construction and instead defer it through our scene class during the application loop.


Summary

I have broken the renderer implementation into multiple articles to avoid them getting too long. In the next article we will revisit our render pass class and add frame buffers to it which are required to fulfill the render pass -> swapchain image relationship.

The code for this article can be found here.

Continue to Part 23: Vulkan frame buffers.

End of part 22