Search icon CANCEL
Subscription
0
Cart icon
Your Cart (0 item)
Close icon
You have no products in your basket yet
Save more on your purchases! discount-offer-chevron-icon
Savings automatically calculated. No voucher code required.
Arrow left icon
Explore Products
Best Sellers
New Releases
Books
Videos
Audiobooks
Learning Hub
Newsletter Hub
Free Learning
Arrow right icon
timer SALE ENDS IN
0 Days
:
00 Hours
:
00 Minutes
:
00 Seconds
Arrow up icon
GO TO TOP
Vulkan 3D Graphics Rendering Cookbook

You're reading from   Vulkan 3D Graphics Rendering Cookbook Implement expert-level techniques for high-performance graphics with Vulkan

Arrow left icon
Product type Paperback
Published in Feb 2025
Publisher Packt
ISBN-13 9781803248110
Length 714 pages
Edition 2nd Edition
Languages
Tools
Arrow right icon
Authors (2):
Arrow left icon
Alexey Medvedev Alexey Medvedev
Author Profile Icon Alexey Medvedev
Alexey Medvedev
Sergey Kosarevsky Sergey Kosarevsky
Author Profile Icon Sergey Kosarevsky
Sergey Kosarevsky
Arrow right icon
View More author details
Toc

Table of Contents (14) Chapters Close

Preface 1. Establishing a Build Environment 2. Getting Started with Vulkan FREE CHAPTER 3. Working with Vulkan Objects 4. Adding User Interaction and Productivity Tools 5. Working with Geometry Data 6. Physically Based Rendering Using the glTF 2.0 Shading Model 7. Advanced PBR Extensions 8. Graphics Rendering Pipeline 9. glTF Animations 10. Image-Based Techniques 11. Advanced Rendering Techniques and Optimizations 12. Other Books You May Enjoy
13. Index

Initializing Vulkan instance and graphical device

As some readers may recall from the first edition of our book, the Vulkan API is significantly more verbose than OpenGL. To make things more manageable, we’ve broken down the process of creating our first graphical demo apps into a series of smaller, focused recipes. In this recipe, we’ll cover how to create a Vulkan instance, enumerate all physical devices in the system capable of 3D graphics rendering, and initialize one of these devices to create a window with an attached surface.

Getting ready

We recommend starting with beginner-friendly Vulkan books, such as The Modern Vulkan Cookbook by Preetish Kakkar and Mauricio Maurer (published by Packt) or Vulkan Programming Guide: The Official Guide to Learning Vulkan by Graham Sellers and John Kessenich (Addison-Wesley Professional).

The most challenging aspect of transitioning from OpenGL to Vulkan—or to any similar modern graphics API—is the extensive amount of explicit code required to set up the rendering process, which, fortunately, only needs to be done once. It’s also helpful to familiarize yourself with Vulkan’s object model. A great starting point is Adam Sawicki’s article, Understanding Vulkan Objects https://gpuopen.com/understanding-vulkan-objects. In the recipes that follow, our goal is to start rendering 3D scenes with the minimal setup needed, demonstrating how modern bindless Vulkan can be wrapped into a more user-friendly API.

All our Vulkan recipes rely on the LightweightVK library, which can be downloaded from https://github.com/corporateshark/lightweightvk using the provided Bootstrap snippet. This library implements all the low-level Vulkan wrapper classes, which we will discuss in detail throughout this book.

{
  "name": "lightweightvk",
  "source": {
    "type": "git",
    "url" : "https://github.com/corporateshark/lightweightvk.git",
    "revision": "v1.3"
  }
}

The complete Vulkan example for this recipe can be found in Chapter02/01_Swapchain.

How to do it...

Before diving into the actual implementation, let’s take a look at some scaffolding code that makes debugging Vulkan backends a bit easier. We will begin with error-checking facilities.

  1. Any function call from a complex API can fail. To handle failures, or at least provide the developer with the exact location of the failure, LightweightVK wraps most Vulkan calls in the VK_ASSERT() and VK_ASSERT_RETURN() macros, which check the results of Vulkan operations. When starting a new Vulkan implementation from scratch, having something like this in place from the beginning can be very helpful.
    #define VK_ASSERT(func) {                                  \
      const VkResult vk_assert_result = func;                  \
      if (vk_assert_result != VK_SUCCESS) {                    \
        LLOGW("Vulkan API call failed: %s:%i\n  %s\n  %s\n",   \
          __FILE__, __LINE__, #func,                           \
        ivkGetVulkanResultString(vk_assert_result));           \
        assert(false);                                         \
      }                                                        \
    }
    
  2. The VK_ASSERT_RETURN() macro is very similar and returns the control to the calling code.
    #define VK_ASSERT_RETURN(func) {                           \
      const VkResult vk_assert_result = func;                  \
      if (vk_assert_result != VK_SUCCESS) {                    \
        LLOGW("Vulkan API call failed: %s:%i\n  %s\n  %s\n",   \
          __FILE__, __LINE__, #func,                           \
        ivkGetVulkanResultString(vk_assert_result));           \
        assert(false);                                         \
        return getResultFromVkResult(vk_assert_result);        \
      }                                                        \
    }
    

