a-simple-triangle / Part 14 - Vulkan setup console

Our Mac console will be the first target to setup Vulkan, though incidentally it will be via the MoltenVK library which gives us the ability to use the Vulkan SDK on Mac and iOS. In this article we will:

Note: During the Vulkan setup articles we will only go as far as initialising Vulkan - leaving the rest of the rendering implementation code until all platforms are able to run it.


Setup script

We’ll be writing some new setup scripts to download the Vulkan SDK for Mac and iOS from the official Vulkan SDK site: https://vulkan.lunarg.com/sdk/home. We’ll put the ability to download the SDK into our existing shared-scripts.sh as we have three targets that will need it - Mac console, Mac app, iOS.

Dust off your shared-scripts.sh file and add the following new method to download the SDK:

fetch_third_party_lib_vulkan_macos() {
    verify_third_party_folder_exists

    pushd ../../third-party
        if [ ! -d "vulkan-mac" ]; then
            echo "Fetching Vulkan SDK (Mac) from: https://sdk.lunarg.com/sdk/download/1.1.92.1/mac/vulkansdk-macos-1.1.92.1.tar.gz?Human=true"
            wget --no-cookies https://sdk.lunarg.com/sdk/download/1.1.92.1/mac/vulkansdk-macos-1.1.92.1.tar.gz?Human=true -O vulkan-mac.tar.gz
            echo "Unzipping Vulkan SDK (Mac) into 'third-party/vulkan-mac' ..."
            tar -xf vulkan-mac.tar.gz
            rm vulkan-mac.tar.gz
            mv vulkansdk-macos-1.1.92.1 vulkan-mac
        fi
    popd    
}

Note: We are using version 1.1.92 deliberately - we want to be using as close to the same version of the Vulkan SDK across Mac, iOS, Android and Windows. Android in particular is not at the bleeding edge for its Vulkan support and the Android NDK version we will be using (version 20 as at the time of writing these articles) is bundled with its vulkan.hpp at version 90 which is close to the 92 that’s available for other platforms. As time passes the Vulkan SDK could be updated across all platforms to a later version if needed.

This setup script looks similar to others we’ve already written - we check if there is a vulkan-mac folder in the third-party folder and if not go and fetch it.

The wget command is slightly different in that it has the --no-cookies argument because the download site tries to set cookies during the request. We also need the peculiar ?Human=true - without this the download link seems to not work. We also use the -O vulkan-mac.tar.gz to tell wget to change the name of the downloaded file.

The file itself is not a zip file, but instead a tar file so we need to use the tar command to uncompress it.

Save and close the shared-scripts.sh file then edit the setup.sh script in your console folder, adding the new shared method invocation to the end of the script to fetch Vulkan:

fetch_third_party_lib_vulkan_macos

Now you can navigate into the console folder in Terminal and run the setup script:

$ ./setup.sh 
Fetching Brew dependency: 'wget'.
Fetching Brew dependency: 'cmake'.
Fetching Brew dependency: 'ninja'.
SDL library already exists in third party folder.
SDL2.framework already exists ...
Fetching Vulkan SDK (Mac) from: https://sdk.lunarg.com/sdk/download/1.1.92.1/mac/vulkansdk-macos-1.1.92.1.tar.gz?Human=true
Saving to: ‘vulkan-mac.tar.gz’
Unzipping Vulkan SDK (Mac) into 'third-party/vulkan-mac' ...

After running the script, observe that you now have a vulkan-mac folder in your third-party folder:

: root
 + third-party
   + vulkan-mac

Next we’ll walk through what different bits of the SDK we’ll need to integrate into our console project.


Vulkan header files

The header files we will need to write our code against can be found here:

third-party/vulkan-mac/macOS/include

There aren’t too many of them and the main one we are interested in is the vulkan.hpp file which provides the C++ interface to the Vulkan SDK. There is also a vulkan.h file for the C based interface.

