a-simple-triangle / Part 20 - Vulkan create device

Vulkan - Create Device

Now that we have completed the setup for all of our platform targets to consume Vulkan the fun really begins! This article will revisit the initialisation code we wrote earlier that bootstraps Vulkan, adding in the following remaining components that Vulkan requires:

Before proceeding I’d highly recommend spending some time reviewing the following web sites to get familiar with some of these concepts - especially the Vulkan Tutorial site which taught me quite a lot about this:

Before we begin, be warned this is a long article! You may need to take a break during it - I certainly needed to while writing it!


Housekeeping - SDL window

For the next part of our implementation we are actually going to be moving the creation of the SDL window from the vulkan-application.cpp into the vulkan-context.cpp for the following reasons:

We will begin by creating the new encapsulation class for an SDL window. In the main/src/core folder create sdl-window.hpp and sdl-window.cpp. Enter the following into the header:

#pragma once

#include "internal-ptr.hpp"
#include "sdl-wrapper.hpp"

namespace ast
{
    struct SDLWindow
    {
        SDLWindow(const uint32_t& windowFlags);

        SDL_Window* getWindow() const;

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

The header will take in what window flags to use when creating the window and provide a getWindow function to access the raw SDL window object which we’ll need a bit later on to generate a surface for Vulkan.

Edit the implementation with the following:

#include "sdl-window.hpp"

using ast::SDLWindow;

struct SDLWindow::Internal
{
    SDL_Window* window;

    Internal(const uint32_t& windowFlags) : window(ast::sdl::createWindow(windowFlags)) {}

    ~Internal()
    {
        SDL_DestroyWindow(window);
    }
};

SDLWindow::SDLWindow(const uint32_t& windowFlags) : internal(ast::make_internal_ptr<Internal>(windowFlags)) {}

SDL_Window* SDLWindow::getWindow() const
{
    return internal->window;
}

Not too much going on, we are just creating and holding an SDL_Window object and destroying it when our class instance goes out of scope. Unfortunately we can’t make the window field a const as we have to interact with the SDL APIs which usually require non-const pointers to SDL objects.

Update OpenGL application

To use this new SDL window class, first revisit our opengl-application.cpp and replace:

#include "../../core/sdl-wrapper.hpp"

with:

#include "../../core/sdl-window.hpp"

Then in the Internal implementation replace the fields and constructor with the following - noting that we now hold an ast::SDLWindow object instead of an SDL_Window* pointer:

struct OpenGLApplication::Internal
{
    const ast::SDLWindow window;
    SDL_GLContext context;
    const std::shared_ptr<ast::OpenGLAssetManager> assetManager;
    ast::OpenGLRenderer renderer;
    std::unique_ptr<ast::Scene> scene;

    Internal() : window(ast::SDLWindow(SDL_WINDOW_OPENGL | SDL_WINDOW_RESIZABLE | SDL_WINDOW_ALLOW_HIGHDPI)),
                 context(::createContext(window.getWindow())),
                 assetManager(::createAssetManager()),
                 renderer(::createRenderer(assetManager)) {}

Replace the remaining use cases of window with window.getWindow() and delete the following line from the destructor:

// Delete this line
SDL_DestroyWindow(window);

Update Vulkan application

Edit vulkan-application.cpp and update the headers and Internal struct to look like this:

#include "vulkan-application.hpp"
#include "../../core/graphics-wrapper.hpp"
#include "vulkan-context.hpp"

using ast::VulkanApplication;

struct VulkanApplication::Internal
{
    const ast::VulkanContext context;

    Internal() : context(ast::VulkanContext()) {}

    void update(const float& delta) {}

    void render() {}
};

Note that we have completely removed the window field and initialisation. The window will instead become part of our VulkanContext. Edit vulkan-context.cpp now and add the following header include:

#include "../../core/sdl-window.hpp"

Now replace the Internal struct implementation with the following:

struct VulkanContext::Internal
{
    const vk::UniqueInstance instance;
    const ast::SDLWindow window;

    Internal() : instance(::createInstance()),
                 window(ast::SDLWindow(SDL_WINDOW_VULKAN | SDL_WINDOW_RESIZABLE | SDL_WINDOW_ALLOW_HIGHDPI))
    {
        ast::log("ast::VulkanContext", "Initialized Vulkan context successfully.");
    }
};

Note that we are now creating and holding the window field during the construction of the context. This will become more important in the next section when we need to create physical and logical devices for Vulkan.

Run your application again - it should all work similarly to before.


Validation layers

By default Vulkan does not perform error checking and validation for how a programmer uses its APIs. It does this to favour speed of execution but means that the programmer is now responsible for knowing if they are calling Vulkan APIs correctly. Vulkan will happily execute code that could cause problems or errors and when problems happen they can be very difficult to diagnose without some kind of validation checking and reporting.

To help us identify Vulkan problems we will update our Vulkan application configuration to include validation layers - but only if we are running a debug version of our program. A validation layer is a layer of code that sits between our own Vulkan API calls and Vulkan itself. We can use numerous layers if we want to, however we’ll use a special validation layer that ships with the Vulkan SDK which includes a bunch of other useful layers for us. Vulkan won’t activate any layers by default so we need to do it ourselves.

Important: I would highly recommend that you don’t skip setting up validation layers. While trying to learn Vulkan I wrote a fair amount of code without using validation layers and got into all kinds of frustrating issues that were impossible to diagnose. Once I had setup the validation layers the issues became crystal clear and the feedback from the validation layers often pinpointed exactly how I was incorrectly using Vulkan.

Determine the layers to apply

Hop into vulkan-context.cpp and add make sure the following headers are included:

#include <set>
#include <vector>

Then create a new free function which we will use to fetch the list of validation layers that we are interested in and that are available on the current device:

namespace
{
    std::vector<std::string> getDesiredValidationLayers()
    {
        std::vector<std::string> result;

#ifndef NDEBUG
        // If we are in a debug build we will cultivate a list of validation layers.
        static const std::string logTag{"ast::VulkanContext::getDesiredValidationLayers"};

        // Collate which validations layers we are interested in applying if they are available.
        std::set<std::string> desiredLayers{"VK_LAYER_LUNARG_standard_validation"};

        // Iterate all the available layers for the current device.
        for (auto const& properties : vk::enumerateInstanceLayerProperties())
        {
            std::string layerName{properties.layerName};

			ast::log(logTag, "Available layer: " + layerName);

            // If we are interested in this layer, add it to the result list.
            if (desiredLayers.count(layerName))
            {
                ast::log(logTag, "*** Found desired layer: " + layerName);
                result.push_back(layerName);
            }
        }
#endif

        return result;
    }

