Voxel Game Series

Game Engine Series: First steps with Vulkan

by

As we’ve discussed in the previous article, our game engine is going to use the modern Vulkan graphics API. In this part of the Voxel Game Series, we’re going to initialize the Vulkan Subsystem.

Other resources

These blog posts are going to follow vulkan-tutorial.com’s structure for a good while. If you want to follow along with them as well, in this article we’re going to get to Instance Creation.

Base Code

Convention: Integer types

In this series, we’re going to use shorthand notations for the (unsigned) integer types like uint16_t. We denote their signedness with the prefix I or U and their width with. It’s useful to create a small module (e.g. types.ixx) with using statements aliasing from <cstdint> (e.g. export using u8 = uint8_t;). You can do the same for floats and doubles, too.

Before delving into rendering, let’s quickly build an application skeleton that we’ll gradually fill with more and more content as the series progresses.

The Application class manages our engine

To get started, let’s make some changes to the main.cpp file in our Game project. Replace the contents of the file with the following code:

import engine.log;
import engine.application;

int main(int argc, char** argv) {
    engine::log_trace("Testing logger. This is a {} message.", "trace");
    engine::log_debug("This is a {} message!", "debug");
    engine::log_info("This is a {} message!", "info");
    engine::log_warning("This is a {} message!", "warning");
    engine::log_error("This is a {} message!", "error");
    engine::log_fatal("This is a {} message!", "fatal");

    engine::Application app {};

    return app.run();
}

The first few lines aren’t required and are just there to test our logger. The meat is the new Application class. In the engine project create a new file engine/application.ixx with the following code:

module;
#include "../prologue.hpp"

export module engine.application;

export namespace engine {

class ENGINE_API Application {
public:
  Application() noexcept;
  ~Application() noexcept;

  Application(const Application&) = delete;
  Application(Application&&) = delete;
  Application& operator=(const Application&) = delete;
  Application& operator=(Application&&) = delete;

  int run() noexcept;
}

}

As you can see, we’ve defined a constructor and a destructor, and deleted the copy and move operations. Finally, we have a run function that takes care of running our engine. It returns an integer as a status code to be returned by main. Implement the functions in application.cpp and provide sensible defaults (e.g. returning 0).

Interfaces for the low-level renderer

As we’ve previously discussed, our game engine will have a low-level and a high-level rendering abstraction. The former uses Graphics APIs like Vulkan or D3D12, while the latter uses the former’s abstractions to provide generic rendering functionality.

For the first few parts of the series, we’ll focus on writing a low-level Vulkan renderer. To get started, we’ll create an abstract class called AbstractGpuDevice. This class is purely virtual and should be inherited by each GpuDevice.

GpuDevice is an abstraction that provides an interface for managing the graphics hardware. It is responsible for initializing the graphics API, creating and managing resources, and submitting commands to the graphics hardware. In essence, it’s the gateway to the graphics hardware from our engine.

To implement the AbstractGpuDevice interface for Vulkan, we’ll create a class called VulkanGpuDevice. This class will inherit from AbstractGpuDevice and provide the actual implementation for the Vulkan API.

One thing you might notice is that we use the convention of prefixing base classes with the word “Abstract”. This is a common practice in object-oriented programming and makes it clear that a class is intended to be used as a base class for other classes.

Vulkan is a complex and error-prone graphics API that requires several steps to initialize, including creating an instance, selecting a physical device, creating a logical device, and setting up queues1. To make the process more manageable, we can split the creation/initialization and runtime functionality of the renderer.

To facilitate the creation of the renderer, we can use a factory pattern. The factory pattern is a creational pattern that provides an interface for creating objects without specifying the exact class of object that will be created. This allows us to separate the code that creates objects from the code that uses them, making our code more modular and easier to maintain. In our case, we can create a factory class that creates an instance of the appropriate AbstractGpuDevice subclass (in our case, VulkanGpuDevice). The factory class can also handle the initialization of the renderer, allowing our Application class to focus on the high-level logic of our game engine.

A generic factory reduces boilerplate

