Vulkan是什么?和我一起完成一个简单的Vulkan应用程序

举报
Tom forever 发表于 2019/09/04 15:43:53 2019/09/04
【摘要】 在本章,你将学到:Vulkan以及它背后的基本原理;如何创建一个最简单的Vulkan应用程序;在本书其余部分将使用到的术语和概念。本章将介绍并解释Vulkan是什么。我们会介绍API背后的基本概念,包括初始化、对象生命周期、Vulkan实例以及逻辑和物理设备。在本章的最后,我们会完成一个简单的Vulkan应用程序,这个程序可以初始化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高得多的性能。

1.2 实例、设备和队列

Vulkan包含了一个层级化的功能结构,从顶层开始是实例,实例聚集了所有支持Vulkan的设备。每个设备提供了一个或者多个队列,这些队列执行应用程序请求的工作。

物理设备通常表示一个单独的硬件或者互相连接的一组硬件。在任何系统里,都有一些数量固定的物理设备,除非这个系统支持重新配置,例如热插拔。由实例创建的逻辑设备是一个与物理设备相关的软件概念,表示与某个特定物理设备相关的预定资源,其中包括了物理设备上可用队列的一个子集。可以通过创建多个逻辑设备来表示一个物理设备,应用程序花大部分时间与逻辑设备交互。

图1.1展示了这个层级关系。图1.1中,应用程序创建了两个Vulkan实例。系统里的3个物理设备能够被这两个实例使用。经过枚举,应用程序在第一个物理设备上创建了一个逻辑设备,在第二个物理设备创建了两个逻辑设备,在第三个物理设备上创建了一个逻辑设备。每个逻辑设备启用了对应物理设备队列的不同子集。在实际开发中,大多数Vulkan应用程序不会这么复杂,而会针对系统里的某个物理设备只创建一个逻辑设备,并且使用一个实例。图1.1仅仅用来展示Vulkan的复杂性。

0101{60%}

图1.1 Vulkan里关于实例、设备和队列的层级关系

后面的小节将讨论如何创建Vulkan实例,如何查询系统里的物理设备,并将一个逻辑设备关联到某个物理设备上,最后获取设备提供的队列句柄。

1.2.1 Vulkan实例

Vulkan可以被看作应用程序的子系统。一旦应用程序连接了Vulkan库并初始化,Vulkan就会追踪一些状态。因为Vulkan并不向应用程序引入任何全局状态,所以所有追踪的状态必须存储在你提供的一个对象里。这就是实例对象,由VkInstance对象来表示。为了构建这个对象,我们会调用第一个Vulkan函数vkCreateInstance(),其原型如下。

cvvvvvvvvvvv.png

该声明是个典型的Vulkan函数:把多个参数传入Vulkan,函数通常接收结构体的指针。这里,pCreateInfo是指向结构体VkInstanceCreateInfo的实例的指针。这个结构体包含了用来描述新的Vulkan实例的参数,其定义如下。

gggggggggggggggggggg.png

几乎每一个用于向API传递参数的Vulkan结构体的第一个成员都是字段sType,该字段告诉Vulkan这个结构体的类型是什么。核心API以及任何扩展里的每个结构体都有一个指定的结构体标签。通过检查这个标签,Vulkan工具、层和驱动可以确定结构体的类型,用于验证以及在扩展里使用。另外,字段pNext允许将一个相连的结构体链表传入函数。这样在一个扩展中,允许对参数集进行扩展,而不用将整个核心结构体替换掉。因为这里使用了核心的实例创建结构体,将字段sType设置为VK_STRUCTURE_TYPE_INSTANCE_CREATE_INFO,并且将pNext设置为nullptr。

字段flags留待将来使用,应该设置为0。下一个字段pApplicationInfo是个可选的指针,指向另一个描述应用程序的结构体。可以将它设置为nullptr,但是推荐填充为有用的信息。pApplicationInfo指向结构体VkApplicationInfo的一个实例,其定义如下。

dddddddddddddddddd.png

我们再一次看到了字段sType和pNext。SType 应该设置为VK_STRUCTURE_TYPE_APPLICATION_INFO,并且可以将pNext设置为nullptr。pApplicationName是个指针,指向以nul为结尾的字符串[1],这个字符串用于包含应用程序的名字。applicationVersion是应用程序的版本号。这样就允许工具和驱动决定如何对待应用程序,而不用猜测[2]哪个应用程序正在运行。同样,pEngineName与engineVersion也分别包含了引擎或者中间件(应用程序基于此构建)的名字和版本号。