Now we can start creating our first Vulkan application. Let’s explore what is going on in the sample application Chapter02/01_Swapchain which creates a window, a Vulkan instance and device together with a Vulkan swapchain, which will be explained in a few moments. The application code is very simple:

  1. We start by initializing the Minilog logging library and creating a GLFW window as we discussed in the recipe Using the GLFW library from the Chapter 1. All the Vulkan setup magic, including creating a context and swapchain, is handled by the lvk::createVulkanContextWithSwapchain() helper function, which we will examine shortly.
    int main(void) {
      minilog::initialize(nullptr, { .threadNames = false });
      int width  = 960;
      int height = 540;
      GLFWwindow* window = lvk::initWindow(
        "Simple example", width, height);
      std::unique_ptr<lvk::IContext> ctx =
        lvk::createVulkanContextWithSwapchain(
          window, width, height, {});
    
  2. The application’s main loop handles updates to the framebuffer size if the window is sized, acquires a command buffer, submits it, and presents the current swapchain image, or texture as it is called in LightweightVK.
      while (!glfwWindowShouldClose(window)) {
        glfwPollEvents();
        glfwGetFramebufferSize(window, &width, &height);
        if (!width || !height) continue;
        lvk::ICommandBuffer& buf = ctx->acquireCommandBuffer();
        ctx->submit(buf, ctx->getCurrentSwapchainTexture());
      }
    
  3. The shutdown process is straightforward. The IDevice object should be destroyed before the GLFW window.
      ctx.reset();
      glfwDestroyWindow(window);
      glfwTerminate();
      return 0;
    }
    

The application should render an empty black window as in the following screenshot:

Figure 2.1: The main loop and swapchain

Let’s explore lvk::createVulkanContextWithSwapchain() and take a sneak peek at its implementation. As before, we will skip most of the error checking in the book text where it doesn’t contribute to the overall understanding:

  1. This helper function calls LightweightVK to create a VulkanContext object, taking the provided GLFW window and display properties for our operating system into account. LightweightVK includes additional code paths for macOS/MoltenVK and Android initialization. We’ll skip them here for the sake of brevity and because not all the demos in this book are compatible with MoltenVK or Android.
    std::unique_ptr<lvk::IContext> createVulkanContextWithSwapchain(
      GLFWwindow* window, uint32_t width, uint32_t height,
      const lvk::vulkan::VulkanContextConfig& cfg,
      lvk::HWDeviceType preferredDeviceType)
    {
      std::unique_ptr<vulkan::VulkanContext> ctx;
    #if defined(_WIN32)
      ctx = std::make_unique<VulkanContext>(cfg,
        (void*)glfwGetWin32Window(window));
    #elif defined(__linux__)
      #if defined(LVK_WITH_WAYLAND)
      wl_surface* waylandWindow = glfwGetWaylandWindow(window);
      if (!waylandWindow) {
        LVK_ASSERT_MSG(false, "Wayland window not found");
        return nullptr;
      }
      ctx = std::make_unique<VulkanContext>(cfg,
        (void*)waylandWindow, (void*)glfwGetWaylandDisplay());
      #else
      ctx = std::make_unique<VulkanContext>(cfg,
        (void*)glfwGetX11Window(window), (void*)glfwGetX11Display());
      #endif // LVK_WITH_WAYLAND
    #else
    #  error Unsupported OS
    #endif
    
  2. Next, we enumerate Vulkan physical devices and attempt to select the most preferred one. We prioritize choosing a discrete GPU first, and if none is available, we opt for an integrated GPU.
      HWDeviceDesc device;
      uint32_t numDevices =
        ctx->queryDevices(preferredDeviceType, &device, 1);
      if (!numDevices) {
        if (preferredDeviceType == HWDeviceType_Discrete) {
          numDevices =
            ctx->queryDevices(HWDeviceType_Integrated, &device);
        } else if (preferredDeviceType == HWDeviceType_Integrated) {
          numDevices =
            ctx->queryDevices(HWDeviceType_Discrete, &device);
        }
      }
    
  3. Once a physical device is selected, we call VulkanContext::initContext(), which creates all Vulkan and LightweightVK internal data structures.
      if (!numDevices) return nullptr;
      Result res = ctx->initContext(device);
      if (!res.isOk()) return nullptr;
    
  4. If we have a non-empty viewport, initialize a Vulkan swapchain. The swapchain creation process will be explained in detail in the next recipe Initializing Vulkan swapchain.
      if (width > 0 && height > 0) {
        res = ctx->initSwapchain(width, height);
        if (!res.isOk()) return nullptr;
      }
      return std::move(ctx);
    }
    