Programmers love abstractions, and the factory pattern is a great way to abstract away the details of object creation. However, writing a factory class for every type of object can quickly become tedious and repetitive. To save time and effort, we can create a generic factory class that can be used to create any type of object. This factory class can take a type parameter and use template metaprogramming to create the appropriate object.

Create a new file util/factory_registry.ixx. It looks like this:

export module engine.util:factory_registry;

import engine.log;

export namespace engine::util {

template <typename Base, typename Key, template <typename...> typename Ptr, typename... Args>
struct FlexibleFactoryRegistry {

};

}

This class has a lot of parameters! Here’s the gist of it:

  • Base — This is the base class that all the objects created by the factory must derive from. This is usually an abstract class or an interface.
  • Key — This is the type of the key used to identify the object to be created. This can be any type, but usually it is a string, an enumeration, or an integer.
  • Ptr — This is the smart pointer type used to manage the lifetime of the created objects. This can be any type of smart pointer, such as std::unique_ptr or std::shared_ptr.
  • Args — This is a variadic parameter pack that represents the constructor arguments for the objects being created. These arguments can be of any type and can have default values.

FlexibleFactoryRegistry contains a private method:

static std::map<Key, Ptr<Base> (*)(Args...)>& data() noexcept
{
    static std::map<Key, Ptr<Base> (*)(Args...)> factories {};
    return factories;
}

It returns a reference to a static map object that stores pointers to functions, which create instances of a Base class, based on a provided key value of type Key. The function takes a variable number of arguments of type Args... that will be forwarded to the constructor of the derived class created by the factory function. By default, data() returns an empty map of type std::map<Key, Ptr<Base> (*)(Args...)>. Since it is static, the same map object will be shared by all instances of the FlexibleFactoryRegistry class.

In simple words: This map maps from a Key (like a string) to a function that can create an object of type Base.

Next is the public create() function:

static Ptr<Base> create(const Key& key, Args... args) noexcept
{
    try {
        if (const auto fun = data().find(key); fun != data().end()) {
            return fun->second(std::forward<Args>(args)...);
        }

        if constexpr (has_formatter<Key>) {
            log_warning("Trying to create unknown type {}.", key);
        } else {
            log_warning("Trying to create unknown type. Additionally, the key type does not support std::format.");
        }
    } catch (const std::exception& e) {
        if constexpr (has_formatter<Key>) {
            log_warning("Failed to create type {} with error: {}", key, e.what());
        } else {
            log_warning("Failed to create type with error: {}. Additionally, the key type does not support std::format.", e.what());
        }
    } catch (...) {
        if constexpr (has_formatter<Key>) {
            log_warning("Unknown exception while creating {}.", key);
        } else {
            log_warning("Unknown exception while creating. Additionally, the key type does not support std::format.");
        }
    }
    return {};
}

This function tries to find the factory function in the map we discusses earlier. If it succeeds, it uses it to create a new object. Otherwise, we would like to print some warnings to the console. Here, we use if constexpr to determine at compile time which error messages to include. We also check if we can print an error message.

has_formatter is a concept

template <typename T>
concept has_formatter = requires { std::formatter<T>::parse; };

that just checks if our Key can be displayed using std::format.

Lastly, we need a function to register factories with our class. This function looks like this:

using func_t = Ptr<Base> (*)(Args...);

static bool register_factory(const Key& name, func_t func) noexcept
{
    data().insert_or_assign(name, func);

    return true;
}

Note that if we call register_factory with the same key twice, the first one is silently getting overwritten.

Finally, let’s define some convenience typedefs so this struct isn’t as exhausting:

template<typename Base, typename... Args>
using SharedFactoryRegistry = FlexibleFactoryRegistry<Base, std::string, std::shared_ptr, Args...>;

template <typename Base, typename... Args>
using SharedTypedFactoryRegistry = FlexibleFactoryRegistry<Base, std::type_index, std::shared_ptr, Args...>;

template <typename Base, typename... Args>
using FactoryRegistry = FlexibleFactoryRegistry<Base, std::string, std::unique_ptr, Args...>;