最后,apiVersion包含了应用程序期望运行的Vulkan API的版本号。这个应该设置为你期望应用程序运行所需的Vulkan的绝对最小版本号——并不是你安装的头文件中的版本号。这样允许更多设备和平台运行应用程序,即使并不能更新它们的Vulkan实现。

1.2.2 Vulkan物理设备

一旦有了实例,就可以查找系统里安装的与Vulkan兼容的设备。Vulkan有两种设备:物理设备和逻辑设备。物理设备通常是系统的一部分——显卡、加速器、数字信号处理器或者其他的组件。系统里有固定数量的物理设备,每个物理设备都有自己的一组固定的功能。

逻辑设备是物理设备的软件抽象,以应用程序指定的方式配置。逻辑设备是应用程序花费大部分时间处理的对象。但是在创建逻辑设备之前,必须查找连接的物理设备。需要调用函数vkEnumeratePhysicalDevices(),其原型如下。

VkResult vkEnumeratePhysicalDevices (    VkInstance                           instance,    uint32_t*                            pPhysicalDeviceCount,    VkPhysicalDevice*                    pPhysicalDevices);

函数vkEnumeratePhysicalDevices()的第一个参数instance是之前创建的实例。下一个参数pPhysicalDeviceCount是一个指向无符号整型变量的指针,同时作为输入和输出。作为输出,Vulkan将系统里的物理设备数量写入该指针变量。作为输入,它会初始化为应用程序能够处理的设备的最大数量。参数pPhysicalDevices是个指向VkPhysicalDevice句柄数组的指针。

如果调用成功,函数vkEnumeratePhysicalDevices()返回VK_SUCCESS,并且将识别出来的物理设备数量存储进pPhysicalDeviceCount中,还将它们的句柄存储进pPhysicalDevices中。代码清单1.1展示了一个例子:构造结构体VkApplicationInfo和VkInstanceCreateInfo,创建Vulkan实例,查询支持设备的数量,并最终查询物理设备的句柄。这是例子框架里面的vkapp::init的简化版本。

代码清单1.1 创建Vulkan实例

kkkkkkkkkkk.png

物理设备句柄用于查询设备的功能,并最终用于创建逻辑设备。第一次执行的查询是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章中讲到。

除了核心特性(有些有更高的限制或约束)之外,Vulkan还可能有一些物理设备支持的可选特性。如果设备宣传支持某个特性,它必须激活(非常像扩展)。但是一旦激活,这个特性就变成了API的“一等公民”,就像任何核心特性一样。为了判定物理设备支持哪些特性,调用vkGetPhysicalDeviceFeatures()。其原型如下。

void vkGetPhysicalDeviceFeatures (    VkPhysicalDevice                 physicalDevice,    VkPhysicalDeviceFeatures*        pFeatures);

结构体vkPhysicalDeviceFeatures也非常大,并且Vulkan支持的每一个可选特性都有一个布尔类型的字段。字段太多,就不在此详细罗列了,但是本章最后展示的例子会读取特性集并输出其内容。

1.2.3 物理设备内存

为了查询堆配置以及设备支持的内存类型,需要调用以下代码。

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类型的标志位组合而成。标志位的含义如下。

每种内存类型都指定了从哪个堆上使用空间,这由结构体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 查询物理设备的属性

ppppppppppppp.png

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 创建一个逻辑设备

qqqqqqqqqq.png

在代码清单1.3运行成功并创建逻辑设备之后,启用的特性集合就存储在了变量requiredFeatures里。这可以留待以后用,选择使用某个特性的代码可以检查这个特性是否成功激活并优雅地回退。ttttttttttttttttttt.png

1.5 Vulkan里的多线程

对多线程应用程序的支持是Vulkan设计中不可或缺的一部分。Vulkan通常会假设应用程序能够保证两个线程会在同一个时间修改同一个对象,这称为外部同步。在Vulkan里性能至上的部分(例如构建命令缓冲区)中,绝大部分Vulkan命令根本没有提供同步功能。

