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.
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:
beginCommandBuffer
: This will create and configure a single command buffer, starting it then handing it back to the caller to use as they see fit. It is the responsibility of the caller to call the matching endCommandBuffer
function to clean up the command. Pay attention to the fact that this function does not return a const&
reference - it actually returns a vk::UniqueCommandBuffer
instance, making this effectively a factory function.endCommandBuffer
: This will end the command buffer specified and use the graphics queue to wait for the commands within the command buffer to be fully completed.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:
std::move
operation to cherry pick the command buffer at position 0, since we know we only created 1 buffer.eOneTimeSubmit
flag.The endCommandBuffer
function does the following:
vk::SubmitInfo
object which describes the command buffer as an object we want to submit to the graphics queue for processing.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.
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:
physicalDevice
: Our own physical device wrapper class to give us access to some memory related functions which we’ll write later in this article.device
: The current logical device is responsible for allocating Vulkan images and their associated memory.width
: The width of the image to allocate.height
: The height of the image to allocate.mipLevels
: How many levels of mip mapping to apply to the image. For some kinds of images we won’t actually want to generate mip maps such as for the multi sampling or depth testing images. Other images such as for texturing geometry we would want mip mapping.sampleCount
: The multi sampling level to apply to the image.format
: The colour format to apply - this would typically be the same format as supported by the swapchain so the image remains compatible with it.tiling
: This specifies how to lay out the image data in memory. You can find out more here: https://www.khronos.org/registry/vulkan/specs/1.1-extensions/man/html/VkImageTiling.html.usageFlags
: An image might be used for different purposes, for example a depth testing image might be only suitable for use with the VK_IMAGE_USAGE_DEPTH_STENCIL_ATTACHMENT_BIT
flag, indicating that it will be compatible with a frame buffer related to depth testing. Read more about the other types of image usage flags here: https://www.khronos.org/registry/vulkan/specs/1.1-extensions/man/html/VkImageUsageFlagBits.html.memoryFlags
: This flag specifies what level of access is going to be applied to the image, for example if only the graphics device will need to access the image data we would use a flag of eDeviceLocal
which resolves to VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT
. Read more about the different types of memory flags here: https://www.khronos.org/registry/vulkan/specs/1.1-extensions/man/html/VkMemoryPropertyFlagBits.html.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:
vk::ImageType::e2D
: This specifies what kind of image to create, for us we just want a vanilla 2 dimensional image.format
: This is the colour format to apply to the image, for some images this would be the colour format of our swapchain.extent
: This is the description of the width, height and depth of the image as previously explained.mipLevels
: What mip mapping levels to apply, typically for geometry texturing this would be greater than 1 but for images such as depth testing or multi sampling it would likely just be 1.1
: The image will only have 1 layer of data.sampleCount
: The multi sampling that will be applied. The Vulkan documentation has some nice information about multi sampling support: https://www.khronos.org/registry/vulkan/specs/1.1-extensions/html/vkspec.html#primsrast-multisampling.tiling
: The way the image data is represented in memory. For more: https://www.khronos.org/registry/vulkan/specs/1.1-extensions/html/vkspec.html#VkImageTiling.usageFlags
: This property describes what kind of behaviour should be associated with the image. You can find more info here: https://www.khronos.org/registry/vulkan/specs/1.1-extensions/html/vkspec.html#VkImageUsageFlagBits. For example, if our image was destined to hold the colour output of our render pass it might have the VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT
flag set which via C++ would be vk::ImageUsageFlagBits::eColorAttachment
.vk::SharingMode::eExclusive
: The sharing mode specifies how the image data can be accessed - we will use exclusive access by default. https://www.khronos.org/registry/vulkan/specs/1.1-extensions/html/vkspec.html#VkSharingMode.0
/ nullptr
: If we specified a sharing mode of concurrent
then we would have to list the queue families that are allowed to access the data in this image. As we are using exclusive
sharing mode this is not applicable. The official docs state:
- 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::ImageLayout::eUndefined
: What kind of layout to assign to the image to begin with. In a later step we have to transition the image layout type to another layout type but initially undefined is a good choice. The types are described here: https://www.khronos.org/registry/vulkan/specs/1.1-extensions/html/vkspec.html#VkImageLayout. We are using undefined
to align with the following documentation description: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:
0
.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.
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.
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.
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:
physicalDevice.getDepthFormat
for the colour format argument.vk::ImageUsageFlagBits::eDepthStencilAttachment
as the image usage flag because our depth testing won’t output a colour attachment.vk::ImageLayout::eUndefined
as the old layout and vk::ImageLayout::eDepthStencilAttachmentOptimal
as the new layout - this will require us to cater for a new image layout transition scenario.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.
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:
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!
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