That covers the high-level code. Now, let’s dive deeper and explore the internals of LightweightVK to see how the actual Vulkan interactions work.

How it works...

There are several helper functions involved in getting Vulkan up and running. It all begins with the creation of a Vulkan instance in VulkanContext::createInstance(). Once the Vulkan instance is created, we can use it to acquire a list of physical devices with the required properties.

  1. First, we need to check if the required Vulkan Validation Layers are available on our system. This ensures we have the flexibility to manually disable validation if no validation layers are present.
    const char* kDefaultValidationLayers[] =
      {"VK_LAYER_KHRONOS_validation"};
    void VulkanContext::createInstance() {
      vkInstance_ = VK_NULL_HANDLE;
      uint32_t numLayerProperties = 0;
      vkEnumerateInstanceLayerProperties(
        &numLayerProperties, nullptr);
      std::vector<VkLayerProperties>
        layerProperties(numLayerProperties);
      vkEnumerateInstanceLayerProperties(
        &numLayerProperties, layerProperties.data());
    
  2. We use a local C++ lambda to iterate through the available validation layers and update VulkanContextConfig::enableValidation accordingly if none are found.
      [this, &layerProperties]() -> void {
        for (const VkLayerProperties& props : layerProperties) {
          for (const char* layer : kDefaultValidationLayers) {
            if (!strcmp(props.layerName, layer)) return;
          }
        }
        config_.enableValidation = false;
      }();
    
  3. Then, we need to specify the names of all Vulkan instance extensions required to run our Vulkan graphics backend. We need VK_KHR_surface and another platform-specific extension which takes an OS window handle and attaches a rendering surface to it. On Linux, we support both libXCB-based window creation and the Wayland protocol. Here is how Wayland support was added to LightweightVK by Roman Kuznetsov: https://github.com/corporateshark/lightweightvk/pull/13.
      std::vector<const char*> instanceExtensionNames = {
        VK_KHR_SURFACE_EXTENSION_NAME,
        VK_EXT_DEBUG_UTILS_EXTENSION_NAME,
    #if defined(_WIN32)
        VK_KHR_WIN32_SURFACE_EXTENSION_NAME,
    #elif defined(VK_USE_PLATFORM_ANDROID_KHR)
        VK_KHR_ANDROID_SURFACE_EXTENSION_NAME,
    #elif defined(__linux__)
      #if defined(VK_USE_PLATFORM_WAYLAND_KHR)
        VK_KHR_WAYLAND_SURFACE_EXTENSION_NAME,
      #else
        VK_KHR_XLIB_SURFACE_EXTENSION_NAME,
      #endif // VK_USE_PLATFORM_WAYLAND_KHR
    #endif
      };
    
  4. We add VK_EXT_validation_features when validation features are requested and available. Additionally, a headless rendering extension, VK_EXT_headless_surface, can also be added here together with all custom instance extensions from VulkanContextConfig::extensionsInstance[].
      if (config_.enableValidation)
        instanceExtensionNames.push_back(
          VK_EXT_VALIDATION_FEATURES_EXTENSION_NAME);
      if (config_.enableHeadlessSurface)
        instanceExtensionNames.push_back(
          VK_EXT_HEADLESS_SURFACE_EXTENSION_NAME);
      for (const char* ext : config_.extensionsInstance) {
        if (ext) instanceExtensionNames.push_back(ext);
      }
    
  5. Next, we specify the enabled Vulkan validation features when validation is enabled.
      VkValidationFeatureEnableEXT validationFeaturesEnabled[] = {
        VK_VALIDATION_FEATURE_ENABLE_GPU_ASSISTED_EXT,
        VK_VALIDATION_FEATURE_ENABLE_GPU_ASSISTED_
          RESERVE_BINDING_SLOT_EXT,
      };
      const VkValidationFeaturesEXT features = {
        .sType = VK_STRUCTURE_TYPE_VALIDATION_FEATURES_EXT,
        .enabledValidationFeatureCount = config_.enableValidation ?
          (uint32_t)LVK_ARRAY_NUM_ELEMENTS(validationFeaturesEnabled) : 0u,
        .pEnabledValidationFeatures = config_.enableValidation ?
          validationFeaturesEnabled : nullptr,
      };
    
  6. The next code snippet might be particularly interesting. Sometimes, we need to disable specific Vulkan validation checks, either for performance reasons or due to bugs in the Vulkan validation layers. Here’s how LightweightVK handles this to work around some known issues with the validation layers (these were the issues at the time of writing this book, of course).
      VkBool32 gpuav_descriptor_checks = VK_FALSE;
      VkBool32 gpuav_indirect_draws_buffers = VK_FALSE;
      VkBool32 gpuav_post_process_descriptor_indexing = VK_FALSE;
    #define LAYER_SETTINGS_BOOL32(name, var)              \
      VkLayerSettingEXT {                                 \
        .pLayerName = kDefaultValidationLayers[0],        \
        .pSettingName = name,                             \
        .type = VK_LAYER_SETTING_TYPE_BOOL32_EXT,         \
        .valueCount = 1,                                  \
        .pValues = var }
      const VkLayerSettingEXT settings[] = {
        LAYER_SETTINGS_BOOL32("gpuav_descriptor_checks",
          &gpuav_descriptor_checks),
        LAYER_SETTINGS_BOOL32("gpuav_indirect_draws_buffers",
          &gpuav_indirect_draws_buffers),
        LAYER_SETTINGS_BOOL32(
          "gpuav_post_process_descriptor_indexing",
          &gpuav_post_process_descriptor_indexing),
      };
    #undef LAYER_SETTINGS_BOOL32
      const VkLayerSettingsCreateInfoEXT layerSettingsCreateInfo = {
        .sType = VK_STRUCTURE_TYPE_LAYER_SETTINGS_CREATE_INFO_EXT,
        .pNext = config_.enableValidation ? &features : nullptr,
        .settingCount = (uint32_t)LVK_ARRAY_NUM_ELEMENTS(settings),
        .pSettings = settings
      };
    
  7. After constructing the list of instance-related extensions, we need to fill in some mandatory information about our application. Here, we request the required Vulkan version, VK_API_VERSION_1_3.
      const VkApplicationInfo appInfo = {
        .sType = VK_STRUCTURE_TYPE_APPLICATION_INFO,
        .pApplicationName = "LVK/Vulkan",
        .applicationVersion = VK_MAKE_VERSION(1, 0, 0),
        .pEngineName = "LVK/Vulkan",
        .engineVersion = VK_MAKE_VERSION(1, 0, 0),
        .apiVersion = VK_API_VERSION_1_3,
      };
    
  8. To create a VkInstance object, we need to populate the VkInstanceCreateInfo structure. We use pointers to the previously mentioned appInfo constant and layerSettingsCreateInfo we created earlier. We also use a list of requested Vulkan layers stored in the global variable kDefaultValidationLayers[], which will allow us to enable debugging output for every Vulkan call. The only layer we use in this book is the Khronos validation layer, VK_LAYER_KHRONOS_validation. Then, we use the Volk library to load all instance-related Vulkan functions for the created VkInstance.

Note

Volk is a meta-loader for Vulkan. It allows you to dynamically load entry points required to use Vulkan without linking to vulkan-1.dll or statically linking the Vulkan loader. Volk simplifies the use of Vulkan extensions by automatically loading all associated entry points. Besides that, Volk can load Vulkan entry points directly from the driver which can increase performance by skipping loader dispatch overhead. https://github.com/zeux/volk

  const VkInstanceCreateInfo ci = {
    .sType = VK_STRUCTURE_TYPE_INSTANCE_CREATE_INFO,
    .pNext = &layerSettingsCreateInfo,
    .pApplicationInfo = &appInfo,
    .enabledLayerCount = config_.enableValidation ?
      (uint32_t)LVK_ARRAY_NUM_ELEMENTS(kDefaultValidationLayers) : 0u,
    .ppEnabledLayerNames = config_.enableValidation ?
      kDefaultValidationLayers : nullptr,
    .enabledExtensionCount =
      (uint32_t)instanceExtensionNames.size(),
    .ppEnabledExtensionNames = instanceExtensionNames.data(),
  };
  VK_ASSERT(vkCreateInstance(&ci, nullptr, &vkInstance_));
  volkLoadInstance(vkInstance_);
  1. Last but not least, let’s print a neatly formatted list of all available Vulkan instance extensions. The function vkEnumerateInstanceExtensionProperties() is called twice: first to get the number of available extensions, and second to retrieve information about them.
      uint32_t count = 0;
      vkEnumerateInstanceExtensionProperties(
        nullptr, &count, nullptr);
      std::vector<VkExtensionProperties>
        allInstanceExtensions(count);
      vkEnumerateInstanceExtensionProperties(
        nullptr, &count, allInstanceExtensions.data()));
      LLOGL("\nVulkan instance extensions:\n");
      for (const VkExtensionProperties& extension : allInstanceExtensions)
        LLOGL("  %s\n", extension.extensionName);
    }
    