template <typename Base, typename... Args>
using TypedFactoryRegistry = FlexibleFactoryRegistry<Base, std::type_index, std::unique_ptr, Args...>;

These four type aliases define factories that take a std::string or a std::type_index and return a std::shared_ptr or a std::unique_ptr, respectively.

Using the generic factory for GPU device creation

Let’s use the monster we have created the fantastic factory we just wrote!

There is stuff omitted for brevity.

To not bloat the blog post even more, I’m omitting certain files. For example, theoretically, each module one file that just exports all partitions. If the code isn’t running, please check if this is the reason.

Create a few classes:

  • renderer/renderer.ixx:

    export module engine.renderer;
    
    import engine.util;
    
    export namespace engine:: renderer {
    
    class AbstractGpuDevice {
    public:
      virtual ~AbstractGpuDevice() noexcept = default;
    };
    
    struct GpuDeviceConfig {};
    
    using GpuDeviceFactory = util::FactoryRegistry<AbstractGpuDevice, const GpuDeviceConfig&>;
    
    }
    

    Here, we defined the Abstract Device. We also want to pass parameters to influence our gpu device creation. For that we defined a new struct GpuDeviceConfig. Currently, it is empty.

    We also define a new type GpuDeviceFactory that can be used to create new GPU Devices.

  • renderer/vulkan/gpu_device.ixx:

    export module engine.renderer.vulkan:gpu_device;
    
    import engine.renderer;
    
    export namespace engine::renderer::vulkan {
    
    class VulkanGpuDevice final : public AbstractGpuDevice {
        virtual ~AbstractGpuDevice() noexcept = default;
    };
    
    }
    

    Currently, this is just boilerplate for the Vulkan Device. In the future it will contain function to create buffers, shaders and textures.

  • renderer/vulkan/gpu_device_factory.ixx:

    export module engine.renderer.vulkan:gpu_device_factory;
    
    import engine.renderer;
    import engine.log;
    
    export namespace engine::renderer::vulkan {
    
    class VulkanDeviceFactory {
    public:
        static std::unique_ptr<AbstractGpuDevice> create(const renderer::GpuDeviceConfig& config) noexcept 
        { 
            log_info("Hello from factory!");
            return nullptr; 
        }
    
    private:
        static bool registered_;
    };
    
    }
    

    This class is what we’re currently concerned with. It is responsible for creating the VulkanGpuDevice. It contains a static member registered_, which tracks if it has been registered with the factory.

  • renderer/vulkan/gpu_device_factory.cpp:

    module engine.renderer.vulkan:device_factory;
    
    namespace engine::renderer::vulkan {
    
    bool DeviceFactory::registered_ = 
        renderer::GpuDeviceFactory::register_factory("vulkan", &VulkanDeviceFactory::create);
    
    }
    

    This member is set in the .cpp file.

Finally, in our application constructor we can now create a new GPU device (namespaces omitted for brevity):

Application::Application() noexcept
{
    GpuDeviceConfig config {};
    std::unique_ptr<AbstractGpuDevice> device = GpuDeviceFactory::create("vulkan", config);
}

If we run this code, we expect to see “Hello from factory!” to be printed on the screen.

Finally: Creating a Vulkan Instance

The first step in using Vulkan is to create an instance. The instance is the connection between the application and the Vulkan API. It represents the application’s view of the underlying Vulkan implementation and allows the application to configure global state and query general capabilities of the GPU(s).

Extensions and layers extend the core Vulkan API

During instance creation, we can specify which layers and extensions we want to enable. Layers provide additional functionality, like debugging and validation, while extensions provide access to additional functionality, like new features or hardware-specific optimizations. The key difference is this:

  • Extensions define new API types (i.e. structs) and functions.

  • Layers extend existing function calls and add new functionality. Layers kind of work like decorator pattern. In pseudo code they look like this:

    int add(int a, int b) { return a + b};
    
    int debug_add(int a, int b) 
    { 
        std::cout << "Adding " << a << " and " << b << ".\n";
        return add(a, b); 
    }
    

    In this code, we have two functions: add and debug_add. The add function simply takes two integers and returns their sum. The debug_add function, on the other hand, first outputs a debug message to the console and then calls the add function to get the sum of the two integers.

    This is similar to how layers work in Vulkan. The add function represents the core Vulkan API, while the debug_add function represents a layer that intercepts the call to add and adds additional functionality (in this case, outputting a debug message).

    In the same way, Vulkan layers intercept calls to core Vulkan functions and add additional functionality such as validation, profiling, or debugging. This allows developers to customize the behavior of the Vulkan API and add their own functionality as needed.

