交换链

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);

此函数在确定支持的功能时会考虑指定的 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();
}

重要的是,我们仅在验证扩展可用后才尝试查询交换链支持。该函数的最后一行更改为

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 颜色格式,其中最常见的格式之一是 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。 现在,让我们浏览列表,看看 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;
}

交换范围

这只剩下一个主要属性,我们将为其添加最后一个函数

VkExtent2D chooseSwapExtent(const VkSurfaceCapabilitiesKHR& capabilities) {

}

交换范围是交换链图像的分辨率,并且几乎总是与我们正在绘制到的窗口的分辨率以像素为单位完全相同(稍后会详细介绍)。 可能的分辨率范围在 VkSurfaceCapabilitiesKHR 结构中定义。 Vulkan 告诉我们通过设置 currentExtent 成员中的宽度和高度来匹配窗口的分辨率。 但是,某些窗口管理器确实允许我们在此处进行差异,这可以通过将 currentExtent 中的宽度和高度设置为特殊值来指示:uint32_t 的最大值。 在这种情况下,我们将选择在 minImageExtentmaxImageExtent 边界内最匹配窗口的分辨率。 但是我们必须以正确的单位指定分辨率。

GLFW 在测量尺寸时使用两个单位:像素和屏幕坐标。 例如,我们之前在创建窗口时指定的 {WIDTH, HEIGHT} 分辨率是以屏幕坐标为单位测量的。 但是 Vulkan 使用像素,因此交换链范围也必须以像素为单位指定。 不幸的是,如果您使用高 DPI 显示器(如 Apple 的 Retina 显示器),屏幕坐标与像素不对应。 相反,由于较高的像素密度,窗口以像素为单位的分辨率将大于以屏幕坐标为单位的分辨率。 因此,如果 Vulkan 没有为我们修复交换范围,我们不能只使用原始的 {WIDTH, HEIGHT}。 相反,我们必须使用 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;
    }
}

此处的 clamp 函数用于将 widthheight 的值限制在实现支持的允许的最小和最大范围之间。

创建交换链

现在,我们有了所有这些辅助函数来帮助我们进行运行时必须做出的选择,我们终于拥有了创建工作交换链所需的所有信息。

创建一个 createSwapChain 函数,该函数从这些调用的结果开始,并确保在创建逻辑设备后从 initVulkan 调用它。

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);
}

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

uint32_t imageCount = swapChainSupport.capabilities.minImageCount;

但是,仅坚持此最小值意味着我们有时可能必须等待驱动程序完成内部操作,然后才能获取另一个图像进行渲染。 因此,建议请求至少比最小值多一个图像

uint32_t imageCount = swapChainSupport.capabilities.minImageCount + 1;

我们还应确保在此过程中不要超过最大图像数量,其中 0 是一个特殊值,表示没有最大值

if (swapChainSupport.capabilities.maxImageCount > 0 && imageCount > swapChainSupport.capabilities.maxImageCount) {
    imageCount = swapChainSupport.capabilities.maxImageCount;
}

按照 Vulkan 对象的惯例,创建交换链对象需要填写一个大型结构。 它开始时非常熟悉

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 指定每个图像包含的层数。 除非您正在开发立体 3D 应用程序,否则此值始终为 1imageUsage 位字段指定我们将在交换链中使用图像进行哪种类型的操作。 在本教程中,我们将直接渲染到它们,这意味着它们用作颜色附件。 您也可以先将图像渲染到单独的图像中,以执行诸如后处理之类的操作。 在这种情况下,您可以使用类似 VK_IMAGE_USAGE_TRANSFER_DST_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
}

接下来,我们需要指定如何处理将在多个队列族之间使用的交换链图像。如果图形队列族与呈现队列不同,我们的应用程序中就会出现这种情况。我们将从图形队列在交换链中的图像上进行绘制,然后将其提交到呈现队列。有两种方法可以处理从多个队列访问的图像

  • VK_SHARING_MODE_EXCLUSIVE:一个图像一次只能由一个队列族拥有,并且必须在另一个队列族中使用它之前显式地转移所有权。此选项提供最佳性能。

  • VK_SHARING_MODE_CONCURRENT:图像可以在多个队列族之间使用,无需显式的所有权转移。