    ...

First off we return an empty list if we are not in debug mode via the #ifndef NDEBUG statement as we don’t want to add any Vulkan layers for release builds.

We then decide which layers we are interested in, electing for the VK_LAYER_LUNARG_standard_validation which itself actually represents a number of other layers. You can find information about all the layers that are available here: https://vulkan.lunarg.com/doc/view/1.0.13.0/windows/layers.html.

The documentation states:

In addition to the above individually specified layers, a built-in meta-layer definition has been provided which simplifies validation for applications. Specifying this short-hand layer definition will load a standard set of validation layers in the optimal order:

VK_LAYER_LUNARG_standard_validation

Specifying this layer definition will load the following layers in the order show below:

VK_LAYER_GOOGLE_threading
VK_LAYER_LUNARG_parameter_validation
VK_LAYER_LUNARG_device_limits
VK_LAYER_LUNARG_object_tracker
VK_LAYER_LUNARG_image
VK_LAYER_LUNARG_core_validation
VK_LAYER_LUNARG_swapchain
VK_LAYER_GOOGLE_unique_objects

So in fact we will be using all of those layers if they are available on the current device which should give us some fairly in depth diagnostics as we are wiring up our Vulkan code.

We then find out what layers are available on the current device through vk::enumerateInstanceLayerProperties(), then iterate them, looking for matching layer names within our set of desired layer names. Each match is added to our resulting list of validation layers, then ultimately returned at the end of the function.

Update Vulkan instance creation to use layers

To apply our validation layers, we will revisit the createInstance free function which bootstraps Vulkan in our program. Just before we define the instanceCreateInfo object in this function we will grab the validation layers to include:

    vk::UniqueInstance createInstance()
    {
        ...

        // Determine what validation layers can and should be activated.
        std::vector<std::string> desiredValidationLayers{::getDesiredValidationLayers()};

        // Pack the validation layers into a data format consumable by Vulkan.
        std::vector<const char*> validationLayers;
        for (const auto& layer : desiredValidationLayers)
        {
            validationLayers.push_back(layer.c_str());
        }

        // Define the info for creating our Vulkan instance.
        vk::InstanceCreateInfo instanceCreateInfo{
            vk::InstanceCreateFlags(),                      // Flags
            &applicationInfo,                               // Application info
            static_cast<uint32_t>(validationLayers.size()), // Enabled layer count
            validationLayers.data(),                        // Enabled layer names
            static_cast<uint32_t>(extensionNames.size()),   // Enabled extension count
            extensionNames.data()                           // Enabled extension names
        };

        // Build a new Vulkan instance from the configuration.
        return vk::createInstanceUnique(instanceCreateInfo);
    }
} // namespace

We begin by calling our getDesiredValidationLayers function, then mutate it into a list of const char* so it is compatible with the Vulkan vk::InstanceCreateInfo object. The code to do this is a little clunky but my C++ foo isn’t strong enough to use a cleaner way.

The validationLayers list of const char* is then included in the instanceCreateInfo object under the enabled layer count and enabled layer names arguments. This will cause them to be bootstrapped into the Vulkan instance and be actively applied during our application.

Activating layers at runtime

Vulkan layers will not necessarily be bundled into an application and in order to make them participate in our program we need to do a tiny bit of configuration in some of our platform target build scripts. I’ll walk through each platform explaining how to do the configuration.

MacOS console project

The Vulkan SDK includes a folder which holds definitions for all the validation layers like so:

root
  + third-party
    + vulkan-mac
      + macOS
        + etc
          + vulkan
            + explicit_layer.d
              VkLayer_core_validation.json
              VkLayer_object_tracker.json
              VkLayer_parameter_validation.json
              VkLayer_standard_validation.json
              VkLayer_threading.json
              VkLayer_unique_objects.json

If you read the Vulkan help document at root/third-party/vulkan-mac/Documentation/getting_started_macos.html you will find a section with the following instructions:

Useful Environment Variables

...

VK_LAYER_PATH - set to point at the layer JSON files in vulkansdk/macOS/etc/vulkan/explicit_layer.d so that the vulkan loader can locate the layers installed in the SDK via these JSON files.

This means we need to tell our console application - when it executes - where it can find the validation layers via the VK_LAYER_PATH environment variable. We will edit our console application launch.json file to apply the environment variable so it is applied whenever we run it through Visual Studio Code.

Open console/.vscode/launch.json and change the existing environment element from this:

"environment": [],

to this:

"environment": [{"name": "VK_LAYER_PATH", "value": "${workspaceFolder}/../../third-party/vulkan-mac/macOS/etc/vulkan/explicit_layer.d"}],

Run the application and you will hopefully see the following kind of output in the DEBUG CONSOLE panel:

ast::VulkanContext::getDesiredValidationLayers: Available layer: VK_LAYER_GOOGLE_unique_objects
ast::VulkanContext::getDesiredValidationLayers: Available layer: VK_LAYER_GOOGLE_threading
ast::VulkanContext::getDesiredValidationLayers: Available layer: VK_LAYER_LUNARG_standard_validation
ast::VulkanContext::getDesiredValidationLayers: *** Found desired layer: VK_LAYER_LUNARG_standard_validation
ast::VulkanContext::getDesiredValidationLayers: Available layer: VK_LAYER_LUNARG_core_validation
ast::VulkanContext::getDesiredValidationLayers: Available layer: VK_LAYER_LUNARG_parameter_validation
ast::VulkanContext::getDesiredValidationLayers: Available layer: VK_LAYER_LUNARG_object_tracker

Note the log message indicating that our desired VK_LAYER_LUNARG_standard_validation layer was found and applied:

ast::VulkanContext::getDesiredValidationLayers: *** Found desired layer: VK_LAYER_LUNARG_standard_validation

Running the application will display something similar to this (the following output is from my Windows machine):

ast::VulkanContext::getDesiredValidationLayers: Available layer: VK_LAYER_NV_optimus
ast::VulkanContext::getDesiredValidationLayers: Available layer: VK_LAYER_RENDERDOC_Capture
ast::VulkanContext::getDesiredValidationLayers: Available layer: VK_LAYER_VALVE_steam_overlay
ast::VulkanContext::getDesiredValidationLayers: Available layer: VK_LAYER_VALVE_steam_fossilize
ast::VulkanContext::getDesiredValidationLayers: Available layer: VK_LAYER_LUNARG_api_dump
ast::VulkanContext::getDesiredValidationLayers: Available layer: VK_LAYER_LUNARG_assistant_layer
ast::VulkanContext::getDesiredValidationLayers: Available layer: VK_LAYER_LUNARG_core_validation
ast::VulkanContext::getDesiredValidationLayers: Available layer: VK_LAYER_LUNARG_device_simulation
ast::VulkanContext::getDesiredValidationLayers: Available layer: VK_LAYER_LUNARG_monitor
ast::VulkanContext::getDesiredValidationLayers: Available layer: VK_LAYER_LUNARG_object_tracker
ast::VulkanContext::getDesiredValidationLayers: Available layer: VK_LAYER_LUNARG_parameter_validation
ast::VulkanContext::getDesiredValidationLayers: Available layer: VK_LAYER_LUNARG_screenshot
ast::VulkanContext::getDesiredValidationLayers: Available layer: VK_LAYER_LUNARG_standard_validation
ast::VulkanContext::getDesiredValidationLayers: *** Found desired layer: VK_LAYER_LUNARG_standard_validation
ast::VulkanContext::getDesiredValidationLayers: Available layer: VK_LAYER_GOOGLE_threading
ast::VulkanContext::getDesiredValidationLayers: Available layer: VK_LAYER_GOOGLE_unique_objects
ast::VulkanContext::getDesiredValidationLayers: Available layer: VK_LAYER_LUNARG_vktrace

Physical device

Vulkan requires us to configure which physical device it should use to perform all of our operations on. This is interesting to us because it allows - and forces us to know about what hardware is available on the current computer. Sometimes we would find that a computer has more than one physical device, for example some laptops might have an integrated GPU and separate discrete GPU. In those situations we have to make our own choice as to which device to use.

For our application we will try to always choose the discrete GPU if there is one available, as we would expect it to perform far better than an integrated device. Of course if there is no discrete physical device we will just use whatever device is available instead.

Vulkan provides us with mechanisms to query for what physical devices are available. Once we’ve obtained a physical device we can tap into its capabilities to know what it can do - this will be important later in our Vulkan implementation. We will model the physical device in our C++ code as a new class named VulkanPhysicalDevice, and our existing Vulkan context class will be responsible for creating and owning an instance of it.

Create vulkan-physical-device.hpp and vulkan-physical-device.cpp under main/src/application/vulkan. Start by editing the header file with the following:

#pragma once

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

namespace ast
{
    struct VulkanPhysicalDevice
    {
        VulkanPhysicalDevice(const vk::Instance& instance);