We use vk::CreateInstance to create an instance

We extend VulkanDeviceFactory with a constructor that takes the config object and a private void-returning function called create_instance(). Also, we give it a private member of type vk::Instance.

class VulkanDeviceFactory {
public:
    static std::unique_ptr<AbstractGpuDevice> create(const renderer::GpuDeviceConfig& config) noexcept;

private:
    VulkanDeviceFactory(const renderer::config& c) noexcept;
    ~VulkanDeviceFactory() noexcept = default;

    std::unique_ptr<AbstractGpuDevice> get() noexcept;

    void create_instance();

    vk::Instance instance_;
};

Let’s start with the constructor:

VulkanDeviceFactory::VulkanDeviceFactory(const renderer::config& c) noexcept 
{
    VULKAN_HPP_DEFAULT_DISPATCHER.init(vkGetInstanceProcAddr);
    
    try {
        create_instance();
    } catch(const std::exception& e) {
        log_fatal("Couldn't create vulkan gpu device: {}", e.what());
    }
}

The general idea is gonna be to construct all required Vulkan components piece-by-piece in the constructor. Then, we use the get() function to create the device from that. The first line is required to load Vulkan function pointers. For now I would suggest just ignoring it.

Inside the create_instance() function, we see a pattern that is common in the API: First, we fill out an XXXCreateInfo struct and then we use createXXX(info) to actually create the object.

void VulkanDeviceFactory::create_instance()
{
    const vk::ApplicationInfo app_info {
        "my cool game", // Application name
        VK_MAKE_VERSION(0, 1, 1), // Application version
        "engine", // Engine name
        VK_MAKE_VERSION(0, 0, 1), // Engine version
        VK_API_VERSION_1_3, // Vulkan API version
    };

    auto extensions = get_instance_extensions();
    auto layers = get_instance_layers();

    const vk::InstanceCreateInfo create_info {
        {}, // Flags
        &app_info,
        layers,
        extensions,
    };

    instance_ = vk::createInstance(create_info);
}

Attention: We use vulkan.hpp!

Because we use <vulkan.hpp> we get a lot of nice wrapper structs in the vk namespace. These make life much easier than if we used the plain C structs.

In this function, we create a Vulkan instance, which is the first step to communicate with the GPU. We start by creating an instance of vk::ApplicationInfo, which provides some basic information about the application, such as its name, version, and engine name.

We then call two helper functions, get_instance_extensions() and get_instance_layers(), to obtain the required extensions and layers for the instance, respectively. Right now, we don’t want to use either, so both of these helper member functions just return an empty std::vector<char*>:

std::vector<const char*> VulkanDeviceFactory::get_instance_extensions() const noexcept 
{
    return {};
}

In the future, we will check for required and optional extensions and enable the ones we need.

With the information obtained from the application info, extensions, and layers, we create an instance of vk::InstanceCreateInfo, which contains all the necessary data to create a Vulkan instance. We pass the instance info to vk::createInstance(), which returns a new instance of vk::Instance.

Homework!

Right now, the engine and application name are hardcoded, as well as their respective versions. Find a way by using the GpuDeviceConfig object defined earlier to parameterize this.

Summary

In this article we created a small application skeleton. We also created a generic factory registry and used it for the construction of our GPU Devices. Finally, we created a Vulkan Instance. In the next article in the series, we’re going to look at validation layers. We’ll also fix the bug that we currently are creating the instance, but not destroying it.

Footnotes

  1. More on that later.

Next article

This is the last article in the series.