We need to add the header location to our CMakeLists.txt file so they are included for us in our code base. Open console/CMakeLists.txt and add the following definition to the include_directories section to include the Vulkan headers:

include_directories(${THIRD_PARTY_DIR}/vulkan-mac/macOS/include)

Dynamic library files

We will be using the following dynamic library files that come bundled with the SDK - note that there is one main file for Vulkan integration but also another one for MoltenVK support:

third-party/vulkan-mac/macOS/lib/libvulkan.1.1.92.dylib
third-party/vulkan-mac/macOS/lib/libMoltenVK.dylib

We’ll be placing these two files in our console/Frameworks folder so they can be found at runtime and we’ll be linking them during the build. The Vulkan system will actually be looking for a file named libvulkan.1.dylib so we’ll also need to do a file naming change as well.

Open the shared-scripts.sh file again and add the following at the end:

setup_vulkan_libs_macos() {
    verify_frameworks_folder_exists

    pushd "Frameworks"
        if [ ! -e "libvulkan.1.dylib" ]; then
            cp ../../../third-party/vulkan-mac/macOS/lib/libvulkan.1.1.92.dylib libvulkan.1.dylib
        fi

        if [ ! -e "libMoltenVK.dylib" ]; then
            cp ../../../third-party/vulkan-mac/macOS/lib/libMoltenVK.dylib libMoltenVK.dylib
        fi
    popd
}

This script will check that we have a Frameworks folder, then check that we have the two dynamic library files that we require. Note that the first cp command renames libvulkan.1.1.92.dylib to libvulkan.1.dylib on the way through - if we didn’t do this then Vulkan would fail to initialise.

Save and close the shared-scripts.sh file and edit your console/setup.sh again, adding the following to the bottom:

setup_vulkan_libs_macos

Save and exit then run setup.sh in your console folder. After running it you will see your folder structure look like this:

: root
  + console
    + Frameworks
      libMoltenVK.dylib
      libvulkan.1.dylib

As well as copying the dylib files we need to register them in our CMakeLists.txt file to link them with our executable.

In the console/CMakeLists.txt file add the following lines just before the add_executable block to declare build variables representing the two dylib files we need to link:

set(DYLIB_VULKAN ${CMAKE_CURRENT_SOURCE_DIR}/Frameworks/libvulkan.1.dylib)
set(DYLIB_MOLTEN_VK ${CMAKE_CURRENT_SOURCE_DIR}/Frameworks/libMoltenVK.dylib)

Then just before the add_custom_command block, add the following to cause the dylib files to be linked to our main build target:

target_link_libraries(
    a-simple-triangle-console
    ${DYLIB_VULKAN}
    ${DYLIB_MOLTEN_VK}
)

If we don’t link these libraries to our target our compilation will fail.


MoltenVK ICD

Vulkan supports Installable Client Drivers or ICDs. To get Vulkan to run on Apple platforms we actually need to use the MoltenVK ICD. Here is a bit of info to explain what an ICD needs to do: https://vulkan.lunarg.com/doc/view/1.0.54.0/windows/LoaderAndLayerInterface.html#user-content-installable-client-drivers. And here is the official MoltenVK site: https://github.com/KhronosGroup/MoltenVK.

When our application tries to initialise Vulkan, it will check to see if there are any ICDs it should use. In the Vulkan SDK for MacOS there is actually a bundled JSON definition that uses the MoltenVK ICD, which we can use as a clue on how to configure our own ICD:

third-party/vulkan-mac/macOS/etc/vulkan/icd.d/MoltenVK_icd.json

If you examine the content of the MoltenVK_icd.json file you will see this:

{
    "file_format_version" : "1.0.0",
    "ICD": {
        "library_path": "../../../lib/libMoltenVK.dylib",
        "api_version" : "1.0.0"
    }
}

