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 likeuint16_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 memberregistered_
, 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
anddebug_add
. Theadd
function simply takes two integers and returns their sum. Thedebug_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 thedebug_add
function represents a layer that intercepts the call toadd
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
-
More on that later. ↩