如果队列族不同,那么我们将在本教程中使用并发模式,以避免执行所有权转移的章节,因为这些章节涉及一些最好在稍后时间解释的概念。并发模式要求你预先指定在哪些队列族之间共享所有权,使用queueFamilyIndexCountpQueueFamilyIndices参数。如果图形队列族和呈现队列族相同,这在大多数硬件上都是如此,那么我们应该坚持使用独占模式,因为并发模式要求你指定至少两个不同的队列族。

createInfo.preTransform = swapChainSupport.capabilities.currentTransform;

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

createInfo.compositeAlpha = VK_COMPOSITE_ALPHA_OPAQUE_BIT_KHR;

compositeAlpha 字段指定是否应使用 alpha 通道与窗口系统中的其他窗口进行混合。你几乎总是希望简单地忽略 alpha 通道,因此使用 VK_COMPOSITE_ALPHA_OPAQUE_BIT_KHR

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

presentMode 成员不言自明。如果 clipped 成员设置为 VK_TRUE,则意味着我们不关心被遮挡像素的颜色,例如因为另一个窗口位于它们前面。除非你真的需要能够读取这些像素并获得可预测的结果,否则启用裁剪将获得最佳性能。

createInfo.oldSwapchain = VK_NULL_HANDLE;

还剩最后一个字段,oldSwapchain。使用 Vulkan,当你的应用程序运行时,你的交换链可能会失效或未优化,例如因为窗口大小已调整。在这种情况下,实际上需要从头开始重新创建交换链,并且必须在此字段中指定对旧交换链的引用。这是一个复杂的主题,我们将在未来的章节中了解更多。现在,我们假设我们只会创建一个交换链。

现在添加一个类成员来存储 VkSwapchainKHR 对象

VkSwapchainKHR swapChain;

现在创建交换链就像调用 vkCreateSwapchainKHR 一样简单

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

参数是逻辑设备、交换链创建信息、可选的自定义分配器和一个指向存储句柄的变量的指针。这没什么意外。应该在设备之前使用 vkDestroySwapchainKHR 清理它

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

现在运行应用程序,以确保交换链已成功创建!如果此时你在 vkCreateSwapchainKHR 中收到访问冲突错误,或者看到类似 Failed to find 'vkGetInstanceProcAddress' in layer SteamOverlayVulkanLayer.dll 的消息,请参阅有关 Steam 覆盖层的常见问题解答条目

尝试在启用验证层的情况下删除 createInfo.imageExtent = extent; 行。你会看到其中一个验证层立即捕获到错误并打印出有用的消息

swap chain validation layer

检索交换链图像

交换链现在已创建,因此剩下的就是检索其中 VkImage 的句柄。我们将在后面的章节中的渲染操作期间引用这些句柄。添加一个类成员来存储句柄

std::vector<VkImage> swapChainImages;

这些图像是由交换链的实现创建的,它们将在交换链销毁后自动清理,因此我们不需要添加任何清理代码。

我将检索句柄的代码添加到 createSwapChain 函数的末尾,紧接在 vkCreateSwapchainKHR 调用之后。检索它们与我们从 Vulkan 中检索对象数组的其他情况非常相似。请记住,我们仅在交换链中指定了最少数量的图像,因此允许实现创建具有更多图像的交换链。这就是为什么我们首先使用 vkGetSwapchainImagesKHR 查询图像的最终数量,然后调整容器大小,最后再次调用它以检索句柄。

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

最后一件事,将我们为交换链图像选择的格式和范围存储在成员变量中。我们将在以后的章节中需要它们。

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

...

swapChainImageFormat = surfaceFormat.format;
swapChainExtent = extent;

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