Almost all the typical work done in 3D rendering applications is performed using device-level functions. They are used to create buffers, images, samplers, or shaders. We use device-level functions to create pipeline objects, synchronization primitives, framebuffers, and many other resources. And, most importantly, they are used to record operations that are later submitted (using device-level functions too) to queues, where these operations are processed by the hardware. This all is done with device-level functions.
Device-level functions, like all other Vulkan functions, can be loaded using the vkGetInstanceProcAddr() function, but this approach is not optimal. Vulkan is designed to be a flexible API. It gives the option to perform operations on multiple devices in a single application, but when we call the vkGetInstanceProcAddr() function, we can't provide any parameter connected with the logical device. So, the function pointer returned by this function can't be connected with the device on which we want to perform the given operation. This device may not even exist at the time the vkGetInstanceProcAddr() function is called. That's why the vkGetInstanceProcAddr() function returns a dispatch function which, based on its arguments, calls the implementation of a function, that is proper for a given logical device. However, this jump has a performance cost: It's very small, but it nevertheless takes some processor time to call the right function.
If we want to avoid this unnecessary jump and acquire function pointers corresponding directly to a given device, we should do that by using a vkGetDeviceProcAddr(). This way, we can avoid the intermediate function call and improve the performance of our application. Such an approach also has some drawbacks: We need to acquire function pointers for each device created in an application. If we want to perform operations on many different devices, we need a separate list of function pointers for each logical device. We can't use functions acquired from one device to perform operations on a different device. But using C++ language's preprocessor, it is quite easy to acquire function pointers specific to a given device:
How do we know if a function is from the device-level and not from the global or instance-level? The first argument of device-level functions is of type VkDevice, VkQueue, or VkCommandBuffer. Most of the functions that will be introduced from now on are from the device level.
To load device-level functions, we should update the ListOfVulkanFunctions.inl file as follows:
#ifndef DEVICE_LEVEL_VULKAN_FUNCTION
#define DEVICE_LEVEL_VULKAN_FUNCTION( function )
#endif
DEVICE_LEVEL_VULKAN_FUNCTION( vkGetDeviceQueue )
DEVICE_LEVEL_VULKAN_FUNCTION( vkDeviceWaitIdle )
DEVICE_LEVEL_VULKAN_FUNCTION( vkDestroyDevice )
DEVICE_LEVEL_VULKAN_FUNCTION( vkCreateBuffer )
DEVICE_LEVEL_VULKAN_FUNCTION( vkGetBufferMemoryRequirements )
// ...
#undef DEVICE_LEVEL_VULKAN_FUNCTION
//
#ifndef DEVICE_LEVEL_VULKAN_FUNCTION_FROM_EXTENSION
#define DEVICE_LEVEL_VULKAN_FUNCTION_FROM_EXTENSION( function, extension )
#endif
DEVICE_LEVEL_VULKAN_FUNCTION_FROM_EXTENSION( vkCreateSwapchainKHR, VK_KHR_SWAPCHAIN_EXTENSION_NAME )
DEVICE_LEVEL_VULKAN_FUNCTION_FROM_EXTENSION( vkGetSwapchainImagesKHR, VK_KHR_SWAPCHAIN_EXTENSION_NAME )
DEVICE_LEVEL_VULKAN_FUNCTION_FROM_EXTENSION( vkAcquireNextImageKHR, VK_KHR_SWAPCHAIN_EXTENSION_NAME )
DEVICE_LEVEL_VULKAN_FUNCTION_FROM_EXTENSION( vkQueuePresentKHR, VK_KHR_SWAPCHAIN_EXTENSION_NAME )
DEVICE_LEVEL_VULKAN_FUNCTION_FROM_EXTENSION( vkDestroySwapchainKHR, VK_KHR_SWAPCHAIN_EXTENSION_NAME )
#undef DEVICE_LEVEL_VULKAN_FUNCTION_FROM_EXTENSION
In the preceding code, we added, names of multiple device-level functions. Each of them is wrapped into a DEVICE_LEVEL_VULKAN_FUNCTION macro (if it is defined in the core API) or a DEVICE_LEVEL_VULKAN_FUNCTION_FROM_EXTENSION macro (if it is introduced by an extension), and is placed between proper #ifndef and #undef preprocessor directives. The list is, of course, incomplete, as there are too many functions to present them all here.
Remember that we shouldn't load functions introduced by a given extension without first enabling the extension during the logical device creation. If an extension is not supported, its functions are not available and the operation of loading them will fail. That's why, similarly to loading instance-level functions, we need to divide function-loading code into two blocks.
First, to implement the device-level core API functions loading using the preceding macros, we should write the following code:
#define DEVICE_LEVEL_VULKAN_FUNCTION( name ) \
name = (PFN_##name)vkGetDeviceProcAddr( device, #name ); \
if( name == nullptr ) { \
std::cout << "Could not load device-level Vulkan function named: " #name << std::endl; \
return false; \
}
#include "ListOfVulkanFunctions.inl"
return true;
In this code sample, we create a macro that, for each occurrence of a DEVICE_LEVEL_VULKAN_FUNCTION() definition found in the ListOfVulkanFunctions.inl file, calls a vkGetDeviceProcAddr() function and provides the name of a procedure we want to load. The result of this operation is cast onto an appropriate type and stored in a variable with exactly the same name as the name of the acquired function. Upon failure, any additional information is displayed on screen.
Next, we need to load functions introduced by extensions. These extensions must have been enabled during logical device creation:
#define DEVICE_LEVEL_VULKAN_FUNCTION_FROM_EXTENSION( name,
extension ) \
for( auto & enabled_extension : enabled_extensions ) { \
if( std::string( enabled_extension ) == std::string( extension )
) { \
name = (PFN_##name)vkGetDeviceProcAddr( logical_device, #name );\
if( name == nullptr ) { \
std::cout << "Could not load device-level Vulkan function named: " \
#name << std::endl; \
return false; \
} \
} \
}
#include "ListOfVulkanFunctions.inl"
return true;
In the preceding code, we define the macro which iterates over all enabled extensions. They are defined in a variable of type std::vector<char const *> named enabled_extensions. In each loop iteration, the name of the enabled extension from the vector is compared with the name of an extension specified for a given function. If they match, the function pointer is loaded; if not, the given function is skipped as we can't load functions from un-enabled extensions.