vulkan开发教程(和我一起完成一个简单的Vulkan应用程序)
在本章,你将学到:
- Vulkan以及它背后的基本原理;
- 如何创建一个最简单的Vulkan应用程序;
- 在本书其余部分将使用到的术语和概念。
本章将介绍并解释Vulkan是什么。我们会介绍API背后的基本概念,包括初始化、对象生命周期、Vulkan实例以及逻辑和物理设备。在本章的最后,我们会完成一个简单的Vulkan应用程序,这个程序可以初始化Vulkan系统,查找可用的Vulkan设备并显示其属性和功能,最后彻底地关闭程序。
1.1 引言Vulkan是一个用于图形和计算设备的编程接口。Vulkan设备通常由一个处理器和一定数量的固定功能硬件模块组成,用于加速图形和计算操作。通常,设备中的处理器是高度线程化的,所以在极大程度上Vulkan里的计算模型是基于并行计算的。Vulkan还可以访问运行应用程序的主处理器上的共享或非共享内存。Vulkan也会给开发人员提供这个内存。
Vulkan是个显式的API,也就是说,几乎所有的事情你都需要亲自负责。驱动程序是一个软件,用于接收API调用传递过来的指令和数据,并将它们进行转换,使得硬件可以理解。在老的API(例如OpenGL)里,驱动程序会跟踪大量对象的状态,自动管理内存和同步,以及在程序运行时检查错误。这对开发人员非常友好,但是在应用程序经过调试并且正确运行时,会消耗宝贵的CPU性能。Vulkan解决这个问题的方式是,将状态跟踪、同步和内存管理交给了应用程序开发人员,同时将正确性检查交给各个层进行代理,而要想使用这些层必须手动启用。这些层在正常情况下不会在应用程序里执行。
由于这些原因,Vulkan难以使用,并且在一定程度上很不稳定。你需要做大量的工作来保证Vulkan运行正常,并且API的错误使用经常会导致图形错乱甚至程序崩溃,而在传统的图形API里你通常会提前收到用于帮助解决问题的错误消息。以此为代价,Vulkan提供了对设备的更多控制、清晰的线程模型以及比传统API高得多的性能。
另外,Vulkan不仅仅被设计成图形API,它还用作异构设备,例如图形处理单元(Graphics Processing Unit,GPU)、数字信号处理器(Digital Signal Processor,DSP)和固定功能硬件。功能可以粗略地划分为几类。Vulkan的当前版本定义了传输类别——用于复制数据;计算类别——用于运行着色器进行计算工作;图形类别——包括光栅化、图元装配、混合、深度和模板测试,以及图形程序员所熟悉的其他功能。
Vulkan设备对每个分类的支持都是可选的,甚至可以根本不支持图形。因此,将图像显示到适配器设备上的API(这个过程叫作展示)不但是可选择的功能,而且是扩展功能,而不是核心API。
1.2 实例、设备和队列Vulkan包含了一个层级化的功能结构,从顶层开始是实例,实例聚集了所有支持Vulkan的设备。每个设备提供了一个或者多个队列,这些队列执行应用程序请求的工作。
Vulkan实例是一个软件概念,在逻辑上将应用程序的状态与其他应用程序或者运行在应用程序环境里的库分开。系统里的物理设备表示为实例的成员变量,每个都有一定的功能,包括一组可用的队列。
物理设备通常表示一个单独的硬件或者互相连接的一组硬件。在任何系统里,都有一些数量固定的物理设备,除非这个系统支持重新配置,例如热插拔。由实例创建的逻辑设备是一个与物理设备相关的软件概念,表示与某个特定物理设备相关的预定资源,其中包括了物理设备上可用队列的一个子集。可以通过创建多个逻辑设备来表示一个物理设备,应用程序花大部分时间与逻辑设备交互。
图1.1展示了这个层级关系。图1.1中,应用程序创建了两个Vulkan实例。系统里的3个物理设备能够被这两个实例使用。经过枚举,应用程序在第一个物理设备上创建了一个逻辑设备,在第二个物理设备创建了两个逻辑设备,在第三个物理设备上创建了一个逻辑设备。每个逻辑设备启用了对应物理设备队列的不同子集。在实际开发中,大多数Vulkan应用程序不会这么复杂,而会针对系统里的某个物理设备只创建一个逻辑设备,并且使用一个实例。图1.1仅仅用来展示Vulkan的复杂性。
图1.1 Vulkan里关于实例、设备和队列的层级关系
后面的小节将讨论如何创建Vulkan实例,如何查询系统里的物理设备,并将一个逻辑设备关联到某个物理设备上,最后获取设备提供的队列句柄。
1.2.1 Vulkan实例
Vulkan可以被看作应用程序的子系统。一旦应用程序连接了Vulkan库并初始化,Vulkan就会追踪一些状态。因为Vulkan并不向应用程序引入任何全局状态,所以所有追踪的状态必须存储在你提供的一个对象里。这就是实例对象,由VkInstance对象来表示。为了构建这个对象,我们会调用第一个Vulkan函数vkCreateInstance(),其原型如下。
VkResult vkCreateInstance ( const VkInstanceCreateInfo* pCreateInfo, const VkAllocationCallbacks* pAllocator, VkInstance* pInstance);
该声明是个典型的Vulkan函数:把多个参数传入Vulkan,函数通常接收结构体的指针。这里,pCreateInfo是指向结构体VkInstanceCreateInfo的实例的指针。这个结构体包含了用来描述新的Vulkan实例的参数,其定义如下。
typedef struct VkInstanceCreateInfo { VkStructureType sType; const void* pNext; VkInstanceCreateFlags flags; const VkApplicationInfo* pApplicationInfo; uint32_t enabledLayerCount; const char* const* ppEnabledLayerNames; uint32_t enabledExtensionCount; const char* const* ppEnabledExtensionNames; } VkInstanceCreateInfo;
几乎每一个用于向API传递参数的Vulkan结构体的第一个成员都是字段sType,该字段告诉Vulkan这个结构体的类型是什么。核心API以及任何扩展里的每个结构体都有一个指定的结构体标签。通过检查这个标签,Vulkan工具、层和驱动可以确定结构体的类型,用于验证以及在扩展里使用。另外,字段pNext允许将一个相连的结构体链表传入函数。这样在一个扩展中,允许对参数集进行扩展,而不用将整个核心结构体替换掉。因为这里使用了核心的实例创建结构体,将字段sType设置为VK_STRUCTURE_TYPE_INSTANCE_CREATE_INFO,并且将pNext设置为nullptr。
字段flags留待将来使用,应该设置为0。下一个字段pApplicationInfo是个可选的指针,指向另一个描述应用程序的结构体。可以将它设置为nullptr,但是推荐填充为有用的信息。pApplicationInfo指向结构体VkApplicationInfo的一个实例,其定义如下。
typedef struct VkApplicationInfo { VkStructureType sType; const void* pNext; const char* pApplicationName; uint32_t applicationVersion; const char* pEngineName; uint32_t engineVersion; uint32_t apiVersion; } VkApplicationInfo;
我们再一次看到了字段sType和pNext。SType 应该设置为VK_STRUCTURE_TYPE_APPLICATION_INFO,并且可以将pNext设置为nullptr。pApplicationName是个指针,指向以nul为结尾的字符串[1],这个字符串用于包含应用程序的名字。applicationVersion是应用程序的版本号。这样就允许工具和驱动决定如何对待应用程序,而不用猜测[2]哪个应用程序正在运行。同样,pEngineName与engineVersion也分别包含了引擎或者中间件(应用程序基于此构建)的名字和版本号。
最后,apiVersion包含了应用程序期望运行的Vulkan API的版本号。这个应该设置为你期望应用程序运行所需的Vulkan的绝对最小版本号——并不是你安装的头文件中的版本号。这样允许更多设备和平台运行应用程序,即使并不能更新它们的Vulkan实现。
回到结构体VkInstanceCreateInfo,接下来是字段enabledLayerCount和ppEnabledLayerNames。这两个分别是你想激活的实例层的个数以及名字。层用于拦截Vulkan的API调用,提供日志、性能分析、调试或者其他特性。如果不需要层,只需要将enabledLayerCount设置为0,将ppEnabledLayerNames设置为nullptr。同样,enabledExtensionCount是你想激活的扩展的个数[3],ppEnabledExtensionNames是名字列表。如果我们不想使用任何的扩展,同样可以将这些字段分别设置为0和nullptr。
最后,回到函数vkCreateInstance(),参数pAllocator是个指向主机内存分配器的指针,该分配器由应用程序提供,用于管理Vulkan系统使用的主机内存。将这个参数设置为nullptr会导致Vulkan系统使用它内置的分配器。在这里先这样设置。应用程序托管的主机内存将会在第2章中讲解。
如果函数vkCreateInstance()成功,会返回VK_SUCCESS,并且会将新实例的句柄放置在变量pInstance里。句柄是用于引用对象的值。Vulkan句柄总是64位宽,与主机系统的位数无关。一旦有了Vulkan实例的句柄,就可以用它调用实例函数了。
1.2.2 Vulkan物理设备
一旦有了实例,就可以查找系统里安装的与Vulkan兼容的设备。Vulkan有两种设备:物理设备和逻辑设备。物理设备通常是系统的一部分——显卡、加速器、数字信号处理器或者其他的组件。系统里有固定数量的物理设备,每个物理设备都有自己的一组固定的功能。
逻辑设备是物理设备的软件抽象,以应用程序指定的方式配置。逻辑设备是应用程序花费大部分时间处理的对象。但是在创建逻辑设备之前,必须查找连接的物理设备。需要调用函数vkEnumeratePhysicalDevices(),其原型如下。
VkResult vkEnumeratePhysicalDevices ( VkInstance instance, uint32_t* pPhysicalDeviceCount, VkPhysicalDevice* pPhysicalDevices);
函数vkEnumeratePhysicalDevices()的第一个参数instance是之前创建的实例。下一个参数pPhysicalDeviceCount是一个指向无符号整型变量的指针,同时作为输入和输出。作为输出,Vulkan将系统里的物理设备数量写入该指针变量。作为输入,它会初始化为应用程序能够处理的设备的最大数量。参数pPhysicalDevices是个指向VkPhysicalDevice句柄数组的指针。
如果你只想知道系统里有多少个设备,将pPhysicalDevices设置为nullptr,这样Vulkan将忽视pPhysicalDeviceCount的初始值,将它重写为支持的设备的数量。可以调用vkEnumerate PhysicalDevices()两次,动态调整VkPhysicalDevice数组的大小:第一次仅将pPhysicalDevices设置为nullptr(尽管pPhysicalDeviceCount仍然必须是个有效的指针),第二次将pPhysicalDevices设置为一个数组(数组的大小已经调整为第一次调用返回的物理设备数量)。
如果调用成功,函数vkEnumeratePhysicalDevices()返回VK_SUCCESS,并且将识别出来的物理设备数量存储进pPhysicalDeviceCount中,还将它们的句柄存储进pPhysicalDevices中。代码清单1.1展示了一个例子:构造结构体VkApplicationInfo和VkInstanceCreateInfo,创建Vulkan实例,查询支持设备的数量,并最终查询物理设备的句柄。这是例子框架里面的vkapp::init的简化版本。
代码清单1.1 创建Vulkan实例
VkResult vkapp::init() { VkResult result = VK_SUCCESS; VkApplicationInfo appInfo = { }; VkInstanceCreateInfo instanceCreateInfo = { }; // 通用的应用程序信息结构体 appInfo.sType = VK_STRUCTURE_TYPE_APPLICATION_INFO; appInfo.pApplicationName = "Application"; appInfo.applicationVersion = 1; appInfo.apiVersion = VK_MAKE_VERSION(1, 0, 0); // 创建实例 instanceCreateInfo.sType = VK_STRUCTURE_TYPE_INSTANCE_CREATE_INFO; instanceCreateInfo.pApplicationInfo = &appInfo; result = vkCreateInstance(&instanceCreateInfo, nullptr, &m_instance); if (result == VK_SUCCESS) { // 首先判断系统里有多少个设备 uint32_t physicalDeviceCount = 0; vkEnumeratePhysicalDevices(m_instance, &physicalDeviceCount, nullptr); if (result == VK_SUCCESS) { // 调整设备数组的大小,并获取物理设备的句柄 m_physicalDevices.resize(physicalDeviceCount); vkEnumeratePhysicalDevices(m_instance, &physicalDeviceCount, &m_physicalDevices[0]); } } return result; }
物理设备句柄用于查询设备的功能,并最终用于创建逻辑设备。第一次执行的查询是vkGet PhysicalDeviceProperties(),该函数会填充描述物理设备所有属性的结构体。其原型如下。
void vkGetPhysicalDeviceProperties ( VkPhysicalDevice physicalDevice, VkPhysicalDeviceProperties* pProperties);
当调用vkGetPhysicalDeviceProperties()时,向参数physicalDevice传递vkEnumeratePhysical Devices()返回的句柄之一,向参数pProperties传递一个指向结构体VkPhysicalDeviceProperties实例的指针。VkPhysicalDeviceProperties是个大结构体,包含了大量描述物理设备属性的字段。其定义如下。
typedef struct VkPhysicalDeviceProperties { uint32_t apiVersion; uint32_t driverVersion; uint32_t vendorID; uint32_t deviceID; VkPhysicalDeviceType deviceType; char deviceName [VK_MAX_PHYSICAL_DEVICE_NAME_SIZE]; uint8_t pipelineCacheUUID[VK_UUID_SIZE]; VkPhysicalDeviceLimits limits; VkPhysicalDeviceSparseProperties sparseProperties; } VkPhysicalDeviceProperties;
字段apiVersion包含了设备支持的Vulkan的最高版本,字段driverVersion包含了用于控制设备的驱动的版本号。这是硬件生产商特定的,所以对比不同的生产商的驱动版本没有任何意义。字段vendorID与deviceID标识了生产商和设备,并且通常是PCI生产商和设备标识符[4]。
字段deviceName包含了可读字符串来命名设备。字段pipelineCacheUUID用于管线缓存,这会在第6章中讲到。
除了刚刚列出的属性之外,结构体VkPhysicalDeviceProperties内嵌了VkPhysicalDeviceLimits和VkPhysicalDeviceSparseProperties,包含了物理设备的最大和最小限制,以及和稀疏纹理有关的属性。这两个结构体里有大量信息,这些字段会在讨论相关特性时介绍,在此不再详述。
除了核心特性(有些有更高的限制或约束)之外,Vulkan还可能有一些物理设备支持的可选特性。如果设备宣传支持某个特性,它必须激活(非常像扩展)。但是一旦激活,这个特性就变成了API的“一等公民”,就像任何核心特性一样。为了判定物理设备支持哪些特性,调用vkGetPhysicalDeviceFeatures()。其原型如下。
void vkGetPhysicalDeviceFeatures ( VkPhysicalDevice physicalDevice, VkPhysicalDeviceFeatures* pFeatures);
结构体vkPhysicalDeviceFeatures也非常大,并且Vulkan支持的每一个可选特性都有一个布尔类型的字段。字段太多,就不在此详细罗列了,但是本章最后展示的例子会读取特性集并输出其内容。
1.2.3 物理设备内存
在许多情况下,Vulkan设备要么是一个独立于主机处理器之外的一块物理硬件,要么工作方式非常不同,以独有的方式访问内存。Vulkan里的设备内存是指,设备能够访问到并且用作纹理和其他数据的后备存储器的内存。内存可以分为几类,每一类都有一套属性,例如缓存标志位以及主机和设备之间的一致性行为。每种类型的内存都由设备的某个堆(可能会有多个堆)进行支持。
为了查询堆配置以及设备支持的内存类型,需要调用以下代码。
void vkGetPhysicalDeviceMemoryProperties ( VkPhysicalDevice physicalDevice, VkPhysicalDeviceMemoryProperties* pMemoryProperties);
查询到的内存组织信息会存储进结构体 VkPhysicalDeviceMemoryProperties中,地址通过pMemoryProperties传入。结构体VkPhysicalDeviceMemoryProperties包含了关于设备的堆以及其支持的内存类型的属性。该结构体的定义如下。
typedef struct VkPhysicalDeviceMemoryProperties { uint32_t memoryTypeCount; VkMemoryType memoryTypes[VK_MAX_MEMORY_TYPES]; uint32_t memoryHeapCount; VkMemoryHeap memoryHeaps[VK_MAX_MEMORY_HEAPS]; } VkPhysicalDeviceMemoryProperties;
内存类型数量包含在字段memoryTypeCount里。可能报告的内存类型的最大数量是VK_MAX_MEMORY_TYPES定义的值,这个宏定义为32。数组memoryTypes包含memoryTypeCount个结构体VkMemoryType对象,每个对象都描述了一种内存类型。VkMemoryType的定义如下。
typedef struct VkMemoryType { VkMemoryPropertyFlags propertyFlags; uint32_t heapIndex; } VkMemoryType;
这是个简单的结构体,只包含了一套标志位以及内存类型的堆栈索引。字段flags描述了内存的类型,并由VkMemoryPropertyFlagBits类型的标志位组合而成。标志位的含义如下。
- VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT意味着内存对于设备来说是本地的(也就是说,物理上是和设备连接的)。如果没有设置这个标志位,可以认为该内存对于主机来说是本地的。
- VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT意味着以这种方式分配的内存可以被主机映射以及读写。如果没有设置这个标志位,那么内存不能被主机直接访问,只能由设备使用。
- VK_MEMORY_PROPERTY_HOST_COHERENT_BIT意味着当这种内存同时被主机和设备访问时,这两个客户之间的访问保持一致。如果没有设置这个标志位,设备或者主机不能看到对方执行的写操作,直到显式地刷新缓存。
- VK_MEMORY_PROPERTY_HOST_CACHED_BIT意味着这种内存里的数据在主机里面进行缓存。对这种内存的读取操作比不设置这个标志位通常要快。然而,设备的访问延迟稍微高一些,尤其当内存也保持一致时。
- VK_MEMORY_PROPERTY_LAZILY_ALLOCATED_BIT意味着这种内存分配类型不一定立即使用关联的堆的空间,驱动可能延迟分配物理内存,直到内存对象用来支持某个资源。
每种内存类型都指定了从哪个堆上使用空间,这由结构体VkMemoryType里的字段heapIndex来标识。这个字段是数组memoryHeaps (在调用vkGetPhysicalDeviceMemoryProperties()返回的结构体VkPhysicalDeviceMemoryProperties里面)的索引。数组memoryHeaps里面的每一个元素描述了设备的一个内存堆。结构体的定义如下。
typedef struct VkMemoryHeap { VkDeviceSize size; VkMemoryHeapFlags flags; } VkMemoryHeap;
同样,这也是个简单的结构体,包含了堆的大小(单位是字节)以及描述这个堆的标识符。在Vulkan 1.0里,唯一定义的标识符是VK_MEMORY_HEAP_DEVICE_LOCAL_BIT。如果定义了这个标识符,堆对于设备来说就是本地的。这对应于以类似方式命名的用于描述内存类型的标识符。
1.2.4 设备队列
Vulkan设备执行提交给队列的工作。每个设备都有一个或者多个队列,每个队列都从属于设备的某个队列族。一个队列族是一组拥有相同功能同时又能并行运行的队列。队列族的数量、每个族的功能以及每个族拥有的队列数量都是物理设备的属性。为了查询设备的队列族,调用vkGetPhysicalDeviceQueueFamilyProperties(),其原型如下。
void vkGetPhysicalDeviceQueueFamilyProperties ( VkPhysicalDevice physicalDevice, uint32_t* pQueueFamilyPropertyCount, VkQueueFamilyProperties* pQueueFamilyProperties);
vkGetPhysicalDeviceQueueFamilyProperties()的运行方式在一定程度上和vkEnumeratePhysical Devices()类似,需要调用前者两次。第一次,将nullptr传递给pQueueFamilyProperties,并给pQueueFamilyPropertyCount传递一个指针,指向表示设备支持的队列族数量的变量。可以使用该值调整VkQueueFamilyProperties类型的数组的大小。接下来,在第二次调用中,将该数组传入pQueueFamilyProperties,Vulkan将会用队列的属性填充该数组。VkQueueFamilyProperties的定义如下。
typedef struct VkQueueFamilyProperties { VkQueueFlags queueFlags; uint32_t queueCount; uint32_t timestampValidBits; VkExtent3D minImageTransferGranularity; } VkQueueFamilyProperties;
该结构体里的第一个字段是queueFlags,描述了队列的所有功能。这个字段由VkQueueFlagBits类型的标志位的组合组成,其含义如下。
- VK_QUEUE_GRAPHICS_BIT 如果设置了,该族里的队列支持图形操作,例如绘制点、线和三角形。
- VK_QUEUE_COMPUTE_BIT如果设置了,该族里的队列支持计算操作,例如发送计算着色器。
- VK_QUEUE_TRANSFER_BIT 如果设置了,该族里的队列支持传送操作,例如复制缓冲区和图像内容。
- VK_QUEUE_SPARSE_BINDING_BIT 如果设置了,该族里的队列支持内存绑定操作,用于更新稀疏资源。
字段queueCount表示族里的队列数量,该值可能是1。如果设备支持具有相同基础功能的多个队列,该值也可能更高。
字段timestampValidBits表示当从队列里取时间戳时,多少位有效。如果这个值设置为0,那么队列不支持时间戳。如果不是0,那么会保证最少支持36位。如果设备的结构体VkPhysicalDeviceLimits里的字段timestampComputeAndGraphics是VK_TRUE,那么所有支持VK_QUEUE_GRAPHICS_BIT或者VK_QUEUE_COMPUTE_BIT的队列都能保证支持36位的时间戳。这种情况下,无须检查每一个队列。
最后,字段minImageTimestampGranularity指定了队列传输图像时支持多少单位(如果有的话)。
注意,有可能出现这种情形,设备报告多个明显拥有相同属性的队列族。一个族里的所有队列实质上都等同。不同族里的队列可能拥有不同的内部功能,而这些不能在Vulkan API里轻易表达。由于这个原因,具体实现可能选择将类似的队列作为不同族的成员。这对资源如何在队列间共享施加了更多限制,这可能允许具体实现接纳这些不同。
代码清单1.2展示了如何查询物理设备的内存属性和队列族属性。需要在创建逻辑设备(在下一节会讲到)之前获取队列族的属性。
代码清单1.2 查询物理设备的属性
uint32_t queueFamilyPropertyCount; std::vector<VkQueueFamilyProperties> queueFamilyProperties; VkPhysicalDeviceMemoryProperties physicalDeviceMemoryProperties; //获取物理设备的内存属性 vkGetPhysicalDeviceMemoryProperties( m_physicalDevices[deviceIndex], &physicalDeviceMemoryProperties); //首先查询物理设备支持的队列族的数量 vkGetPhysicalDeviceQueueFamilyProperties( m_physicalDevices[0], &queueFamilyPropertyCount, nullptr); //为队列属性结构体分配足够的空间 queueFamilyProperties.resize(queueFamilyPropertyCount); //现在查询所有队列族的实际属性 vkGetPhysicalDeviceQueueFamilyProperties( m_physicalDevices[0], &queueFamilyPropertyCount, queueFamilyProperties.data());
1.2.5 创建逻辑设备
在枚举完系统里的所有物理设备之后,应用程序应该选择一个设备,并且针对该设备创建逻辑设备。逻辑设备代表处于初始化状态的设备。在创建逻辑设备时,可以选择可选特性,开启需要的扩展,等等。创建逻辑设备需要调用vkCreateDevice(),其原型如下。
VkResult vkCreateDevice ( VkPhysicalDevice physicalDevice, const VkDeviceCreateInfo* pCreateInfo, const VkAllocationCallbacks* pAllocator, VkDevice* pDevice);
把与逻辑设备相对应的物理设备传给physicalDevice,把关于新的逻辑对象的信息传给结构体VkDeviceCreateInfo的实例pCreateInfo。VkDeviceCreateInfo的定义如下。
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;
字段sType应该设置为VK_STRUCTURE_TYPE_DEVICE_CREATE_INFO。通常,除非你希望使用扩展,否则pNext应该设置为nullptr。Vulkan当前版本没有为字段flags定义标志位,所以将这个字段设置为0。
接下来是队列创建信息。pQueueCreateInfos是指向结构体VkDeviceQueueCreateInfo的数组的指针,每个结构体VkDeviceQueueCreateInfo的对象允许描述一个或者多个队列。数组里的结构体数量由queueCreateInfoCount给定。VkDeviceQueueCreateInfo的定义如下。
typedef struct VkDeviceQueueCreateInfo { VkStructureType sType; const void* pNext; VkDeviceQueueCreateFlags flags; uint32_t queueFamilyIndex; uint32_t queueCount; const float* pQueuePriorities; } VkDeviceQueueCreateInfo;
字段sType设置成VK_STRUCTURE_TYPE_DEVICE_QUEUE_CREATE_INFO。Vulkan当前版本没有为字段flags定义标志位,所以将这个字段设置为0。字段queueFamilyIndex指定了你希望创建的队列所属的族,这是个索引值,与调用vkGetPhysicalDeviceQueueFamilyProperties()返回的队列族的数组对应。为了在这个族里创建队列,将queueCount设置为你希望创建的队列个数。当然,设备在你选择的族中支持的队列数量必须不小于这个值。
字段pQueuePriorities是个可选的指针,指向浮点数数组,表示提交给每个队列的工作的相对优先级。这些数字是个归一化的数字,取值范围是0.0~1.0。给高优先级的队列会分配更多的处理资源或者更频繁地调度它们。将pQueuePriorities设置为nullptr等同于为所有的队列都指定相同的默认优先级。
请求的队列按照优先级排序,并且给它们指定了与设备相关的相对优先级。一个队列能够表示的离散的优先级数量是设备特定的参数。这个参数从结构体VkPhysicalDeviceLimits(调用vkGetPhysicalDeviceProperties()的返回值)里的字段discreteQueuePriorities得到。例如,如果设备只支持高低两种优先级的工作负载,这个字段就是2。所有设备最少支持两个离散的优先级。然而,如果设备支持任意的优先级,这个字段的数值就会非常大。不管discreteQueuePriorities的数值有多大,队列的相对优先级仍然是浮点数。
回到结构体VkDeviceCreateInfo,字段enabledLayerCount、ppEnabledLayerNames、enabled ExtensionCount与ppEnabledExtensionNames用于激活层和扩展。本章后面会讲到这两个主题。现在将enabledLayerCount和enabledExtensionCount设置为0,将ppEnabledLayerNames和ppEnabed ExtensionNames设置为nullptr。
VkDeviceCreateInfo的最后一个字段是pEnabledFeatures,这是个指向结构体VkPhysical DeviceFeatures的实例的指针,这个实例指明了哪些可选扩展是应用程序希望使用的。如果你不想使用任何可选的特性,只需要将它设置为nullptr。当然,这种方式下Vulkan就会相当受限,大量有意思的功能就不能使用了。
为了判断某个设备支持哪些可选的特性,像之前讨论的那样调用vkGetPhysicalDeviceFeatures()即可。vkGetPhysicalDeviceFeatures()将设备支持的特性组写入你传入结构体VkPhysicalDeviceFeatures的实例。查询物理设备的特性并将结构体VkPhysicalDeviceFeatures原封不动地传给vkCreateDevice(),你会激活设备支持的所有可选特性,同时也不会请求设备不支持的特性。
然而,激活所有支持的特性会带来性能影响。对于有些特性,Vulkan具体实现可能需要分配额外的内存,跟踪额外的状态,以不同的方式配置硬件,或者执行其他影响应用程序性能的操作。所以,激活不会使用的特性不是个好主意。你应该查询设备支持的特性,然后激活应用程序需要的特性。
代码清单1.3展示了一个简单的例子,它查询设备支持的特性并设置应用程序需要的功能列表。此处需要支持曲面细分和几何着色器,如果设备支持,就激活多次间接绘制(multidraw indirect),代码接下来使用第一个队列的单一实例创建设备。
代码清单1.3 创建一个逻辑设备
VkResult result; VkPhysicalDeviceFeatures supportedFeatures; VkPhysicalDeviceFeatures requiredFeatures = {}; vkGetPhysicalDeviceFeatures( m_physicalDevices[0], &supportedFeatures); requiredFeatures.multiDrawIndirect = supportedFeatures.multiDrawIndirect; requiredFeatures.tessellationShader = VK_TRUE; requiredFeatures.geometryShader = VK_TRUE; const VkDeviceQueueCreateInfo deviceQueueCreateInfo = { VK_STRUCTURE_TYPE_DEVICE_QUEUE_CREATE_INFO, // sType nullptr, // pNext 0, // flags 0, // queueFamilyIndex 1, // queueCount nullptr // pQueuePriorities }; const VkDeviceCreateInfo deviceCreateInfo = { VK_STRUCTURE_TYPE_DEVICE_CREATE_INFO, // sType nullptr, // pNext 0, // flags 1, // queueCreateInfoCount &deviceQueueCreateInfo, // pQueueCreateInfos 0, // enabledLayerCount nullptr, // ppEnabledLayerNames 0, // enabledExtensionCount nullptr, // ppEnabledExtensionNames &requiredFeatures // pEnabledFeatures }; result = vkCreateDevice( m_physicalDevices[0], &deviceCreateInfo, nullptr, &m_logicalDevice);
在代码清单1.3运行成功并创建逻辑设备之后,启用的特性集合就存储在了变量requiredFeatures里。这可以留待以后用,选择使用某个特性的代码可以检查这个特性是否成功激活并优雅地回退。
1.3 对象类型和函数约定事实上,Vulkan里面的所有东西都表示为对象,这些对象靠句柄引用。句柄可以分为两大类:可调度对象和不可调度对象。在极大程度上,这与应用程序无关,仅仅影响API的构造以及系统级别的组件,例如Vulkan加载器和层如何与这些对象互操作。
可调度对象内部包含了一个调度表,其实就是函数表,在应用程序调用Vulkan时,各种组件据此判断执行哪一部分代码。这些类型的对象通常是重量级的概念,目前有实例(VkInstance)、物理设备(VkPhysicalDevice)、逻辑设备(VkDevice)、命令缓冲区(VkCommandBuffer)和队列(VkQueue)。其他剩余的对象都可以被视为不可调度对象。
任何Vulkan函数的第一个参数总是个可调度对象,唯一的例外是创建和初始化实例的相关函数。
1.4 管理内存Vulkan提供两种内存:主机内存和设备内存。通常,Vulkan API创建的对象需要一定数量的主机内存。Vulkan实现在这里存储对象的状态并实现这个API所需的数据。资源对象(例如缓冲区和图像)需要一定数量的设备内存。这就是用于存储资源里数据的内存。
应用程序有可能为Vulkan具体的实现管理主机内存,但是要求应用程序管理设备内存。因此,需要创建设备内存管理子系统。可以查询创建的每个资源,得到用于支持它的内存的数量和类型。应用程序分配正确数量的内存并在使用资源对象前将它附加在这个对象上。
对于高级API,例如OpenGL,这个功能由驱动程序代替应用程序执行。然而,有的应用程序需要大量的小资源,有的应用程序需要少量非常大的资源。有些应用程序在执行期间创建和销毁资源,而有的在初始化时创建所有的资源,直到程序结束才释放。
这些情况下的分配策略可能相当不同,不存在万全之策。因为OpenGL驱动无法预测应用程序的行为,所以必须调整分配策略,以适应你的使用方式。另一方面,作为应用程序的开发者,你完全知道应用程序的行为。可以将资源分为长期和短期两组。可以将一起使用的资源放入几个池式分配的内存里。你可以决定应用程序使用哪种分配策略。
需要特别注意的是,每次动态内存分配都会在系统上产生开销。因此,尽量少分配对象是非常重要的。推荐做法是,设备内存分配器要分配大块的内存。大量小的资源可以放置在少数几个设备内存块里面。关于设备内存分配器的例子会在第2章中讨论,到时会讨论内存分配里的很多细节。
1.5 Vulkan里的多线程对多线程应用程序的支持是Vulkan设计中不可或缺的一部分。Vulkan通常会假设应用程序能够保证两个线程会在同一个时间修改同一个对象,这称为外部同步。在Vulkan里性能至上的部分(例如构建命令缓冲区)中,绝大部分Vulkan命令根本没有提供同步功能。
为了具体定义各种Vulkan命令中和线程相关的请求,把防止主机同步访问的每一个参数标识为外部同步。在某些情况下,把对象的句柄或者其他的数据内嵌到数据结构体里,包括进数组里,或者通过间接方式传入指令中。那些参数也必须在外部同步。
这么做的目的是Vulkan实现从来不需要在内部使用互斥量或者其他的同步原语来保护数据结构体。这意味着多线程程序很少由于跨线程引起卡顿或者阻塞。
除了在跨线程使用共享对象时要求主机同步访问之外,Vulkan还包含了若干高级特性,专门用来允许多线程执行任务时互不阻塞。这些高级特性如下。
- 主机内存分配可以通过如下方式进行:将一个主机内存分配结构体传入创建对象的函数。通过每个线程使用一个分配器,这个分配器里的数据结构体就不需要保护了。主机内存分配器在第2章中会讲到。
- 命令缓冲区是从内存池中分配的,并且访问内存池是由外部同步的。如果应用程序对每个线程都使用单独的命令池,那么命令缓冲区就可以从池内分配空间,而不会互相造成阻塞。命令缓冲区和池将在第3章里讲到。
- 描述符是从描述符池里的集合分配的。描述符代表了运行在设备上的着色器使用的资源。这将在第6章里讲到。如果每个线程都使用单独的池,描述符集就可以从池中分配,而不会彼此阻塞线程。
- 副命令缓冲区允许大型渲染通道(必须包含在某个命令缓冲区里)里的内容并行产生,然后聚集起来,就像它们是从主命令缓冲区调用的一样。副命令缓冲区会在第13章里讲到。
当你正在编写一个非常简单的单线程应用程序时,创建用于分配对象的内存池就显得冗余了。然而,随着应用程序使用的线程不断增多,为了提高性能,这些对象就必不可少了。
在本书剩下的篇幅中,在讲解命令时,和多线程有关的额外需求都会明确指出来。
1.6 数学概念计算机图形学和大多数异构计算应用程序都严重地依赖数学。大多数Vulkan设备都是基于极其强大的计算处理器的。在本书写作时,即使是很普通的移动处理器也提供了每秒几十亿次浮点运算(GFLOPS)的数据处理能力,而高端台式机和工作站的处理器又提供每秒几万亿次浮点运算(TFLOPS)的数据处理能力。因此,有趣的应用程序构建在数学密集型的着色器之上。另外,Vulkan处理管线中的一些固定功能构建在“硬连接”到设备和规范的数学概念之上。
1.6.1 向量和矩阵在图形程序中最基本的“积木”之一就是向量。不管它代表位置、方向、颜色或者其他量,向量在图形学著作中会从头到尾使用到。向量的一种常用形式是齐次向量,这也是个向量,只不过比它所表示的数值多一个维度。这些向量用于存储投影坐标。用任何标量乘以一个齐次向量会产生一个新的向量,代表了相同的投影坐标。要投影一个点向量,需要每一个元素都除以最后一个元素,这样会产生具有x、y、z和1.0(如果是4个元素的向量)这类形式的向量。
如果要将一个向量从一个坐标空间变换到另一个,需要将这个向量乘以一个矩阵。因为3D空间里的点由具有4个元素的齐次向量表示,所以变换矩阵就应该是4×4的矩阵。
3D空间里的点由齐次向量表示,按照惯例,里面的4个元素分别是x、y、z和w。对于一个点来说,成员w一般来说最开始是1.0,与投影变换矩阵相乘以后就改变了。在除以w之后,这个点就经历了所有的变换,完成了投影变换。如果变换矩阵里没有投影变换矩阵,w仍然是1.0,除以1.0对向量来说没有任何影响。如果向量经过透视变换,w就不等于1.0了,但是使用这个透视变换矩阵除以向量以后,w就由变成1.0了。
同时,3D空间里的方向也由齐次向量来表示,只是w是0.0。如果用正确构造的4×4投影变换矩阵乘以方向向量,w值仍是0.0,这样不会对其他元素产生影响。只需要丢弃额外的元素,你就能像4D齐次3D点向量那样,让3D方向向量经历同样的变换,使它经过同样的旋转、缩放和其他的变换。
1.6.2 坐标系
Vulkan通过将端点或者拐角表示成3D空间里的点,来表示基本图元,例如线和三角形。这些基本单位称为顶点。输入Vulkan系统的3D坐标系空间(表示为w元素是1.0的齐次向量)里的顶点坐标,这些顶点坐标是相对于当前对象的原点的数值。这个坐标空间称为对象空间或者模型空间。
一般情况下,管线里的第一个着色器会将这个顶点变换到观察空间中,也就是相对于观察者的位置。这个变换操作是通过用一个变换矩阵乘以这个顶点的位置向量实现的。这个矩阵通常称为对象-视图变换矩阵,或者模型-视图变换矩阵。
有时候,需要顶点的绝对坐标,例如查找某个顶点相对于其他对象的距离。这个全局空间称为世界空间,是顶点位置相对于全局原点的位置。
从观察坐标系出来后,把顶点位置变换到裁剪空间。这是Vulkan中几何处理部分的最后一个空间,也是当把顶点推送进3D应用程序使用的投影空间时,这些顶点变换进的空间。把这个空间称为裁剪空间是因为在这个空间里大多数实现都执行裁剪操作,也就是渲染的可见区域之外的图元部分都会被移除。
从裁剪空间出来后,顶点位置通过除以w归一化。这样就产生了一个新的坐标空间,叫作标准化设备坐标(NDC)。而这个操作通常称为透视除法。在这个空间里,在x和y两个方向上坐标系上的可见部分是−1.0~1.0,z方向上是0.0~1.0。这个区域之外的任何东西都会在透视除法之前被剔除掉。
最终,顶点的标准化设备坐标由视口变换矩阵进行变换,这个变换矩阵描述了NDC如何映射到正在被渲染的窗口或者图像中。
1.7 增强Vulkan尽管Vulkan的核心API的设计规范相当丰富,但绝不是包罗万象的。有些功能是可选的,而更多的是以层(修改或者增强了现有的行为)和扩展(增加了Vulkan的新功能)的形式使用的。两种增强机制在下面会讲到。
1.7.1 层
层是Vulkan中的一种特性,允许修改它的行为。通常,层完全或者部分拦截Vulkan,并增加新的功能,例如日志、追踪、诊断、性能分析等。层可以添加到实例层面,这样,它会影响整个Vulkan实例,也有可能影响由实例创建的每个设备。或者,层可以添加到设备层面中,这样,它仅仅会影响激活这个层的设备。
为了查询系统里的实例可用的层,调用vkEnumerateInstanceLayerProperties(),其原型如下。
VkResult vkEnumerateInstanceLayerProperties ( uint32_t* pPropertyCount, VkLayerProperties* pProperties);
如果pProperties是nullptr,那么pPropertyCount应该指向一个变量,用于接收Vulkan可用的层的数量。如果pProperties不是nullptr,那么它应该指向结构体VkLayerProperties类型的数组,会向这个数组填充关于系统里注册的层的信息。这种情况下,pPropertyCount指向的变量的初始值是pProperties 指向的数组的长度,并且这个变量会被重写成数组里由指令重写的条目数。
数组pProperties 里的每个元素都是结构体VkLayerProperties的实例,其定义如下。
typedef struct VkLayerProperties { char layerName[VK_MAX_EXTENSION_NAME_SIZE]; uint32_t specVersion; uint32_t implementationVersion; char description[VK_MAX_DESCRIPTION_SIZE]; } VkLayerProperties;
每一个层都有个正式的名字,存储在结构体VkLayerProperties里的成员layerName中。每个层的规范都可能不断改进,进一步明晰,或者添加新功能,层实现的版本号存储在specVersion中。
随着规范不断改进,具体实现也需要不断改进。具体实现的版本号存储在结构体VkLayer Properties的字段implementationVersion里。这样就允许改进性能,修正Bug,实现更丰富的可选特性集,等等。应用程序作者可能识别出某个层的特定实现,并选择使用它,只要这个实现的版本号超过了某个版本(例如,后一个版本有个已知的严重Bug需要修复)。
最终,描述层的可读字符串存储在description中。这个字段的唯一目的是输出日志,或者在用户界面展示,仅仅用作提供信息。
代码清单1.4演示了如何查询Vulkan系统支持的实例层。
代码清单1.4 查询实例层
uint32_t numInstanceLayers = 0; std::vector<VkLayerProperties> instanceLayerProperties; //查询实例层 vkEnumerateInstanceLayerProperties( &numInstanceExtensions, nullptr); //如果有支持的层,查询它们的属性 if (numInstanceLayers != 0) { instanceLayerProperties.resize(numInstanceLayers); vkEnumerateInstanceLayerProperties( nullptr, &numInstanceLayers, instanceLayerProperties.data()); }
如前所述,不但可以在实例层面注入层,而且可以应用在设备层面应用层。为了检查哪些层是设备可用的,调用vkEnumerateDeviceLayerProperties(),其原型如下。
VkResult vkEnumerateDeviceLayerProperties ( VkPhysicalDevice physicalDevice, uint32_t* pPropertyCount, VkLayerProperties* pProperties);
因为系统里的每个物理设备可用的层可能不一样,所以每个物理设备可能报告出一套不同的层。需要查询可用层的物理设备通过physicalDevice传入。传入vkEnumerateDeviceLayerProperties()的参数pPropertyCount和pProperties的行为与传入vkEnumerateInstanceLayerProperties()的相似。设备层也由结构体VkLayerProperties的实例描述。
为了在实例层面激活某个层,需要将其名字包含在结构体VkInstanceCreateInfo的字段ppEnabledLayerNames里,这个结构体用于创建实例。同样,为了在创建对应系统里的某个物理设备的逻辑设备时激活某个层,需要将这个层的名字包含在结构体VkDeviceCreateInfo的成员ppEnabledLayerNames里,这个结构体用于创建设备。
官方SDK包含若干个层,大部分与调试、参数验证和日志有关。具体内容如下。
- VK_LAYER_LUNARG_api_dump 将Vulkan的函数调用以及参数输出到控制台。
- VK_LAYER_LUNARG_core_validation 执行对用于描述符集、管线状态和动态状态的参数和状态的验证;验证SPIR-V模块和图形管线之间的接口;跟踪和验证用于支持对象的GPU内存的使用。
- VK_LAYER_LUNARG_device_limits 保证作为参数或者数据结构体成员传入Vulkan的数值处于设备支持的特性集范围内。
- VK_LAYER_LUNARG_image 验证图像使用和支持的格式是否相一致。
- VK_LAYER_LUNARG_object_tracker 执行Vulkan对象追踪,捕捉内存泄漏、释放后使用的错误以及其他的无效对象使用。
- VK_LAYER_LUNARG_parameter_validation 确认所有传入Vulkan函数的参数值都有效。
- VK_LAYER_LUNARG_swapchain 执行WSI(Window System Integration,这将在第5章中讲解)扩展提供的功能的验证。
- VK_LAYER_GOOGLE_threading 保证Vulkan命令在涉及多线程时有效使用,保证两个线程不会同时访问同一个对象(如果这种操作不允许的话)。
- VK_LAYER_GOOGLE_unique_objects 确保每个对象都有一个独一无二的句柄,以便于应用程序追踪状态,这样能避免下述情况的发生:某个实现可能删除代表了拥有相同参数的对象的句柄。
除此之外,把大量不同的层分到单个更大的层中,这个层名叫VK_LAYER_LUNARG_standard_validation,这样就很容易开启了。本书的应用程序框架在调试模式下编译时激活了这个层,而在发布模式下关闭了所有的层。
1.7.2 扩展
对于任何跨平台的开放式API(例如Vulkan),扩展都是最根本的特性。这些扩展允许实现者不断试验、创新并且最终推动技术进步。有用的特性最初作为扩展出现,经过实践证明后,最终变成API的未来版本。然而,扩展并不是没有开销的。有些扩展可能要求具体实现跟踪额外的状态,在命令缓冲区构建时进行额外的检查,或者即使扩展没有直接使用,也会带来性能损失。因此,扩展在使用前必须被应用程序显式启用。这意味着,应用程序如果不使用某个扩展就不需要为此付出增加性能开销和提高复杂性的代价。这也意味着,不会出现意外使用某个扩展的特性,这可以改善可移植性。
扩展可以分为两类:实例扩展和设备扩展。实例扩展用于在某个平台上整体增强Vulkan系统。这种扩展或者通过设备无关的层提供,或者只是每个设备都暴露出来并提升进实例的扩展。设备扩展用于扩展系统里一个或者多个设备的能力,但是这种能力没必要每个设备都具备。
每个扩展都可以定义新的函数、类型、结构体、枚举,等等。一旦激活,就可以认为这个扩展是API的一部分,对应用程序可用。实例和设备扩展必须在创建Vlukan实例与设备时激活。这导致了“鸡和蛋”的悖论:在初始化Vulkan实例之前我们怎么知道哪些扩展可用?
Vulkan实例创建之前,只有少数的函数可用,查询支持的实例扩展是其中一个。通过调用函数vkEnumerateInstanceExtensionProperties()来执行这个操作,其原型如下。
VkResult vkEnumerateInstanceExtensionProperties ( const char* pLayerName, uint32_t* pPropertyCount, VkExtensionProperties* pProperties);
字段pLayerName是可能提供扩展的层的名字,目前将这个字段设置为nullptr。pPropertyCount指向一个变量,用于存储从Vulkan查询到的实例扩展的数量,pProperties是个指向结构体VkExtensionProperties类型的数组的指针,会向这个数组中填充支持的扩展的信息。如果pProperties是nullptr,那么pPropertyCount指向的变量的初始值就会被忽略,并重写为支持的实例扩展的数量。
如果pProperties不是nullptr,那么数组里的条目数量就是pPropertyCount指向的变量的值,此时,数组里的条目会被填充为支持的扩展的信息。pPropertyCount指向的变量会重写为实际填充到pProperties 的条目的数量。
为了正确查询所有支持的实例扩展,调用vkEnumerateInstanceExtensionProperties()两次。第一次调用时,将pProperties设置为nullptr,以获取支持的实例扩展的数量。接着正确调整接收扩展属性的数组的大小,并再次调用vkEnumerateInstanceExtensionProperties(),这一次用pProperties传入数组的地址。代码清单1.5展示了如何操作。
代码清单1.5 查询实例扩展
uint32_t numInstanceExtensions = 0; std::vector<VkExtensionProperties> instanceExtensionProperties; //查询实例扩展 vkEnumerateInstanceExtensionProperties( nullptr, &numInstanceExtensions, nullptr); //如果有支持的扩展,查询它们的属性 if (numInstanceExtensions != 0) { instanceExtensionProperties.resize(numInstanceExtensions); vkEnumerateInstanceExtensionProperties( nullptr, &numInstanceExtensions, instanceExtensionProperties.data()); }
在代码清单1.5执行后,instanceExtensionProperties就包含了实例支持的扩展列表。VkExtension Properties类型的数组的每个元素描述了一个扩展。VkExtensionProperties的定义如下。
typedef struct VkExtensionProperties { char extensionName[VK_MAX_EXTENSION_NAME_SIZE]; uint32_t specVersion; } VkExtensionProperties;
结构体VkExtensionProperties仅仅包含扩展名和版本号。扩展可能随着新的修订版的推出增加新的功能。字段specVersion允许在扩展中增加新的小功能,而无须创建新的扩展。扩展的名字存储在extensionName里面。
就像你之前看到的,当创建Vulkan实例时,结构体VkInstanceCreateInfo有一个名叫ppEnabled ExtensionNames的成员,这个指针指向一个用于命名需要激活的扩展的字符串数组。如果某个平台上的Vulkan系统支持某个扩展,这个扩展就会包含在vkEnumerateInstanceExtensionProperties()返回的数组里,然后它的名字就可以通过结构体VkInstanceCreateInfo里的字段ppEnabledExtension Names传递给vkCreateInstance()。
查询支持的设备扩展是个相似的过程,需要调用函数vkEnumerateDeviceExtensionProperties(),其原型如下。
VkResult vkEnumerateDeviceExtensionProperties ( VkPhysicalDevice physicalDevice, const char* pLayerName, uint32_t* pPropertyCount, VkExtensionProperties* pProperties);
vkEnumerateDeviceExtensionProperties()的原型和vkEnumerateInstanceExtensionProperties()几乎一样,只是多了一个参数physicalDevice。参数physicalDevice是需要查询扩展的设备的句柄。就像vkEnumerateInstanceExtensionProperties()一样,如果pProperties是nullptr,vkEnumerateDevice ExtensionProperties()将pPropertyCount重写成支持的扩展的数量;如果pProprties不是nullptr,就用支持的扩展的信息填充这个数组。结构体VkExtensionProperties同时用于实例扩展和设备扩展。
当创建逻辑设备时,结构体VkDeviceCreateInfo里的字段ppEnabledExtensionNames可能包含一个指针,指向vkEnumerateDeviceExtensionProperties()返回的字符串中的一个。
有些扩展以可以调用的额外入口点的形式提供了新的功能。这些以函数指针的形式提供,这些指针必须在扩展激活后从实例或者设备中查询。实例函数对整个实例有效。如果某个扩展扩充了实例层面的功能,你应该使用实例层面的函数指针访问新特性。
为了获取实例层面的函数指针,调用vkGetInstanceProcAddr(),其原型如下。
PFN_vkVoidFunction vkGetInstanceProcAddr ( VkInstance instance, const char* pName);
参数instance是需要获取函数指针的实例的句柄。如果应用程序使用了多个Vulkan实例,那么这个指令返回的函数指针只对引用的实例所拥有的对象有效。函数名通过pName传入,这是个以nul结尾的UTF-8类型的字符串。如果识别了函数名并且激活了这个扩展,vkGetInstance ProcAddr()的返回值是一个函数指针,可以在应用程序里调用。
PFN_vkVoidFunction是个函数指针定义,其声明如下。
VKAPI_ATTR void VKAPI_CALL vkVoidFunction(void);
Vulkan里没有这种特定签名的函数,扩展也不太可能引入这样的函数。绝大部分情况下,需要在使用前将生成的函数指针类型强制转换为有正确签名的函数指针。
实例层面的函数指针对这个实例所拥有的所有对象都有效——假如创建这些对象(或者设备本身,如果函数在这个设备上调度)的设备支持这个扩展,并且这个设备激活了这个扩展。由于每个设备可能在不同的Vulkan驱动里实现,因此实例函数指针必须通过一个间接层登录正确的模块进行调度。因为管理这个间接层可能引起额外开销,所以为了避免这个开销,你可以获取一个特定于设备的函数指针,这样可以直接进入正确的驱动。
为了获取设备层面的函数指针,调用vkGetDeviceProcAddr(),其原型如下。
PFN_vkVoidFunction vkGetDeviceProcAddr ( VkDevice device, const char* pName);
使用函数指针的设备通过参数device传入。需要查询的函数的名字需要使用pName传入,这是个以nul 结尾的UTF-8类型的字符串。返回的函数指针只在参数device指定的设备上有效。device必须指向支持这个扩展(提供了这个新函数)的设备,并且这个扩展已经激活。
vkGetDeviceProcAddr()返回的函数指针特定于参数device。即使同样的物理设备使用同样的参数创建出了多个逻辑设备,你也只能在查询这个函数指针的逻辑设备上使用该指针。
1.8 彻底地关闭应用程序在程序结束之前,你需要自己负责清理干净。在许多情况下,操作系统会在应用程序结束时清理已经创建的资源。然而,应用程序和代码同时结束的情景并不经常出现。比如你正在写一个大型应用程序的组件,应用程序可能结束了使用Vulkan实现的渲染和计算操作,但是并没有完全退出。
在清除时,通常来说,较好的做法如下。
- 完成或者终结应用程序正在主机和设备上、Vulkan相关的所有线程里所做的所有工作。
- 按照创建对象的时间逆序销毁对象。
逻辑设备很可能是初始化应用程序时创建的最后一个对象(除了运行时使用的对象之外)。在销毁设备之前,需要保证它没有正在执行来自应用程序的任何工作。为了达到这个目的,调用vkDeviceWaitIdle(),其原型如下。
VkResult vkDeviceWaitIdle ( VkDevice device);
把设备的句柄传入device。当vkDeviceWaitIdle()返回时,所有提交给设备的工作都保证已经完成了——当然,除非同时你继续向设备提交工作。需要保证其他可能向设备提交工作的线程已经终止了。
一旦确认了设备处于空闲状态,就可以安全地销毁它了。这需要调用vkDestroyDevice(),其原型如下。
void vkDestroyDevice ( VkDevice device, const VkAllocationCallbacks* pAllocator);
把需要销毁的设备的句柄传递给参数device,并且访问该设备需要在外部同步。需要注意的是,其他指令对设备的访问都不需要外部同步。然而,应用程序需要保证当访问该设备的其他指令正在另一个线程里执行时,这个设备不要销毁。
pAllocator应该指向一个分配的结构体,该结构体需要与创建设备的结构体兼容。一旦设备对象被销毁了,就不能继续向它提交指令了。进一步说,设备句柄就不可能再作为任何函数的参数了,包括其他将设备句柄作为第一个参数的对象销毁方法。这是应该按照创建对象的时间逆序来销毁对象的另一个原因。
一旦与Vulkan实例相关联的所有设备都销毁了,销毁实例就安全了。这是通过调用函数vkDestroyInstance()实现的,其原型如下。
void vkDestroyInstance ( VkInstance instance, const VkAllocationCallbacks* pAllocator);
将需要销毁的实例的句柄传给instance,与vkDestroyDevice()一样,与创建实例使用的分配结构体相兼容的结构体的指针应该传递给pAllocator。如果传递给vkCreateInstance()的参数pAllocator是nullptr,那么传递给vkDestroyInstance()的参数pAllocator也应该是这样。
需要注意的是,物理设备不用销毁。物理设备并不像逻辑设备那样由一个专用的创建函数来创建。相反,物理设备通过调用vkEnumeratePhysicalDevices()来获取,并且属于实例。因此,当实例销毁后,和每个物理设备相关的实例资源也都销毁了。
1.9 总结本章介绍了Vulkan。你已看到了Vulkan状态整体上如何包含在一个实例里。实例提供了访问物理设备的权限,每个物理设备提供了一些用于执行工作的队列。本章还演示了如何根据物理设备创建逻辑设备,如何扩展Vulkan,如何判断实例,设备能用哪些扩展,以及如何启用这些扩展。最后还演示了如何彻底地关闭Vulkan系统,操作顺序依次是等待设备完成应用程序提交,销毁设备句柄,销毁实例句柄。
[1] 是的,确实是nul。字面量为零的ASCII字符被官方称为NUL。现在,不要再告诉我应该改成NULL。这是个指针,不是字符的名字。
[2] 对于一个程序来说是最好的,但在另一个程序中就未必如此。另外,程序是由人编写的,人在写代码时就会有Bug。为了完全优化,或者消除应用程序的Bug,驱动有时候会使用可执行文件的名字,甚至使用应用程序的行为来猜测正在哪个应用程序上运行,并相应地改变行为。虽然并不完美,但这个新的机制至少消除了猜测。
[3] 和OpenGL一样,Vulkan支持将扩展作为API的中心部分。然而,在OpenGL里,我们会创建一个运行上下文,查询支持的扩展,然后开始使用它们。这意味着,驱动需要假设应用程序可能在任何时候突然开始使用某个扩展,并随时准备好。另外,驱动不可能知道你正在查找哪些扩展,这一点更加重了这个过程的困难程度。在Vulkan里,要求应用程序选择性地加入扩展,并显式地启用它们。这允许驱动关闭没有使用的扩展,这也使得应用程序突然开始使用本没有打算启用的扩展中的部分功能变得更加困难。
[4] 并没有关于PCI厂商或者设备标识符的官方的中央版本库。PCI SIG(可从pcisig网站获取)将厂商标识符指定给了它的成员,这些成员又将设备标识符指定给了它们的产品。人和机器同时可读的清单可从pcidatabase网站获取。
本文摘自《Vulkan 应用开发指南》
《Vulkan 应用开发指南》
[美] 格拉汉姆·塞勒斯(Graham Sellers) 著,李晓波 等 译
下一代OpenGL规范已经重新进行了设计,从而使得应用程序可以直接控制GPU的加速。本书系统地介绍下一代OpenGL规范Vulkan、它的目标以及构建其API的关键概念,揭示了Vulkan的独特性。
本书讨论的主题非常宽泛,从绘图命令到内存,再到计算着色器的线程。本书重点展示了如何处理现在由开发人员负责的同步、调度和内存管理等任务。本书是Vulkan开发人员的指南和参考手册,有助于读者迅速掌握跨平台图形的下一代规范。你将从本书中学习到可用于从视频游戏到医学成像等领域的3D开发技术,以及解决复杂的科学计算问题的先进方法。
本书主要内容
- 大量经过反复测试的代码示例,用于演示Vulkan的功能并展示它与OpenGL的区别。
- Vulkan中的新内存系统。
- 队列、命令和移动数据的方法。
- SPIR-V二进制着色语言和计算/图形管道。
- 绘图命令、几何处理、片段处理、同步原语,以及将Vulkan数据读入应用程序。
- 完整的案例研究应用程序:使用复杂的多通道架构和多个处理队列的延迟渲染。
- Vulkan函数和SPIR-V操作码,以及完整的Vulkan词汇表。
免责声明:本文仅代表文章作者的个人观点,与本站无关。其原创性、真实性以及文中陈述文字和内容未经本站证实,对本文以及其中全部或者部分内容文字的真实性、完整性和原创性本站不作任何保证或承诺,请读者仅作参考,并自行核实相关内容。文章投诉邮箱:anhduc.ph@yahoo.com