[Vulkan教程]绘制一个三角形/呈现/交换链(Swip chain)

Vulkan没有默认缓冲区的概念,因此需要一个东西提供一个缓冲区来让我们渲染,然后我们才能在屏幕上看到画面。这个东西就是交换链,它必须在Vulkan中显式创建。交换链本质上是一个等待呈现在屏幕上的图像队列。我们的程序会获取一张图像,绘制之后将它返回到队列中。队列的工作原理和呈现一张从队列获取的图像的条件,取决于交换链的设置方式,但交换链的一般目的是使图像的呈现与屏幕的刷新率同步

检查交换链支持

由于各种原因,并不是所有显卡都能将图像呈现到屏幕上。比如,它们是为服务器设计的,并且没有任何显示输出。其次,因为图像呈现与窗口系统和窗口表面密切相关,它不是Vulkan核心的一部分。在查询交换链支持之后我们需要启用VK_KHR_swapchain扩展。

为此,我们首先扩展isDeviceSuitable函数来检查对此扩展的支持。我们已经知道如何列出VkPhysicalDevice支持的扩展,做法很简单。值得注意的是,Vulkan头文件提供了一个很好的宏VK_KHR_SWAPCHAIN_EXTENSION_NAME代表VK_KHR_swapchain。使用宏可以避免拼写错误。

首先生命一个需要的设备扩展的列表,和验证层相似:

  1. const std::vector<const char*> deviceExtensions = {
  2. VK_KHR_SWAPCHAIN_EXTENSION_NAME
  3. };
  4. 123

然后,创建一个新的函数checkDeviceExtensionSupport进行额外的检查,并在isDeviceSuitable中调用:

  1. bool isDeviceSuitable(VkPhysicalDevice device) {
  2. QueueFamilyIndices indices = findQueueFamilies(device);
  3. bool extensionsSupported = checkDeviceExtensionSupport(device);
  4. return indices.isComplete() && extensionsSupported;
  5. }
  6. bool checkDeviceExtensionSupport(VkPhysicalDevice device) {
  7. return true;
  8. }
  9. 1234567891011

完成该函数以枚举扩展并检查是否所有必须的扩展都在其中。

  1. bool checkDeviceExtensionSupport(VkPhysicalDevice device) {
  2. uint32_t extensionCount;
  3. vkEnumerateDeviceExtensionProperties(device, nullptr, &extensionCount, nullptr);
  4. std::vector<VkExtensionProperties> availableExtensions(extensionCount);
  5. vkEnumerateDeviceExtensionProperties(device, nullptr, &extensionCount, availableExtensions.data());
  6. std::set<std::string> requiredExtensions(deviceExtensions.begin(), deviceExtensions.end());
  7. for (const auto& extension : availableExtensions) {
  8. requiredExtensions.erase(extension.extensionName);
  9. }
  10. return requiredExtensions.empty();
  11. }
  12. 123456789101112131415

我选择在这里使用一个字符串集合来表示未经确认的所需扩展,这样可以在枚举扩展时,很容易的确认一个删除一个。当然,你也可以像checkValidationLayerSupport函数那样使用嵌套循环。性能差异不用考虑。现在可以运行一下代码,确认你的显卡可以创建交换链。值得一提的是,前面我们已经检查了呈现队列的支持,这其实也说明交换链扩展也是支持的。明确做一些事情还是好的,并且启用的扩展也需要被明确指定。

启用设备扩展

使用交换链需要首先启用VK_KHR_swapchain扩展。要启用扩展只需要简单改一下逻辑设备的创建就行:

  1. createInfo.enabledExtensionCount = static_cast<uint32_t>(deviceExtensions.size());
  2. createInfo.ppEnabledExtensionNames = deviceExtensions.data();
  3. 12

记得一定要删除之前的createInfo.enabledExtensionCount = 0;

查询交换链支持详情

仅仅检查交换链的支持是不够的,因为它有可能和窗口表面不兼容。相比创建实例,创建交换链需要更多的设置,所以在继续之前,我们还需要查询更多的详细信息。

首先我们需要检查三个属性:

  • 表面的基本能力(最小、最大图像数,图像的最小、最大宽高)
  • 表面格式(像素格式、色彩空间)
  • 可用的呈现模式

