交换链重建

引言

我们现在的应用程序成功绘制了一个三角形,但仍然有一些情况它无法正确处理。窗口表面有可能发生变化,导致交换链不再兼容。导致这种情况发生的原因之一是窗口大小的改变。我们必须捕获这些事件并重新创建交换链。

重新创建交换链

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

void recreateSwapChain() {
    vkDeviceWaitIdle(device);

    createSwapChain();
    createImageViews();
    createFramebuffers();
}

我们首先调用 vkDeviceWaitIdle,因为就像上一章一样,我们不应该触碰可能仍在使用的资源。显然,我们必须重新创建交换链本身。图像视图需要重新创建,因为它们直接基于交换链图像。最后,帧缓冲直接依赖于交换链图像,因此也必须重新创建。

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

void cleanupSwapChain() {

}

void recreateSwapChain() {
    vkDeviceWaitIdle(device);

    cleanupSwapChain();

    createSwapChain();
    createImageViews();
    createFramebuffers();
}

请注意,为简单起见,我们这里不重新创建渲染通道。理论上,交换链图像格式在应用程序的生命周期中可能会发生变化,例如,当将窗口从标准范围移动到高动态范围监视器时。这可能需要应用程序重新创建渲染通道,以确保动态范围之间的变化得到正确反映。

我们将所有作为交换链刷新的一部分重新创建的对象的清理代码从 cleanup 移动到 cleanupSwapChain

void cleanupSwapChain() {
    for (auto framebuffer : swapChainFramebuffers) {
        vkDestroyFramebuffer(device, framebuffer, nullptr);
    }

    for (auto imageView : swapChainImageViews) {
        vkDestroyImageView(device, imageView, nullptr);
    }

    vkDestroySwapchainKHR(device, swapChain, nullptr);
}

void cleanup() {
    cleanupSwapChain();

    vkDestroyPipeline(device, graphicsPipeline, nullptr);
    vkDestroyPipelineLayout(device, pipelineLayout, nullptr);

    vkDestroyRenderPass(device, renderPass, nullptr);

    for (size_t i = 0; i < MAX_FRAMES_IN_FLIGHT; i++) {
        vkDestroySemaphore(device, renderFinishedSemaphores[i], nullptr);
        vkDestroySemaphore(device, imageAvailableSemaphores[i], nullptr);
        vkDestroyFence(device, inFlightFences[i], nullptr);
    }

    vkDestroyCommandPool(device, commandPool, nullptr);

    vkDestroyDevice(device, nullptr);

    if (enableValidationLayers) {
        DestroyDebugUtilsMessengerEXT(instance, debugMessenger, nullptr);
    }

    vkDestroySurfaceKHR(instance, surface, nullptr);
    vkDestroyInstance(instance, nullptr);

    glfwDestroyWindow(window);

    glfwTerminate();
}

请注意,在 chooseSwapExtent 中,我们已经查询了新的窗口分辨率,以确保交换链图像具有(新的)正确大小,因此无需修改 chooseSwapExtent(请记住,在创建交换链时,我们已经不得不使用 glfwGetFramebufferSize 来获取表面的像素分辨率)。

这就是重新创建交换链所需的全部操作!然而,这种方法的缺点是,我们需要在创建新的交换链之前停止所有渲染。可以在旧交换链的图像上绘制命令仍在进行时创建新的交换链。您需要将之前的交换链传递给 VkSwapchainCreateInfoKHR 结构中的 oldSwapchain 字段,并在您完成使用旧交换链后立即销毁它。

次优或过时的交换链

现在我们只需要弄清楚何时需要重新创建交换链,并调用我们新的 recreateSwapChain 函数。幸运的是,Vulkan 通常会在呈现期间直接告诉我们交换链不再足够。vkAcquireNextImageKHRvkQueuePresentKHR 函数可以返回以下特殊值来指示这种情况。

  • VK_ERROR_OUT_OF_DATE_KHR:交换链已与表面不兼容,无法再用于渲染。通常在窗口调整大小后发生。

  • VK_SUBOPTIMAL_KHR:交换链仍然可以成功呈现到表面,但表面属性不再完全匹配。

VkResult result = vkAcquireNextImageKHR(device, swapChain, UINT64_MAX, imageAvailableSemaphores[currentFrame], VK_NULL_HANDLE, &imageIndex);

if (result == VK_ERROR_OUT_OF_DATE_KHR) {
    recreateSwapChain();
    return;
} else if (result != VK_SUCCESS && result != VK_SUBOPTIMAL_KHR) {
    throw std::runtime_error("failed to acquire swap chain image!");
}

如果在尝试获取图像时发现交换链已过时,则无法再将其呈现。因此,我们应该立即重新创建交换链,并在下一个 drawFrame 调用中重试。

