跳到主要内容

交换链

Vulkan没有默认帧缓冲的概念,需要一个能够缓冲渲染结果的组件,在Vulkan中,这一组件就是交换链。Vulkan交换链必须显式创建,不存在默认的交换链。交换链本质上是一个包含了若干等待呈现的图像队列,应用程序从交换链获取一张图像,然后在图像上进行渲染绘制,完成绘制后,将图像返回到交换链的队列中。交换链队列的工作方式和呈现图像到表面的时机取决于交换链的设置,通常来说,交换链呈现图像和屏幕刷新率同步。

检测交换链的支持情况

并不是所有的显卡设备都具有将图像呈现到屏幕的能力。比如,为服务器设计的显卡是没有任何显示输出的。此外,由于图像呈现非常依赖于窗口系统以及与窗口系统密切相关的表面,这些并非Vulkan的核心内容,因此必须在启用了设备扩展VK_KHR_swapchain后才能使用交换链。

首先需要检测是否支持扩展VK_KHR_swapchain,之前我们介绍了如何查询VkPhysicalDevice支持的扩展列表,只需要在列表中检测VK_KHR_swapchain是否存在即可。Vulkan库头文件有一个VK_KHR_SWAPCHAIN_EXTENSION_NAME宏,它等价于VK_KHR_swapchain,使用这个宏而不是直接使用VK_KHR_swapchain,防止出现拼写错误。

首先,定义所需的的设备扩展列表,类似启用校验层:

const std::vector<const char*> deviceExtensions = {
VK_KHR_SWAPCHAIN_EXTENSION_NAME
};

接着,添加checkDeviceExtensionSupport函数,在isDeviceSuitable中调用它:

bool isDeviceSuitable(VkPhysicalDevice device) {
QueueFamilyIndices indices = findQueueFamilies(device);

bool extensionsSupported = checkDeviceExtensionSupport(device);

return indices.isComplete() && extensionsSupported;
}

bool checkDeviceExtensionSupport(VkPhysicalDevice device) {
return true;
}

修改checkDeviceExtensionSupport函数,遍历设备扩展列表,检测所需的扩展是否存在:

bool checkDeviceExtensionSupport(VkPhysicalDevice device) {
uint32_t extensionCount;
vkEnumerateDeviceExtensionProperties(device, nullptr, &extensionCount, nullptr);

std::vector<VkExtensionProperties> availableExtensions(extensionCount);
vkEnumerateDeviceExtensionProperties(device, nullptr, &extensionCount, availableExtensions.data());

std::set<std::string> requiredExtensions(deviceExtensions.begin(), deviceExtensions.end());

for (const auto& extension : availableExtensions) {
requiredExtensions.erase(extension.extensionName);
}

return requiredExtensions.empty();
}

我们将所需的扩展保存在集合中,然后遍历设备所有的可用扩展,并从集合中的剔除它,如果最后集合中的元素个数为0,说明我们所需的扩展全部满足。实际上,如果设备支持呈现队列,那么就一定支持交换链,但我们最好还是显式地执行交换链扩展检测。

启用交换链扩展,只需要对逻辑设备的创建过程进行微调:

createInfo.enabledExtensionCount = static_cast<uint32_t>(deviceExtensions.size());
createInfo.ppEnabledExtensionNames = deviceExtensions.data();

查询交换链的细节信息

只检查交换链是否可用还不够,它可能实际上与我们的窗口表面不兼容。创建交换链所需的设置比实例和设备多得多,在创建交换链之前我们需要查询更多信息。

需要检查三种最基本的属性:

  • 基础表面功能(交换链的最小/最大图像数量,最小/最大图像宽度、高度)
  • 表面格式(像素格式,颜色空间)
  • 可用的呈现模式

类似于findQueueFamilies,我们使用结构体来存储查询到的交换链细节信息:

struct SwapChainSupportDetails {
VkSurfaceCapabilitiesKHR capabilities;
std::vector<VkSurfaceFormatKHR> formats;
std::vector<VkPresentModeKHR> presentModes;
};

添加querySwapChainSupport函数返回细节信息:

SwapChainSupportDetails querySwapChainSupport(VkPhysicalDevice device) {
SwapChainSupportDetails details;

return details;
}

本节先介绍如何查询,下一节再对它们的具体意义进行说明。

首先,调用下面的函数查询基础表面功能:

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

这一函数以VkPhysicalDeviceVkSurfaceKHR对象作为参数,查询交换链信息的相关函数都需要这两个参数,它们是交换链的核心组件。

下一步,查询表面支持的格式。查询结果是一个结构体列表,还是熟悉的味道,函数调用2次,首先查询格式数量,然后分配数组保存结果:

uint32_t formatCount;
vkGetPhysicalDeviceSurfaceFormatsKHR(device, surface, &formatCount, nullptr);

if (formatCount != 0) {
details.formats.resize(formatCount);
vkGetPhysicalDeviceSurfaceFormatsKHR(device, surface, &formatCount, details.formats.data());
}

确保数组大小足够保存结果,最后,使用同样的方式调用vkGetPhysicalDeviceSurfacePresentModesKHR查询支持的呈现模式:

uint32_t presentModeCount;
vkGetPhysicalDeviceSurfacePresentModesKHR(device, surface, &presentModeCount, nullptr);

if (presentModeCount != 0) {
details.presentModes.resize(presentModeCount);
vkGetPhysicalDeviceSurfacePresentModesKHR(device, surface, &presentModeCount, details.presentModes.data());
}

现在所有查询到的信息已经保存了下来,对isDeviceSuitable函数进行补充,检测交换链的能力是否满足需求。对于本教程而言,只需要交换链至少支持一种图像格式和一种窗口表面的呈现模式即可:

bool swapChainAdequate = false;
if (extensionsSupported) {
SwapChainSupportDetails swapChainSupport = querySwapChainSupport(device);
swapChainAdequate = !swapChainSupport.formats.empty() && !swapChainSupport.presentModes.empty();
}

综合上面的内容,isDeviceSuitable函数最后一行修改为:

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

正确设置交换链

如果swapChainAdequate为真,说明交换链的能力满足我们的需要,但不同的设置会有不同的优化结果。接下来,我们编写一组函数来查找合适的设置,主要有三个设置:

  • 表面格式(颜色,深度)
  • 呈现模式(显示图像到屏幕的时机)
  • 交换范围(交换链中的图像的分辨率)

上面的每个设置都有一个理想值,如果理想值不满足,我们会查找一个尽可能好的替代值。

表面格式

添加chooseSwapSurfaceFormat函数来选择合适的表面格式:

VkSurfaceFormatKHR chooseSwapSurfaceFormat(const std::vector<VkSurfaceFormatKHR> &availableFormats) {

}

每个VkSurfaceFormatKHR条目包含一个formatlorSpace成员。

  • format用于指定颜色通道和存储类型。

    比如,VK_FORMAT_B8G8R8A8_UNORM表示我们以B,G,R和A的顺序,每个颜色通道用8位无符号整型数,每像素总共使用32位表示。

  • colorSpace用来表示是否支持SRGB颜色空间(包含VK_COLOR_SPACE_SRGB_NONLINEAR_KHR标记位)。

    注意VK_COLOR_SPACE_SRGB_NONLINEAR_KHR在之前的Vulkan规范中叫做VK_COLORSPACE_SRGB_NONLINEAR_KHR

对于颜色空间,我们使用SRGB,它是标准颜色空间,使用它可以得到更加准确的颜色表示,一种常见的sRGB 颜色格式是VK_FORMAT_B8G8R8A8_SRGB

检查获取到的格式列表是否包含需要的格式:

for (const auto& availableFormat : availableFormats) {
if (availableFormat.format == VK_FORMAT_B8G8R8A8_SRGB && availableFormat.colorSpace == VK_COLOR_SPACE_SRGB_NONLINEAR_KHR) {
return availableFormat;
}
}

如果找不到我们想要的格式,可以根据格式的“可用”程度对其排名,但大多数情况下,直接使用第一个即可:

VkSurfaceFormatKHR chooseSwapSurfaceFormat(const std::vector<VkSurfaceFormatKHR>& availableFormats) {
for (const auto& availableFormat : availableFormats) {
if (availableFormat.format == VK_FORMAT_B8G8R8A8_SRGB && availableFormat.colorSpace == VK_COLOR_SPACE_SRGB_NONLINEAR_KHR) {
return availableFormat;
}
}

return availableFormats[0];
}

呈现模式

呈现模式可以说是交换链最重要的设置,它决定了什么条件下图像才会显示到屏幕。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_KHR保证一定可用,所以我们还需要编写一个函数来查找可用的最佳呈现模式:

VkPresentModeKHR chooseSwapPresentMode(const std::vector<VkPresentModeKHR> availablePresentModes) {
return VK_PRESENT_MODE_FIFO_KHR;
}

作者个人认为三重缓冲综合来说表现最佳,三重缓冲避免了撕裂现象,同时具有较低的延迟。优先检查VK_PRESENT_MODE_MAILBOX_KHR模式是否可用,可用的话就使用它:

VkPresentModeKHR chooseSwapPresentMode(const std::vector<VkPresentModeKHR> availablePresentModes) {
for (const auto& availablePresentMode : availablePresentModes) {
if (availablePresentMode == VK_PRESENT_MODE_MAILBOX_KHR)
{
return availablePresentMode;
}
}
return VK_PRESENT_MODE_FIFO_KHR;
}

交换范围

现在只剩最后一个属性需要设置了,我们添加chooseSwapExtent函数来设置它:

VkExtent2D chooseSwapExtent(const VkSurfaceCapabilitiesKHR &capabilities) {

}

交换范围是交换链中图像的分辨率,它几乎总是和要显示图像的窗口分辨率相同,VkSurfaceCapabilitiesKHR结构体定义了可用的分辨率范围,该结构体的currentExtent成员变量表示窗口的交换范围。一些窗口系统会返回特殊值:uint32_t的最大值,表示允许应用程序自己选择合适的交换范围,这种情况下我们选择在minImageExtentmaxImageExtent范围内最匹配的分辨率。

我们使用glfwGetFramebufferSize来查询窗口的分辨率(以像素为单位),然后再将其与最小和最大图像范围进行匹配:

#include <cstdint> // Necessary for uint32_t
#include <limits> // Necessary for std::numeric_limits
#include <algorithm> // Necessary for std::clamp

...

VkExtent2D chooseSwapExtent(const VkSurfaceCapabilitiesKHR& capabilities) {
if (capabilities.currentExtent.width != std::numeric_limits<uint32_t>::max()) {
return capabilities.currentExtent;
} else {
int width, height;
glfwGetFramebufferSize(window, &width, &height);

VkExtent2D actualExtent = {
static_cast<uint32_t>(width),
static_cast<uint32_t>(height)
};

actualExtent.width = std::clamp(actualExtent.width, capabilities.minImageExtent.width, capabilities.maxImageExtent.width);
actualExtent.height = std::clamp(actualExtent.height, capabilities.minImageExtent.height, capabilities.maxImageExtent.height);

return actualExtent;
}
}

创建交换链

现在,我们已经编写了很多辅助代码来帮助选择最合适的设置,可以创建交换链了。

添加createSwapChain函数,它选择合适的交换链设置,然后在initVulkan函数中调用它:

void initVulkan() {
createInstance();
setupDebugCallback();
createSurface();
pickPhysicalDevice();
createLogicalDevice();
createSwapChain();
}

void createSwapChain() {
SwapChainSupportDetails swapChainSupport = querySwapChainSupport(physicalDevice);

VkSurfaceFormatKHR surfaceFormat = chooseSwapSurfaceFormat(swapChainSupport.formats);
VkPresentModeKHR presentMode = chooseSwapPresentMode(swapChainSupport.presentModes);
VkExtent2D extent = chooseSwapExtent(swapChainSupport.capabilities);
}

除了上面这些,还需要设置交换链中的图像个数,也就是交换链的队列可以容纳的图像个数。我们使用交换链支持的最小图像个数+1,来实现三重缓冲。而且不能超过最大值,maxImageCount为0表明,只要内存足够,我们可以使用任意数量的图像:

uint32_t imageCount = swapChainSupport.capabilities.minImageCount + 1;
if (swapChainSupport.capabilities.maxImageCount > 0 && imageCount > swapChainSupport.capabilities.maxImageCount) {
imageCount = swapChainSupport.capabilities.maxImageCount;
}

创建交换链需要填写一个包含大量信息的结构体,这一结构体的一些成员我们已经非常熟悉:

VkSwapchainCreateInfoKHR createInfo = {};
createInfo.sType = VK_STRUCTURE_TYPE_SWAPCHAIN_CREATE_INFO_KHR;
createInfo.surface = surface;

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

createInfo.minImageCount = imageCount;
createInfo.imageFormat = surfaceFormat.format;
createInfo.imageColorSpace = surfaceFormat.colorSpace;
createInfo.imageExtent = extent;
createInfo.imageArrayLayers = 1;
createInfo.imageUsage = VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT;

imageArrayLayers用于指定每个图像包含的层数,通常它的值为1,只有VR相关的应用程序会超过1。imageUsage用于表面应用会在图像上进行怎样的操作。本教程,我们在图像上进行绘制操作,也就是将图像作为一个颜色附件来使用。如果读者需要对图像进行后期处理之类的操作,可以使用VK_IMAGE_USAGE_TRANSFER_DST_BIT作为imageUsage的值,将渲染的图像传输到交换链图像。

QueueFamilyIndices indices = findQueueFamilies(physicalDevice);
uint32_t queueFamilyIndices[] = {(uint32_t) indices.graphicsFamily, (uint32_t) indices.presentFamily};