        const vk::PhysicalDevice& getPhysicalDevice() const;

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

To obtain a physical device we need a vk::Instance as you can see in the constructor. Next edit the implementation with the following:

#include "vulkan-physical-device.hpp"
#include "../../core/log.hpp"

using ast::VulkanPhysicalDevice;

namespace
{
    vk::PhysicalDevice createPhysicalDevice(const vk::Instance& instance)
    {
        static const std::string logTag{"ast::VulkanPhysicalDevice::createPhysicalDevice"};

        // Ask Vulkan for all the available physical devices in the current instance.
        std::vector<vk::PhysicalDevice> devices{instance.enumeratePhysicalDevices()};

        // If there are no physical devices available that can drive Vulkan then we
        // will deliberately throw an exception which will ultimately cause our
        // application to fall back to the OpenGL implementation.
        if (devices.empty())
        {
            throw std::runtime_error(logTag + ": No Vulkan physical devices found.");
        }

        // Pick the first device as the default which will be used if no better devices are found.
        vk::PhysicalDevice selectedDevice{devices[0]};

        // Grab the properties of the first device so we can query them for capabilities.
        vk::PhysicalDeviceProperties selectedProperties{selectedDevice.getProperties()};

        // If the device we have selected by default does not have a discrete GPU, then we will
        // try to search through any other physical devices looking for one with a discrete GPU.
        // Some computers will have an 'integrated' GPU as well as a 'discrete' GPU, where the
        // integrated GPU is typically not designed for high end graphics tasks, whereas a discrete
        // GPU is exactly for that purpose.
        if (selectedProperties.deviceType != vk::PhysicalDeviceType::eDiscreteGpu)
        {
            for (size_t i = 1; i < devices.size(); i++)
            {
                vk::PhysicalDevice& nextDevice{devices[i]};
                vk::PhysicalDeviceProperties nextProperties{nextDevice.getProperties()};

                // If we find a device with a discrete GPU, then choose it and we are done.
                if (nextProperties.deviceType == vk::PhysicalDeviceType::eDiscreteGpu)
                {
                    selectedDevice = nextDevice;
                    selectedProperties = nextProperties;
                    break;
                }
            }
        }

        // Next we need to make sure that the physical device can support a swapchain.
        bool hasSwapchainSupport{false};
        std::string swapchainName{VK_KHR_SWAPCHAIN_EXTENSION_NAME};

        // Traverse all the extensions available in the physical device, looking for the
        // presence of the swapchain extension.
        for (const auto& extension : selectedDevice.enumerateDeviceExtensionProperties())
        {
            if (extension.extensionName == swapchainName)
            {
                hasSwapchainSupport = true;
                break;
            }
        }

        // We can't render without swapchain support.
        if (!hasSwapchainSupport)
        {
            throw std::runtime_error(logTag + ": Swapchain support not found.");
        }

        // We should now have selected a physical device, which may or may not have a discrete GPU
        // but will have been selected with a preference to having one.
        ast::log(logTag, "Physical device: " + std::string{selectedProperties.deviceName} + ".");

        return selectedDevice;
    }
} // namespace

struct VulkanPhysicalDevice::Internal
{
    const vk::PhysicalDevice physicalDevice;