findQueueFamilies相似,我们用一个结构体来保存查询到的详细信息。上面三种类型的属性用以下结构体表示:

  1. struct SwapChainSupportDetails {
  2. VkSurfaceCapabilitiesKHR capabilities;
  3. std::vector<VkSurfaceFormatKHR> formats;
  4. std::vector<VkPresentModeKHR> presentModes;
  5. };
  6. 12345

我们现在创建一个新函数querySwapChainSupport来填充这个结构体。

  1. SwapChainSupportDetails querySwapChainSupport(VkPhysicalDevice device) {
  2. SwapChainSupportDetails details;
  3. return details;
  4. }
  5. 12345

本节介绍如何查询这些信息,这些信息的含义和确切值我们在下一节讨论。

首先检查基本的表面能力。非常简单:

  1. vkGetPhysicalDeviceSurfaceCapabilitiesKHR(device, surface, &details.capabilities);
  2. 1

函数需要指定VkPhysicalDeviceVkSurfaceKHR窗口表面。所有支持查询函数都使用这两个参数,因为它们是交换链的核心组件。

下一步是检查支持的表面格式。这是个列表,它遵循熟悉的两个函数调用习惯:

  1. uint32_t formatCount;
  2. vkGetPhysicalDeviceSurfaceFormatsKHR(device, surface, &formatCount, nullptr);
  3. if (formatCount != 0) {
  4. details.formats.resize(formatCount);
  5. vkGetPhysicalDeviceSurfaceFormatsKHR(device, surface, &formatCount, details.formats.data());
  6. }
  7. 1234567

确保数组可以容纳所有可用格式。最后,查询支持的呈现模式,和上面一样:

  1. uint32_t presentModeCount;
  2. vkGetPhysicalDeviceSurfacePresentModesKHR(device, surface, &presentModeCount, nullptr);
  3. if (presentModeCount != 0) {
  4. details.presentModes.resize(presentModeCount);
  5. vkGetPhysicalDeviceSurfacePresentModesKHR(device, surface, &presentModeCount, details.presentModes.data());
  6. }
  7. 1234567

现在所有信息都有了,我们再扩展一下isDebiceSuitable函数,判断交换链支持是否足够。本教程中,交换链只需要窗口表面拥有一种图像格式和一种呈现模式就行了。

  1. bool swapChainAdequate = false;
  2. if (extensionsSupported) {
  3. SwapChainSupportDetails swapChainSupport = querySwapChainSupport(device);
  4. swapChainAdequate = !swapChainSupport.formats.empty() && !swapChainSupport.presentModes.empty();
  5. }
  6. 12345

我们仅在验证层扩展可用时查询图像格式和交换链呈现模式的支持情况。函数返回改为:

  1. return indices.isComplete() && extensionsSupported && swapChainAdequate;
  2. 1

为交换链选择正确的设置

如果swapChainAdequate为真,那交换链就可用了,但可能有不同的优化模式。我们现在编写几个函数来为交换链选择最佳设置。这里有三种设置类型需要确定:

  • 表面格式(颜色 深度)
  • 呈现模式(“交换”图像到屏幕的条件)
  • 交换范围(交换链中的图像分辨率)

表面格式

先搞个函数,大致长这样,等下把之前结构体里的formates传给它:

  1. VkSurfaceFormatKHR chooseSwapSurfaceFormat(const std::vector<VkSurfaceFormatKHR>& availableFormats) {
  2. }
  3. 123

每个VkSurfaceFormatKHR都有一个format和一个colorSpaceformat说明了颜色的通道和类型。例如,VK_FORMAT_B8G8R8A8_SRGB意味着我们以8位无符号整数的顺序存储B、G、R和alpha通道,一个像素共32位。colorSpaceVK_COLOR_SPACE_SRGB_NONLINEAR_KHR标志指示是否支持SRGB颜色空间。注意,该标志在旧版本规范中为VK_COLORSPACE_SRGB_NONLINEAR_KHR

如果可用,颜色空间我们就用SRGB,因为它会产生更准确的感知颜色。它几乎是图像的标准色彩空间,和我们将使用到的纹理一样。因此,我们还要使用SRGB颜色格式,其中一种最常见的是VK_FORMAT_B8G8R8A8

