没有任何秘密的 API:Vulkan* 简介第 1 部分:序言

下载 [PDF 736 KB]

Github 示例代码链接


请前往:没有任何秘密的 API:Vulkan* 简介第 0 部分:前言


目录

教程 1:Vulkan* – 序言

我们从不显示任何内容的简单应用开始。 因为教程太长,因此本文不展示完整的源代码(以及窗口、渲染循环等)。 大家可以访问 https://github.com/GameTechDev/IntroductionToVulkan,在提供的示例中获取包含完整源代码的示例项目。 此处我仅展示与 Vulkan 相关的部分代码。 在应用中使用 Vulkan API 的方法有多种:

  1. 可以动态加载驱动程序的库来提供 Vulkan API 实施,并自己获取它提供的函数指示器。
  2. 可以使用 Vulkan SDK 并链接至提供的 Vulkan Runtime (Vulkan Loader) 静态库。
  3. 可以使用 Vulkan SDK,在运行时动态加载 Vulkan Loader 库,并通过它加载函数指示器。

不建议使用第一种方法。 硬件厂商可以任意修改它们的驱动程序,从而可能影响与特定应用的兼容性。 甚至还会破坏应用,并要求开发人员编写支持 Vulkan 的应用,以覆写部分代码。 这就是为什么最好使用部分抽象层的原因。

建议使用 Vulkan SDK 的 Vulkan Loader。 它能够提供更多配置功能和更高的灵活性,无需修改 Vulkan 应用源代码。 有关灵活性的一个示例是层。 Vulkan API 要求开发人员创建严格遵守 API 使用规则的应用。 如果出现错误,驱动程序几乎不会提供反馈,仅报告部分严重且重大的错误(比如内存不足)。 因为使用该方法,所以 API 本身能够尽可能的小、快。 但如果我们希望获得更多有关哪些地方出错的信息,那么必须启用调试/验证层。 不同的层级用途各不相同,比如内存使用、相应参数传递、对象寿命检查等等。 这些层级都会降低应用的性能,但会为我们提供更多信息。

我们还需选择是静态链接至 Vulkan Loader ,还是动态加载并在运行时由我们自己获取函数指示器。 选择哪一种只是个人喜好问题。 本文将重点介绍第三种使用 Vulkan 的访问,从 Vulkan Runtime 库动态加载函数指示器。 该方法与我们希望在 Windows* 系统上使用 OpenGL* 时的做法类似,采用该方法时,默认实施仅提供部分基础函数。 剩下的函数必须使用 wglGetProcAddress() 或标准窗口 GetProcAddress() 函数动态加载。 这就是创建 GLEW 或 GL3W 等 wrangler 库的对象。

加载 Vulkan Runtime 库并获取导出函数指示器

在本教程中,我们将逐步介绍如何自己获取 Vulkan 函数指示器。 我们从 Vulkan Runtime 库 (Vulkan Loader) 加载这些指示器,该运行时库应与支持 Vulkan 的显卡驱动程序一同安装。 面向 Vulkan 的动态库 (Vulkan Loader) 在 Windows* 和 Linux* 上分别命名为 vulkan-1.dll 和 libvulkan.so。

从现在起,我引用第一个教程的源代码,重点为 Tutorial01.cpp 文件。 因此在应用的初始化代码中,我们需要使用如下代码加载 Vulkan 库:

#if defined(VK_USE_PLATFORM_WIN32_KHR)
VulkanLibrary = LoadLibrary( "vulkan-1.dll" );
#elif defined(VK_USE_PLATFORM_XCB_KHR) || defined(VK_USE_PLATFORM_XLIB_KHR)
VulkanLibrary = dlopen( "libvulkan.so", RTLD_NOW );
#endif

if( VulkanLibrary == nullptr ) {
  std::cout << "Could not load Vulkan library!" << std::endl;
  return false;
}
return true;

1.Tutorial01.cpp, function LoadVulkanLibrary()

VulkanLibrary 是 Windows 中类型 HMODULE 的变量,或者是 Linux 中 void* 的变量。 如果加载函数的库返回的值不是 0,那么我们可以加载所有导出的函数。 Vulkan 库和 Vulkan 实施(不同厂商的驱动程序)都要求仅展示一个可通过操作系统拥有的标准技巧(比如之前提到的 Windows 中的 GetProcAddress() 或 Linux 中的 dlsym())加载的函数。 Vulkan API 的其他函数可能也能通过该方法获取,但无法保证(甚至不建议)。 必须导出的一个函数是 vkGetInstanceProcAddr()

该函数用于加载其他所有 Vulkan 函数。 为减轻获取所有 Vulkan API 函数地址的工作负担,最便利的方法是将它们的名称放在宏中。 这样我们不用在多个位置(比如定义、声明或加载)复制函数名称,并且能够将它们放在一个标头文件中。 这种单一文件日后也可通过 #include 指令用于不同的用途。 我们可以像下面这样声明导出的函数:

#if !defined(VK_EXPORTED_FUNCTION)
#define VK_EXPORTED_FUNCTION( fun )
#endif

VK_EXPORTED_FUNCTION( vkGetInstanceProcAddr )

#undef VK_EXPORTED_FUNCTION

2.ListOfFunctions.inl

现在我们定义能够展示 Vulkan API 的函数的变量。 这可通过以下命令来实现:

#include "vulkan.h"

namespace ApiWithoutSecrets {

#define VK_EXPORTED_FUNCTION( fun ) PFN_##fun fun;
#define VK_GLOBAL_LEVEL_FUNCTION( fun ) PFN_##fun fun;
#define VK_INSTANCE_LEVEL_FUNCTION( fun ) PFN_##fun fun;
#define VK_DEVICE_LEVEL_FUNCTION( fun ) PFN_##fun fun;

#include "ListOfFunctions.inl"

}

3.VulkanFunctions.cpp