if (indices.graphicsFamily != indices.presentFamily) {
createInfo.imageSharingMode = VK_SHARING_MODE_CONCURRENT;
createInfo.queueFamilyIndexCount = 2;
createInfo.pQueueFamilyIndices = queueFamilyIndices;
} else {
createInfo.imageSharingMode = VK_SHARING_MODE_EXCLUSIVE;
createInfo.queueFamilyIndexCount = 0; // Optional
createInfo.pQueueFamilyIndices = nullptr; // Optional
}

接着,我们需要指定如何在多个队列族中使用交换链图像,如果图形和呈现不是同一个队列族,这一设置将会非常有用。我们通过图形队列在交换链图像上进行绘制,然后将图像提交给呈现队列来显示。有两种方式在多个队列间访问图像:

  • VK_SHARING_MODE_EXCLUSIVE:图像同一时刻只能被一个队列族拥有,在另一队列族使用它之前,必须显式地改变图像所有权,这一模式的性能表现最佳。
  • VK_SHARING_MODE_CONCURRENT:图像可以在多个队列族间使用,不需要显式地改变图像所有权。

如果图形和呈现不是同一个队列族,我们使用协同模式VK_SHARING_MODE_CONCURRENT来避免处理图像所有权问题。协同模式下,需要使用queueFamilyIndexCountpQueueFamilyIndices来指定需要共享图像所有权的队列族。

如果图形和呈现是同一个队列族(大多数情况如此),就使用独占模式VK_SHARING_MODE_EXCLUSIVE,因为协同模式需要至少两个不同的队列族。

createInfo.preTransform = swapChainSupport.capabilities.currentTransform;

我们可以为交换链图像指定一个固定的变换操作(需要交换链具有supportedTransforms特性),比如顺时针旋转90度或是水平翻转。如果读者不需要进行任何变换操作,指定currentTransform变换即可。

createInfo.compositeAlpha = VK_COMPOSITE_ALPHA_OPAQUE_BIT_KHR;

compositeAlpha用于指定alpha通道是否用来和其它窗口进行混合操作。通常将其设置为VK_COMPOSITE_ALPHA_OPAQUE_BIT_KHR来忽略掉alpha通道。

createInfo.presentMode = presentMode;
createInfo.clipped = VK_TRUE;

presentMode用于设置呈现模式。

clipped设置为VK_TRUE表示我们不关心被其它窗口遮挡的像素,这允许Vulkan采取一定的优化措施,但如果回读窗口的像素值就可能出现问题。

createInfo.oldSwapchain = VK_NULL_HANDLE;

最后是oldSwapchain,应用程序在运行过程中交换链可能会失效,比如改变窗口大小后,交换链需要重建,重建时需要之前的交换链,我们会在之后的章节介绍具体细节。现在,我们还没有创建任何交换链,将它设置为VK_NULL_HANDLE即可。

添加一个VkSwapchainKHR类成员变量来存储交换链:

VkSwapchainKHR swapChain;

调用vkCreateSwapchainKHR函数创建交换链:

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

vkCreateSwapchainKHR函数的参数依次是逻辑设备、交换链创建信息、自定义内存分配器和用于存储交换链句柄的指针。

我们还需要在cleanup函数中调用vkDestroySwapchainKHR来销毁交换链对象:

void cleanup() {
vkDestroySwapchainKHR(device, swapChain, nullptr);
...
}

现在编译运行程序,确保成功创建交换链。

移除createInfo.imageExtent=extent;这行代码,然后启用校验层,编译运行程序,就可以捕获错误,得到一些有用地信息:

swap chain validation layer

获取交换链图像

我们已经创建了交换链,接下来要做的就是获取交换链图像,我们会在之后使用这些图像进行渲染操作。现在,添加成员变量用于存储这些图像句柄:

std::vector<VkImage> swapChainImages

交换链图像由交换链自己负责创建,并在交换链销毁时自动清除,不需要我们进行创建和清除操作。

createSwapChain函数的尾部,添加代码来获取交换链图像句柄。获取它们的方法和获取其它Vulkan对象的方法类似,首先获取交换链图像的数量,然后分配数组空间,再获取交换链图像句柄:

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

我们在创建交换链时指定了minImageCount成员变量来请求最小需要的交换链图像数量。具体的驱动实现可能会创建比这个数量更多的交换链图像,因此需要显式地查询交换链图像数量,确保不会出错。

最后,在成员变量中存储设置的交换链图像格式和范围,在之后的章节会使用它们:

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

...

swapChainImageFormat = surfaceFormat.format;
swapChainExtent = extent;

现在,我们已经拥有了可以进行绘制操作的交换链图像,以及可以呈现图像的窗口表面。从下一章节开始,我们开始学习真正的图形管线部分。

本章节代码:C++代码