遍历列表来寻找首选组合:

  1. for (const auto& availableFormat : availableFormats) {
  2. if (availableFormat.format == VK_FORMAT_B8G8R8A8_SRGB && availableFormat.colorSpace == VK_COLOR_SPACE_SRGB_NONLINEAR_KHR) {
  3. return availableFormat;
  4. }
  5. }
  6. 12345

如果没有上面的组合,我们可以对它们进行排名然后选择,大多数情况下,使用第一种格式就行。

  1. VkSurfaceFormatKHR chooseSwapSurfaceFormat(const std::vector<VkSurfaceFormatKHR>& availableFormats) {
  2. for (const auto& availableFormat : availableFormats) {
  3. if (availableFormat.format == VK_FORMAT_B8G8R8A8_SRGB && availableFormat.colorSpace == VK_COLOR_SPACE_SRGB_NONLINEAR_KHR) {
  4. return availableFormat;
  5. }
  6. }
  7. return availableFormats[0];
  8. }
  9. 123456789

呈现模式

呈现模式可以说是交换链最重要的设置,因为它代表在屏幕上显式图像的实际条件。Vulkan中有四种可能的模式:

  • VK_PRESENT_MODE_IMMEDIATE_KHR:我们的程序提交的图像会立刻传输到屏幕上,这可能会导致画面撕裂。
  • VK_PRESENT_MODE_FIFO_KHR:交换链作为一个队列,显示器刷新时,从队列前面获取图像,程序会在队列后面插入渲染的图像。如果队列已满,则程序需要等待。这与现代游戏中的垂直同步最为相似。显示刷新时刻被称作“垂直空白”(额,啥意思?)
  • VK_PRESENT_MODE_FIFO_RELAXED_KHR:这个模式和上面那个区别在于,如果程序的渲染速度比较慢,在队列为空时,渲染的图像会立即传输到屏幕上。这可能导致画面撕裂。
  • VK_PRESENT_MODE_MAILBOX_KHR:这是第二种模式的一种变体。当队列满的时候,不会阻塞应用程序,而是将已经排列的对象简单地替换为较新的图像。此模式用于在避免画面撕裂的同时尽可能快地渲染每一帧,从而比标准垂直同步有更小的延迟。

只有VK_PRESENT_MODE_FIFO模式保证可用,因此我们需要写一个函数来寻找一个最佳模式:

  1. VkPresentModeKHR chooseSwapPresentMode(const std::vector<VkPresentModeKHR>& availablePresentModes) {
  2. return VK_PRESENT_MODE_FIFO_KHR;
  3. }
  4. 123

我个人认为,不考虑省电的话,VK_PRESENT_MODE_MAILBOX_KHR是一个非常好的权衡。它能使我们避免画面撕裂的同时,仍保持比较低的延迟。在需要省电的移动设备上,我们可能更倾向于使用VK_PRESENT_MODE_FIFO_KHR。现在,让我们遍历列表,看看VK_PRESENT_MODE_MAILBOX_KHR是否可用:

  1. VkPresentModeKHR chooseSwapPresentMode(const std::vector<VkPresentModeKHR>& availablePresentModes) {
  2. for (const auto& availablePresentMode : availablePresentModes) {
  3. if (availablePresentMode == VK_PRESENT_MODE_MAILBOX_KHR) {
  4. return availablePresentMode;
  5. }
  6. }
  7. return VK_PRESENT_MODE_FIFO_KHR;
  8. }
  9. 123456789

交换范围

只剩下一个主要属性,我们为其创建最后一个函数:

  1. VkExtent2D chooseSwapExtent(const VkSurfaceCapabilitiesKHR& capabilities) {
  2. }
  3. 123

交换范围指的是交换链图像的分辨率,它一般和窗口分辨率完全相同(以像素为单位,稍后会详细介绍)。可能的分辨率范围在VkSurfaceCapabilitiesKHR结构中定义。Vulkan中通过currentExten成员设置宽度和高度以匹配窗口的分辨率。然而,一些窗口管理器允许我们在这里设置不同的大小,这会通过一个特殊值uint32_t最大值来告知我们。在这种情况下,我们需要在minImageExtentmaxImageExtent范围内选择最匹配窗口的分辨率。我们一定要用正确的单位指定分辨率。