这里我们首先包含 vulkan.h 文件,它正式提供给希望在应用中使用 Vulkan API 的开发人员。 该文件与 OpenGL 库中的 gl.h 文件类似。 它定义开发 Vulkan 应用时所必须的所有枚举、结构、类型和函数类型。 接下来定义来自各“级”(稍后将具体介绍这些级)的函数的宏。 函数定义要求提供函数类型和函数名称。 幸运的是,Vulkan 中的函数类型可从函数名称轻松派生出来。 例如,vkGetInstanceProcAddr() 函数的类型定义如下:

typedef PFN_vkVoidFunction (VKAPI_PTR *PFN_vkGetInstanceProcAddr)(VkInstance instance, const char* pName);

4.Vulkan.h

展示该函数的变量定义如下:

PFN_vkGetInstanceProcAddr vkGetInstanceProcAddr;

这是 VulkanFunctions.cpp 文件的宏进行扩展的目标。 它们提取函数名称(隐藏在“fun”参数中)并在开头部分添加“PFN_”。 然后,宏在类型后面放置一个空格,并在之后添加函数名称和分号。 函数“粘贴”至符合 #include “ListOfFunctions.inl” 指令的文件。

但我们必须牢记,如果希望自己定义 Vulkan 函数的原型,那么必须定义 VK_NO_PROTOTYPES 预处理器指令。 默认情况下,vulkan.h 标头文件包含所有函数的定义。 这将有助于静态链接至 Vulkan Runtime。 因此当我们添加自己的定义时,将会出现编译错误,声明特定变量(面向函数指示器)已定义多次(因为我们打破了“一种定义”规则)。 我们可以使用之前提到的预处理器宏禁用 vulkan.h 文件的定义。

同样,我们需要声明 VulkanFunctions.cpp 文件中定义的变量,以便它们显示在代码的其他部分。 这可通过相同的方法完成,但“extern”需要放在各函数的前面。 比较 VulkanFunctions.h 文件。

现在我们有了可用来保存从 Vulkan 库中获取的函数地址的变量。 为了只加载一个导出的函数,我们使用以下代码:

#if defined(VK_USE_PLATFORM_WIN32_KHR)
#define LoadProcAddress GetProcAddress
#elif defined(VK_USE_PLATFORM_XCB_KHR) || defined(VK_USE_PLATFORM_XLIB_KHR)
#define LoadProcAddress dlsym
#endif

