a-simple-triangle / Part 28 - Vulkan render scene

It’s been a long road getting Vulkan up and running - on a few occasions I really had to force my motivation levels to complete some of the more tricky Vulkan articles though I’m glad I pushed through to this point. This article will reward us finally by seeing our 3D scene come to life in our Vulkan renderer.

We will:


Update asset manager

When we get to our rendering code we’ll need to ask our asset manager for its cached pipeline instances. For our application we’ve only written one pipeline however it would be reasonable to expect a fully featured application would have many pipelines.

Edit vulkan-asset-manager.hpp and add the pipeline header to it:

#include "vulkan-pipeline.hpp"

Then add a new function signature to allow a visitor to ask for a pipeline:

namespace ast
{
    struct VulkanAssetManager
    {
        ...

        const ast::VulkanPipeline& getPipeline(const ast::assets::Pipeline& pipeline) const;

Go to vulkan-asset-manager.cpp and remove the Vulkan pipeline include statement since it is now in the header. Add the public function implementation at the bottom of the file:

const ast::VulkanPipeline& VulkanAssetManager::getPipeline(const ast::assets::Pipeline& pipeline) const
{
    return internal->pipelineCache.at(pipeline);
}

Nothing more to do in the asset manager - to be honest we probably should have added this getter function a few articles ago.


Implement the pipeline render function

Our Vulkan pipeline class has the following function signature which is intended to be used inside our render loop though it currently has an empty implementation:

namespace ast
{
    struct VulkanPipeline
    {
        ...

        void render(
            const ast::VulkanAssetManager& assetManager,
            const std::vector<ast::StaticMeshInstance>& staticMeshInstances) const;

Now is the time for us to implement this render function but we need to make a slight adjustment to its signature to require a command buffer to be passed in as an argument. We need this because most rendering operations actually need to be recorded into a command buffer rather than directly executed. We will also need a Vulkan logical device to enable us to create descriptor sets (more on that in a bit).

The command buffer that our pipeline will need should be the currently active command buffer that our Vulkan render context class holds, which we expect should be aligned to the current swapchain image being used in the render loop.

We don’t yet have a way to get the currently active command buffer but we’ll update our Vulkan render context later in this article to expose it. For the moment though we’ll just add the logical device and command buffer arguments to our pipeline class, so this:

void render(
    const ast::VulkanAssetManager& assetManager,
    const std::vector<ast::StaticMeshInstance>& staticMeshInstances) const;

becomes this:

void render(const ast::VulkanDevice& device,
            const vk::CommandBuffer& commandBuffer,
            const ast::VulkanAssetManager& assetManager,
            const std::vector<ast::StaticMeshInstance>& staticMeshInstances) const;

Now for the implementation, edit vulkan-pipeline.cpp and update the render function inside the Internal structure to this:

struct VulkanPipeline::Internal
{
    ...

    void render(const ast::VulkanDevice& device,
                const vk::CommandBuffer& commandBuffer,
                const ast::VulkanAssetManager& assetManager,
                const std::vector<ast::StaticMeshInstance>& staticMeshInstances)
    {
    }

Also update the public render function at the bottom of the file to sync up the changes to the arguments:

void VulkanPipeline::render(const ast::VulkanDevice& device,
                            const vk::CommandBuffer& commandBuffer,
                            const ast::VulkanAssetManager& assetManager,
                            const std::vector<ast::StaticMeshInstance>& staticMeshInstances) const
{
    internal->render(device, commandBuffer, assetManager, staticMeshInstances);
}

While we are here, update the include statements in the cpp file to the following to set us up for the remaining implementation:

#include "vulkan-pipeline.hpp"
#include "../../core/asset-inventory.hpp"
#include "../../core/assets.hpp"
#include "../../core/vertex.hpp"
#include "vulkan-mesh.hpp"
#include "vulkan-texture.hpp"
#include <unordered_map>

Pipeline render implementation

The flow of our render implementation goes a bit like this:

: loop through each mesh instance in the mesh instances list to render

