Skip to content

门泊吴船亦已谋

Vulkan 画三角形 第六章 创建交换链

我们在把图像展示到屏幕之前,需要先把它们渲染到缓冲区中,而交换链就是存放缓冲区的地方。交换链本质上是一个存放需要展示到屏幕的图像的队列。我们的应用程序要做的就是从这个队列中取出一个图像,然后在上面渲染,最后再返回给队列。这个队列是怎么工作的以及队列中的图像什么时候展示到屏幕上,它们都取决于交换链是怎么创建的,但是交换链一般的目的都是同步图像展示和屏幕刷新率

为了能够使用交换链,我们需要在创建逻辑设备的时候启用交换链扩展,此外也可以在选取物理设备的时候检查显卡是否支持交换链。之前文章的代码中已经包含了这些内容,所以我们继续往下看

还是先创建一个成员变量

VkSwapchainKHR swapchain;

然后添加一个成员函数并在 initVulkan 中调用它

void initVulkan() {
    createInstance();
    createSurface();
    pickPhysicalDevice();
    createLogicalDevice();
    createSwapchain();
}

void createSwapchain() {

}

于是我们就可以开始填充这个函数了

选取交换链的正确配置

为了最优化效果,我们在创建交换链之前,需要为它选择一些配置。我们需要决定三种类型的配置

  • 表面格式(颜色深度,每个颜色通道的位数)
  • 展示模式(什么条件下把图像展示到屏幕上)
  • 交换范围(交换链中图像的分辨率)

选取表面格式

首先是表面格式。我们需要查询显卡支持的表面格式,然后从中选取一个合适的

// 选取表面格式
uint32_t formatCount;
vkGetPhysicalDeviceSurfaceFormatsKHR(physicalDevice, surface, &formatCount, nullptr);
std::vector<VkSurfaceFormatKHR> formats(formatCount);
vkGetPhysicalDeviceSurfaceFormatsKHR(physicalDevice, surface, &formatCount, formats.data());
VkSurfaceFormatKHR formatChosen = formats[0];
for (const auto &availableFormat : formats) {
    if (availableFormat.format == VK_FORMAT_B8G8R8A8_SRGB && availableFormat.colorSpace == VK_COLOR_SPACE_SRGB_NONLINEAR_KHR) {
        formatChosen = availableFormat;
        break;
    }
}

稍微说明一下,VkSurfaceFormatKHR 结构体包含 format 和 colorSpace 两个成员。format 成员表示颜色通道和类型,例如最常见的 VK_FORMAT_B8G8R8A8_SRGB 格式就意味着每个像素由 B、G、R、A 四个通道组成,每个通道各占 8 位,总共 32 位。而 colorSpace 通过 VK_COLOR_SPACE_SRGB_NONLINEAR_KHR 标记来是否支持 SRGB 的颜色空间

然后上面的代码中,我们优先选择颜色空间为 SRGB 且格式为 VK_FORMAT_B8G8R8A8_SRGB 的表面格式,如果没有就直接使用数组中的第一个格式

选取展示模式

接下来我们选择展示模式

展示模式是交换链的最重要的配置,因为它代表了把图片显示到屏幕上的实际条件。Vulkan 中有四种可选的展示模式

  • VK_PRESENT_MODE_IMMEDIATE_KHR
    应用程序提交的图像会被立刻传递到屏幕上,这可能会造成撕裂现象
  • VK_PRESENT_MODE_FIFO_KHR
    交换链是一个队列,当显示器刷新时会取走队首的图像进行展示,应用程序在队尾插入渲染好的图像。如果队列满了,应用程序将会需要等待屏幕刷新。非常类似于现代游戏中的垂直同步。另外显示器刷新的瞬间也被称为 vertical blank
  • VK_PRESENT_MODE_FIFO_RELAXED_KHR
    与第二个模式类似,唯一的不同是如果应用程序渲染比较慢导致队列为空,而显示器却需要刷新的时候,显示器不会等待到下一次刷新(或者说 vertical blank),而是在图像被应用程序渲染好之后立刻展示图像。这同样可能会导致撕裂
  • VK_PRESENT_MODE_MAILBOX_KHR
    这又是第二个模式的另一个变种。不同之处在于,队列满的时候不会阻塞应用程序,而是把队列中的旧图像直接替换为应用程序新渲染好的图像。这个模式可以用于实现三重缓冲,比起垂直同步的双重缓冲,它能够减少很多的延迟问题,且也能避免撕裂现象

不过只有 VK_PRESENT_MODE_FIFO_KHR 模式是确保能够可用的,为了方便,我们可以直接使用这个模式

// 选取展示模式
VkPresentModeKHR presentModeChosen = VK_PRESENT_MODE_FIFO_KHR;

选取交换范围

这个就比较简单了,就是通过窗体的分辨率来指定交换链中图像的宽和高。我们可以通过获取 VkSurfaceCapabilitiesKHR 结构体来得到窗体的当前分辨率

但是有的窗体管理器可能不太一样,它们允许我们指定不同的值。这种情况下,表示当前窗体分辨率的成员 currentExtent 中的高和宽都是 uint32_t 的最大值,我们需要通过 minImageExtent 和 maxImageExtent 来选择一个合适的交换范围。下面的代码就处理了这种情况

