Recently I've been trying to squeeze a few hours at a time from my weekends to work on my Vulkan renderer projects. Because the extremely limited time I have, any feature I want need to planned well and get implemented in a few hours or I need to break it down to do so. One of such feature I've want to implement is a modular Vulkan Feature management.
Root Issue: hard-coding enabled features
When creating an Vulkan device, you have a lot of options which extensions you want to enable, which feature you want to enable. This is done by inserting features
into the pNext
chain and extensions into ppEnabledExtenionNames
.
typedef struct VkDeviceCreateInfo {
VkStructureType sType;
const void* pNext;
VkDeviceCreateFlags flags;
uint32_t queueCreateInfoCount;
const VkDeviceQueueCreateInfo* pQueueCreateInfos;
uint32_t enabledLayerCount;
const char* const* ppEnabledLayerNames;
uint32_t enabledExtensionCount;
const char* const* ppEnabledExtensionNames;
const VkPhysicalDeviceFeatures* pEnabledFeatures;
} VkDeviceCreateInfo;
Issue is that you also need to query those features before you enabling them. Some device many supports RayTracingPipeline
some may not. Hard coding everything in the device creation code often leads to non-portable binaries. It works on your desktop GPU, then you check out the code on your laptop, it doesn't run anymore.
It has been like this for me for a long time I've want to resolve it once for all. Clearly we need some abstraction on top of it.
Abstracting an interface of Vulkan features
For extensions it's relatively easy, you just passing as array of const char*
, features on the other hand are more of headache.
In the end, we need to chain those features together.
VkDeviceCreateInfo info{};
//some info setup code.
VkPhysicalDeviceFeatures2 feats{};
feats.features.somefatures0 = VK_TRUE;
//enabling other features
info.pNext = &feats;
//Second feature struct
VkPhysicalDeviceVulkan11Features feats11 {};
feats.pNext = &feats11;
feats11.somefeatures0 = VK_TRUE;
//enabling other features.
//this goes on until your have chain all the feature structures.
VkPhysicalDeviceSomeFeatures featsN {};
featsN_1.pNext = &featsN;
featsN.pNext = nullptr; //the last one.
Apparently if we want to do this every single time it will be a lot of boring and typing. Note to mention you have to check those features availability before putting it in VkDeviceCreateInfo
. There must be a better way doing it, right?
Taking advantage of T::*
type in C++
The issue here is there is too many VkPhysicalDeviceSomeFeatures
types in Vulkan. Creating an interface contains all those types would be insane, and I have only a few hours. However all those VkPhysicalDeviceSomeFeatures
looks similar:
typedef struct VkPhysicalDeviceFeatures {
VkBool32 robustBufferAccess;
VkBool32 fullDrawIndexUint32;
VkBool32 imageCubeArray;
VkBool32 independentBlend;
VkBool32 geometryShader;
VkBool32 tessellationShader;
VkBool32 sampleRateShading;
//...
};
They are merely just list of VkBool32
. After experimenting a bit. I found that I can simply write a templated function to enable one feature using T::*
pointer.
template<FEAT> //FEAT is a type like VkPhysicalDeviceFeatures
feature<FEAT>& feature::require(vk::Bool32 FEATS::*feat)
{
//for enabling the feature
m_feats.*feat = true;
//for query the feature.
m_requests.emplace_back([feat](FEATS& feats) -> bool
{ return feats.*feat == true; });
return *this;
}
The feature class is something like this.
//FEATS is some type like VkPhysicalDeviceFeatures
template <typename FEATS>
struct feature
{
//actual features stores here
FEATS m_feats;
//query fucntions
std::vector<std::function<bool(FEATS&)>> m_requests;
//for chaining the features together.
void** pnext() override { return &m_feats.pNext; }
};
like in require()
, at feature query time I will need to check against my feature query std::functions
, and at device creation I can append those features
together.
Feature Manager class
On top of this I need a manager interface to manage all the features together. I choose to implement like this
class feature_descriptor
{
public:
feature_descriptor() noexcept;
bool check(vk::PhysicalDevice phy_dev) const;
feature_descriptor& require_extension(char* extension);
template <class FEATS>
feature<FEATS>& require_feature()
{
std::unique_ptr<feature<FEATS>> feats =
std::make_unique<feature<FEATS>>();
//code to please inside feature_descriptor
//...
return *static_cast<feature<FEATS>*>(feats);
}
};
In this way I can request a feature pack such as vk::PhysicalDeviceVulkan12Features
and enable each feature like this:
//enabling Vulkan 1.2 features
desc.require_feature<vk::PhysicalDeviceVulkan12Features>()
.require(&vk::PhysicalDeviceVulkan12Features::timelineSemaphore)
.require(&vk::PhysicalDeviceVulkan12Features::bufferDeviceAddress);
//enabling Vulkan 1.3 features
desc.require_feature<vk::PhysicalDeviceVulkan13Features>()
.require(&vk::PhysicalDeviceVulkan13Features::dynamicRendering);
Much easier right 😄?