  - get the mesh from the asset manager
  - populate the push constants with the transformation matrix of the mesh
  - bind the pipeline to the command buffer
  - bind the vertex buffer for the mesh to the command buffer
  - bind the index buffer for the mesh to the command buffer
  - get the texture instance to apply to the mesh from the asset manager
  - get the descriptor set for the texture that defines how it maps to the pipeline's descriptor set layout
  - bind the descriptor set for the texture into the pipeline's descriptor set layout
  - tell the command buffer to draw the mesh

: end loop

Texture descriptor sets

The descriptor set steps will be new for us - we’ve not needed to create descriptor sets before. I mentioned in an earlier article a couple of links that help to describe what descriptor sets are:

In our scenario, each texture we want to bind to our shader must have a descriptor set that aligns with the descriptor set layout for the pipeline it is bound within. The relationship between a texture and a pipeline is that a texture needs a descriptor set for each pipeline layout it might be consumed in. We are going to make it the responsibility of the pipeline itself to generate descriptor sets for textures it will bind rather than the texture itself creating its descriptor sets. By doing this we decouple textures from Vulkan lifecycle changes and from needing to know anything about the pipelines they might be used in.

I know this sounds a bit confusing - we’ll just have to walk through the code slowly and I’ll try to explain as we go.

Descriptor set pool

In Vulkan if we want to create a new descriptor set - which each unique texture we want to bind will need - we must do it through a descriptor pool. There are probably a few ways to share a descriptor pool between multiple pipelines but we will choose to make our pipeline class create its own descriptor pool internally, meaning it will be destroyed and recreated whenever the pipeline is.

Add a new createDescriptorPool free function to the anonymous namespace to help us generate a new descriptor pool:

namespace
{
    ...

