SwallowJoe的博客

Be a real go-getter,
NEVER SETTLE!

0%

Vulkan入门(10)-重建交换链

简述

在上一篇里,我们已经成功绘制了一个颜色渐变的三角形,并将其显示在窗口上了。但是窗口Surface可能会发生变化,从而使交换链不再与之兼容,比如是窗口大小的变化。所以我们必须捕获这些事件并重新创建交换链。

一. 重建交换链

创建一个新的recreateSwapChain函数,该函数调用createSwapChain和所有依赖于交换链或窗口大小的对象的创建函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
void cleanupSwapChain() {

}

void recreateSwapChain() {
// 等待队列中所有操作完成
vkDeviceWaitIdle(device);
// 先清理后创建
cleanupSwapChain();

createSwapChain();
createImageViews();
createRenderPass();
createGraphicsPipeline();
createFramebuffers();
createCommandBuffers();
}

首先调用vkDeviceWaitIdle,确保不触及可能仍在使用的资源。显然,我们必须做的第一件事是重新创建交换链本身。

需要重新创建图像视图,因为它们直接基于交换链图像。渲染通道需要重新创建,因为它取决于交换链图像的格式。交换链的图像格式很少在窗口调整等操作期间发生改变,但它仍然应该被处理。视口和剪刀矩形大小是在图形管道创建时指定的,因此管道也需要重新构建。可以通过使用动态状态的视图和剪刀矩形来避免这种情况。最后,帧缓冲区和命令缓冲区也直接依赖于交换链图像。

为了确保这些对象的旧版本在重新创建它们之前得到清理,我们应该将一些清理代码移到一个单独的函数中,我们可以从recreateSwapChain函数调用这个函数cleanupSwapChain。

1.1 cleanupSwapChain

将所有和交换链相关的资源从cleanup函数中移到此函数内:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
void cleanupSwapChain() {
// 释放所有的帧缓冲区
for (auto framebuffer : swapChainFramebuffers) {
vkDestroyFramebuffer(device, framebuffer, nullptr);
}
// 释放指令缓冲区
// 可以从头重新创建命令池,但相当浪费。所以选择使用vkFreeCommandBuffers函数清理现有的命令缓冲区。
// 这样可以重用现有的池来分配新的命令缓冲区。
vkFreeCommandBuffers(device, commandPool,
static_cast<uint32_t>(commandBuffers.size()), commandBuffers.data());
// 销毁图形管道
vkDestroyPipeline(device, graphicsPipeline, nullptr);
// 释放管道布局
vkDestroyPipelineLayout(device, pipelineLayout, nullptr);
// 释放渲染通道
vkDestroyRenderPass(device, renderPass, nullptr);
// 释放交换链对应的图像视图
for (auto imageView : swapChainImageViews) {
vkDestroyImageView(device, imageView, nullptr);
}
// 释放交换链
vkDestroySwapchainKHR(device, swapChain, nullptr);
}

void cleanup() {
cleanupSwapChain();
...
}

可以从头重新创建命令池,但相当浪费。所以选择使用vkFreeCommandBuffers函数清理现有的命令缓冲区, 这样可以重用现有的池来分配新的命令缓冲区。

1.2 获取窗口最新大小

为了正确地处理窗口的大小,我们还需要查询framebuffer的当前大小,以确保交换链图像具有(新的)正确的大小。为了做到这一点,改变chooseSwapExtent函数来考虑实际的大小:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 选择交换链分辨率
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::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));
return actualExtent;
}
}

通过glfwGetFramebufferSize函数来获取当前窗口大小。

这就是重建交换链所需要的全部!然而,这种方法的缺点是:需要在创建新的交换链之前停止所有的呈现。当从旧的交换链在图像上绘制命令时,可以创建一个新的交换链。需要将之前的交换链传递给VkSwapchainCreateInfoKHR结构中的oldswarechain字段,并在使用完旧的交换链后立即销毁它。

1.3 次优或过时的交换链

现在,我们只需要确定何时需要重新创建交换链,并调用新的recreateSwapChain函数。幸运的是,Vulkan通常会告诉我们交换链在显示过程中不再足够。vkAcquireNextImageKHR和vkQueuePresentKHR函数可以返回以下特殊值来表示这一点:

  1. VK_ERROR_OUT_OF_DATE_KHR: 交换链已经变得与表面不兼容,不能再用于渲染。通常发生在窗口大小调整之后。
  2. VK_SUBOPTIMAL_KHR:交换链仍然可以成功地呈现到表面,但是表面的属性不再完全匹配。
1
2
3
4
5
6
7
8
9
10
VkResult result = vkAcquireNextImageKHR(device, swapChain,
std::numeric_limits<uint64_t>::max(),
imageAvailableSemaphores[currentFrame], VK_NULL_HANDLE, &imageIndex);
if (result == VK_ERROR_OUT_OF_DATE_KHR) {
recreateSwapChain();
// 已经获得了一个映像。VK_SUCCESS和VK_SUBOPTIMAL_KHR都被认为是“成功”返回码, 也可以去掉return
return;
} else if (result != VK_SUCCESS && result != VK_SUBOPTIMAL_KHR) {
throw std::runtime_error("failed to acquire swap chain image!");
}

如果交换链在试图获取映像时已经过期,那么就不可能再向其呈现。因此,应该立即重新创建交换链,并在下一个drawFrame调用中再次尝试。然而,如果在这中止绘图,那么栅栏将永远不会通过vkqueuessubmit提交,当我们稍后尝试等待它时,它将处于一个意想不到的状态。我们可以重建fence作为交换链重建的一部分,但是移动vkResetFences调用更容易:

1
2
3
4
5
vkResetFences(device, 1, &inFlightFences[currentFrame]);
// VkQueue是Vulkan中应用程序向GPU提交命令的唯一途径
if (vkQueueSubmit(graphicsQueue, 1, &submitInfo, inFlightFences[currentFrame]) != VK_SUCCESS) {
throw std::runtime_error("failed to submit draw command buffer!");
}

把fence放在vkQueueSubmit之前,而不是在vkWaitForFences后立刻调用。这是什么原理呢?

1.3.1 fence

fence是一种同步原语,可用于将依赖项从队列插入到主机。fence有两种状态——有信号的和没有信号的, fence可以作为队列提交命令执行的一部分发出信号。

使用vkResetFences可以将fence置为unsignal状态。主机可以通过vkWaitForFences命令来等待fence,并且可以通过vkGetFenceStatus来查询当前的状态。

如果vkWaitForFences被调用时条件被满足,那么vkWaitForFences会立即返回。如果在vkWaitForFences被调用的时候条件没有被满足,那么vkWaitForFences将会阻塞并等待到超时纳秒,直到条件被满足。这里的条件就是fence状态是不是signal状态。vkQueueSubmit会将fence置为signal状态,那么vkWaitForFences就会通过。

所以,当vkWaitForFences之后立刻调用vkResetFences,那么当vkAcquireNextImageKHR发生异常导致返回时,下次在进入drawFrame调用vkWaitForFences就永远处于等待状态了。

1.3.2 vkQueuePresentKHR

如果交换链不是最优的,也可以继续呈现,因为我们已经获得了一个映像。VK_SUCCESS和VK_SUBOPTIMAL_KHR都被认为是“成功”返回码。

1
2
3
4
5
6
result = vkQueuePresentKHR(presentQueue, &presentInfo);
if (result == VK_ERROR_OUT_OF_DATE_KHR || result == VK_SUBOPTIMAL_KHR) {
recreateSwapChain();
} else if (result != VK_SUCCESS) {
throw std::runtime_error("failed to present swap chain image!");
}

vkQueuePresentKHR函数返回具有相同含义的相同值。 在这种情况下,如果交换链不是次优的,为获得最好的结果,最好重新创建交换链。

1.4 主动处理窗口变化

尽管许多驱动程序和平台在调整窗口大小后会自动触发VK_ERROR_OUT_OF_DATE_KHR,但不能保证一定会发生这种情况。所以最好通过监听窗口变化来主动重建交换链。

添加一个新的成员变量,该变量指示已调整大小:

1
2
3
4
5
6
7
8
9
10
bool framebufferResized = false;

void drawFrame() {
...
result = vkQueuePresentKHR(presentQueue, &presentInfo);
if (result == VK_ERROR_OUT_OF_DATE_KHR || result == VK_SUBOPTIMAL_KHR || framebufferResized) {
framebufferResized = false;
recreateSwapChain();
...
}

1.4.1 监听窗口变化

要实际检测窗口大小调整,可以使用GLFW框架中的glfwSetFramebufferSizeCallback函数来设置回调:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
void initWindow() {
glfwInit();

glfwWindowHint(GLFW_CLIENT_API, GLFW_NO_API);

window = glfwCreateWindow(WIDTH, HEIGHT, "Vulkan", nullptr, nullptr);
glfwSetWindowUserPointer(window, this);
glfwSetFramebufferSizeCallback(window, framebufferResizeCallback);
}

static void framebufferResizeCallback(GLFWwindow* window, int width, int height) {
auto app =
reinterpret_cast<HelloTriangleApplication*>(glfwGetWindowUserPointer(window));
app->framebufferResized = true;
}

因为glfw回调只能通过静态函数实现,所以通过glfwSetWindowUserPointer保存当前实例指针。

1.5 窗口最小化

还有一种特殊状态是,当最小化窗口时,拿到的窗口大小是0, 这样创建出来的帧缓冲区大小也应该是0,根本不需要渲染。所以这里做一个简单的等待处理:

1
2
3
4
5
6
7
8
9
10
void recreateSwapChain() {
int width = 0, height = 0;
while (width == 0 || height == 0) {
glfwGetFramebufferSize(window, &width, &height);
glfwWaitEvents();
}
vkDeviceWaitIdle(device);

...
}

突然有点好奇这个绘制的刷新率,通过在drawFrame里嵌入函数computeRefreshRate来计算刷新率:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
void computeRefreshRate() {
static float fps = 0;
static int64_t count = 0;
static int64_t lastCount = 0;
static auto lastTimestamp = std::chrono::high_resolution_clock::now();
static auto now = std::chrono::high_resolution_clock::now();

count++;

now = std::chrono::high_resolution_clock::now();
float duration = std::chrono::duration_cast<std::chrono::duration<float>>(now-lastTimestamp).count();
if (duration >= 1) {
lastTimestamp = now;
fps = (count - lastCount)/duration;
lastCount = count;
std::cout<<"computeRefreshRate: fps="<<fps<<", count="<<count<<std::endl;
}
}

算出来高达4k,看起来这个fence也并没有同步gpu显示,只是不停的提交。

那么如果我们想吧这个实时刷新率显示在我们程序的左上角,该怎么做呢?

欢迎关注我的其它发布渠道