为了具体定义各种Vulkan命令中和线程相关的请求,把防止主机同步访问的每一个参数标识为外部同步。在某些情况下,把对象的句柄或者其他的数据内嵌到数据结构体里,包括进数组里,或者通过间接方式传入指令中。那些参数也必须在外部同步。

这么做的目的是Vulkan实现从来不需要在内部使用互斥量或者其他的同步原语来保护数据结构体。这意味着多线程程序很少由于跨线程引起卡顿或者阻塞。

除了在跨线程使用共享对象时要求主机同步访问之外,Vulkan还包含了若干高级特性,专门用来允许多线程执行任务时互不阻塞。这些高级特性如下。

  • 主机内存分配可以通过如下方式进行:将一个主机内存分配结构体传入创建对象的函数。通过每个线程使用一个分配器,这个分配器里的数据结构体就不需要保护了。主机内存分配器在第2章中会讲到。

  • 命令缓冲区是从内存池中分配的,并且访问内存池是由外部同步的。如果应用程序对每个线程都使用单独的命令池,那么命令缓冲区就可以从池内分配空间,而不会互相造成阻塞。命令缓冲区和池将在第3章里讲到。

  • 描述符是从描述符池里的集合分配的。描述符代表了运行在设备上的着色器使用的资源。这将在第6章里讲到。如果每个线程都使用单独的池,描述符集就可以从池中分配,而不会彼此阻塞线程。

  • 副命令缓冲区允许大型渲染通道(必须包含在某个命令缓冲区里)里的内容并行产生,然后聚集起来,就像它们是从主命令缓冲区调用的一样。副命令缓冲区会在第13章里讲到。

当你正在编写一个非常简单的单线程应用程序时,创建用于分配对象的内存池就显得冗余了。然而,随着应用程序使用的线程不断增多,为了提高性能,这些对象就必不可少了。

在本书剩下的篇幅中,在讲解命令时,和多线程有关的额外需求都会明确指出来。asolidvhrwofvj;.png

有时候,需要顶点的绝对坐标,例如查找某个顶点相对于其他对象的距离。这个全局空间称为世界空间,是顶点位置相对于全局原点的位置。

从观察坐标系出来后,把顶点位置变换到裁剪空间。这是Vulkan中几何处理部分的最后一个空间,也是当把顶点推送进3D应用程序使用的投影空间时,这些顶点变换进的空间。把这个空间称为裁剪空间是因为在这个空间里大多数实现都执行裁剪操作,也就是渲染的可见区域之外的图元部分都会被移除。

从裁剪空间出来后,顶点位置通过除以w归一化。这样就产生了一个新的坐标空间,叫作标准化设备坐标(NDC)。而这个操作通常称为透视除法。在这个空间里,在xy两个方向上坐标系上的可见部分是−1.0~1.0,z方向上是0.0~1.0。这个区域之外的任何东西都会在透视除法之前被剔除掉。

最终,顶点的标准化设备坐标由视口变换矩阵进行变换,这个变换矩阵描述了NDC如何映射到正在被渲染的窗口或者图像中。assasaasaassaas.png

代码清单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);

oooooooooooooooooooo.png

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 查询实例扩展

1.png

在代码清单1.5执行后,instanceExtensionProperties就包含了实例支持的扩展列表。VkExtension Properties类型的数组的每个元素描述了一个扩展。VkExtensionProperties的定义如下。

2.png

结构体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(),其原型如下。

5.png

参数instance是需要获取函数指针的实例的句柄。如果应用程序使用了多个Vulkan实例,那么这个指令返回的函数指针只对引用的实例所拥有的对象有效。函数名通过pName传入,这是个以nul结尾的UTF-8类型的字符串。如果识别了函数名并且激活了这个扩展,vkGetInstance ProcAddr()的返回值是一个函数指针,可以在应用程序里调用。

PFN_vkVoidFunction是个函数指针定义,其声明如下。

VKAPI_ATTR void VKAPI_CALL vkVoidFunction(void);

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系统,操作顺序依次是等待设备完成应用程序提交,销毁设备句柄,销毁实例句柄。




本文转载自异步社区。

文链接:https://www.epubit.com/articleDetails?id=ef5196cecfb74bbbb4e52791a903868d


【版权声明】本文为华为云社区用户转载文章,如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。