Note

If you’ve looked at the actual source code in VulkanClasses.cpp, you’ll have noticed that we skipped the Debug Messenger initialization code here. It will be covered later in the recipe Setting up Vulkan debugging capabilities.

Once we’ve created a Vulkan instance, we can access the list of Vulkan physical devices, which are necessary to continue setting up our Vulkan context. Here’s how we can enumerate Vulkan physical devices and choose a suitable one:

  1. The function vkEnumeratePhysicalDevices() is called twice: first to get the number of available physical devices and allocate std::vector storage for it, and second to retrieve the actual physical device data.
    uint32_t lvk::VulkanContext::queryDevices(
      HWDeviceType deviceType,
      HWDeviceDesc* outDevices,
      uint32_t maxOutDevices)
    {
      uint32_t deviceCount = 0;
      vkEnumeratePhysicalDevices(vkInstance_, &deviceCount, nullptr);
      std::vector<VkPhysicalDevice> vkDevices(deviceCount);
      vkEnumeratePhysicalDevices(
        vkInstance_, &deviceCount, vkDevices.data());
    
  2. We iterate through the vector of devices to retrieve their properties and filter out non-suitable ones. The local lambda function convertVulkanDeviceTypeToLVK() converts a Vulkan enum, VkPhysicalDeviceType, into a LightweightVK enum, HWDeviceType.

More information

enum HWDeviceType {
  HWDeviceType_Discrete = 1,
  HWDeviceType_External = 2,
  HWDeviceType_Integrated = 3,
  HWDeviceType_Software = 4,
};
  const HWDeviceType desiredDeviceType = deviceType;
  uint32_t numCompatibleDevices = 0;
  for (uint32_t i = 0; i < deviceCount; ++i) {
    VkPhysicalDevice physicalDevice = vkDevices[i];
    VkPhysicalDeviceProperties deviceProperties;
    vkGetPhysicalDeviceProperties(
      physicalDevice, &deviceProperties);
    const HWDeviceType deviceType =
      convertVulkanDeviceTypeToLVK(deviceProperties.deviceType);
    if (desiredDeviceType != HWDeviceType_Software &&
        desiredDeviceType != deviceType) continue;
    if (outDevices && numCompatibleDevices < maxOutDevices) {
      outDevices[numCompatibleDevices] =
        {.guid = (uintptr_t)vkDevices[i], .type = deviceType};
      strncpy(outDevices[numCompatibleDevices].name,
              deviceProperties.deviceName,
              strlen(deviceProperties.deviceName));
      numCompatibleDevices++;
    }
  }
  return numCompatibleDevices;
}

