交换链
Vulkan 没有“默认帧缓冲”的概念,因此它需要一个基础设施,该基础设施将拥有我们将在屏幕上可视化之前渲染到的缓冲区。此基础设施被称为交换链,必须在 Vulkan 中显式创建。交换链本质上是一个等待呈现到屏幕的图像队列。我们的应用程序将获取这样一个图像来绘制,然后将其返回到队列。队列的确切工作方式以及从队列中呈现图像的条件取决于交换链的设置方式,但交换链的总体目的是将图像的呈现与屏幕的刷新率同步。
检查交换链支持
并非所有显卡都能够出于各种原因直接将图像呈现到屏幕,例如,因为它们是为服务器设计的,并且没有任何显示输出。其次,由于图像呈现与窗口系统和与窗口关联的表面紧密相关,因此它实际上不是 Vulkan 核心的一部分。在查询其支持后,您必须启用 VK_KHR_swapchain
设备扩展。
为此,我们将首先扩展 isDeviceSuitable
函数,以检查是否支持此扩展。我们之前已经了解了如何列出 VkPhysicalDevice
支持的扩展,因此这样做应该相当简单。请注意,Vulkan 头文件提供了一个很好的宏 VK_KHR_SWAPCHAIN_EXTENSION_NAME
,它被定义为 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;
}
修改函数体以枚举扩展,并检查所有必需的扩展是否都在其中。
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();
}
我选择在这里使用一组字符串来表示未确认的必需扩展。这样,我们可以在枚举可用扩展序列时轻松地将其剔除。当然,您也可以像在 checkValidationLayerSupport
中那样使用嵌套循环。性能差异是无关紧要的。现在运行代码并验证您的显卡是否确实能够创建交换链。应该注意的是,我们在上一章中检查过的呈现队列的可用性意味着必须支持交换链扩展。但是,明确说明事情仍然是好的,并且扩展确实必须显式启用。
启用设备扩展
使用交换链首先需要启用 VK_KHR_swapchain
扩展。启用扩展只需要对逻辑设备创建结构进行少量更改
createInfo.enabledExtensionCount = static_cast<uint32_t>(deviceExtensions.size());
createInfo.ppEnabledExtensionNames = deviceExtensions.data();
执行此操作时,请确保替换现有行 createInfo.enabledExtensionCount = 0;
。
查询交换链支持的详细信息
仅检查交换链是否可用是不够的,因为它实际上可能与我们的窗口表面不兼容。创建交换链还涉及比实例和设备创建更多的设置,因此我们需要查询更多详细信息才能继续进行。
基本上我们需要检查三种属性
- 基本表面功能(交换链中图像的最小/最大数量,图像的最小/最大宽度和高度)
- 表面格式(像素格式,色彩空间)
- 可用呈现模式
与 findQueueFamilies
类似,我们将使用一个结构来传递这些详细信息,一旦它们被查询。上述三种类型的属性以以下结构和结构列表的形式出现
struct SwapChainSupportDetails {
VkSurfaceCapabilitiesKHR capabilities;
std::vector<VkSurfaceFormatKHR> formats;
std::vector<VkPresentModeKHR> presentModes;
};
我们现在将创建一个新函数 querySwapChainSupport
,它将填充此结构。
SwapChainSupportDetails querySwapChainSupport(VkPhysicalDevice device) {
SwapChainSupportDetails details;
return details;
}
本节介绍如何查询包含此信息的结构。这些结构的含义以及它们确切包含哪些数据将在下一节中讨论。
让我们从基本表面功能开始。这些属性易于查询,并返回到单个 VkSurfaceCapabilitiesKHR
结构中。
vkGetPhysicalDeviceSurfaceCapabilitiesKHR(device, surface, &details.capabilities);
此函数在确定受支持的功能时会考虑指定的 VkPhysicalDevice
和 VkSurfaceKHR
窗口表面。所有的支持查询函数都将这两个作为第一个参数,因为它们是交换链的核心组件。
下一步是查询支持的表面格式。由于这是一个结构列表,因此它遵循 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();
}
重要的是,我们只在验证扩展可用后才尝试查询交换链支持。函数的最后一行更改为
return indices.isComplete() && extensionsSupported && swapChainAdequate;
为交换链选择正确的设置
如果满足 swapChainAdequate
条件,则支持肯定是足够的,但可能仍然存在许多不同的优化模式。我们现在将编写几个函数来找到最佳可能的交换链的正确设置。有三种类型的设置需要确定
- 表面格式(颜色深度)
- 呈现模式(“交换”图像到屏幕的条件)
- 交换范围(交换链中图像的分辨率)
对于这些设置中的每一个,我们都会有一个理想的值,如果可用,我们将使用它,否则我们将创建一些逻辑来找到下一个最佳值。
表面格式
此设置的函数从这里开始。稍后我们将传递 SwapChainSupportDetails
结构的 formats
成员作为参数。
VkSurfaceFormatKHR chooseSwapSurfaceFormat(const std::vector<VkSurfaceFormatKHR>& availableFormats) {
}
每个 VkSurfaceFormatKHR
条目都包含一个 format
和一个 colorSpace
成员。 format
成员指定颜色通道和类型。例如,VK_FORMAT_B8G8R8A8_SRGB
表示我们按顺序存储 B、G、R 和 alpha 通道,每个通道使用 8 位无符号整数,总共每像素 32 位。 colorSpace
成员指示是否支持 SRGB 色彩空间,使用 VK_COLOR_SPACE_SRGB_NONLINEAR_KHR
标志。请注意,此标志在旧版本的规范中曾被称为 VK_COLORSPACE_SRGB_NONLINEAR_KHR
。
对于色彩空间,如果 SRGB 可用,我们将使用 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
是一个非常好的折衷方案。它使我们能够避免撕裂,同时仍然保持相当低的延迟,方法是渲染尽可能最新的新图像,直到垂直消隐。在能源消耗更重要的移动设备上,您可能需要改用 VK_PRESENT_MODE_FIFO_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;
}
交换范围
现在,让我们浏览列表,看看 VK_PRESENT_MODE_MAILBOX_KHR
是否可用
VkExtent2D chooseSwapExtent(const VkSurfaceCapabilitiesKHR& capabilities) {
}
这只剩下一个主要属性,为此我们将添加最后一个函数
交换范围是交换链图像的分辨率,它几乎总是完全等于我们正在绘制到的窗口的像素分辨率(稍后会详细介绍)。可能的分辨率范围在 VkSurfaceCapabilitiesKHR
结构中定义。Vulkan 告诉我们通过在 currentExtent
成员中设置宽度和高度来匹配窗口的分辨率。但是,某些窗口管理器确实允许我们在此处有所不同,这通过将 currentExtent
中的宽度和高度设置为特殊值来指示:uint32_t
的最大值。在这种情况下,我们将选择最适合 minImageExtent
和 maxImageExtent
边界内窗口的分辨率。但是我们必须以正确的单位指定分辨率。
#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;
}
}
GLFW 在测量尺寸时使用两个单位:像素和屏幕坐标。例如,我们在创建窗口时较早指定的 {WIDTH, HEIGHT}
分辨率以屏幕坐标测量。但是 Vulkan 使用像素,因此交换链范围也必须以像素为单位指定。不幸的是,如果您使用的是高 DPI 显示器(例如 Apple 的 Retina 显示器),则屏幕坐标不对应于像素。相反,由于更高的像素密度,窗口的像素分辨率将大于屏幕坐标分辨率。因此,如果 Vulkan 没有为我们修复交换范围,我们不能只使用原始的 {WIDTH, HEIGHT}
。相反,我们必须使用 glfwGetFramebufferSize
查询窗口的像素分辨率,然后再将其与最小和最大图像范围进行匹配。
创建交换链
这里的 clamp
函数用于将 width
和 height
的值限制在实现支持的允许的最小和最大范围之间。
现在我们有了所有这些辅助函数来帮助我们做出运行时必须做出的选择,我们终于拥有了创建工作交换链所需的所有信息。
void initVulkan() {
createInstance();
setupDebugMessenger();
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);
}
创建一个 createSwapChain
函数,该函数以这些调用的结果开头,并确保在逻辑设备创建后从 initVulkan
调用它。
uint32_t imageCount = swapChainSupport.capabilities.minImageCount;
除了这些属性之外,我们还必须决定我们希望在交换链中拥有多少图像。实现指定了它运行所需的最小数量
uint32_t imageCount = swapChainSupport.capabilities.minImageCount + 1;
但是,仅仅坚持这个最小值意味着我们有时可能需要等待驱动程序完成内部操作,然后才能获取另一个图像进行渲染。因此,建议请求至少比最小值多一个图像
if (swapChainSupport.capabilities.maxImageCount > 0 && imageCount > swapChainSupport.capabilities.maxImageCount) {
imageCount = swapChainSupport.capabilities.maxImageCount;
}
我们还应该确保在执行此操作时不要超过图像的最大数量,其中 0
是一个特殊值,表示没有最大值
VkSwapchainCreateInfoKHR createInfo{};
createInfo.sType = VK_STRUCTURE_TYPE_SWAPCHAIN_CREATE_INFO_KHR;
createInfo.surface = surface;
按照 Vulkan 对象的惯例,创建交换链对象需要填写一个大型结构。它开始时非常熟悉
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;
在指定交换链应绑定到的表面之后,指定交换链图像的详细信息
QueueFamilyIndices indices = findQueueFamilies(physicalDevice);
uint32_t queueFamilyIndices[] = {indices.graphicsFamily.value(), indices.presentFamily.value()};
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
}
imageArrayLayers
指定每个图像由多少层组成。除非您正在开发立体 3D 应用程序,否则这始终为 1
。 imageUsage
位字段指定我们将用于交换链中图像的操作类型。在本教程中,我们将直接渲染到它们,这意味着它们用作颜色附件。您也可以先将图像渲染到单独的图像以执行后处理之类的操作。在这种情况下,您可以改用 VK_IMAGE_USAGE_TRANSFER_DST_BIT
之类的值,并使用内存操作将渲染的图像传输到交换链图像。
- 接下来,我们需要指定如何处理将在多个队列族中使用的交换链图像。如果图形队列族与呈现队列不同,则在我们的应用程序中就是这种情况。我们将从图形队列在交换链中的图像上绘制,然后在呈现队列上提交它们。有两种方法可以处理从多个队列访问的图像
-
VK_SHARING_MODE_EXCLUSIVE
:图像一次由一个队列族拥有,并且必须显式传输所有权,然后才能在另一个队列族中使用它。此选项提供最佳性能。
VK_SHARING_MODE_CONCURRENT
:图像可以在多个队列族之间使用,而无需显式的所有权传输。
createInfo.preTransform = swapChainSupport.capabilities.currentTransform;
如果队列族不同,那么我们将在本教程中使用并发模式,以避免进行所有权章节,因为这些章节涉及一些在稍后时间更好地解释的概念。并发模式要求您预先指定将使用 queueFamilyIndexCount
和 pQueueFamilyIndices
参数在哪些队列族之间共享所有权。如果图形队列族和呈现队列族相同(大多数硬件都是这种情况),那么我们应该坚持使用独占模式,因为并发模式要求您指定至少两个不同的队列族。
createInfo.compositeAlpha = VK_COMPOSITE_ALPHA_OPAQUE_BIT_KHR;
我们可以指定是否应将某个变换应用于交换链中的图像(如果支持)(capabilities
中的 supportedTransforms
),例如顺时针旋转 90 度或水平翻转。要指定您不希望进行任何变换,只需指定当前变换即可。
createInfo.presentMode = presentMode;
createInfo.clipped = VK_TRUE;
compositeAlpha
字段指定 alpha 通道是否应用于与窗口系统中的其他窗口混合。您几乎总是希望简单地忽略 alpha 通道,因此为 VK_COMPOSITE_ALPHA_OPAQUE_BIT_KHR
。
createInfo.oldSwapchain = VK_NULL_HANDLE;
presentMode
成员不言自明。如果将 clipped
成员设置为 VK_TRUE
,则意味着我们不关心被遮挡的像素的颜色,例如因为另一个窗口在它们前面。除非您真的需要能够读回这些像素并获得可预测的结果,否则您将通过启用剪裁获得最佳性能。
这只剩下一个字段,oldSwapChain
。使用 Vulkan,您的交换链可能会在应用程序运行时变为无效或未优化,例如因为窗口已调整大小。在这种情况下,实际上需要从头开始重新创建交换链,并且必须在此字段中指定对旧交换链的引用。这是一个复杂的主题,我们将在以后的章节中了解更多信息。现在,我们假设我们只会创建一个交换链。
VkSwapchainKHR swapChain;
现在添加一个类成员来存储 VkSwapchainKHR
对象
if (vkCreateSwapchainKHR(device, &createInfo, nullptr, &swapChain) != VK_SUCCESS) {
throw std::runtime_error("failed to create swap chain!");
}
创建交换链现在就像调用 vkCreateSwapchainKHR
一样简单
void cleanup() {
vkDestroySwapchainKHR(device, swapChain, nullptr);
...
}
参数是逻辑设备、交换链创建信息、可选的自定义分配器以及指向用于存储句柄的变量的指针。那里没有惊喜。应在使用设备之前使用 vkDestroySwapchainKHR
清理它
现在运行应用程序以确保交换链已成功创建!如果此时您在 vkCreateSwapchainKHR
中遇到访问冲突错误,或者看到类似 Failed to find 'vkGetInstanceProcAddress' in layer SteamOverlayVulkanLayer.dll
的消息,请参阅有关 Steam 覆盖层的 FAQ 条目。
检索交换链图像
尝试在启用验证层的情况下删除 createInfo.imageExtent = extent;
行。您会看到其中一个验证层立即捕获到错误,并打印出有用的消息
std::vector<VkImage> swapChainImages;
交换链现在已创建,因此剩下的就是检索其中 VkImage
的句柄。我们将在后面的章节中在渲染操作期间引用这些句柄。添加一个类成员来存储句柄
图像是由实现为交换链创建的,并且它们将在交换链被销毁后自动清理,因此我们不需要添加任何清理代码。
vkGetSwapchainImagesKHR(device, swapChain, &imageCount, nullptr);
swapChainImages.resize(imageCount);
vkGetSwapchainImagesKHR(device, swapChain, &imageCount, swapChainImages.data());
我正在将检索句柄的代码添加到 createSwapChain
函数的末尾,就在 vkCreateSwapchainKHR
调用之后。检索它们与我们从 Vulkan 检索对象数组的其他时间非常相似。请记住,我们仅在交换链中指定了最少数量的图像,因此实现允许创建具有更多图像的交换链。这就是为什么我们将首先使用 vkGetSwapchainImagesKHR
查询图像的最终数量,然后调整容器大小,最后再次调用它以检索句柄的原因。
VkSwapchainKHR swapChain;
std::vector<VkImage> swapChainImages;
VkFormat swapChainImageFormat;
VkExtent2D swapChainExtent;
...
swapChainImageFormat = surfaceFormat.format;
swapChainExtent = extent;
最后一件事,将我们为交换链图像选择的格式和范围存储在成员变量中。我们将在以后的章节中需要它们。
我们现在有一组可以绘制并可以呈现到窗口的图像。下一章将开始介绍如何将图像设置为渲染目标,然后我们将开始研究实际的图形管线和绘制命令!