The api_version represents Vulkan 1.0.0 - which is what MoltenVK is aligned with - and the library_path defines a relative location to find the ICD dynamic library to use at run time - observe that it points at the libMoltenVK.dylib file within the main Vulkan SDK folder.

We need to replicate this ICD configuration for our console target by adding a vulkan/icd.d/MoltenVK_icd.json file which has a library_path pointing into our console/Frameworks folder.

We won’t copy the one from the Vulkan SDK - the library_path wouldn’t work for us anyway - we will just create our own MoltenVK_icd.json file under a vulkan/icd.d folder within our console folder.

Important: Vulkan will look for a folder named vulkan/icd.d in the same location as the executable - if it isn’t in exactly that path it won’t find it and therefore Vulkan will fail to initialise for MoltenVK based platforms.

To include a MoltenVK ICD in our console project, create a file named MoltenVK_icd.json in the following folder structure - you’ll need to create the vulkan/icd.d folders too. We only need to do this once so you can check this new file into version control afterward:

: root
  + console
    + vulkan
      + icd.d
        MoltenVK_icd.json

Edit MoltenVK_icd.json with the following content then close it - noting that the library_path points down into our Frameworks folder to find the MoltenVK dynamic library which is the implementation of the ICD for MacOS:

{
    "file_format_version" : "1.0.0",
    "ICD": {
        "library_path": "../../Frameworks/libMoltenVK.dylib",
        "api_version" : "1.0.0"
    }
}

We will need to make sure this new vulkan folder is available within the out folder after a build so Vulkan within our application executable can find it when it starts - remember it will be looking for a vulkan/icd.d/MoltenVK_icd.json path to boostrap Vulkan. We will use a symlink again, very similar to how we included the assets folder. Edit console/cmake-post-build.sh and update it to look like the following:

#!/bin/bash

echo "Adding Frameworks @rpath to binary ..."
install_name_tool -add_rpath @loader_path/../Frameworks out/a-simple-triangle-console

pushd out
    # See if there is an `assets` folder already.
    if [ ! -d "assets" ]; then
        # If there isn't create a new symlink named `assets`.
        echo "Linking 'assets' path to '../../main/assets'"
        ln -s ../../main/assets assets
    fi

    # See if there is a `vulkan` folder already.
    if [ ! -d "vulkan" ]; then
        # If there isn't create a new symlink named `vulkan`.
        echo "Linking 'vulkan' path to '../vulkan'"
        ln -s ../vulkan vulkan
    fi
popd

Note the addition of the vulkan folder check and symlink setup near the bottom - the rest of the script hasn’t changed.

Perform a CMake refresh then if you run your project now you will find a new vulkan symlink in the out folder.


Creating the Vulkan application

Before adding our new Vulkan application we need to update our graphics wrapper and SDL wrapper headers to import the Vulkan C++ dependencies. Once we do this, the Vulkan APIs will become available in our code base since we previously included them in our CMakeLists.txt file.

First off, edit main/src/core/graphics-wrapper.hpp and add the Vulkan header include statement within the __APPLE__ block - the vulkan.hpp file is the C++ Vulkan wrapper header since we would prefer it over the C style APIs found in vulkan.h:

#elif __APPLE__
#include <vulkan/vulkan.hpp>

...

Note: There are advantages to using the C++ vs the C header for Vulkan, a key one is the access to a range of Unique* Vulkan class variants that basically act like smart pointers and clean themselves up when they go out of scope. If we were to use the C header instead, it would force us to do all the memory clean up manually - and tediously - which is error prone and requires heaps of boilerplate code to run at the correct points in time. The C++ Vulkan header is actually just a wrapper over the top of the C header with some helpful components to allow us to write more C++ styled code.

Next we’ll add some of the built in SDL Vulkan functionality to expose some Vulkan related SDL APIs that we’ll need. Edit main/src/core/sdl-wrapper.hpp and add the following include statement:

#include <SDL_vulkan.h>