GLFW 在测量尺寸时使用两种单位:像素和屏幕坐标。例如,我们在创建窗口时指定的分辨率{WIDTH,HEIGHT}是以屏幕坐标来衡量的。但是,Vulkan使用像素坐标,所以交换链范围也必须以像素为单位来指定。如果你使用的是高DPI显示器(如Apple的Retina显示器),则屏幕坐标和像素坐标是不对应的。因为更高的像素密度,以像素为单位的窗口分辨率将大于以屏幕坐标为单位的分辨率。在Vulkan不修正交换范围的情况下,我们就不能使用原始的{WIDTH,HEIGHT}。所以,我们需要使用glfwGetFramebufferSize函数来查询以像素为单位的窗口分辨率,然后再将其与最小最大图像范围进行匹配。

  1. #include <cstdint> // Necessary for UINT32_MAX
  2. #include <algorithm> // Necessary for std::min/std::max
  3. ...
  4. VkExtent2D chooseSwapExtent(const VkSurfaceCapabilitiesKHR& capabilities) {
  5. if (capabilities.currentExtent.width != UINT32_MAX) {
  6. return capabilities.currentExtent;
  7. } else {
  8. int width, height;
  9. glfwGetFramebufferSize(window, &width, &height);
  10. VkExtent2D actualExtent = {
  11. static_cast<uint32_t>(width),
  12. static_cast<uint32_t>(height)
  13. };
  14. actualExtent.width = std::clamp(actualExtent.width, capabilities.minImageExtent.width, capabilities.maxImageExtent.width);
  15. actualExtent.height = std::clamp(actualExtent.height, capabilities.minImageExtent.height, capabilities.maxImageExtent.height);
  16. return actualExtent;
  17. }
  18. }
  19. 1234567891011121314151617181920212223

clamp函数(C++17)将宽度和高度限制在允许的最小和最大范围之间。

创建交换链

现在,我们已经拥有了所有来帮助我们在运行时做出选择的辅助函数,创建交换链所需的所有信息都有了。

创建一个createSwapChain函数,然后在initVulkan中创建逻辑设备后调用。

  1. void initVulkan() {
  2. createInstance();
  3. setupDebugMessenger();
  4. createSurface();
  5. pickPhysicalDevice();
  6. createLogicalDevice();
  7. createSwapChain();
  8. }
  9. void createSwapChain() {
  10. SwapChainSupportDetails swapChainSupport = querySwapChainSupport(physicalDevice);
  11. VkSurfaceFormatKHR surfaceFormat = chooseSwapSurfaceFormat(swapChainSupport.formats);
  12. VkPresentModeKHR presentMode = chooseSwapPresentMode(swapChainSupport.presentModes);
  13. VkExtent2D extent = chooseSwapExtent(swapChainSupport.capabilities);
  14. }
  15. 12345678910111213141516

除了这些属性之外,我们还必须决定我们希望在交换链中拥有多少图像。Vulkan实现决定了其运行所需的最小数量:

  1. uint32_t imageCount = swapChainSupport.capabilities.minImageCount;
  2. 1

简单地使用这个最小值可能会让我们有时必须等待驱动程序完成内部操作,然后才能获取一个图像进行渲染。因此,建议请求至少比最小值多一张图像:

  1. uint32_t imageCount = swapChainSupport.capabilities.minImageCount + 1;
  2. 1

我们应该保证+1后不会超过最大图像数(最大图像数为0表示没有最大值限制):

  1. if (swapChainSupport.capabilities.maxImageCount > 0 && imageCount > swapChainSupport.capabilities.maxImageCount) {
  2. imageCount = swapChainSupport.capabilities.maxImageCount;
  3. }
  4. 123

和Vulkan传统一样,创建交换链也需要填充一个大结构体,前几个属性我们很熟悉:

  1. VkSwapchainCreateInfoKHR createInfo{};
  2. createInfo.sType = VK_STRUCTURE_TYPE_SWAPCHAIN_CREATE_INFO_KHR;
  3. createInfo.surface = surface;
  4. 123

指定绑定的表面后,需要指定交换链图像的详细信息:

  1. createInfo.minImageCount = imageCount;
  2. createInfo.imageFormat = surfaceFormat.format;
  3. createInfo.imageColorSpace = surfaceFormat.colorSpace;
  4. createInfo.imageExtent = extent;
  5. createInfo.imageArrayLayers = 1;
  6. createInfo.imageUsage = VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT;
  7. 123456

