Logical device and queues

Introduction

After selecting a physical device to use, we need to set up a logical device to interface with it. The logical device creation process is similar to the instance creation process and describes the features we want to use. We also need to specify which queues to create now that we’ve queried which queue families are available. You can even create multiple logical devices from the same physical device if you have varying requirements.

Start by adding a new class member to store the logical device handle in.

vk::raii::Device device;

Next, add a createLogicalDevice function that is called from initVulkan.

void initVulkan() {
    createInstance();
    setupDebugMessenger();
    pickPhysicalDevice();
    createLogicalDevice();
}

void createLogicalDevice() {

}

Specifying the queues to be created

The creation of a logical device involves specifying a bunch of details in structs again, of which the first one will be VkDeviceQueueCreateInfo. This structure describes the number of queues we want for a single queue family. Right now we’re only interested in a queue with graphics capabilities.

std::vector<vk::QueueFamilyProperties> queueFamilyProperties = physicalDevice.getQueueFamilyProperties();

vk::DeviceQueueCreateInfo deviceQueueCreateInfo { .queueFamilyIndex = graphicsIndex };

The currently available drivers will only allow you to create a small number of queues for each queue family, and you don’t really need more than one. That’s because you can create all the command buffers on multiple threads and then submit them all at once on the main thread with a single low-overhead call.

Vulkan lets you assign priorities to queues to influence the scheduling of command buffer execution using floating point numbers between 0.0 and 1.0. This is required even if there is only a single queue:

float queuePriority = 0.0f;
vk::DeviceQueueCreateInfo deviceQueueCreateInfo { .queueFamilyIndex = graphicsIndex, .queueCount = 1, .pQueuePriorities = &queuePriority };

Specifying used device features

The next information to specify is the set of device features that we’ll be using. These are the features that we queried support for with vkGetPhysicalDeviceFeatures in the previous chapter, like geometry shaders. Right now we don’t need anything special, so we can simply define it and leave everything to VK_FALSE. We’ll come back to this structure once we’re about to start doing more interesting things with Vulkan.

vk::PhysicalDeviceFeatures deviceFeatures;

Specifying any extra features or updates we’d like our device to support

Vulkan is built to be backwards compatible. Thus, if you do nothing else, you will get a Vulkan instance just as it was originally released in Vulkan 1 .0. This is necessary as code written back then would need to still work so any additional features must be new code which would need to be turned on. So, let’s tell Vulkan that we use some of the more modern features which make it easier to work with.

First, let’s query for the features of the physical device:

 // query for Vulkan 1.3 features
 auto features = physicalDevice.getFeatures2();

Now, let’s tell Vulkan that there’s a few features we wish to use, by turning on dynamicRendering and the extendedDynamicState.

vk::PhysicalDeviceVulkan13Features vulkan13Features;
vk::PhysicalDeviceExtendedDynamicStateFeaturesEXT extendedDynamicStateFeatures;
vulkan13Features.dynamicRendering = vk::True;
extendedDynamicStateFeatures.extendedDynamicState = vk::True;

Note that these are just variables that we created, and aren’t part of the device create info logic structure. In Vulkan, all structures have a .pNext member variable which allows for chaining. So, we need to tell each object about the next object in the chain like this:

vulkan13Features.pNext = &extendedDynamicStateFeatures;
features.pNext = &vulkan13Features;

Note here that features is the same object that we got when we queried the physical device for the features. Each pNext member variable points to the next feature structure in the chain. So all that’s left is to ensure that when we create the logical device with a VkDeviceCreateInfo structure we set .pNext in that structure to the pointer to the features.

Specifying device extensions

For our application to work properly, we need to enable certain device extensions. These extensions provide additional functionality that we’ll need later in the tutorial.

std::vector<const char*> deviceExtensions = {
    vk::KHRSwapchainExtensionName,
    vk::KHRSpirv14ExtensionName,
    vk::KHRSynchronization2ExtensionName,
    vk::KHRCreateRenderpass2ExtensionName
};

The VK_KHR_swapchain extension is required for presenting rendered images to the window. The other extensions provide additional functionality that we’ll use in later parts of the tutorial.

Creating the logical device

With the previous structures in place, we can start filling in the main VkDeviceCreateInfo structure.

vk::DeviceCreateInfo      deviceCreateInfo{ .pNext =  &features, .queueCreateInfoCount = 1, .pQueueCreateInfos = &deviceQueueCreateInfo };
deviceCreateInfo.enabledExtensionCount = deviceExtensions.size();
deviceCreateInfo.ppEnabledExtensionNames = deviceExtensions.data();

The remainder of the information bears a resemblance to the VkInstanceCreateInfo struct and requires you to specify extensions and validation layers. The difference is that these are device-specific this time.

An example of a device-specific extension is VK_KHR_swapchain, which allows you to present rendered images from that device to windows. It is possible that there are Vulkan devices in the system that lack this ability, for example, because they only support compute operations. We will come back to this extension in the swap chain chapter.

Previous implementations of Vulkan made a distinction between instance and device-specific validation layers, but this is no longer the case. That means that the enabledLayerCount and ppEnabledLayerNames fields of VkDeviceCreateInfo are ignored by up-to-date implementations.

As mentioned earlier, we need several device-specific extensions for our application to work properly.

device = vk::raii::Device( physicalDevice, deviceCreateInfo );

The parameters are the physical device to interface with, and the usage info we just specified, the optional allocation callbacks pointer and a pointer to a variable to store the logical device handle in. Similarly to the instance creation function, this call can throw errors based on enabling non-existent extensions or specifying the desired usage of unsupported features.

Logical devices don’t interact directly with instances, which is why it’s not included as a parameter.

Retrieving queue handles

The queues are automatically created along with the logical device, but we don’t have a handle to interface with them yet. First, add a class member to store a handle to the graphics queue:

vk::raii::Queue graphicsQueue;

Device queues are implicitly cleaned up when the device is destroyed, so we don’t need to do anything in cleanup.

We can use the vkGetDeviceQueue function to retrieve queue handles for each queue family. The parameters are the logical device, queue family, queue index and a pointer to the variable to store the queue handle in. Because we’re only creating a single queue from this family, we’ll simply use index 0.

graphicsQueue = vk::raii::Queue( device, graphicsIndex, 0 );

With the logical device and queue handles, we can now actually start using the graphics card to do things! In the next few chapters, we’ll set up the resources to present results to the window system.