Our Vulkan application and associated classes will live in a new folder named main/src/application/vulkan. Create it now then within it create the files vulkan-application.hpp and vulkan-application.cpp.

Edit vulkan-application.hpp with the following:

#pragma once

#include "../../core/internal-ptr.hpp"
#include "../application.hpp"

namespace ast
{
    struct VulkanApplication : public ast::Application
    {
        VulkanApplication();

        void update(const float& delta) override;

        void render() override;

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

The header looks almost identical to the existing opengl-application.hpp header.

Now edit vulkan-application.cpp to add the skeleton of our implementation:

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

using ast::VulkanApplication;

struct VulkanApplication::Internal
{
    const ast::VulkanContext context;
    SDL_Window* window;

    Internal() : context(ast::VulkanContext()),
                 window(ast::sdl::createWindow(SDL_WINDOW_VULKAN | SDL_WINDOW_RESIZABLE | SDL_WINDOW_ALLOW_HIGHDPI)) {}

    void update(const float& delta) {}

    void render() {}

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

VulkanApplication::VulkanApplication() : internal(ast::make_internal_ptr<Internal>()) {}

void VulkanApplication::update(const float& delta)
{
    internal->update(delta);
}

void VulkanApplication::render()
{
    internal->render();
}

Our internal struct creates an SDL window in a very similar way to our OpenGL application, but passes in the SDL_WINDOW_VULKAN argument instead.

The context field will hold the Vulkan instance itself along with a bunch of state that it requires to run. We’ll author the VulkanContext class next:

const ast::VulkanContext context;

The Vulkan context class

Unlike the OpenGL context from our OpenGL application, the Vulkan context is not part of any Vulkan SDK - it is completely our own invention. To use Vulkan in an application we need a Vulkan instance, which for us will be of the type vk::UniqueInstance. Once we have an instance we need to construct and configure many other Vulkan components before we can do anything useful with it. We are writing the context class as a tidy way to encapsulate the instance and all the other code that will follow for Vulkan setup.

Create the files vulkan-context.hpp and vulkan-context.cpp under main/src/application/vulkan/. Edit the header file with the following:

#pragma once

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

namespace ast
{
    struct VulkanContext
    {
        VulkanContext();

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

Very standard stuff - actually nothing special at all about the header. Next edit the implementation file with the following:

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

using ast::VulkanContext;

namespace
{
    vk::UniqueInstance createInstance()
    {
        vk::ApplicationInfo applicationInfo{
            "A Simple Triangle",      // Application name
            VK_MAKE_VERSION(1, 0, 0), // Application version
            "A Simple Triangle",      // Engine name
            VK_MAKE_VERSION(1, 0, 0), // Engine version
            VK_MAKE_VERSION(1, 0, 0)  // Vulkan API version
        };

        // Find out what the mandatory Vulkan extensions are on the current device,
        // by this stage we would have already determined that the extensions are
        // available via the 'ast::vulkan::isVulkanAvailable()' call in our main engine.
        std::vector<std::string> requiredExtensionNames{
            ast::vulkan::getRequiredVulkanExtensionNames()};

        // Pack the extension names into a data format consumable by Vulkan.
        std::vector<const char*> extensionNames;
        for (const auto& extension : requiredExtensionNames)
        {
            extensionNames.push_back(extension.c_str());
        }

        // Define the info for creating our Vulkan instance.
        vk::InstanceCreateInfo instanceCreateInfo{
            vk::InstanceCreateFlags(),                    // Flags
            &applicationInfo,                             // Application info
            0,                                            // Enabled layer count
            nullptr,                                      // 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

struct VulkanContext::Internal
{
    const vk::UniqueInstance instance;

    Internal() : instance(::createInstance())
    {
        ast::log("ast::VulkanContext", "Initialized Vulkan context successfully.");
    }
};

VulkanContext::VulkanContext() : internal(ast::make_internal_ptr<Internal>()) {}

Note: You will get syntax errors as we haven’t yet authored the vulkan-common.hpp header or its functions but don’t worry we’ll do that shortly.

Let’s digest the implementation. Our Internal struct simply holds a Vulkan instance and initialises it through the ::createInstance() free function. As previously mentioned, by using the Unique* variants of Vulkan classes we get automatic lifecycle management of them:

struct VulkanContext::Internal
{
    const vk::UniqueInstance instance;

    Internal() : instance(::createInstance())
    {
        ast::log("ast::VulkanContext", "Initialized Vulkan context successfully.");
    }
};

The ::createInstance() free function needs a bit of explaining. It starts off by creating a structure that describes our application to Vulkan. Most of the fields are up to us to decide on their values except the final API version which should reflect which version of Vulkan we are targetting:

vk::ApplicationInfo applicationInfo{
    "A Simple Triangle",      // Application name
    VK_MAKE_VERSION(1, 0, 0), // Application version
    "A Simple Triangle",      // Engine name
    VK_MAKE_VERSION(1, 0, 0), // Engine version
    VK_MAKE_VERSION(1, 0, 0)  // Vulkan API version
};

Note: Vulkan makes very heavy use of Info configuration structures to perform many of its operations. Get used to this style of creating a pile of info objects to perform Vulkan commands!

The next line is probably quite mysterious as we haven’t actually written the ast::vulkan::getRequiredVulkanExtensionNames() function yet.

std::vector<std::string> requiredExtensionNames{
    ast::vulkan::getRequiredVulkanExtensionNames()};

The function will return a list of what Vulkan extensions are required by the current device to operate successfully. For example on my Macbook Air the list of extensions required to create an instance successfully looks like this:

VK_KHR_surface
VK_MVK_macos_surface

The next block of code simply takes this vector of std::string extension names and converts them to vector of const char* so we can feed them into the subsequent Vulkan configuration which can’t take std::string objects:

std::vector<const char*> extensionNames;
for (const auto& extension : requiredExtensionNames)
{
    extensionNames.push_back(extension.c_str());
}

We use the applicationInfo object along with the extension names to form the configuration of the Vulkan instance we want to create:

vk::InstanceCreateInfo instanceCreateInfo{
    vk::InstanceCreateFlags(),                    // Flags
    &applicationInfo,                             // Application info
    0,                                            // Enabled layer count
    nullptr,                                      // Enabled layer names
    static_cast<uint32_t>(extensionNames.size()), // Enabled extension count
    extensionNames.data()                         // Enabled extension names
};

The final statement creates a new vk::UniqueInstance using the instance configuration object and returns it to the caller:

return vk::createInstanceUnique(instanceCreateInfo);

The Vulkan instance forms the core of our implementation - we will be adding many more Vulkan configuration objects and data structures in the next bunch of articles to get ourselves into a state where we can actually render something - so hang in there!


Vulkan common functions

We’ll now author the ast::vulkan::getRequiredVulkanExtensionNames() function used in our instance creation as a free function within the ast::vulkan namespace. We will write it as a free function as we’ll need to use it again later in our implementation elsewhere.

Create vulkan-common.hpp and vulkan-common.cpp within the main/src/application/vulkan folder. We’ll actually be authoring two functions, the getRequiredVulkanExtensionNames function but also another named isVulkanAvailable that can tell us whether or not we should even attempt to initialise Vulkan on the current device.

Edit vulkan-common.hpp with the following - noting that there is no class or struct definition, just free functions within the ast::vulkan namespace:

#pragma once

#include <string>
#include <vector>

namespace ast::vulkan
{
    std::vector<std::string> getRequiredVulkanExtensionNames();

    bool isVulkanAvailable();
} // namespace ast::vulkan

Then edit vulkan-common.cpp with the following to implement the functions:

#include "vulkan-common.hpp"
#include "../../core/graphics-wrapper.hpp"
#include "../../core/log.hpp"
#include "../../core/sdl-wrapper.hpp"
#include <set>

std::vector<std::string> ast::vulkan::getRequiredVulkanExtensionNames()
{
    uint32_t extensionCount;
    SDL_Vulkan_GetInstanceExtensions(nullptr, &extensionCount, nullptr);

    auto extensionNames{std::make_unique<const char**>(new const char*[extensionCount])};
    SDL_Vulkan_GetInstanceExtensions(nullptr, &extensionCount, *extensionNames);
    std::vector<std::string> result(*extensionNames, *extensionNames + extensionCount);

    return result;
}

bool ast::vulkan::isVulkanAvailable()
{
    static const std::string logTag{"ast::vulkan::isVulkanAvailable"};

    // Check if SDL itself can load Vulkan.
    if (SDL_Vulkan_LoadLibrary(nullptr) != 0)
    {
        ast::log(logTag, "No SDL Vulkan support found.");
        return false;
    }

    // Determine what Vulkan extensions are required by SDL to be able to run Vulkan then pump
    // them into a 'set' so we can evaluate them easily.
    std::vector<std::string> requiredExtensionNamesSource{
        ast::vulkan::getRequiredVulkanExtensionNames()};

    std::set<std::string> requiredExtensionNames(
        requiredExtensionNamesSource.begin(),
        requiredExtensionNamesSource.end());

    // There should always be required extensions so we should never get 0.
    if (requiredExtensionNames.empty())
    {
        ast::log(logTag, "No Vulkan required extensions found.");
        return false;
    }

    // Iterate all the available Vulkan extensions on the current device, draining
    // each one from the required extensions set along the way.
    for (auto const& availableExtension : vk::enumerateInstanceExtensionProperties())
    {
        requiredExtensionNames.erase(availableExtension.extensionName);
    }

    // If our required extensions set isn't empty it means that one or more of
    // them were not found in the available extensions, so we don't have what
    // we require to successfully create a Vulkan instance.
    if (!requiredExtensionNames.empty())
    {
        ast::log(logTag, "Missing one or more required Vulkan extensions.");
        return false;
    }

    ast::log(logTag, "Vulkan is available.");
    return true;
}

getRequiredVulkanExtensionNames function

This is the function we called to create the Vulkan instance earlier:

std::vector<std::string> ast::vulkan::getRequiredVulkanExtensionNames()

First we need to query SDL to find out how many Vulkan extensions are required to support provisioning a Vulkan instance, asking for the result to be inserted into the extensionCount field:

uint32_t extensionCount;
SDL_Vulkan_GetInstanceExtensions(nullptr, &extensionCount, nullptr);

We then query SDL a second time, using the extensionCount from the first query to extract the names of the required extensions. The double query seems very quirky but due to the way the SDL function operates we have to call the same SDL_Vulkan_GetInstanceExtensions twice but parametised differently to get the different attributes. The SDL collection of extension names is in a format that isn’t particularly friendly for us in C++ land so we need to coerce the result into a vector of std::string objects before returning it:

auto extensionNames{std::make_unique<const char**>(new const char*[extensionCount])};
SDL_Vulkan_GetInstanceExtensions(nullptr, &extensionCount, *extensionNames);
std::vector<std::string> result(*extensionNames, *extensionNames + extensionCount);

return result;

isVulkanAvailable

This function will be used in our core engine shortly to evaluate whether there is any point in attempting to initialise Vulkan on the current device.

bool ast::vulkan::isVulkanAvailable()

The first criteria is to query the SDL Vulkan implementation to find out if it can successfully start the Vulkan loader - for our Mac console this would mean bootstrapping the MoltenVK ICD. On other platforms such as Windows this might mean trying to locate and initialise the Vulkan graphics driver implementation. Of course if there isn’t one, then Vulkan is pretty much not an option …

if (SDL_Vulkan_LoadLibrary(nullptr) != 0)
{
    ast::log(logTag, "No SDL Vulkan support found.");
    return false;
}

Next we use our getRequiredVulkanExtensionNames function to grab the collection of what Vulkan extensions must be supported on the device to be able to create a Vulkan instance. We then create a set populated with the collection of extension names to make it easier to evaluate how many of the required extensions are available.

std::vector<std::string> requiredExtensionNamesSource{
    ast::vulkan::getRequiredVulkanExtensionNames()};

std::set<std::string> requiredExtensionNames(
    requiredExtensionNamesSource.begin(),
    requiredExtensionNamesSource.end());

The collection of required extensions should at a minimum contain more than zero elements - if for some reason there are no required extensions it is a sign that Vulkan probably can’t be run on the device:

if (requiredExtensionNames.empty())
{
    ast::log(logTag, "No Vulkan required extensions found.");
    return false;
}

The set of required extensions is then drained by all the currently available extensions that are reported via the result of the vk::enumerateInstanceExtensionProperties() command:

for (auto const& availableExtension : vk::enumerateInstanceExtensionProperties())
{
    requiredExtensionNames.erase(availableExtension.extensionName);
}

If our set still contains any elements it means that there were required extensions that couldn’t be found in the collection of available extensions - ultimately meaning that we don’t in fact have the capability to successfully create a Vulkan instance:

if (!requiredExtensionNames.empty())
{
    ast::log(logTag, "Missing one or more required Vulkan extensions.");
    return false;
}

If all our required extensions could be found then we are in business!

ast::log(logTag, "Vulkan is available.");
return true;

Save your code and observe that the syntax errors should now be resolved in the vulkan-context class.


Bootstrapping Vulkan

The final change we need to make is in our core engine code which creates our application. At the moment it only tries to create the OpenGL application like so:

std::unique_ptr<ast::Application> resolveApplication()
{
    static const std::string logTag{classLogTag + "resolveApplication"};

    try
    {
        ast::log(logTag, "Creating OpenGL application ...");
        return std::make_unique<ast::OpenGLApplication>();
    }
    catch (const std::exception& error)
    {
        ast::log(logTag, "OpenGL application failed to initialize.", error);
    }

    throw std::runtime_error(logTag + " No applications can run in the current environment");
}

We will update this method to do the following:

Add the following header includes to engine.cpp:

#include "../application/vulkan/vulkan-application.hpp"
#include "../application/vulkan/vulkan-common.hpp"

Then update the resolveApplication function with the following:

std::unique_ptr<ast::Application> resolveApplication()
{
    static const std::string logTag{classLogTag + "resolveApplication"};

    if (ast::vulkan::isVulkanAvailable())
    {
        try
        {
            ast::log(logTag, "Creating Vulkan application ...");
            return std::make_unique<ast::VulkanApplication>();
        }
        catch (const std::exception& error)
        {
            ast::log(logTag, "Vulkan application failed to initialize.", error);
        }
    }

    try
    {
        ast::log(logTag, "Creating OpenGL application ...");
        return std::make_unique<ast::OpenGLApplication>();
    }
    catch (const std::exception& error)
    {
        ast::log(logTag, "OpenGL application failed to initialize.", error);
    }

    throw std::runtime_error(logTag + " No applications can run in the current environment");
}

Save and run the console project and as long as you are running on a Mac with Metal support you should see an empty black window appear but with the Vulkan console output indicating that it worked successfully.

Note: In the screenshot below I’m directly running ./a-simple-triangle-console via Terminal in the out folder to make it a bit easier to see the logging output from our application. When running through Visual Studio Code you will see the same thing except the logging statements would be displayed within the appropriate console output panel.


Summary

Next up we’ll setup the MacOS application to initialise Vulkan.

The code for this article can be found here.

Continue to Part 15: Vulkan setup MacOS.

End of part 14