imageArrayLayers指定了每个图像的层数,除非开发VR程序,否则始终设为1imageUsage指定了交换链图像的用途。在本教程中,我们会直接渲染它们,这意味着它们会作为颜色附件。你也可以先将场景渲染到单独的图像,然后执行后处理等操作,这时,你应该使用VK_IMAGE_USAGE_TRANSFER_DST_BIT之类的值,然后使用内存操作将渲染好的图像传输到交换链图像。

  1. QueueFamilyIndices indices = findQueueFamilies(physicalDevice);
  2. uint32_t queueFamilyIndices[] = {indices.graphicsFamily.value(), indices.presentFamily.value()};
  3. if (indices.graphicsFamily != indices.presentFamily) {
  4. createInfo.imageSharingMode = VK_SHARING_MODE_CONCURRENT;
  5. createInfo.queueFamilyIndexCount = 2;
  6. createInfo.pQueueFamilyIndices = queueFamilyIndices;
  7. } else {
  8. createInfo.imageSharingMode = VK_SHARING_MODE_EXCLUSIVE;
  9. createInfo.queueFamilyIndexCount = 0; // Optional
  10. createInfo.pQueueFamilyIndices = nullptr; // Optional
  11. }
  12. 123456789101112

接下来,我们需要指定如何在多个队列簇(TODO 是队列簇还是队列?)之间使用交换链图像。比如图形队列和和呈现队列不同的时候。我们会在图形队列中绘制交换链图像,然后将它们提交到呈现队列中。有两种方法处理多个队列会访问的图像:

  • VK_SHARING_MODE_EXCLUSIVE:一个图像只能同时被一个队列簇拥有,在另一个队列簇使用前,必须明确转移所有权。此选项可提供最佳性能。
  • VK_SHARING_MODE_CONCURRENT:图像可以跨多个队列簇使用,无需明确转移所有权。

If the queue families differ, then we’ll be using the concurrent mode in this tutorial to avoid having to do the ownership chapters, because these involve some concepts that are better explained at a later time.(这句话意思可能是:如果队列簇不同,我们会在本教程中使用并发模式,这样就不用再整一章讲怎么转移所有权了。这里涉及到一些概念,我们必须在之后讲才行。)并发模式要求我们使用queueFamilyIndexCountpQueueFamilyIndices参数提前指定将在哪些队列簇之间共享所有权。如果图形队列簇和呈现队列簇相同(大多数硬件上如此),那么我们应该坚持使用独占模式,因为并发模式要求我们至少指定两个不同的队列簇。


  1. createInfo.preTransform = swapChainSupport.capabilities.currentTransform;
  2. 1

如果支持,我们可以指定对交换链中的图像应用某种变换(capabilities中的suportedTransforms),例如顺时针旋转90度或水平翻转。如果不需要任何转换,只需指定当前转换即可。


  1. createInfo.compositeAlpha = VK_COMPOSITE_ALPHA_OPAQUE_BIT_KHR;
  2. 1

compositeAlpha字段用来指定是否使用alpha通道与窗口系统中的其它窗口混合。我们一般会忽略alpha通道,所以设置为VK_COMPOSITE_ALPHA_OPAQUE_BIT_KHR即可。(TODO,与其它窗口混合)


  1. createInfo.presentMode = presentMode;
  2. createInfo.clipped = VK_TRUE;
  3. 12

presentMode不言自明。如果clipped成员设置为VK_TRUE,则意味着我们我们不关心被遮挡的像素颜色,例如有窗口挡在它们面前。除非你需要回读这些像素以获得可预测的结果,否则我们可以通过启用剪裁来获得最佳性能。


  1. createInfo.oldSwapchain = VK_NULL_HANDLE;
  2. 1

现在只剩最后一个字段oldSwapChain。使用Vulkan时,交换链可能会在我们的程序运行时变得无效或不是最优的,比如窗口大小改变的时候。在这种情况下,交换链需要从头开始重新创建,并且必须通过此字段引用旧交换链。因为它比较复杂,我们会在之后的章节中详细介绍。现在我们假设我们只会创建一个交换链。


添加一个类成员来保存VkSwapchainKHR对象:

  1. VkSwapchainKHR swapChain;
  2. 1