    Internal(const vk::Instance& instance)
        : physicalDevice(::createPhysicalDevice(instance)) {}
};

VulkanPhysicalDevice::VulkanPhysicalDevice(const vk::Instance& instance)
    : internal(ast::make_internal_ptr<Internal>(instance)) {}

const vk::PhysicalDevice& VulkanPhysicalDevice::getPhysicalDevice() const
{
    return internal->physicalDevice;
}

Internal struct

Ok let’s dissect this new code starting with the Internal struct. We will create a vk::PhysicalDevice in the constructor and hold onto it in the const vk::PhysicalDevice physicalDevice field. Note that the physical device doesn’t need to be explicitly destroyed.

createPhysicalDevice free function

We obtain the physical device by first asking the Vulkan instance we were given to enumerate all the physical devices available via the enumeratePhysicalDevices function:

vk::PhysicalDevice createPhysicalDevice(const vk::Instance& instance)
{
    std::vector<vk::PhysicalDevice> devices{instance.enumeratePhysicalDevices()};

Given the list of available physical devices, we ensure that there is at least one physical device available and throw an exception if there isn’t. You can see this scenario play out by running our Android application on an emulator - the core vk::Instance will work but there will be no physical devices available. Because we are throwing an exception, our engine will catch it and default to the OpenGL application instead:

if (devices.empty())
{
    throw std::runtime_error(logTag + ": No Vulkan physical devices found.");
}

Since we know there is at least one physical device available, we grab the first one to be our default device in case we can’t find a better candidate in the list. For a lot of computers there will actually only be one physical device anyway, but we need to accommodate the scenario I mentioned earlier where a computer might have multiple devices.

vk::PhysicalDevice selectedDevice{devices[0]};

We also grab the physical device properties for the first device via the getProperties function of the device itself. It is worth noting that the vulkan.hpp C++ header is giving us some of these helpful contextual functions such as the getProperties function on a vk::PhysicalDevice object, whereas the raw vulkan.h would not.

vk::PhysicalDeviceProperties selectedProperties{selectedDevice.getProperties()};

Once we have a default physical device selected we check to see whether it has a discrete GPU by looking at its deviceType property. If it does not have a discrete GPU then we will proceed to enumerate the remaining physical devices in the list - if there are any.

As we iterate the remaining devices we simply ask the same question about whether it has a discrete GPU and if it does then update our selectedDevice and selectedProperties to choose it instead.

if (selectedProperties.deviceType != vk::PhysicalDeviceType::eDiscreteGpu)
{
    for (size_t i = 1; i < devices.size(); i++)
    {
        vk::PhysicalDevice& nextDevice{devices[i]};
        vk::PhysicalDeviceProperties nextProperties{nextDevice.getProperties()};

        // If we find a device with a discrete GPU, then choose it and we are done.
        if (nextProperties.deviceType == vk::PhysicalDeviceType::eDiscreteGpu)
        {
            selectedDevice = nextDevice;
            selectedProperties = nextProperties;
            break;
        }
    }
}

With a physical device selected, we need to evaluate whether it has support for Vulkan swapchains - which we haven’t covered just yet (we will get to it later). In short, a swapchain is a mechanism by which we can maintain multiple screen buffers where one buffer is displayed on the screen while the other buffer(s) are being used to prepare the next render frames. The frame buffers are then swapped using the presentation queue and the cycle goes on like that repeatedly. This allows rendering to be always working to get the next frame ready without needing to be waiting for a single frame buffer all the time. If there is no swapchain support we cannot use Vulkan for our application.

To find out if there is swapchain support we look for the VK_KHR_SWAPCHAIN_EXTENSION_NAME extension name within the list of extensions possessed by the physical device.

bool hasSwapchainSupport{false};
std::string swapchainName{VK_KHR_SWAPCHAIN_EXTENSION_NAME};

for (const auto& extension : selectedDevice.enumerateDeviceExtensionProperties())
{
    if (extension.extensionName == swapchainName)
    {
        hasSwapchainSupport = true;
        break;
    }
}

if (!hasSwapchainSupport)
{
    throw std::runtime_error(logTag + ": Swapchain support not found.");
}

Finally we print out a log statement to show what physical device was selected and return the result:

ast::log(logTag, "Physical device: " + std::string{selectedProperties.deviceName} + ".");

return selectedDevice;

Other public functions

The getPhysicalDevice public function simply returns the physical device as a const reference.

We will add more functionality to our VulkanPhysicalDevice class later but for now its a good start. We can now update our VulkanContext class to include a physical device. Edit vulkan-context.cpp and add the following header include statement:

#include "vulkan-physical-device.hpp"

The update the Internal struct to declare and construct a physical device object:

struct VulkanContext::Internal
{
    const vk::UniqueInstance instance;
    const ast::VulkanPhysicalDevice physicalDevice;
    const ast::SDLWindow window;

    Internal() : instance(::createInstance()),
                 physicalDevice(ast::VulkanPhysicalDevice(*instance)),
                 window(ast::SDLWindow(SDL_WINDOW_VULKAN | SDL_WINDOW_RESIZABLE | SDL_WINDOW_ALLOW_HIGHDPI))
    {
        ast::log("ast::VulkanContext", "Initialized Vulkan context successfully.");
    }
};

Note: Observe that we are creating the physicalDevice field before the window field. This is to ensure that if no physical devices are available the window will not be created.

If you run your application now you should see logging output telling you which physical device was selected, for example when I ran this on my own devices I got the following outputs:

// Macbook Air 2012: console / desktop application
ast::VulkanPhysicalDevice::createPhysicalDevice: Physical device: Intel HD Graphics 4000.

// iOS iPod Touch
ast::VulkanPhysicalDevice::createPhysicalDevice: Physical device: Apple A8 GPU.

// Android emulator
ast::VulkanPhysicalDevice::createPhysicalDevice: No Vulkan physical devices found.

// Android Samsung Galaxy S7 Edge
ast::VulkanPhysicalDevice::createPhysicalDevice: Physical device: Mali-T880.

// Acer Predator Helios 500 gaming laptop
ast::VulkanPhysicalDevice::createPhysicalDevice: Physical device: GeForce GTX 1070.

Creating a Vulkan surface

Vulkan expects us to interact with a GPU through a logical device rather than directly to a physical device. The logical device, or just device for brevity will be used quite a bit in our code. However before we create a logical device we will need to have created a surface. The surface represents the canvas upon which Vulkan will be drawing to and can be obtained via our SDL window, which is why we moved the SDL window code into our Vulkan context class.

Let’s get the surface created within our Vulkan context class. We will use the SDL_Vulkan_CreateSurface function to obtain the surface then hold it in a vk::UniqueSurfaceKHR object.

We will wrap the surface in a new class using our internal_ptr component to neatly encapsulate the code required for dealing with it.

Add the files vulkan-surface.hpp and vulkan-surface.cpp to the application/vulkan folder. Edit the header file with the following:

#pragma once

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

namespace ast
{
    struct VulkanSurface
    {
        VulkanSurface(const vk::Instance& instance,
                      const ast::VulkanPhysicalDevice& physicalDevice,
                      const ast::SDLWindow& window);

        const vk::SurfaceKHR& getSurface() const;

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

The constructor takes in our Vulkan instance, physical device and the window against which to create the surface. The getSurface function will simply provide a way to access the wrapped surface object publicly.

Edit the implementation with the following:

#include "vulkan-surface.hpp"

using ast::VulkanSurface;

namespace
{
    vk::UniqueSurfaceKHR createSurface(const vk::Instance& instance,
                                       const ast::VulkanPhysicalDevice& physicalDevice,
                                       const ast::SDLWindow& window)
    {
        static const std::string logTag{"ast::VulkanSurface::createSurface"};

        VkSurfaceKHR sdlSurface;

        // Ask SDL to create a Vulkan surface from its window.
        if (!SDL_Vulkan_CreateSurface(window.getWindow(), instance, &sdlSurface))
        {
            throw std::runtime_error(logTag + ": SDL could not create a Vulkan surface.");
        }

        // Wrap the result in a Vulkan managed surface object.
        return vk::UniqueSurfaceKHR{sdlSurface, instance};
    }
} // namespace

struct VulkanSurface::Internal
{
    const vk::UniqueSurfaceKHR surface;

    Internal(const vk::Instance& instance,
             const ast::VulkanPhysicalDevice& physicalDevice,
             const ast::SDLWindow& window)
        : surface(::createSurface(instance, physicalDevice, window)) {}
};

VulkanSurface::VulkanSurface(const vk::Instance& instance,
                             const ast::VulkanPhysicalDevice& physicalDevice,
                             const ast::SDLWindow& window)
    : internal(ast::make_internal_ptr<Internal>(instance, physicalDevice, window)) {}

const vk::SurfaceKHR& VulkanSurface::getSurface() const
{
    return *internal->surface;
}

The surface field is created by calling the createSurface free function along with the Vulkan instance provided in the constructor.

The createSurface free function takes the Vulkan instance to create the surface within along with the physical device and SDL window that is being used. The SDL_Vulkan_CreateSurface function is invoked with the arguments and SDL will attempt to acquire the appropriate surface for the operating system being used:

namespace
{
    vk::UniqueSurfaceKHR createSurface(const vk::Instance& instance,
                                       const ast::VulkanPhysicalDevice& physicalDevice,
                                       const ast::SDLWindow& window)
    {
        static const std::string logTag{"ast::VulkanSurface::createSurface"};

        VkSurfaceKHR sdlSurface;

        // Ask SDL to create a Vulkan surface from its window.
        if (!SDL_Vulkan_CreateSurface(window.getWindow(), instance, &sdlSurface))
        {
            throw std::runtime_error(logTag + ": SDL could not create a Vulkan surface.");
        }

If for some reason the SDL method to create the surface fails, then we throw an exception as we can’t really progress with Vulkan.

Note: You will find that each target platform will have its own SurfaceKHR variant that will be compiled in as the implementation during a build. For example if you browse the Vulkan C++ header you will find definitions including:

  • VK_USE_PLATFORM_ANDROID_KHR
  • VK_USE_PLATFORM_WIN32_KHR
  • VK_USE_PLATFORM_IOS_MVK
  • VK_USE_PLATFORM_MACOS_MVK

We then lift the SDL surface into a C++ vk::UniqueSurfaceKHR object so it can be destroyed as part of our class lifecycle and return it.

// Wrap the result in a Vulkan managed surface object.
return vk::UniqueSurfaceKHR{sdlSurface, instance};

Update the Vulkan context

The VulkanContext will create an instance of our surface class and hold it as a member field. Edit vulkan-context.cpp and add the following header:

#include "vulkan-surface.hpp"

Update the Internal member fields and constructor to look like this to include a new ast::VulkanSurface field:

struct VulkanContext::Internal
{
    const vk::UniqueInstance instance;
    const ast::VulkanPhysicalDevice physicalDevice;
    const ast::SDLWindow window;
    const ast::VulkanSurface surface;

    Internal() : instance(::createInstance()),
                 physicalDevice(ast::VulkanPhysicalDevice(*instance)),
                 window(ast::SDLWindow(SDL_WINDOW_VULKAN | SDL_WINDOW_RESIZABLE | SDL_WINDOW_ALLOW_HIGHDPI)),
                 surface(ast::VulkanSurface(*instance, physicalDevice, window))

Create the logical device

As I mentioned earlier, we need to use a logical device as the conduit for many Vulkan operations which is, somewhat formally, described here: https://www.khronos.org/registry/vulkan/specs/1.0/html/chap4.html#devsandqueues-devices.

We will use the physical device and surface from our Vulkan context to create our device, which will be represented by the Vulkan class vk::UniqueDevice. To model our device we will write another wrapper class - get used to it there will be quite a few for the Vulkan implementation! Add the files vulkan-device.hpp and vulkan-device.cpp to the application/vulkan folder. Edit the header with the following:

#pragma once

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

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

        const vk::Device& getDevice() const;

        uint32_t getGraphicsQueueIndex() const;

        uint32_t getPresentationQueueIndex() const;

        bool hasDiscretePresentationQueue() const;

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

And the implementation - which will need some explanation :)

#include "vulkan-device.hpp"
#include <vector>

using ast::VulkanDevice;

namespace
{
    struct QueueConfig
    {
        uint32_t graphicsQueueIndex;
        uint32_t presentationQueueIndex;
        bool hasDiscretePresentationQueue;
    };

    QueueConfig getQueueConfig(const vk::PhysicalDevice& physicalDevice, const vk::SurfaceKHR& surface)
    {
        static const std::string logTag{"ast::VulkanPhysicalDevice::getGraphicsQueueFamilyIndex"};

        // Assign properties to track selection of graphics and presentation queues,
        // initially with an unset marker value.
        constexpr uint32_t unsetQueueIndex{std::numeric_limits<uint32_t>::max()};
        uint32_t graphicsQueueIndex{unsetQueueIndex};
        uint32_t presentationQueueIndex{unsetQueueIndex};

        // Fetch all the queue family properties supported by the physical device.
        std::vector<vk::QueueFamilyProperties> queueFamilies{physicalDevice.getQueueFamilyProperties()};

        // Search through the available queue families, looking for one that supports graphics.
        for (size_t i = 0; i < queueFamilies.size(); i++)
        {
            vk::QueueFamilyProperties properties{queueFamilies[i]};

            // If the current queue family has graphics capability we will evaluate it as a
            // candidate for both the graphics queue and the presentation queue.
            if (properties.queueCount > 0 && properties.queueFlags & vk::QueueFlagBits::eGraphics)
            {
                const uint32_t currentQueueIndex{static_cast<uint32_t>(i)};

                // If we haven't yet chosen an index for the graphics queue (because it is unset),
                // then remember this index as the graphics queue index.
                if (graphicsQueueIndex == unsetQueueIndex)
                {
                    graphicsQueueIndex = currentQueueIndex;
                }

                // We now need to see if the queue index can also behave as a presentation queue and
                // if so, both the graphics and presentation queue indices will be the same, effectively
                // meaning that we will only need to create a single queue and use it for both purposes.
                if (physicalDevice.getSurfaceSupportKHR(currentQueueIndex, surface))
                {
                    graphicsQueueIndex = currentQueueIndex;
                    presentationQueueIndex = currentQueueIndex;

                    // Since we have now discovered a queue index that can do BOTH graphics and presentation
                    // we won't bother looking any further in the loop.
                    break;
                }
            }
        }

        // If we couldn't find any queues that can perform graphics operations we are out.
        if (graphicsQueueIndex == unsetQueueIndex)
        {
            throw std::runtime_error(logTag + ": Could not find a graphics queue.");
        }

        // If we found a graphics queue, but not one that could also do presentation then we
        // need to find a discrete presentation queue to use instead, meaning we will end
        // up with two queues to manage instead of one that can do both.
        if (presentationQueueIndex == unsetQueueIndex)
        {
            for (size_t i = 0; i < queueFamilies.size(); i++)
            {
                const uint32_t currentQueueIndex{static_cast<uint32_t>(i)};

                if (physicalDevice.getSurfaceSupportKHR(currentQueueIndex, surface))
                {
                    presentationQueueIndex = currentQueueIndex;
                    break;
                }
            }
        }

        // Once more we will see if we have a presentation queue or not - this time if
        // we still do not have one then we are out again.
        if (presentationQueueIndex == unsetQueueIndex)
        {
            throw std::runtime_error(logTag + ": Could not find a presentation queue.");
        }

        // At this point we have valid graphics queue and presentation queue indices
        // so we will wrap them up in a result object and return it. Note that we
        // will also record whether the presentation queue is 'discrete' meaning that
        // it is a different queue to the graphics queue.
        return QueueConfig{
            graphicsQueueIndex,
            presentationQueueIndex,
            graphicsQueueIndex != presentationQueueIndex};
    }

    vk::UniqueDevice createDevice(const ast::VulkanPhysicalDevice& physicalDevice,
                                  const QueueConfig& queueConfig)
    {
        const float deviceQueuePriority{1.0f};

        // Hold a list of queue creation objects to use in our logical device,
        // initialising it with the graphics queue configuration.
        std::vector<vk::DeviceQueueCreateInfo> queueCreateInfos{
            vk::DeviceQueueCreateInfo{
                vk::DeviceQueueCreateFlags(),   // Flags
                queueConfig.graphicsQueueIndex, // Queue family index
                1,                              // Queue count
                &deviceQueuePriority            // Queue priority
            }};

        // Add a presentation queue if the presentation queue needs to be
        // discrete from the graphics queue.
        if (queueConfig.hasDiscretePresentationQueue)
        {
            queueCreateInfos.push_back(vk::DeviceQueueCreateInfo{
                vk::DeviceQueueCreateFlags(),       // Flags
                queueConfig.presentationQueueIndex, // Queue family index
                1,                                  // Queue count
                &deviceQueuePriority                // Queue priority
            });
        }

        // We also need to request the swapchain extension be activated as we will need to use a swapchain
        std::vector<const char*> extensionNames{VK_KHR_SWAPCHAIN_EXTENSION_NAME};

        // Take the queue and extension name configurations and form the device creation definition.
        vk::DeviceCreateInfo deviceCreateInfo{
            vk::DeviceCreateFlags(),                        // Flags
            static_cast<uint32_t>(queueCreateInfos.size()), // Queue create info list count
            queueCreateInfos.data(),                        // Queue create info list
            0,                                              // Enabled layer count
            nullptr,                                        // Enabled layer names
            static_cast<uint32_t>(extensionNames.size()),   // Enabled extension count
            extensionNames.data(),                          // Enabled extension names
            nullptr                                         // Physical device features
        };

        // Create a logical device with all the configuration we collated.
        return physicalDevice.getPhysicalDevice().createDeviceUnique(deviceCreateInfo);
    }
} // namespace

struct VulkanDevice::Internal
{
    const QueueConfig queueConfig;
    const vk::UniqueDevice device;

    Internal(const ast::VulkanPhysicalDevice& physicalDevice, const ast::VulkanSurface& surface)
        : queueConfig(::getQueueConfig(physicalDevice.getPhysicalDevice(), surface.getSurface())),
          device(::createDevice(physicalDevice, queueConfig)) {}

    ~Internal()
    {
        // We need to wait for the device to become idle before allowing it to be destroyed.
        device->waitIdle();
    }
};

VulkanDevice::VulkanDevice(const ast::VulkanPhysicalDevice& physicalDevice)
    : internal(ast::make_internal_ptr<Internal>(physicalDevice)) {}

const vk::Device& VulkanDevice::getDevice() const
{
    return *internal->device;
}

uint32_t VulkanDevice::getGraphicsQueueIndex() const
{
    return internal->queueConfig.graphicsQueueIndex;
}

uint32_t VulkanDevice::getPresentationQueueIndex() const
{
    return internal->queueConfig.presentationQueueIndex;
}

bool VulkanDevice::hasDiscretePresentationQueue() const
{
    return internal->queueConfig.hasDiscretePresentationQueue;
}

Graphics and presentation queues

The Internal struct creates and holds the following field which is initialised in the constructor via the getQueueConfig function:

struct VulkanDevice::Internal
{
    const QueueConfig queueConfig;
    const vk::UniqueDevice device;

    Internal(const ast::VulkanPhysicalDevice& physicalDevice, const ast::VulkanSurface& surface)
        : queueConfig(::getQueueConfig(physicalDevice.getPhysicalDevice(), surface.getSurface())),
          device(::createDevice(physicalDevice, queueConfig)) {}

This field is of the type QueueConfig which we have declared ourselves inside our anonymous namespace. In Vulkan we need to wire up a graphics queue and a presentation queue. All Vulkan commands are sent through these queues during our rendering code with the graphics queue used for processing Vulkan commands and the presentation queue for displaying a rendered frame to the output display hardware. If the device we are running our application on cannot resolve both of these queues it means that Vulkan will not run for graphics rendering.

namespace
{
    struct QueueConfig
    {
        uint32_t graphicsQueueIndex;
        uint32_t presentationQueueIndex;
        bool hasDiscretePresentationQueue;
    };

The properties held in the QueueConfig struct are:

The getQueueConfig function is used to query the capabilities of the current physical device, looking for an appropriate graphics and presentation queue. It starts off by declaring two variables which hold the best queue index we can find for each queue we are looking for. We assign the unsetQueueIndex value to them as a marker to know if they’ve been given a real value later in the function. It might have been nice instead using something like std::optional here but I found it wouldn’t work for me on MacOS, so we will just use the maximum value of a uint32_t as a magic marker number instead. We also fetch all the queue family properties from the current physical device via getQueueFamilyProperties() which contains all the information we need to evaluate where the graphics and presentation queues can be found:

namespace
{
    QueueConfig getQueueConfig(const vk::PhysicalDevice& physicalDevice, const vk::SurfaceKHR& surface)
    {
        static const std::string logTag{"ast::VulkanPhysicalDevice::getGraphicsQueueFamilyIndex"};

        // Assign properties to track selection of graphics and presentation queues,
        // initially with an unset marker value.
        constexpr uint32_t unsetQueueIndex{std::numeric_limits<uint32_t>::max()};
        uint32_t graphicsQueueIndex{unsetQueueIndex};
        uint32_t presentationQueueIndex{unsetQueueIndex};

        // Fetch all the queue family properties supported by the physical device.
        std::vector<vk::QueueFamilyProperties> queueFamilies{physicalDevice.getQueueFamilyProperties()};

Next we start iterating the queue families that the physical device supports, grabbing the properties for each one as we go:

// Search through the available queue families, looking for one that supports graphics.
for (size_t i = 0; i < queueFamilies.size(); i++)
{
    vk::QueueFamilyProperties properties{queueFamilies[i]};

Within the loop, we take a look at the properties at the current loop index - which we cache as currentQueueIndex - and perform the following evaluations:

    // If the current queue family has graphics capability we will evaluate it as a
    // candidate for both the graphics queue and the presentation queue.
    if (properties.queueCount > 0 && properties.queueFlags & vk::QueueFlagBits::eGraphics)
    {
        const uint32_t currentQueueIndex{static_cast<uint32_t>(i)};

        // If we haven't yet chosen an index for the graphics queue (because it is unset),
        // then remember this index as the graphics queue index.
        if (graphicsQueueIndex == unsetQueueIndex)
        {
            graphicsQueueIndex = currentQueueIndex;
        }

        // We now need to see if the queue index can also behave as a presentation queue and
        // if so, both the graphics and presentation queue indices will be the same, effectively
        // meaning that we will only need to create a single queue and use it for both purposes.
        if (physicalDevice.getSurfaceSupportKHR(currentQueueIndex, surface))
        {
            graphicsQueueIndex = currentQueueIndex;
            presentationQueueIndex = currentQueueIndex;

            // Since we have now discovered a queue index that can do BOTH graphics and presentation
            // we won't bother looking any further in the loop.
            break;
        }
    }
}

Once the loop has completed we could find ourselves in the following situations:

No graphics queue index was found

If we didn’t discover any queue family indices that can be used for a graphics queue, then we cannot use Vulkan on the current device.

if (graphicsQueueIndex == unsetQueueIndex)
{
    throw std::runtime_error(logTag + ": Could not find a graphics queue.");
}

Graphics queue was found but presentation queue was not found

This condition means that although we found a graphics queue, we could not find one that could also do presentation operations - we can tell because the presentationQueueIndex variable still holds the unsetQueueIndex value meaning we never assigned it an index in the loop. This doesn’t mean that there is no queue that can do presentation, but it does requires us to look for some other queue that can. We do this by iterating once again through all the queue families, but this time only performing the getSurfaceSupportKHR check. We break from this loop as soon as we find a match - if we find a match!

if (presentationQueueIndex == unsetQueueIndex)
{
    for (size_t i = 0; i < queueFamilies.size(); i++)
    {
        const uint32_t currentQueueIndex{static_cast<uint32_t>(i)};

        if (physicalDevice.getSurfaceSupportKHR(currentQueueIndex, surface))
        {
            presentationQueueIndex = currentQueueIndex;
            break;
        }
    }
}

We follow up by checking if our presentationQueueIndex is still unset. If it is unset then there is no queue on the current device that can present graphics for us, so we cannot use Vulkan:

if (presentationQueueIndex == unsetQueueIndex)
{
    throw std::runtime_error(logTag + ": Could not find a presentation queue.");
}

Finally we know that we have valid graphics and presentation queue indices so we will put them into a QueueConfig object which is returned. We also record whether we we found a discrete presentation queue which differs from the graphics queue. This is important for bootstrapping the logical device and much later on during our rendering code:

    return QueueConfig{
        graphicsQueueIndex,
        presentationQueueIndex,
        graphicsQueueIndex != presentationQueueIndex};
}

We also expose the queue config properties via the implementation of the public getter functions at the bottom of the class file.

Creating the logical device

The Internal struct also creates and holds a vk::UniqueDevice field, which is exposed publicly as a const reference to a vk::Device. The destructor needs to wait for the device to be idle before destroying it - if we don’t do this we will experience crashing behaviour on shutdown once our rendering loop is in motion.

The createDevice free function does all the hard work. Let’s step through it now. The createDevice function requires a physical device as input - a logical device is always associated with some physical device. A logical device needs to be supplied with a list of queues describing which physical device aspects it can use. We will use the QueueConfig object we just created to tell us which queue indices represent the graphics and presentation queues.

We start by creating a list of vk::DeviceQueueCreateInfo objects which represent the queues we will be asking our logical device to use. We initialise the list with a queue configuration object containing the graphics queue index along with the deviceQueuePriority. The device queue priority allows multiple groups of queues to be arranged in priority order in case some are more important than others - for us it doesn’t make much difference so we will just set the priority to 1.0f.

vk::UniqueDevice createDevice(const ast::VulkanPhysicalDevice& physicalDevice,
                              const QueueConfig& queueConfig)
{
    const float deviceQueuePriority{1.0f};

    // Hold a list of queue creation objects to use in our logical device,
    // initialising it with the graphics queue configuration.
    std::vector<vk::DeviceQueueCreateInfo> queueCreateInfos{
        vk::DeviceQueueCreateInfo{
            vk::DeviceQueueCreateFlags(),   // Flags
            queueConfig.graphicsQueueIndex, // Queue family index
            1,                              // Queue count
            &deviceQueuePriority            // Queue priority
        }};

Next we check the queueConfig to see if we need to accommodate a discrete presentation queue. If we do, we add another queue creation configuration object into our list which is largely the same as for the graphics queue but referencing the presentation queue index instead:

// Add a presentation queue if the presentation queue needs to be
// discrete from the graphics queue.
if (queueConfig.hasDiscretePresentationQueue)
{
    queueCreateInfos.push_back(vk::DeviceQueueCreateInfo{
        vk::DeviceQueueCreateFlags(),       // Flags
        queueConfig.presentationQueueIndex, // Queue family index
        1,                                  // Queue count
        &deviceQueuePriority                // Queue priority
    });
}

Our logical device will also need to support the use of a swapchain - we already checked if our physical device supports swapchains but we need to ask our logical device to enable it.

std::vector<const char*> extensionNames{VK_KHR_SWAPCHAIN_EXTENSION_NAME};

We then form the device creation configuration object feeding into it the other configurations we just wrote, then ask the physical device to create a logical device for us which we return to the caller:

vk::DeviceCreateInfo deviceCreateInfo{
    vk::DeviceCreateFlags(),                        // Flags
    static_cast<uint32_t>(queueCreateInfos.size()), // Queue create info list count
    queueCreateInfos.data(),                        // Queue create info list
    0,                                              // Enabled layer count
    nullptr,                                        // Enabled layer names
    static_cast<uint32_t>(extensionNames.size()),   // Enabled extension count
    extensionNames.data(),                          // Enabled extension names
    nullptr                                         // Physical device features
};

return physicalDevice.getPhysicalDevice().createDeviceUnique(deviceCreateInfo);

Note: The physical device features property is null at the moment, but in a later article we will revisit this function to add support for things like anisotropic filtering during the logical device creation.

Updating the Vulkan context

We can now instantiate a new logical device which will be owned by our VulkanContext class. Revisit vulkan-context.cpp and add the following header:

#include "vulkan-device.hpp"

Hop down to the Internal struct and update the member fields and constructor to look like so:

struct VulkanContext::Internal
{
    const vk::UniqueInstance instance;
    const ast::VulkanPhysicalDevice physicalDevice;
    const ast::SDLWindow window;
    const ast::VulkanSurface surface;
    const ast::VulkanDevice device;

    Internal() : instance(::createInstance()),
                 physicalDevice(ast::VulkanPhysicalDevice(*instance)),
                 window(ast::SDLWindow(SDL_WINDOW_VULKAN | SDL_WINDOW_RESIZABLE | SDL_WINDOW_ALLOW_HIGHDPI)),
                 surface(ast::VulkanSurface(*instance, physicalDevice, window)),
                 device(ast::VulkanDevice(physicalDevice, surface))

Note the addition of the device member field and its construction.

Run the application and though you won’t see any additional log output you can verify that everything is still working OK.


Summary

We’ll break this article here so it doesn’t get too long however we still have a way to go before our Vulkan renderer can be used to draw our scene. In the next article we will tackle the Vulkan swapchain which I’ve mentioned a few times earlier in this article.

The code for this article can be found here.

Continue to Part 21: Vulkan create swapchain.

End of part 20