// 选择交换范围
VkSurfaceCapabilitiesKHR capabilities;
vkGetPhysicalDeviceSurfaceCapabilitiesKHR(physicalDevice, surface, &capabilities);
VkExtent2D actualExtent = capabilities.currentExtent;
if (capabilities.currentExtent.width == UINT32_MAX) {
    actualExtent = {WIDTH, HEIGHT};
    actualExtent.width = std::max(capabilities.minImageExtent.width, std::min(capabilities.maxImageExtent.width, actualExtent.width));
    actualExtent.height = std::max(capabilities.minImageExtent.height, std::min(capabilities.maxImageExtent.height, actualExtent.height));
}

创建交换链

跟之前的一样,在创建之前我们需要先填充一个结构体

VkSwapchainCreateInfoKHR createInfo = {};
createInfo.sType = VK_STRUCTURE_TYPE_SWAPCHAIN_CREATE_INFO_KHR;
createInfo.surface = surface;
createInfo.minImageCount = capabilities.minImageCount;
createInfo.imageFormat = formatChosen.format;
createInfo.imageColorSpace = formatChosen.colorSpace;
createInfo.imageExtent = actualExtent;
createInfo.imageArrayLayers = 1;
createInfo.imageUsage = VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT;

上面这段代码大部分内容只是把之前选取好的配置填进去
后面的 imageArrayLayers 表示每张图像由多少层组成,如果不是要开发立体三维应用,填一就行了
最后的 imageUsage 是一个位域,用于指定我们会使用交换链中的图像来做什么类型的操作。我们只是需要直接渲染到图像上,这意味着交换链中的图像只是被当作颜色附着来使用,所以使用了 VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT 的标志位

接下来需要指定图像的共享模式

createInfo.imageSharingMode = VK_SHARING_MODE_EXCLUSIVE;

共享模式指定了图像在不同队列之间传递规则,比如说如果我们的图形队列跟展示队列不是同一个队列,我们就需要通过图形队列在交换链中的图像上渲染,再在展示队列上提交它们,这就需要图像在不同队列中共享

有两种共享模式

  • VK_SHARING_MODE_EXCLUSIVE
    一个图像只能被一个队列族所有,如果要用于其他的队列族,必须显式地传递所有权。这个模式具有最优的性能
  • VK_SHARING_MODE_CONCURRENT
    图像能被用于多个不同的队列族,不需要显式的所有权传递

不过对于大多数硬件而言,图形队列族和展示队列族都可以是同一个队列族,并且前文中我们所选择的队列族也同时支持图形与展示,图像不会在不同队列族之间共享。因此我们使用 VK_SHARING_MODE_EXCLUSIVE 就行了
另外,如果是 VK_SHARING_MODE_CONCURRENT 的话,还需要在结构体中指定哪些队列族之间需要共享图像

然后指定交换链中图像的变换,比如旋转 90 度或者垂直翻转这些。我们不需要这样的功能,因此使用当前变换即可

createInfo.preTransform = capabilities.currentTransform;

接着指定一下混合透明度,compositeAlpha 成员用于指定 Alpha 通道是否用于跟窗体系统中的其他窗体进行混合,我们几乎不需要考虑这种情况,因此使用 VK_COMPOSITE_ALPHA_OPAQUE_BIT_KHR

createInfo.compositeAlpha = VK_COMPOSITE_ALPHA_OPAQUE_BIT_KHR;

接下来传入 presentMode

createInfo.presentMode = presentModeChosen;

然后是指定剪裁,把 clipped 设为 VK_TRUE 意味着我们不考虑被遮挡的像素的颜色,比如它前面可能有一别的窗体。除非真的有回读这些像素的需要,为了最佳的性能,我们还是启用剪裁

createInfo.clipped = VK_TRUE;

下一个是 oldSwapchain。在运行过程中,你的交换链可能会失效或者失去优化,比如窗体的尺寸被用户调整的时候。这种情况下我们需要重新从头开始创建交换链,之前的交换链就必须在这里被传入。这是一个非常复杂的话题,我们先暂时假设我们只会创建一个交换链

createInfo.oldSwapchain = VK_NULL_HANDLE;

最后,我们终于可以根据这个结构体创建交换链了

if (vkCreateSwapchainKHR(device, &createInfo, nullptr, &swapchain))
    throw std::runtime_error("failed to create swapchain!");

销毁交换链

修改 cleanup 函数如下

void cleanup() {
    vkDestroySwapchainKHR(device, swapchain, nullptr);
    vkDestroyDevice(device, nullptr);
    vkDestroySurfaceKHR(instance, surface, nullptr);
    vkDestroyInstance(instance, nullptr);

    glfwDestroyWindow(window);
    glfwTerminate();
}

取回交换链的图像

创建了交换链之后,我们需要取回交换链中图像的句柄

添加一个成员变量用于保存它们的句柄

std::vector<VkImage> swapchainImages;

然后在 createSwapchain 函数的末尾添加如下代码

uint32_t imageCount;
vkGetSwapchainImagesKHR(device, swapchain, &imageCount, nullptr);
swapchainImages.resize(imageCount);
vkGetSwapchainImagesKHR(device, swapchain, &imageCount, swapchainImages.data());

保存格式与范围

我们需要在成员变量中保存一下之前为交换链选取的格式与范围以备之后的使用

VkFormat swapchainImageFormat;
VkExtent2D swapchainExtent;

再在 createSwapchain 函数的末尾添加如下代码

swapchainImageFormat = formatChosen.format;
swapchainExtent = actualExtent;