现在创建交换链就很简单了,只需要调用vkCreateSwapchainKHR

  1. if (vkCreateSwapchainKHR(device, &createInfo, nullptr, &swapChain) != VK_SUCCESS) {
  2. throw std::runtime_error("failed to create swap chain!");
  3. }
  4. 123

参数分别是逻辑设备,交换链创建信息、可选的自定义分配器和指向用于存储交换链句柄的指针。没有需要特别说明的。交换链应该在设备之前销毁:

  1. void cleanup() {
  2. vkDestroySwapchainKHR(device, swapChain, nullptr);
  3. ...
  4. }
  5. 1234

现在可以运行一下程序,确保成功创建交换链。如果你收到vkCreateSwapchainKHR内存访问异常,或看到一个类似Failed to find 'vkGetInstanceProcAddress' in layer steamoverlayvulkanlayer.so的错误信息,你应该查看关于流覆盖层相关的FAQ条目(TODO 翻译FAQ)。

如果你删除createInfo.imageExtent = extent;这一行,并且启用了验证层,你会看到一个验证层会立即捕获错误并打印出有用的信息:

  1. validation layer: Validation Error: [ VUID-VkSwapchainCreateInfoKHR-imageExtent-01689 ] Object 0: handle = 0x1f89658, type = VK_OBJECT_TYPE_DEVICE; | MessageID = 0x13140d69 | vkCreateSwapchainKHR(): pCreateInfo->imageExtent = (0, 0) which is illegal. The Vulkan spec states: imageExtent members width and height must both be non-zero (https://www.khronos.org/registry/vulkan/specs/1.2-extensions/html/vkspec.html#VUID-VkSwapchainCreateInfoKHR-imageExtent-01689)
  2. validation layer: Validation Error: [ VUID-VkSwapchainCreateInfoKHR-imageExtent-01274 ] Object 0: handle = 0x1f89658, type = VK_OBJECT_TYPE_DEVICE; | MessageID = 0x7cd0911d | vkCreateSwapchainKHR() called with imageExtent = (0,0), which is outside the bounds returned by vkGetPhysicalDeviceSurfaceCapabilitiesKHR(): currentExtent = (800,600), minImageExtent = (800,600), maxImageExtent = (800,600). The Vulkan spec states: imageExtent must be between minImageExtent and maxImageExtent, inclusive, where minImageExtent and maxImageExtent are members of the VkSurfaceCapabilitiesKHR structure returned by vkGetPhysicalDeviceSurfaceCapabilitiesKHR for the surface (https://www.khronos.org/registry/vulkan/specs/1.2-extensions/html/vkspec.html#VUID-VkSwapchainCreateInfoKHR-imageExtent-01274)
  3. 12

检索交换链中的图像

交换链现在已经创建了,剩下的工作就是检索其中的VkImage句柄。我们会在后面的章节执行渲染操作时引用它们。添加一个类成员来存储这些句柄:

std::vector<VkImage> swapChainImages;
1

图像是由交换链创建的,一旦交换链销毁,它们会被自动清理,因此我们不需要手动清理。

我将检索图像句柄的代码放在了createSwapChain函数的最后,vkCreateSwapchainKHR调用之后。检索它们与我们从Vulkan检索其他对象数组相似。需要注意的是,我们仅是指定了交换链图像的最小数目,但实现它可能创建了更多。所以,我们会首先使用vkGetSwapchainImagesKHR查询图像数量,然后调整容器大小,最后再次调用它检索句柄。

vkGetSwapchainImagesKHR(device, swapChain, &imageCount, nullptr);
swapChainImages.resize(imageCount);
vkGetSwapchainImagesKHR(device, swapChain, &imageCount, swapChainImages.data());
123

最后一件事就是将我们为交换链图像选择的格式和范围保存到成员变量中。我们将会在之后的章节中用到它们。

VkSwapchainKHR swapChain;
std::vector<VkImage> swapChainImages;
VkFormat swapChainImageFormat;
VkExtent2D swapChainExtent;

...

swapChainImageFormat = surfaceFormat.format;
swapChainExtent = extent;
123456789

我们现在有一组可以绘制并呈现到窗口的图像了,下一章我们将开始介绍如何将图像设置为渲染目标。然后,我们再开始研究实际的图形管道和绘制命令!