如果交换链是次优的,您也可以决定这样做,但我选择在这种情况下继续,因为我们已经获取了一个图像。VK_SUCCESSVK_SUBOPTIMAL_KHR 都被认为是“成功”返回代码。

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

currentFrame = (currentFrame + 1) % MAX_FRAMES_IN_FLIGHT;

vkQueuePresentKHR 函数返回相同的值,含义相同。在这种情况下,如果交换链是次优的,我们也会重新创建它,因为我们想要获得最佳结果。

修复死锁

如果我们现在尝试运行代码,则有可能遇到死锁。调试代码后,我们发现应用程序到达 vkWaitForFences 但从未继续执行。这是因为当 vkAcquireNextImageKHR 返回 VK_ERROR_OUT_OF_DATE_KHR 时,我们会重新创建交换链,然后从 drawFrame 返回。但在此之前,当前帧的栅栏已被等待并重置。由于我们立即返回,因此没有提交工作进行执行,并且栅栏永远不会被触发,导致 vkWaitForFences 永远停止。

幸运的是,有一个简单的修复方法。延迟重置栅栏,直到我们确定我们将使用它提交工作。因此,如果我们提前返回,栅栏仍然会被触发,并且下次我们使用相同的栅栏对象时,vkWaitForFences 不会死锁。

现在 drawFrame 的开头应如下所示

vkWaitForFences(device, 1, &inFlightFences[currentFrame], VK_TRUE, UINT64_MAX);

uint32_t imageIndex;
VkResult result = vkAcquireNextImageKHR(device, swapChain, UINT64_MAX, imageAvailableSemaphores[currentFrame], VK_NULL_HANDLE, &imageIndex);

if (result == VK_ERROR_OUT_OF_DATE_KHR) {
    recreateSwapChain();
    return;
} else if (result != VK_SUCCESS && result != VK_SUBOPTIMAL_KHR) {
    throw std::runtime_error("failed to acquire swap chain image!");
}

// Only reset the fence if we are submitting work
vkResetFences(device, 1, &inFlightFences[currentFrame]);

显式处理调整大小

尽管许多驱动程序和平台在窗口调整大小后会自动触发 VK_ERROR_OUT_OF_DATE_KHR,但这并不能保证一定会发生。这就是为什么我们要添加一些额外的代码来显式地处理调整大小的情况。首先添加一个新的成员变量来标记是否发生了调整大小的操作。

std::vector<VkFence> inFlightFences;

bool framebufferResized = false;

然后应该修改 drawFrame 函数来检查这个标志。

if (result == VK_ERROR_OUT_OF_DATE_KHR || result == VK_SUBOPTIMAL_KHR || framebufferResized) {
    framebufferResized = false;
    recreateSwapChain();
} else if (result != VK_SUCCESS) {
    ...
}

重要的是在 vkQueuePresentKHR 之后执行此操作,以确保信号量处于一致的状态,否则可能永远无法正确等待已发信号的信号量。现在,为了实际检测调整大小的操作,我们可以在 GLFW 框架中使用 glfwSetFramebufferSizeCallback 函数来设置一个回调函数。

void initWindow() {
    glfwInit();

    glfwWindowHint(GLFW_CLIENT_API, GLFW_NO_API);

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

static void framebufferResizeCallback(GLFWwindow* window, int width, int height) {

}

我们将回调函数创建为 static 函数的原因是,GLFW 不知道如何使用正确的 this 指针来调用我们的 HelloTriangleApplication 实例的成员函数。

但是,我们在回调中获得了对 GLFWwindow 的引用,并且还有一个 GLFW 函数允许你在其中存储一个任意指针:glfwSetWindowUserPointer

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

现在可以使用 glfwGetWindowUserPointer 从回调中检索此值,以正确设置该标志。

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

现在尝试运行程序并调整窗口大小,看看帧缓冲是否确实随着窗口正确调整大小。

处理最小化

还有另一种情况,交换链可能会过时,那就是一种特殊的窗口大小调整:窗口最小化。这种情况很特殊,因为它会导致帧缓冲大小为 0。在本教程中,我们将通过扩展 recreateSwapChain 函数来处理这种情况,即暂停直到窗口再次位于前台。

void recreateSwapChain() {
    int width = 0, height = 0;
    glfwGetFramebufferSize(window, &width, &height);
    while (width == 0 || height == 0) {
        glfwGetFramebufferSize(window, &width, &height);
        glfwWaitEvents();
    }

    vkDeviceWaitIdle(device);

    ...
}

glfwGetFramebufferSize 的初始调用处理了大小已正确的情况,而 glfwWaitEvents 将没有什么可等待的。

恭喜,你现在已经完成了你的第一个表现良好的 Vulkan 程序!在下一章中,我们将摆脱顶点着色器中硬编码的顶点,并实际使用顶点缓冲区。