Once we’ve selected a suitable Vulkan physical device, we can create a logical representation of a single GPU, or more precisely, a device VkDevice. We can think of Vulkan devices as collections of queues and memory heaps. To use a device for rendering, we need to specify a queue capable of executing graphics-related commands, along with a physical device that has such a queue. Let’s explore LightweightVK and some parts of the function VulkanContext::initContext(), which, among many other things we’ll cover later, detects suitable queue families and creates a Vulkan device. As before, most of the error checking will be omitted here in the text.

  1. The first thing we do in VulkanContext::initContext() is retrieve all supported extensions of the physical device we selected earlier and the Vulkan driver. We store them in allDeviceExtensions to later decide which features we can enable. Note how we iterate over the validation layers to check which extensions they bring in.
    lvk::Result VulkanContext::initContext(const HWDeviceDesc& desc)
    {
      vkPhysicalDevice_ = (VkPhysicalDevice)desc.guid;
      std::vector<VkExtensionProperties> allDeviceExtensions;
      getDeviceExtensionProps(
        vkPhysicalDevice_, allDeviceExtensions);
      if (config_.enableValidation) {
        for (const char* layer : kDefaultValidationLayers)
          getDeviceExtensionProps(
            vkPhysicalDevice_, allDeviceExtensions, layer);
      }
    
  2. Then, we can retrieve all Vulkan features and properties for this physical device.
      vkGetPhysicalDeviceFeatures2(
        vkPhysicalDevice_, &vkFeatures10_);
      vkGetPhysicalDeviceProperties2(
        vkPhysicalDevice_, &vkPhysicalDeviceProperties2_);
    
  3. The class member variables vkFeatures10_, vkFeatures11_, vkFeatures12_, and vkFeatures13_ are declared in VulkanClasses.h and correspond to the Vulkan features for Vulkan versions 1.0 to 1.3. These structures are chained together using their pNext pointers as follows:
      // lightweightvk/lvk/vulkan/VulkanClasses.h
      VkPhysicalDeviceVulkan13Features vkFeatures13_ = {
        .sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_VULKAN_1_3_FEATURES};
      VkPhysicalDeviceVulkan12Features vkFeatures12_ = {
        .sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_VULKAN_1_2_FEATURES,
        .pNext = &vkFeatures13_};
      VkPhysicalDeviceVulkan11Features vkFeatures11_ = {
        .sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_VULKAN_1_1_FEATURES,
        .pNext = &vkFeatures12_};
      VkPhysicalDeviceFeatures2 vkFeatures10_ = {
        .sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_FEATURES_2,
        .pNext = &vkFeatures11_};
      // ...
    
  4. Let’s get back to initContext() and print some information related to the Vulkan physical device and a list of all supported extensions. This is very useful for debugging.
      const uint32_t apiVersion =
        vkPhysicalDeviceProperties2_.properties.apiVersion;
      LLOGL("Vulkan physical device: %s\n",
            vkPhysicalDeviceProperties2_.properties.deviceName);
      LLOGL("           API version: %i.%i.%i.%i\n",
            VK_API_VERSION_MAJOR(apiVersion),
            VK_API_VERSION_MINOR(apiVersion),
            VK_API_VERSION_PATCH(apiVersion),
            VK_API_VERSION_VARIANT(apiVersion));
      LLOGL("           Driver info: %s %s\n",
            vkPhysicalDeviceDriverProperties_.driverName,
            vkPhysicalDeviceDriverProperties_.driverInfo);
      LLOGL("Vulkan physical device extensions:\n");
      for (const VkExtensionProperties& ext : allDeviceExtensions) {
        LLOGL("  %s\n", ext.extensionName);
      }
    
  5. Before creating a VkDevice object, we need to find the queue family indices and create queues. This code block creates one or two device queues—graphical and compute—based on the actual queue availability on the provided physical device. The helper function lvk::findQueueFamilyIndex(), implemented in lvk/vulkan/VulkanUtils.cpp, returns the first dedicated queue family index that matches the requested queue flag. It’s recommended to take a look at it to see how it ensures the selection of dedicated queues first.

    Note

    In Vulkan, queueFamilyIndex is the index of the queue family to which the queue belongs. A queue family is a collection of Vulkan queues with similar properties and functionality. Here deviceQueues_ is member field of VulkanContext holding a structure with queues information:

    struct DeviceQueues {
      const static uint32_t INVALID = 0xFFFFFFFF;
      uint32_t graphicsQueueFamilyIndex = INVALID;
      uint32_t computeQueueFamilyIndex = INVALID;
      VkQueue graphicsQueue = VK_NULL_HANDLE;
      VkQueue computeQueue = VK_NULL_HANDLE;
    };
    
  deviceQueues_.graphicsQueueFamilyIndex =
    lvk::findQueueFamilyIndex(vkPhysicalDevice_,
      VK_QUEUE_GRAPHICS_BIT);
  deviceQueues_.computeQueueFamilyIndex =
    lvk::findQueueFamilyIndex(vkPhysicalDevice_,
      VK_QUEUE_COMPUTE_BIT);
  const float queuePriority = 1.0f;
  const VkDeviceQueueCreateInfo ciQueue[2] = {
    { .sType = VK_STRUCTURE_TYPE_DEVICE_QUEUE_CREATE_INFO,
      .queueFamilyIndex = deviceQueues_.graphicsQueueFamilyIndex,
      .queueCount = 1,
      .pQueuePriorities = &queuePriority, },
    { .sType = VK_STRUCTURE_TYPE_DEVICE_QUEUE_CREATE_INFO,
      .queueFamilyIndex = deviceQueues_.computeQueueFamilyIndex,
      .queueCount = 1,
      .pQueuePriorities = &queuePriority, },
  };
  1. Sometimes, especially on mobile GPUs, graphics and compute queues might be the same. Here we take care of such corner cases.
      const uint32_t numQueues =
        ciQueue[0].queueFamilyIndex == ciQueue[1].queueFamilyIndex ?
          1 : 2;
    
  2. Let’s construct a list of extensions that our logical device is required to support. A device must support a swapchain object, which allows us to present rendered frames onto the screen. We use Vulkan 1.3, which includes all the necessary functionality, so no extra extensions are required. However, users can provide additional custom extensions via VulkanContextConfig::extensionsDevice[].
      std::vector<const char*> deviceExtensionNames = {
        VK_KHR_SWAPCHAIN_EXTENSION_NAME,
      };
      for (const char* ext : config_.extensionsDevice) {
        if (ext) deviceExtensionNames.push_back(ext);
      }
    
  3. Let’s request all the necessary Vulkan 1.01.3 features we’ll be using in our Vulkan implementation. The most important features are descriptor indexing from Vulkan 1.2 and dynamic rendering from Vulkan 1.3, which we’ll discuss in subsequent chapters. Take a look at how to request these and other features we’ll be using.

Note

Descriptor indexing is a set of Vulkan 1.2 features that enable applications to access all of their resources and select among them using integer indices in shaders.

Dynamic rendering is a Vulkan 1.3 feature that allows applications to render directly into images without the need to create render pass objects or framebuffers.

  VkPhysicalDeviceFeatures deviceFeatures10 = {
      .geometryShader = vkFeatures10_.features.geometryShader,
      .sampleRateShading = VK_TRUE,
      .multiDrawIndirect = VK_TRUE,
    // ...
  };
  1. The structures are chained together using their pNext pointers. Note how we access the vkFeatures10_ through vkFeatures13_ structures here to enable optional features only if they are actually supported by the physical device. The complete list is quite long, so we skip some parts of it here.
      VkPhysicalDeviceVulkan11Features deviceFeatures11 = {
        .sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_VULKAN_1_1_FEATURES,
        .pNext = config_.extensionsDeviceFeatures,
        .storageBuffer16BitAccess = VK_TRUE,
        .samplerYcbcrConversion = vkFeatures11_.samplerYcbcrConversion,
        .shaderDrawParameters = VK_TRUE,
      };
      VkPhysicalDeviceVulkan12Features deviceFeatures12 = {
        .sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_VULKAN_1_2_FEATURES,
        .pNext = &deviceFeatures11,
        .drawIndirectCount = vkFeatures12_.drawIndirectCount,
        // ...
        .descriptorIndexing = VK_TRUE,
        .shaderSampledImageArrayNonUniformIndexing = VK_TRUE,
        .descriptorBindingSampledImageUpdateAfterBind = VK_TRUE,
        .descriptorBindingStorageImageUpdateAfterBind = VK_TRUE,
        .descriptorBindingUpdateUnusedWhilePending = VK_TRUE,
        .descriptorBindingPartiallyBound = VK_TRUE,
        .descriptorBindingVariableDescriptorCount = VK_TRUE,
        .runtimeDescriptorArray = VK_TRUE,
        // ...
      };
      VkPhysicalDeviceVulkan13Features deviceFeatures13 = {
        .sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_VULKAN_1_3_FEATURES,
        .pNext = &deviceFeatures12,
        .subgroupSizeControl = VK_TRUE,
        .synchronization2 = VK_TRUE,
        .dynamicRendering = VK_TRUE,
        .maintenance4 = VK_TRUE,
      };
    
  2. A few more steps before we can create the actual VkDevice object. We check our list of requested device extensions against the list of available extensions. Any missing extensions are printed into the log, and the initialization function returns. This is very convenient for debugging.
      std::string missingExtensions;
      for (const char* ext : deviceExtensionNames) {
        if (!hasExtension(ext, allDeviceExtensions))
          missingExtensions += "\n   " + std::string(ext);
      }
      if (!missingExtensions.empty()) {
        MINILOG_LOG_PROC(minilog::FatalError,
          "Missing Vulkan device extensions: %s\n",
          missingExtensions.c_str());
        return Result(Result::Code::RuntimeError);
      }
    
  3. One last important thing worth mentioning before we proceed with creating a device: because some Vulkan features are mandatory for our code, we enable them unconditionally. We should check all the requested Vulkan features against the actual available features. With the help of C-macros, we can do this in a very clean way. When we’re missing some Vulkan features, this code will print a neatly formatted list of missing features, each marked with the corresponding Vulkan version. This is invaluable for debugging and makes your Vulkan backend adjustable to fit different devices.
      std::string missingFeatures;
    #define CHECK_VULKAN_FEATURE(                       \
      reqFeatures, availFeatures, feature, version)     \
      if ((reqFeatures.feature == VK_TRUE) &&           \
          (availFeatures.feature == VK_FALSE))          \
            missingFeatures.append("\n   " version " ." #feature);
    #define CHECK_FEATURE_1_0(feature)                               \
      CHECK_VULKAN_FEATURE(deviceFeatures10, vkFeatures10_.features, \
      feature, "1.0 ");
        CHECK_FEATURE_1_0(robustBufferAccess);
        CHECK_FEATURE_1_0(fullDrawIndexUint32);
        CHECK_FEATURE_1_0(imageCubeArray);
        … // omitted a lot of other Vulkan 1.0 features here
    #undef CHECK_FEATURE_1_0
    #define CHECK_FEATURE_1_1(feature)                      \
      CHECK_VULKAN_FEATURE(deviceFeatures11, vkFeatures11_, \
        feature, "1.1 ");
        CHECK_FEATURE_1_1(storageBuffer16BitAccess);
        CHECK_FEATURE_1_1(uniformAndStorageBuffer16BitAccess);
        CHECK_FEATURE_1_1(storagePushConstant16);
        … // omitted a lot of other Vulkan 1.1 features here
    #undef CHECK_FEATURE_1_1
    #define CHECK_FEATURE_1_2(feature)                      \
      CHECK_VULKAN_FEATURE(deviceFeatures12, vkFeatures12_, \
      feature, "1.2 ");
        CHECK_FEATURE_1_2(samplerMirrorClampToEdge);
        CHECK_FEATURE_1_2(drawIndirectCount);
        CHECK_FEATURE_1_2(storageBuffer8BitAccess);
        … // omitted a lot of other Vulkan 1.2 features here
    #undef CHECK_FEATURE_1_2
    #define CHECK_FEATURE_1_3(feature)                      \
      CHECK_VULKAN_FEATURE(deviceFeatures13, vkFeatures13_, \
      feature, "1.3 ");
        CHECK_FEATURE_1_3(robustImageAccess);
        CHECK_FEATURE_1_3(inlineUniformBlock);
        … // omitted a lot of other Vulkan 1.3 features here
    #undef CHECK_FEATURE_1_3
      if (!missingFeatures.empty()) {
        MINILOG_LOG_PROC(minilog::FatalError,
          "Missing Vulkan features: %s\n", missingFeatures.c_str());
        return Result(Result::Code::RuntimeError);
      }
    
  4. Finally, we are ready to create the Vulkan device, load all related Vulkan functions with Volk, and retrieve the actual device queues based on the queue family indices we selected earlier in this recipe.
      const VkDeviceCreateInfo ci = {
        .sType = VK_STRUCTURE_TYPE_DEVICE_CREATE_INFO,
        .pNext = createInfoNext,
        .queueCreateInfoCount = numQueues,
        .pQueueCreateInfos = ciQueue,
        .enabledExtensionCount = deviceExtensionNames.size(),
        .ppEnabledExtensionNames = deviceExtensionNames.data(),
        .pEnabledFeatures = &deviceFeatures10,
      };
      vkCreateDevice(vkPhysicalDevice_, &ci, nullptr, &vkDevice_);
      volkLoadDevice(vkDevice_);
      vkGetDeviceQueue(vkDevice_,
        deviceQueues_.graphicsQueueFamilyIndex, 0,
        &deviceQueues_.graphicsQueue);
      vkGetDeviceQueue(vkDevice_,
        deviceQueues_.computeQueueFamilyIndex, 0,
        &deviceQueues_.computeQueue);
      // ... other code in initContext() is unrelated to this recipe
    }
    

The VkDevice object is now ready to be used, but the initialization of the Vulkan rendering pipeline is far from complete. The next step is to create a swapchain object. Let’s proceed to the next recipe to learn how to do this.

You have been reading a chapter from
Vulkan 3D Graphics Rendering Cookbook - Second Edition
Published in: Feb 2025
Publisher: Packt
ISBN-13: 9781803248110
Register for a free Packt account to unlock a world of extra content!
A free Packt account unlocks extra newsletters, articles, discounted offers, and much more. Start advancing your knowledge today.
Unlock this book and the full library FREE for 7 days
Get unlimited access to 7000+ expert-authored eBooks and videos courses covering every tech area you can think of
Renews at $19.99/month. Cancel anytime
Visually different images