    vk::UniqueDescriptorPool createDescriptorPool(const ast::VulkanDevice& device)
    {
        const uint32_t maxDescriptors{64};

        vk::DescriptorPoolSize combinedImageSamplerPoolSize{
            vk::DescriptorType::eCombinedImageSampler, // Type
            maxDescriptors};                           // Max descriptor count

        std::array<vk::DescriptorPoolSize, 1> poolSizes{combinedImageSamplerPoolSize};

        vk::DescriptorPoolCreateInfo info{
            vk::DescriptorPoolCreateFlagBits::eFreeDescriptorSet, // Flags
            maxDescriptors,                                       // Max sets
            static_cast<uint32_t>(poolSizes.size()),              // Pool size count
            poolSizes.data()};                                    // Pool sizes

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

The maxDescriptors defines how many descriptor sets can be added to the pool and allocates storage for that number. I haven’t been able to determine a clear rule as to what this number should be, but at the very least it should be big enough to hold the maximum number of descriptors you expect could be needed.

In our use case we are only adding combined image sampler descriptors so realistically we only need to allow for the total number of unique texture samplers that could possibly be needed within our pipeline since every unique texture has its own sampler. In fact in the current state of our application we only have 2 possible textures because of our ast::assets::Texture enumeration so technically I could have set the max descriptors to 2, however that’s hardly a scalable option if we added more textures:

namespace ast::assets
{
    ...

    enum class Texture
    {
        Crate,
        RedCrossHatch
    };
}

I’ve gone for 64 here but I suppose different applications might need more or less of them.

Within a descriptor pool we must also define how many of each type of descriptor sets are permitted to be in the pool. Our pool only has 1 descriptor set type (vk::DescriptorType::eCombinedImageSampler) so it can claim the full value of maxDescriptors as its size. If we added other descriptor set types to the pool, they would need to share the pool.

The info object takes in the maxDescriptors and all the individual pool size definitions for each descriptor set type. Something to take note of is the flags are set to vk::DescriptorPoolCreateFlagBits::eFreeDescriptorSet. If we did not set this flag then we get Vulkan validation errors when our pipeline is destroyed and regenerated due to lifecycle changes. Having this flag means the pool will evict all of its descriptor sets when it goes out of scope.

At the end of the function the logical device takes the info object and returns a new descriptor pool.

Update the Internal structure to hold an instance of a descriptor pool, initialising it in the constructor:

struct VulkanPipeline::Internal
{
    ...
    const vk::UniqueDescriptorPool descriptorPool;

    Internal(...)
          ...
          descriptorPool(::createDescriptorPool(device)) {}

Creating a descriptor set cache

In the render flow we will need to obtain a descriptor set in order to bind a texture to the descriptor set layout - effectively gluing it into the following fragment shader uniform:

layout(binding = 0) uniform sampler2D texSampler;

Because we don’t know ahead of time which textures will be bound in our render loop we will lazily create and cache unique texture descriptor sets as they are encountered. We will hold the cache as a field in our Internal structure. Add the following to act as our texture id -> descriptor set cache:

struct VulkanPipeline::Internal
{
    ...
    std::unordered_map<ast::assets::Texture, vk::UniqueDescriptorSet> textureSamplerDescriptorSets;

There is no need to initialise it in the constructor.

Create a texture sampler descriptor set

Next we’ll author a new free function in the anonymous namespace that takes a texture and creates a descriptor set from it which we can use to bind into the texture sampler uniform in the fragment shader. Add the following to the anonymous namespace:

namespace
{
    ...

    vk::UniqueDescriptorSet createTextureSamplerDescriptorSet(const ast::VulkanDevice& device,
                                                              const vk::DescriptorPool& descriptorPool,
                                                              const vk::DescriptorSetLayout& descriptorSetLayout,
                                                              const ast::VulkanTexture& texture)
    {
        vk::DescriptorSetAllocateInfo createInfo{
            descriptorPool,        // Descriptor pool
            1,                     // Descriptor set count
            &descriptorSetLayout}; // Descriptor set layouts

        vk::UniqueDescriptorSet descriptorSet{
            std::move(device.getDevice().allocateDescriptorSetsUnique(createInfo)[0])};

        vk::DescriptorImageInfo imageInfo{
            texture.getSampler(),                     // Sampler
            texture.getImageView().getImageView(),    // Image view
            vk::ImageLayout::eShaderReadOnlyOptimal}; // Image layout

        vk::WriteDescriptorSet writeInfo{
            descriptorSet.get(),                       // Destination set
            0,                                         // Destination binding
            0,                                         // Destination array element
            1,                                         // Descriptor count
            vk::DescriptorType::eCombinedImageSampler, // Descriptor type
            &imageInfo,                                // Image info
            nullptr,                                   // Buffer info
            nullptr};                                  // Texel buffer view

        device.getDevice().updateDescriptorSets(1, &writeInfo, 0, nullptr);

        return descriptorSet;
    }

The createInfo object describes how to allocate a new descriptor set from the descriptor pool:

vk::DescriptorSetAllocateInfo createInfo{
    descriptorPool,        // Descriptor pool
    1,                     // Descriptor set count
    &descriptorSetLayout}; // Descriptor set layouts

Notice that it takes the descriptorSetLayout which it uses to figure out what the descriptor sets are that should be made. We also specify 1 to indicate we only want a single descriptor set allocated.

Next we ask our logical device to manufacture the collection of descriptor sets defined in the createInfo object:

vk::UniqueDescriptorSet descriptorSet{
    std::move(device.getDevice().allocateDescriptorSetsUnique(createInfo)[0])};

The syntax seems a bit odd here, but we must perform a std::move because we asked for the UniqueDescriptorSet type which can’t be copied. We also dereference the descriptor set at element position [0] which we know is the only one because we asked to create just a single descriptor set but the allocateDescriptorSetsUnique actually returns a vector of descriptor sets even if there was only 1 created.

An imageInfo object is then defined to describe how to associate a sampler with an imageView, declaring that the associated descriptor set will be read only inside our shader. We use the texture argument to discover which texture sampler and image view to use. Note that this is why we added the getSampler and getImageView functions to our ast::Texture class earlier:

vk::DescriptorImageInfo imageInfo{
    texture.getSampler(),                     // Sampler
    texture.getImageView().getImageView(),    // Image view
    vk::ImageLayout::eShaderReadOnlyOptimal}; // Image layout

To commit our descriptor set configurations we must write them to the allocated descriptor set instance via our logical device:

vk::WriteDescriptorSet writeInfo{
    descriptorSet.get(),                       // Destination set
    0,                                         // Destination binding
    0,                                         // Destination array element
    1,                                         // Descriptor count
    vk::DescriptorType::eCombinedImageSampler, // Descriptor type
    &imageInfo,                                // Image info
    nullptr,                                   // Buffer info
    nullptr};                                  // Texel buffer view

device.getDevice().updateDescriptorSets(1, &writeInfo, 0, nullptr);

You can see that the writeInfo object specifies the newly created descriptorSet as the descriptor set to write the configuration into, along with a type of eCombinedImageSampler and the imageInfo object which describes the relationship between which texture sampler and which image view.

Finally we return the now updated descriptor set instance that has been written to with all of its appropriate configurations:

return descriptorSet;

Fetching and putting descriptor sets into the cache

One last helper function to write before our render function implementation - we need a way to fetch a texture descriptor set from our cache and insert a new one created with our createTextureSamplerDescriptorSet function if the cache doesn’t have it. Add the following function inside our Internal structure to help us with this:

struct VulkanPipeline::Internal
{
    ...

    const vk::DescriptorSet& getTextureSamplerDescriptorSet(const ast::VulkanDevice& device,
                                                            const ast::VulkanTexture& texture)
    {
        if (textureSamplerDescriptorSets.count(texture.getTextureId()) == 0)
        {
            textureSamplerDescriptorSets.insert(std::make_pair(
                texture.getTextureId(),
                ::createTextureSamplerDescriptorSet(device,
                                                    descriptorPool.get(),
                                                    descriptorSetLayout.get(),
                                                    texture)));
        }

        return textureSamplerDescriptorSets.at(texture.getTextureId()).get();
    }

The code is quite similar to our asset manager implementation, we take the texture ID from the texture we are trying to find via the texture.getTextureId() function and check to see if our cache contains it:

if (textureSamplerDescriptorSets.count(texture.getTextureId()) == 0)

If the cache does not contain a descriptor set yet for this type of texture we insert one into our cache using the createTextureSamplerDescriptorSet function we wrote earlier:

textureSamplerDescriptorSets.insert(std::make_pair(
    texture.getTextureId(),
    ::createTextureSamplerDescriptorSet(device,
                                        descriptorPool.get(),
                                        descriptorSetLayout.get(),
                                        texture)));

Either way we return the descriptor set in the cache for the texture ID:

return textureSamplerDescriptorSets.at(texture.getTextureId()).get();

Render function

Ok here is the fun part, recall the pseudo flow of our render function is like this:

: loop through each mesh instance in the meshes list to render

  - get the mesh from the asset manager
  - populate the push constants with the transformation matrix of the mesh
  - bind the pipeline to the command buffer
  - bind the vertex buffer for the mesh to the command buffer
  - bind the index buffer for the mesh to the command buffer
  - get the texture instance to apply to the mesh from the asset manager
  - get the descriptor set for the texture that defines how it maps to the pipeline's descriptor set layout
  - bind the descriptor set for the texture into the pipeline's descriptor set layout
  - tell the command buffer to draw the mesh

: end loop

Add the following header to import the Vulkan asset manager properly - so far we only used a forward declaration in the Vulkan pipeline header:

#include "vulkan-asset-manager.hpp"

Important: Be sure you add the Vulkan asset manager #include statement to vulkan-pipeline.cpp not vulkan-pipeline.hpp!

Next edit the render function in the Internal structure with the following code:

struct VulkanPipeline::Internal
{
    ...

    void render(const ast::VulkanDevice& device,
                const vk::CommandBuffer& commandBuffer,
                const ast::VulkanAssetManager& assetManager,
                const std::vector<ast::StaticMeshInstance>& staticMeshInstances)
    {
        for (const ast::StaticMeshInstance& meshInstance : staticMeshInstances)
        {
            const ast::VulkanMesh& mesh{assetManager.getStaticMesh(meshInstance.getMesh())};
            const glm::mat4& transform{meshInstance.getTransformMatrix()};

            commandBuffer.pushConstants(pipelineLayout.get(),
                                        vk::ShaderStageFlagBits::eAllGraphics,
                                        0,
                                        sizeof(glm::mat4),
                                        &transform);

            commandBuffer.bindPipeline(vk::PipelineBindPoint::eGraphics, pipeline.get());

            vk::DeviceSize offsets[]{0};
            commandBuffer.bindVertexBuffers(0, 1, &mesh.getVertexBuffer(), offsets);

            commandBuffer.bindIndexBuffer(mesh.getIndexBuffer(), 0, vk::IndexType::eUint32);

            const ast::VulkanTexture& texture{assetManager.getTexture(meshInstance.getTexture())};

            const vk::DescriptorSet& textureSamplerDescriptorSet{
                getTextureSamplerDescriptorSet(device, texture)};

            commandBuffer.bindDescriptorSets(vk::PipelineBindPoint::eGraphics,
                                             pipelineLayout.get(),
                                             0,
                                             1, &textureSamplerDescriptorSet,
                                             0, nullptr);

            commandBuffer.drawIndexed(mesh.getNumIndices(), 1, 0, 0, 0);
        }

If you compare the code with the psuedo flow you should see they match up fairly well, though we’ll walk through the function a bit at a time to clarify.

Firstly we start a loop through each mesh instance to render and for each one fetch the mesh asset from the asset manager:

for (const ast::StaticMeshInstance& meshInstance : staticMeshInstances)
{
    const ast::VulkanMesh& mesh{assetManager.getStaticMesh(meshInstance.getMesh())};

We then grab the transform matrix of the current mesh instance using meshInstance.getTransformMatrix(), which represents the MVP or Model, View, Projection of the mesh to apply in the shader. The matrix is fed into our shader via the push constant we configured earlier through the pipeline layout associated with the pipeline we are in:

const glm::mat4& transform{meshInstance.getTransformMatrix()};

commandBuffer.pushConstants(pipelineLayout.get(),
                            vk::ShaderStageFlagBits::eAllGraphics,
                            0,
                            sizeof(glm::mat4),
                            &transform);

Note: We must put the transform matrix into a local variable or some platforms will not compile (such as Android).

Recall that our vertex shader has the following push constant declared with a mat4 mvp field, which is where our transform matrix will be injected:

layout(push_constant) uniform PushConstants {
    mat4 mvp;
} pushConstants;

Next we direct the command buffer to bind our pipeline to the eGraphics point:

commandBuffer.bindPipeline(vk::PipelineBindPoint::eGraphics, pipeline.get());

Then we bind the vertices of the current mesh into the command buffer by asking our mesh for its vertex buffer. Note that offsets argument needs to be declared as its own object instead of placing it inline inside the function invocation, otherwise we get a run time error:

vk::DeviceSize offsets[]{0};
commandBuffer.bindVertexBuffers(0, 1, &mesh.getVertexBuffer(), offsets);

The indices of the mesh are bound next by asking our mesh for its index buffer along with the size of data for each element (uint32_t):

commandBuffer.bindIndexBuffer(mesh.getIndexBuffer(), 0, vk::IndexType::eUint32);

Next up we fetch the texture asset that the mesh instance wants to be painted with from the asset manager:

const ast::VulkanTexture& texture{assetManager.getTexture(meshInstance.getTexture())};

We then use the getTextureSamplerDescriptorSet function which we recently authored to get the descriptor set for the specific texture type we need to apply to the texture sampler uniform in the fragment shader. Remember - if the texture descriptor set cache doesn’t yet have a descriptor set for the texture it will create one and cache it on the way:

const vk::DescriptorSet& textureSamplerDescriptorSet{
    getTextureSamplerDescriptorSet(device, texture)};

With a descriptor set chosen which represents the texture sampler for the current texture we can ask the command buffer to bind it into the descriptor set layout:

commandBuffer.bindDescriptorSets(vk::PipelineBindPoint::eGraphics,
                                 pipelineLayout.get(),
                                 0,
                                 1, &textureSamplerDescriptorSet,
                                 0, nullptr);

This has the effect of placing the texture sampler from the Vulkan texture into the following uniform in the fragment shader script:

layout(binding = 0) uniform sampler2D texSampler;

Finally we tell the command buffer to perform a draw operation specifying the number of indices it should process:

commandBuffer.drawIndexed(mesh.getNumIndices(), 1, 0, 0, 0);

That completes our render function implementation! Of course if you run the program at this point we still don’t see anything because nothing is yet invoking our pipeline to render anything.


Expose active rendering command buffer

If we cast our minds way back to when we authored the ast::VulkanContext class we had the following sequence of function calls on each render loop:

render begin -> render -> render end

In the render begin phase our Vulkan render context performed a bunch of operations to get the next Vulkan swapchain image ready and to prepare the appropriate command buffer for that swapchain image:

bool renderBegin()
{
    if (!renderContext.renderBegin(device))
    {
        recreateRenderContext();
        return false;
    }

    return true;
}

In the render phase which sits in the middle, we have no implementation in vulkan-context.cpp:

void render(const ast::assets::Pipeline& pipeline,
            const std::vector<ast::StaticMeshInstance>& staticMeshInstances)
{
    // TODO: Implement me
}

In the render end phase our Vulkan render context ends the active command buffer and presents the resulting rendered frame to the user:

void renderEnd()
{
    if (!renderContext.renderEnd(device))
    {
        recreateRenderContext();
    }
}

Update the render function in vulkan-context.cpp class to look like this - yes it will have syntax errors:

void render(const ast::assets::Pipeline& pipeline,
            const std::vector<ast::StaticMeshInstance>& staticMeshInstances)
{
    assetManager.getPipeline(pipeline).render(device,
                                              renderContext.getActiveCommandBuffer(),
                                              assetManager,
                                              staticMeshInstances);
}

We ask the asset manager to get the pipeline based on the pipeline argument and call its render function, passing in the required components. One of the components that is passed to the pipeline’s render function doesn’t actually exist yet:

renderContext.getActiveCommandBuffer()

Remember that our pipeline render function requires us to pass it a command buffer which should be the currently active command buffer in the render loop. The problem is that our ast::VulkanRenderContext class doesn’t expose its internal command buffer during its rendering flow. Let’s address this problem now - edit vulkan-render-context.hpp and add a getActiveCommandBuffer function signature to expose the active command buffer:

namespace ast
{
    struct VulkanRenderContext
    {
        ...

        const vk::CommandBuffer& getActiveCommandBuffer() const;

Edit vulkan-render-context.cpp - we will add a new function to the Internal structure that can tell us at any time what the current active command buffer is:

struct VulkanRenderContext::Internal
{
    ...

    const vk::CommandBuffer& getActiveCommandBuffer() const
    {
        return commandBuffers[currentSwapchainImageIndex].get();
    }

We can then update the internal renderBegin function changing the following line:

const vk::CommandBuffer& commandBuffer{commandBuffers[currentSwapchainImageIndex].get()};

to this:

const vk::CommandBuffer& commandBuffer{getActiveCommandBuffer()};

Also update the internal renderEnd function changing the following line:

const vk::CommandBuffer& commandBuffer{commandBuffers[currentSwapchainImageIndex].get()};

to this:

const vk::CommandBuffer& commandBuffer{getActiveCommandBuffer()};

And finally we can add the public getActiveCommandBuffer function to the bottom of the file which will also use this new internal function:

const vk::CommandBuffer& VulkanRenderContext::getActiveCommandBuffer() const
{
    return internal->getActiveCommandBuffer();
}

We made it!!!

Ok, run your program and ….

MacOS (console and bundled application):

Windows:

Android:

iOS:

Yay! It only took about 16 articles to get Vulkan up and running!!


Summary

I am very happy to have been able to get to this point with a basic application that can run on so many different platforms with a shared code base, supporting both OpenGL and Vulkan. Before I finish this series we will fix a bug related to resizing the window and add some very basic input to allow us to move around our 3D scene.

The code for this article can be found here.

Continue to Part 29: Window resize events.

End of part 28