#define VK_EXPORTED_FUNCTION( fun )                                                   \
if( !(fun = (PFN_##fun)LoadProcAddress( VulkanLibrary, #fun )) ) {                \
  std::cout << "Could not load exported function: " << #fun << "!" << std::endl;  \
  return false;                                                                   \
}

#include "ListOfFunctions.inl"

return true;

5.Tutorial01.cpp, function LoadExportedEntryPoints()

宏从“fun”参数中提取函数名称,将其转化成字符串(带 #)并从 VulkanLibrary 中获取它的地址。 地址可通过(Windows 上的) GetProcAddress() 或(Linux 上的) dlsym() 获取,并保存在 fun 呈现的变量中。 如果操作失败而且库没有显示函数,我们通过打印相应信息并返回假值来报告该问题。 宏在通过 ListOfFunctions.inl 包含的行上运行。 这样我们就不用多次写入函数名称。

由于我们有主函数加载程序,因此可以加载剩下的 Vulkan API 程序。 它们可分为三类:

  • 全局级函数。 支持创建 Vulkan 实例。
  • 实例级函数。 检查可用的支持 Vulkan 的硬件以及显示的 Vulkan 特性。
  • 设备级函数。 负责执行通常在 3D 应用中完成的工作(比如绘制)。

我们从获取全局级的实例创建函数开始。

获取全局级函数指示器

在创建 Vulkan 实例之前,我们必须获取支持创建工作的函数地址。 以下是函数列表:

  • vkCreateInstance
  • vkEnumerateInstanceExtensionProperties
  • vkEnumerateInstanceLayerProperties

最重要的函数是 vkCreateInstance(),它支持我们创建“Vulkan 实例”。 从应用视角来看,Vulkan 实例相当于 OpenGL 的渲染环境。 它保存按照应用状态(Vulkan 中没有全局状态),比如启用的实例级层和扩展功能。 其他两种函数支持我们检查有哪些实例层和实例扩展功能可用。 验证层可根据它们调试的功能分成实例级和设备级。 Vulkan 中的扩展功能与 OpenGL 中的扩展功能类似:展示核心规范不需要的附加功能,而且并非所有硬件厂商都会实施这些功能。 扩展功能(比如层)也可分为实例级扩展功能和设备级扩展功能,不同层级的扩展必须单独启用。 在 OpenGL 中,所有扩展功能(通常)都在创建的环境中提供;使用 Vulkan 时,必须在它们展示的功能能够使用之前启用它们。

我们调用函数 vkGetInstanceProcAddr() 获取实例级程序的地址。 它提取两个参数:实例和函数名称。 我们还没有实例,因此第一个参数为“null”。 这就是为什么这些函数有时调用 null 实例或非实例级函数的原因。 通过 vkGetInstanceProcAddr() 函数获取的第二个参数是我们希望获取地址的程序名称。 我们可以只加载没有实例的全局级函数。 不能加载其他第一个参数中未提供实例句柄的函数。

加载全局级函数的代码如下所示:

#define VK_GLOBAL_LEVEL_FUNCTION( fun )                                                   \
if( !(fun = (PFN_##fun)vkGetInstanceProcAddr( nullptr, #fun )) ) {                    \
  std::cout << "Could not load global level function: " << #fun << "!" << std::endl;  \
  return false;                                                                       \
}

#include "ListOfFunctions.inl"

return true;

6.Tutorial01.cpp, function LoadGlobalLevelEntryPoints()

该代码与用于加载导出函数(库展示的 vkGetInstanceProcAddr())的代码之间唯一不同点在于,我们不使用操作系统提供的函数(比如 GetProcAddress()),而是调用第一个参数设为 null 的 vkGetInstanceProcAddr()

如果您按照本教程自己编写代码,务必将包含在合理命名的宏中的全局级函数添加至 ListOfFunctions.inl 标头文件:

#if !defined(VK_GLOBAL_LEVEL_FUNCTION)
#define VK_GLOBAL_LEVEL_FUNCTION( fun )
#endif

VK_GLOBAL_LEVEL_FUNCTION( vkCreateInstance )
VK_GLOBAL_LEVEL_FUNCTION( vkEnumerateInstanceExtensionProperties )
VK_GLOBAL_LEVEL_FUNCTION( vkEnumerateInstanceLayerProperties )

#undef VK_GLOBAL_LEVEL_FUNCTION

7.ListOfFunctions.inl

创建 Vulkan 实例

加载全局级函数后,现在我们可以创建 Vulkan 实例。 可以通过调用拥有三个参数的 vkCreateInstance() 函数完成。

  • 第一个参数包含有关应用、请求的 Vulkan 版本,以及我们希望启用的实例级层和扩展功能的信息。 这都可以通过结构完成(结构在 Vulkan 中非常普遍)。
  • 第二个参数为结构指示器提供与内存分配相关的函数列表。 它们可用于调试,但该特性是可选的,而且我们可以依赖内置的内存分配方法。
  • 第三个参数是我们希望保存 Vulkan 实例句柄的变量地址。 在 Vulkan API 中,操作结果通常保存在我们提供地址的变量中。 返回值仅用于一些通过/未通过通知。 以下是有关实例创建的完整源代码:
VkApplicationInfo application_info = {
  VK_STRUCTURE_TYPE_APPLICATION_INFO,             // VkStructureType            sType
  nullptr,                                        // const void                *pNext
  "API without Secrets: Introduction to Vulkan",  // const char                *pApplicationName
  VK_MAKE_VERSION( 1, 0, 0 ),                     // uint32_t                   applicationVersion
  "Vulkan Tutorial by Intel",                     // const char                *pEngineName
  VK_MAKE_VERSION( 1, 0, 0 ),                     // uint32_t                   engineVersion
  VK_API_VERSION                                  // uint32_t                   apiVersion
};

VkInstanceCreateInfo instance_create_info = {
  VK_STRUCTURE_TYPE_INSTANCE_CREATE_INFO,         // VkStructureType            sType
  nullptr,                                        // const void*                pNext
  0,                                              // VkInstanceCreateFlags      flags
  &application_info,                              // const VkApplicationInfo   *pApplicationInfo
  0,                                              // uint32_t                   enabledLayerCount
  nullptr,                                        // const char * const        *ppEnabledLayerNames
  0,                                              // uint32_t                   enabledExtensionCount
  nullptr                                         // const char * const        *ppEnabledExtensionNames
};

if( vkCreateInstance( &instance_create_info, nullptr, &Vulkan.Instance ) != VK_SUCCESS ) {
  std::cout << "Could not create Vulkan instance!" << std::endl;
  return false;
}
return true;

8.Tutorial01.cpp, function CreateInstance()

大部分 Vulkan 结构的开头是描述结构类型的字段。 参数通过指示器提供给函数,以避免复制较大的内存块。 有时在结构内部,也会将指示器提供给其他结构。 对于需要知道它应读取多少字节以及成员如何对齐的驱动程序来说,通常会提供结构类型。 那么这些参数到底有什么意义?

  • sType – 结构类型。 在这种情况下,它通过提供 VK_STRUCTURE_TYPE_APPLICATION_INFO 的值,通知驱动程序我们将提供有关实例创建的信息。
  • pNext – 未来版本的 Vulkan API 可能会提供有关实例创建的其他信息,该参数用于此目的。 目前它留作将来使用。
  • flags – 另一个留作将来使用的参数;目前必须设为 0。
  • pApplicationInfo – 包含应用信息(比如名称、版本、所需 Vulkan API 版本等)的另一结构的地址。
  • enabledLayerCount – 定义我们希望启用的实例级验证层的数量。
  • ppEnabledLayerNames – 包含我们希望启用的层级名称的 enabledLayerCount 要素阵列。
  • enabledExtensionCount – 我们希望启用的实例级扩展功能数量。
  • ppEnabledExtensionNames – 与层级一样,该参数必须指向至少包含我们希望使用的实例级扩展功能的名称的 enabledExtensionCount 要素的阵列。

大部分参数都可以设为 null 或 0。 最重要的一个参数(除结构类型信息外)是指向类型变量 VkApplicationInfo 的参数。 因此指定实例创建信息之前,我们还需要指定描述应用的另一变量。 该变量包含应用名称、正在使用的引擎名称,或所需的 Vulkan API 版本(与 OpenGL 版本类似,如果驱动程序不支持该版本,将无法创建实例)。 该信息可能对驱动程序非常有用。 请记住,一些显卡厂商会提供专门用于特定用途(比如特定游戏)的驱动程序。 如果显卡厂商知道引擎游戏使用哪种显卡,将可以优化驱动程序的行为,从而加快游戏的速度。 该应用信息结构可用来实现这一目的。 VkApplicationInfo 结构的参数包括:

  • sType – 结构类型。 此处为 VK_STRUCTURE_TYPE_APPLICATION_INFO,即有关应用的信息。
  • pNext – 留作将来使用。
  • pApplicationName – 应用名称。
  • applicationVersion – 应用版本,使用 Vulkan 宏创建版本非常方便。 它包括主要版本和次要版本,并将数字合成一个 32 位的值。
  • pEngineName – 应用使用的引擎名称。
  • engineVersion – 我们在应用中使用的引擎版本。
  • apiVersion – 我们希望使用的 Vulkan API 版本。 最好提供包含时在 Vulkan 标头中定义的版本,这就是我们为什么使用在 vulkan.h 标头文件中查找的 VK_API_VERSION。

定义了这两个结构后,现在我们可以调用 vkCreateInstance() 函数并检查实例是否已创建。 如果创建成功,实例句柄将保存在我们提供地址的变量中,并返回 VK_SUCCESS (0!)。

获取实例级函数指示器

我们已经创建了一个 Vulkan 实例。 接下来是获取函数指示器,以便创建逻辑设备,它可视作物理设备上的用户视图。 计算机上可能安装了许多支持 Vulkan 的不同设备。 每台设备都具备不同的特性、功能和性能,或者支持的功能也各不相同。 如果希望使用 Vulkan,那么必须指定用来执行操作的设备。 可以使用不同用途的设备(比如一台用于渲染 3D 图形、一台用于物理计算,另一台用于媒体解码)。 必须检查有多少设备,其中哪些可用,具备哪些功能,以及支持哪些操作。 这可通过实例级函数来完成。 我们使用之前用过的 vkGetInstanceProcAddr() 函数来获取这些函数的地址。 但这次要提供句柄,才能创建 Vulkan 实例。

使用 vkGetInstanceProcAddr() 函数加载每个 Vulkan 程序,且 Vulkan 实例句柄带有部分权衡。 将 Vulkan 用于数据处理时,必须创建一台逻辑设备并获取设备级函数。 但在运行应用的计算机上可能有许多支持 Vulkan 的设备。 确定使用哪台设备取决于前面提到的逻辑设备。 但 vkGetInstanceProcAddr() 无法认出逻辑设备,因为没有相应的参数。 使用该函数获取设备级程序时,事实上我们获取的是简单“jump”函数的地址。 这些函数提取逻辑设备的句柄,并跳至相应实施(为特定设备实施的函数)。 此次跳跃产生的开销是可以避免的。 建议使用其他函数单独加载每台设备的程序。 但仍然需要使用 vkGetInstanceProcAddr() 函数加载支持创建此类逻辑设备的函数。

部分实例级函数包括:

  • vkEnumeratePhysicalDevices
  • vkGetPhysicalDeviceProperties
  • vkGetPhysicalDeviceFeatures
  • vkGetPhysicalDeviceQueueFamilyProperties
  • vkCreateDevice
  • vkGetDeviceProcAddr
  • vkDestroyInstance

这些函数是本教程用于创建逻辑设备所必需的。 但扩展功能也提供一些其他的实例级函数。 通过示例解决方案的源代码形成的标头文件中的列表将展开。 用于加载所有函数的源代码为:

#define VK_INSTANCE_LEVEL_FUNCTION( fun )                                                   \
if( !(fun = (PFN_##fun)vkGetInstanceProcAddr( Vulkan.Instance, #fun )) ) {              \
  std::cout << "Could not load instance level function: " << #fun << "!" << std::endl;  \
  return false;                                                                         \
}

#include "ListOfFunctions.inl"

return true;

9.Tutorial01.cpp, function LoadInstanceLevelEntryPoints()

用于加载实例级函数的代码与加载全局级函数的代码大体上相同。 我们只需将 vkGetInstanceProcAddr() 函数的第一个参数从 null 改成创建 Vulkan 实例句柄。 当然我们还可以在实例级函数上运行,因此现在我们重新定义 VK_INSTANCE_LEVEL_FUNCTION() 宏,而非 VK_GLOBAL_LEVEL_FUNCTION() 宏。 我们还需定义实例级的函数。 像之前一样,最好通过共享标头中收集的包含宏的名称列表来完成,例如:

#if !defined(VK_INSTANCE_LEVEL_FUNCTION)
#define VK_INSTANCE_LEVEL_FUNCTION( fun )
#endif

VK_INSTANCE_LEVEL_FUNCTION( vkDestroyInstance )
VK_INSTANCE_LEVEL_FUNCTION( vkEnumeratePhysicalDevices )
VK_INSTANCE_LEVEL_FUNCTION( vkGetPhysicalDeviceProperties )
VK_INSTANCE_LEVEL_FUNCTION( vkGetPhysicalDeviceFeatures )
VK_INSTANCE_LEVEL_FUNCTION( vkGetPhysicalDeviceQueueFamilyProperties )
VK_INSTANCE_LEVEL_FUNCTION( vkCreateDevice )
VK_INSTANCE_LEVEL_FUNCTION( vkGetDeviceProcAddr )
VK_INSTANCE_LEVEL_FUNCTION( vkEnumerateDeviceExtensionProperties )

#undef VK_INSTANCE_LEVEL_FUNCTION

10.ListOfFunctions.inl

实例级函数在物理设备上运行。 在 Vulkan 中可以看“物理设备”到和“逻辑设备”(简单称为设备)。 顾名思义,物理设备指安装在计算机上、运行支持 Vulkan 且能够执行 Vulkan 命令的应用的物理显卡(或其他硬件组件)。 如前所述,此类设备可以显示并实施不同(可选)Vulkan 特性,具备不同的功能(比如总内存,或能够处理不同大小的缓冲区对象),或提供不同的扩展功能。 此类硬件可以是专用(独立)显卡,也可以是内置(集成)于主处理器中的附加芯片。 甚至还可以是 CPU 本身。 实例级函数支持我们检查所有参数。 检查后,必须(根据检查结果和需求)决定使用哪台物理设备。 我们还希望使用多台设备(这也是可能的),但这种场景目前太过高级。 因此如果希望发挥物理设备的能力,那么我们必须创建一台逻辑设备,以呈现我们在应用中的选择(以及启用的层、扩展功能、特性等)。 创建设备(并获取队列)后,我们准备使用 Vulkan,方法与创建渲染环境后准备使用 OpenGL 的方法相同。

创建逻辑设备

创建逻辑设备之前,必须首先进行检查,看看系统中有多少可供执行应用的物理设备。 接下来获取所有可用物理设备的句柄:

uint32_t num_devices = 0;
if( (vkEnumeratePhysicalDevices( Vulkan.Instance, &num_devices, nullptr ) != VK_SUCCESS) ||
    (num_devices == 0) ) {
  std::cout << "Error occurred during physical devices enumeration!" << std::endl;
  return false;
}

std::vector<VkPhysicalDevice> physical_devices( num_devices );
if( vkEnumeratePhysicalDevices( Vulkan.Instance, &num_devices, &physical_devices[0] ) != VK_SUCCESS ) {
  std::cout << "Error occurred during physical devices enumeration!" << std::endl;
  return false;
}

11.Tutorial01.cpp, function CreateDevice()

如要检查有多少可用设备,可以调用 vkEnumeratePhysicalDevices() 函数。 调用两次,第一次调用时将最后一个参数设为 null。 这样驱动程序会知道我们仅要求知道可用物理设备的数量。 该数量将保存在我们在第二个参数中提供地址的变量中。

知道有多少可用物理设备后,我们可以准备保存它们的句柄。 我使用矢量,因此无需担心内存分配和取消分配问题。 再次调用 vkEnumeratePhysicalDevices() 时,所有参数不等于 null,我们将获取在最后一个参数中提供地址的阵列中的物理设备的句柄。 该阵列的大小可能与第一次调用后的返回值不同,但必须与在第二个参数中定义的要素数量相同。

例如,有 4 台可用物理设备,但我们只对第 1 台感兴趣。 因此在第一次调用后,在 num_devices中设置一个为 4 的值。 这样我们将知道这里有任意兼容 Vulkan 的设备,然后继续。 我们将该值覆写成 1,因为无论有多少设备,我们只希望使用 1 台设备。 第二次调用后,我们将仅获取一个物理设备句柄。

提供的设备数量将由枚举的物理设备数量所取代(当然不会大于我们提供的值)。 例如,我们不希望两次调用这个函数。 我们的应用支持多达 10 台设备,并且我们提供该值和 10 要素静态阵列指示器。 驱动程序通常返回实际枚举的设备数量。 如果没有设备,我们提供的变量地址中将保存 0。 如果有这种设备,我们也会知道。 我们无法告知是否有超过 10 台设备。

由于我们有所有兼容 Vulkan 的物理设备的句柄,现在可以检查各设备的属性。 在示例代码中,这一过程在循环中完成:

VkPhysicalDevice selected_physical_device = VK_NULL_HANDLE;
uint32_t selected_queue_family_index = UINT32_MAX;
for( uint32_t i = 0; i < num_devices; ++i ) {
  if( CheckPhysicalDeviceProperties( physical_devices[i], selected_queue_family_index ) ) {
    selected_physical_device = physical_devices[i];
  }
}

12.Tutorial01.cpp, function CreateDevice()

设备属性

我创建了 CheckPhysicalDeviceProperties() 函数。 它提取物理设备的句柄,并检查特定设备是否具备足够的功能供应用正常运行。 如果是,返回真值,并将队列家族索引保存在第二个参数中提供在变量中。 队列和队列家族将在后续章节中介绍。

以下是 CheckPhysicalDeviceProperties() 函数的前半部分:

VkPhysicalDeviceProperties device_properties;
VkPhysicalDeviceFeatures   device_features;

vkGetPhysicalDeviceProperties( physical_device, &device_properties );
vkGetPhysicalDeviceFeatures( physical_device, &device_features );

uint32_t major_version = VK_VERSION_MAJOR( device_properties.apiVersion );
uint32_t minor_version = VK_VERSION_MINOR( device_properties.apiVersion );
uint32_t patch_version = VK_VERSION_PATCH( device_properties.apiVersion );

if( (major_version < 1) &&
    (device_properties.limits.maxImageDimension2D < 4096) ) {
  std::cout << "Physical device " << physical_device << " doesn't support required parameters!" << std::endl;
  return false;
}

13.Tutorial01.cpp, function CheckPhysicalDeviceProperties()

在函数的开头,查询物理设备的属性和特性。 属性包含的字段有:支持的 Vulkan API 版本、设备名称和类型(集成或专用/独立 GPU)、厂商 ID 和限制。 限制描述如何创建大纹理、anti-aliasing 中支持多少实例、或者特定着色器阶段可以使用多少缓冲区。

设备特性

特性是与扩展功能类似的附加硬件功能。 驱动程序也许没有必要支持这些,而且默认情况下不启用。 特性包含多个项目,比如几何体和镶嵌着色器多个视口、逻辑运算,或其他纹理压缩格式。 如果特定物理设备支持任意特性,那么我们将能够在逻辑设备创建期间启用该特性。 在 Vulkan 中默认不启用特性。 但 Vulkan 规范指出,部分特性可能会对性能(比如稳定性)造成影响。

查询硬件信息和功能后,我提供了一个有关如何使用这些查询的小示例。 我“保留” VK_MAKE_VERSION 宏并检索主要版本和次要版本,并修改了设备属性 apiVersion 字段的版本。 检查它是否高于我希望使用的版本,还检查我能否创建特定大小的 2D 纹理。 在本示例中,我没有使用任何特性,但如果希望使用特性(比如几何体着色器),必须检查它是否支持,并且在逻辑设备创建过程中必须(明确)启用它。 这就是我们为什么需要创建逻辑设备,不直接使用物理设备。 逻辑设备代表物理设备以及我们为其启用的所有特性和扩展功能。

检查物理设备的功能的下一部分 - 队列 - 需要另作解释。

队列、队列家族和命令缓冲区

如果我们希望处理数据(比如通过顶点数据和顶点属性绘制 3D 场景),要调用传递至驱动程序的 Vulkan 函数。 这些函数不直接传递,因为将每个请求单独向下发送至通信总线的效率非常低。 最好是将它们集中起来,分组传递。 在 OpenGL中,驱动程序自动完成该过程,用户是看不见的。 OpenGL API 调用在缓冲区中排队,如果该缓冲区已满(或我们请求刷新),整个缓冲区会传递至硬件以作处理。 在 Vulkan 中,该机制对用户是直接可见的,更重要的是,用户必须为命令专门创建并管理缓冲区。 这些是(方便)调用的命令缓冲区。

指令缓冲区(作为整个对象)被传递至硬件,以通过队列来执行。 然而,这些缓冲区包含不同的操作类型,比如图形命令(用于譬如在典型 3D 游戏中生成和显示图像)或计算命令(用于处理数据)。 特定命令类型可能由专用硬件处理,因此队列也可分成不同类型。 在 Vulkan 中,这些队列类型是调用的家族。 每个队列家族都可支持不同的操作类型。 因此我们还必须检查特定物理设备是否支持我们希望执行的操作类型。 另外,我们还可以在一台设备上执行一类操作,在另一台设备上执行另一类操作,但需要检查它的可行性。 这类检查由 CheckPhysicalDeviceProperties() 函数的后半部分完成:

uint32_t queue_families_count = 0;
vkGetPhysicalDeviceQueueFamilyProperties( physical_device, &queue_families_count, nullptr );
if( queue_families_count == 0 ) {
  std::cout << "Physical device " << physical_device << " doesn't have any queue families!" << std::endl;
  return false;
}

std::vector<VkQueueFamilyProperties> queue_family_properties( queue_families_count );
vkGetPhysicalDeviceQueueFamilyProperties( physical_device, &queue_families_count, &queue_family_properties[0] );
for( uint32_t i = 0; i < queue_families_count; ++i ) {
  if( (queue_family_properties[i].queueCount > 0) &&
      (queue_family_properties[i].queueFlags & VK_QUEUE_GRAPHICS_BIT) ) {
    queue_family_index = i;
    return true;
  }
}

std::cout << "Could not find queue family with required properties on physical device " << physical_device << "!" << std::endl;
return false;

14.Tutorial01.cpp, function CheckPhysicalDeviceProperties()

我们必须首先检查特定物理设备中有多少可用的队列家族。 其检查方式与枚举物理设备类似。 首先我们调用 vkGetPhysicalDeviceQueueFamilyProperties(),其最后一个参数设为 null。 这样在“queue_count”中,将保存不同列队家族的可变数量。 接下来为该数量的队列家族的属性准备一个位置(如果想这样做 - 其机制与枚举物理设备类似)。 然后再次调用函数,各队列家族的属性将保存在提供的阵列中。

各队列家族的属性包含队列标记、家族中可用队列的数量、时间邮戳支持和图像传输粒度。 现在,最重要的部分是家族中的队列数量和标记。 (位字段)标记定义特定队列家族支持的操作类型(可能支持多种)。 它可以是图形、计算、传输(复制等内存操作),或(针对百万纹理等稀疏资源的)稀疏绑定操作。 未来可能出现其他类型的操作。

在本示例中,我们检查图形操作支持,如果找到该支持,那么就可以使用特定物理设备。 请记住,我们还需牢记指定的家族索引。 选择物理设备后,我们可以创建将在应用其他部分代表该设备的逻辑设备,如下例所示:

if( selected_physical_device == VK_NULL_HANDLE ) {
  std::cout << "Could not select physical device based on the chosen properties!" << std::endl;
  return false;
}

std::vector<float> queue_priorities = { 1.0f };

VkDeviceQueueCreateInfo queue_create_info = {
  VK_STRUCTURE_TYPE_DEVICE_QUEUE_CREATE_INFO,     // VkStructureType              sType
  nullptr,                                        // const void                  *pNext
  0,                                              // VkDeviceQueueCreateFlags     flags
  selected_queue_family_index,                    // uint32_t                     queueFamilyIndex
  static_cast<uint32_t>(queue_priorities.size()), // uint32_t                     queueCount
  &queue_priorities[0]                            // const float                 *pQueuePriorities
};

VkDeviceCreateInfo device_create_info = {
  VK_STRUCTURE_TYPE_DEVICE_CREATE_INFO,           // VkStructureType                    sType
  nullptr,                                        // const void                        *pNext
  0,                                              // VkDeviceCreateFlags                flags
  1,                                              // uint32_t                           queueCreateInfoCount
  &queue_create_info,                             // const VkDeviceQueueCreateInfo     *pQueueCreateInfos
  0,                                              // uint32_t                           enabledLayerCount
  nullptr,                                        // const char * const                *ppEnabledLayerNames
  0,                                              // uint32_t                           enabledExtensionCount
  nullptr,                                        // const char * const                *ppEnabledExtensionNames
  nullptr                                         // const VkPhysicalDeviceFeatures    *pEnabledFeatures
};

if( vkCreateDevice( selected_physical_device, &device_create_info, nullptr, &Vulkan.Device ) != VK_SUCCESS ) {
  std::cout << "Could not create Vulkan device!" << std::endl;
  return false;
}

Vulkan.QueueFamilyIndex = selected_queue_family_index;
return true;

15.Tutorial01.cpp, function CreateDevice()

首先确保退出设备特性循环后,我们找到了可满足需求的设备。 然后通过调用 vkCreateDevice() 创建逻辑设备。 它提取物理设备的句柄和包含创建设备所需的信息的结构地址。 该结构的类型为 VkDeviceCreateInfo 并包含以下字段:

  • sType – 所提供结构的标准类型,此处的 VK_STRUCTURE_TYPE_DEVICE_CREATE_INFO 表示我们为设备创建提供参数。
  • pNext – 指向扩展特定结构的参数;此外我们设为 null。
  • flags – 另一留作将来使用的参数,必须是 0。
  • queueCreateInfoCount – 不同队列家族的数量,我们通过它创建队列和设备。
  • pQueueCreateInfos – queueCreateInfoCount 要素(指定我们希望创建的队列)阵列的指示器。
  • enabledLayerCount – 待启用的设备级验证层数量。
  • ppEnabledLayerNames – 包含待启用设备级的 enabledLayerCount 名称的阵列的指示器。
  • enabledExtensionCount – 为设备启用的扩展功能数量。
  • ppEnabledExtensionNames – 包含 enabledExtensionCount 要素的指示器;各要素必须包含应该启用的扩展功能的名称。
  • pEnabledFeatures – 结构指示器(表示为该设备启用的其他特性)(请参阅“设备”部分)。

特性(如前所述)是默认禁用的附加硬件功能。 如果希望启用所有可用特性,不能简单用 ones 填充该结构。 如果部分特性不支持,设备创建将失败。 相反,我们应传递调用 vkGetPhysicalDeviceFeatures() 时填充的结构。 这是启用所有支持特性的最简单的方法。 如果我们仅对部分特定特性感兴趣,那么查询面向可用特性的驱动程序,并清空所有不需要的字段。 如果不希望使用任何附加特性,可以清空该结构(用 0 填充),或为该参数传递一个 null 指示器(如本例所示)。

队列与设备一同自动创建。 如要指定希望启用的队列类型,需要提供其他 VkDeviceQueueCreateInfo 结构阵列。 该阵列必须包含 queueCreateInfoCount 要素。 该阵列中的每个要素都必须引用不同的队列家族;我们仅引用一次特定队列家族。

VkDeviceQueueCreateInfo 结构包含以下字段:

  • sType – 结构类型,此处 VK_STRUCTURE_TYPE_DEVICE_QUEUE_CREATE_INFO 表示它为队列创建信息。
  • pNext – 为扩展功能预留的指示器。
  • flags – 留作将来使用的值。
  • queueFamilyIndex – 队列家族(通过它创建队列)的索引。
  • queueCount – 我们希望在特定队列家族中启用的队列数量(希望通过该家族使用的队列数量)以及 pQueuePriorities 阵列中的要素数量。
  • pQueuePriorities – 包含浮点值(描述通过该家族在各队列中执行的操作的优先级)的阵列。

如前所述,包含 VkDeviceQueueCreateInfo 要素的阵列中的各要素必须描述一个不同的队列家族。 索引是一个数,必须小于 vkGetPhysicalDeviceQueueFamilyProperties() 函数提供的值(必须小于可用队列家族的数量)。 在本示例中,我们仅对一个队列家族中的一个队列感兴趣。 因此我们必须记住该队列家族索引。 它用于此处。 如果想准备一个更为复杂的场景,还应记住各家族的列队数量,因为各家族可能支持不同数量的队列。 而且创建的队列不能超过特定家族中的可用队列!

值得注意的一点是,不同队列家族可能有类似(甚至相同)的属性),这意味着它们可能支持类似的操作类型,即支持图形操作的队列家族不止一个。 此外,各家族可能包含不同数量的队列。

我们还必须向各队列分配一个浮点值(从 0.0 到 1.0,包括这两个值)。 我们为特定队列提供的值越大(相对于分配至其他队列的值),特定队列处理命令的时间越长(相对于其他队列)。 但这种关系并不绝对。 优先级也不会影响执行顺序。 它只是一个提示。

优先级仅在单台设备上有关系。 如果在多台设备上执行操作,优先级会影响各台设备(而非两台设备之间)的处理时间。 带有特定值的队列的重要性可能仅高于相同设备上优先级较低的队列。 不同设备的队列独立对待。 一旦我们填充了这些结构并调用 vkCreateDevice(),如果成功,创建的逻辑设备将保存在我们提供地址的变量中(在本示例中称为 VulkanDevice)。 如果该函数失败,将返回与 VK_SUCCESS 相反的值。

获取设备级函数指示器

我们创建了一台逻辑设备。 现在可以用它加载设备级函数。 正如我之前提到的在真实场景中,将有多家硬件厂商在单台计算机上为我们提供 Vulkan 实施的情况。 OpenGL 中现在就出现了这种情况。 许多计算机都有主要用于游戏的专用/独立显卡,但也有内置于处理器的英特尔显卡(当然也能用于游戏)。 因此未来将有更多设备支持 Vulkan。 而且借助 Vulkan。我们可以将处理分成任意硬件。 是否还记得什么时候出现了专门用于物理处理的扩展卡? 或者往前回顾,带有附加显卡“加速器”的正常“2D”卡(是否还记得 Voodoo 卡)? Vulkan 已准备好应对这种场景。

那么,如果有多台设备,我们该怎么处理设备级函数? 我们可以加载通用程序。 这可通过 vkGetInstanceProcAddr() 函数来完成。 它返回派遣函数的地址,执行根据提供的逻辑设备句柄跳至相应实施的行为。 但我们可以通过分别加载各逻辑设备的函数,避免这种开销。 使用这种方法时,必须记住,只能基于供加载该函数的设备来调用特定函数。 因此如果在应用中使用较多设备,必须从各设备加载函数。 这并不是特别困难。 而且,尽管这样会导致保存较多函数(并根据供加载的设备对它们进行分组),但我们可以避免抽象层,并节约一部分处理器时间。 加载函数的方式与加载导出函数、全局级函数和实例级函数的方式类似:

#define VK_DEVICE_LEVEL_FUNCTION( fun )                                                   \
if( !(fun = (PFN_##fun)vkGetDeviceProcAddr( Vulkan.Device, #fun )) ) {                \
  std::cout << "Could not load device level function: " << #fun << "!" << std::endl;  \
  return false;                                                                       \
}

#include "ListOfFunctions.inl"

return true;

16.Tutorial01.cpp, function LoadDeviceLevelEntryPoints()

这次我们使用 vkGetDeviceProcAddr() 函数和逻辑设备句柄。 设备级函数放在共享标头中。 这次它们包含在 VK_DEVICE_LEVEL_FUNCTION() 宏中,如下所示:

#if !defined(VK_DEVICE_LEVEL_FUNCTION)
#define VK_DEVICE_LEVEL_FUNCTION( fun )
#endif

VK_DEVICE_LEVEL_FUNCTION( vkGetDeviceQueue )
VK_DEVICE_LEVEL_FUNCTION( vkDestroyDevice )
VK_DEVICE_LEVEL_FUNCTION( vkDeviceWaitIdle )

#undef VK_DEVICE_LEVEL_FUNCTION

17.ListOfFunctions.inl

所有函数都不是来自于导出、全局或实例级,而是来自设备级。 第一个参数会造成另一个区别:对于设备级函数,列表中的第一个参数只能是类型 VkDevice、VkQueue 或 VkCommandBuffer。 在接下来的教程中,如果出现新的函数,必须添加至 ListOfFunctions.inl 并进一步添加至 VK_DEVICE_LEVEL_FUNCTION 部分(有一些明显的例外情况,比如扩展功能)。

检索队列

创建设备后,我们需要能够为数据处理提交部分命令的队列。 队列通过逻辑设备自动创建,但为了使用这些队列,我们必须特别要求队列句柄。 这可通过 vkGetDeviceQueue() 完成,如下所示:

vkGetDeviceQueue( Vulkan.Device, Vulkan.QueueFamilyIndex, 0, &Vulkan.Queue );

18.Tutorial01.cpp, function GetDeviceQueue()

如要检索队列句柄,必须提供用于获取队列的逻辑设备。 还需要队列家族索引,该索引必须是我们在逻辑设备创建期间提供的索引之一(不能创建其他队列或使用我们没有请求的家族的队列)。 最后一个参数是来自特定家族的队列索引;它必须小于从特定家族请求的队列总数。 例如,如果设备支持 3 号家族的 5 个队列,而我们希望该家族提供 2 个队列,那么队列索引必须小于 2。 对于我们希望检索的各个队列来说,必须调用该函数并进行单独查询。 如果函数调用成功,请求队列的句柄会保存在我们在最后一个参数中提供地址的变量中。 从这时起,希望(使用命令缓冲区)执行的所有工作都可提交至获取的队列中以供处理。

Tutorial01 执行

如前所述,本教程提供的示例无法演示所有内容。 不过我们了解了足够多的信息。 那么,我们如何知道一切是否进展顺利? 如果出现正常的应用窗口,控制台/终端没有打印任何内容,表示 Vulkan 设置成功。 从下一教程开始,操作结果将显示在屏幕上。

清空

我们还需牢记的一点是:清空和释放资源。 必须以特定的顺序(通常与创建顺序相反)进行清空。

应用关闭后,操作系统应释放内存及其他所有相关资源。 这应包含 Vulkan;驱动程序通常清空没有引用的资源。 遗憾的是,这种清空没有以相应的顺序执行,因此可能会导致应用在关闭过程中崩溃。 最佳实践是自己执行清理。 以下是释放在第一个教程中创建的资源所需的示例代码:

if( Vulkan.Device != VK_NULL_HANDLE ) {
  vkDeviceWaitIdle( Vulkan.Device );
  vkDestroyDevice( Vulkan.Device, nullptr );
}

if( Vulkan.Instance != VK_NULL_HANDLE ) {
  vkDestroyInstance( Vulkan.Instance, nullptr );
}

if( VulkanLibrary ) {
#if defined(VK_USE_PLATFORM_WIN32_KHR)
  FreeLibrary( VulkanLibrary );
#elif defined(VK_USE_PLATFORM_XCB_KHR) || defined(VK_USE_PLATFORM_XLIB_KHR)
  dlclose( VulkanLibrary );
#endif
}

19.Tutorial01.cpp, destructor

我们应该经常检查,看是否已创建任何特定资源。 没有逻辑设备就没有设备级函数指示器,也就无法调用相应的资源清理函数。 同样,如果没有实例,就无法获取 vkDestroyInstance() 函数的指示器。 一般来说,我们不能释放没有创建的资源。

必须确保对象在删除之前,没有经过设备的使用。 因此有一个等候函数,等特定设备的所有队列上的处理过程完成之后才进行拦截。 接下来,我们使用 vkDestroyDevice() 函数破坏逻辑设备。 与此相关的所有队列都会自动破坏掉,然后破坏实例。 在这之后我们就能够释放(或卸载)供获取所有函数的 Vulkan 库。

结论

本教程介绍了如何在应用中为使用 Vulkan 做准备。 首先“连接”Vulkan Runtime 库,并从中加载全局级函数。 然后创建 Vulkan 实例并加载实例级函数。 之后检查哪些物理设备可用,以及它们具备的特性、属性和功能。 接下来创建逻辑设备,并描述必须与设备一同创建的队列及其数量。 然后使用新创建的逻辑设备句柄检索设备级函数。 另外需要做的一件事是检索供我们提交待执行工作的队列。


请前往:没有任何秘密的 API:Vulkan* 简介第 2 部分:交换链


声明

本文件不构成对任何知识产权的授权,包括明示的、暗示的,也无论是基于禁止反言的原则或其他。

英特尔明确拒绝所有明确或隐含的担保,包括但不限于对于适销性、特定用途适用性和不侵犯任何权利的隐含担保,以及任何对于履约习惯、交易习惯或贸易惯例的担保。

本文包含尚处于开发阶段的产品、服务和/或流程的信息。 此处提供的信息可随时改变而毋需通知。 联系您的英特尔代表,了解最新的预测、时间表、规格和路线图。

本文件所描述的产品和服务可能包含使其与宣称的规格不符的设计缺陷或失误。 这些缺陷或失误已收录于勘误表中,可索取获得。

如欲获取本文提及的带订购编号的文档副本,可致电 1-800-548-4725,或访问 www.intel.com/design/literature.htm

该示例源代码根据英特尔示例源代码许可协议发布。

英特尔和 Intel 标识是英特尔在美国和/或其他国家的商标。

*其他的名称和品牌可能是其他所有者的资产。

英特尔公司 © 2016 年版权所有。

有关编译器优化的更完整信息,请参阅优化通知