Skip to content

门泊吴船亦已谋

Vulkan 画三角形「十四」渲染和展示

本文中我们将会结合之前的东西,最后在屏幕上把三角形画出来

首先添加一个 drawFrame 函数,并在 mainLoop 的循环中调用它

void mainLoop() {
    while (!glfwWindowShouldClose(window)) {
        glfwPollEvents();
        drawFrame();
    }
}

void drawFrame() {

}

同步

drawFrame 函数将会执行如下几个操作

  • 向交换链请求一个图像
  • 执行命令缓冲区,并把这个图像作为它的帧缓冲区中的附着
  • 把这个图像返还给交换链用于展示

这些操作分别都是一次函数调用,但是它们其实是异步执行的。这些函数调用将会在操作实际完成之前就直接返回,执行的顺序就是未定义的。这非常不幸,因为我们希望每一个操作都要在之前的操作完成后才能执行

有两种方式来同步这些交换链事件:屏障和信号量。它们都是能够用于协调操作的对象,通过这样的方式,一个操作等待信号量或屏障的释放,而另一个操作完成后释放信号量

它们的不同之处在于,屏障可以被我们的程序使用诸如 vkWaitForFences 等的函数调用来访问,而信号量就不可以。屏障主要是用于同步应用本身与渲染操作的,然而信号量是用于同步命令队列之间或之内的操作。我们这里希望同步绘制命令和展示的队列操作,所以信号量最合适

信号量

我们需要一个信号量来处理图像已经被交换链返回且已经能够用于渲染的事件,还需要另外一个来处理渲染已经结束且可以用于展示的事件。于是我们创建两个成员变量来存储这两个信号量对象

VkSemaphore imageAvailableSemaphore;
VkSemaphore renderFinishedSemaphore;

于是添加一个成员函数用于创建信号量并在 initVulkan 中调用它

void initVulkan() {
    createInstance();
    createSurface();
    pickPhysicalDevice();
    createLogicalDevice();
    createSwapchain();
    createImageViews();
    createRenderPass();
    createGraphicsPipeline();
    createFramebuffers();
    createCommandPool();
    createCommandBuffers();
    createSemaphores();
}

void createSemaphores() {
    VkSemaphoreCreateInfo semaphoreInfo = {};
    semaphoreInfo.sType = VK_STRUCTURE_TYPE_SEMAPHORE_CREATE_INFO;
    if (vkCreateSemaphore(device, &semaphoreInfo, nullptr, &imageAvailableSemaphore) != VK_SUCCESS  vkCreateSemaphore(device, &semaphoreInfo, nullptr, &renderFinishedSemaphore) != VK_SUCCESS)
        throw std::runtime_error("faild to create semaphores!");
}

最后在 cleanup 中销毁它们

void cleanup() {
    vkDestroySemaphore(device, renderFinishedSemaphore, nullptr);
    vkDestroySemaphore(device, imageAvailableSemaphore, nullptr);
    vkDestroyCommandPool(device, commandPool, nullptr);
    for (auto framebuffer : swapchainFramebuffers)
        vkDestroyFramebuffer(device, framebuffer, nullptr);
    vkDestroyPipeline(device, graphicsPipeline, nullptr);
    vkDestroyPipelineLayout(device, pipelineLayout, nullptr);
    vkDestroyRenderPass(device, renderPass, nullptr);
    for (auto imageView : swapchainImageViews)
        vkDestroyImageView(device, imageView, nullptr);
    vkDestroySwapchainKHR(device, swapchain, nullptr);
    vkDestroyDevice(device, nullptr);
    vkDestroySurfaceKHR(instance, surface, nullptr);
    vkDestroyInstance(instance, nullptr);

    glfwDestroyWindow(window);
    glfwTerminate();
}

向交换链请求图像

正如之前所说,在 drawFrame 函数中要做的第一件事就是向交换链请求一张图像。因为交换链是一个扩展特性,所以我们肯定会用到一个带有 vk*KHR 这样的命名规则的函数

我们在 drawFrame 函数中继续填写

uint32_t imageIndex;
vkAcquireNextImageKHR(device, swapchain, UINT64_MAX, imageAvailableSemaphore, VK_NULL_HANDLE, &imageIndex);

前两个参数是逻辑设备和我们需要请求图像的交换链。第三个参数指定了等待图像请求的超时时间,以纳秒为单位。使用 64 位无符号整数的最大值来禁用超时。

需要注意的是,我们把获取好的图像进行展示之后,它就会被归还到交换链中。我们还可以从交换链同时获取多张图像,但是交换链中没有被获取的图像数量总是会大于等于 VkSurfaceCapabilitiesKHR::minImageCount - 1,所以如果在没有获取的图像数量为 VkSurfaceCapabilitiesKHR::minImageCount - 1 时尝试获取图像,那么只有在有展示结束的图像被归还后才能获取成功,信号量才会被释放。这就意味着如果我们之前设置的交换链图像总数与这个最小图像数相等,我们在获取一张图像之后就必须对它进行展示,否则将无法获取到下一张图像

接下来两个参数指定了需要在请求图像完成后被释放的同步对象,图像在被用于展示之后会变成可用的状态。这个同步对象被释放的时候,我们就可以使用这个图像来进行绘制。我们可以指定一个信号量、屏障或者它们两者,当然我们这里只用了 imageAvailableSemaphore

最后一个参数指定了一个用于输出请求的图像索引的地址,当交换链图像变为可用状态时它的索引就会被输出到这里。这个索引其实是我们的成员变量 swapChainImages 数组中 VkImage 对象的索引。我们之后还可以使用这个索引来选取图像相对应的命令缓冲区

提交命令缓冲区

队列提交与同步可以通过 VkSubmitInfo 结构体中的参数来进行配置

VkSubmitInfo submitInfo = {};
submitInfo.sType = VK_STRUCTURE_TYPE_SUBMIT_INFO;
VkSemaphore waitSemaphores[] = {imageAvailableSemaphore};
VkPipelineStageFlags waitStages[] = {VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT};
submitInfo.waitSemaphoreCount = 1;
submitInfo.pWaitSemaphores = waitSemaphores;
submitInfo.pWaitDstStageMask = waitStages;
submitInfo.commandBufferCount = 1;
submitInfo.pCommandBuffers = &commandBuffers[imageIndex];
VkSemaphore signalSemaphores[] = {renderFinishedSemaphore};
submitInfo.signalSemaphoreCount = 1;
submitInfo.pSignalSemaphores = signalSemaphores;
if (vkQueueSubmit(graphicsAndPresentQueue, 1, &submitInfo, VK_NULL_HANDLE) != VK_SUCCESS)
    throw std::runtime_error("failed to submit draw command buffer!");

第一个参数是大部分 Vulkan 中的结构体都会有的 sType

接下来的三个参数指定了在执行前需要等待的信号量,以及在流水线中的哪个阶段中等待。我们希望在图像可用之后就把颜色数据写入进去,因此我们指定了图形流水线中写入颜色附着的阶段。理论上,这意味着这个实现可以在图像还没有准备好的情况下,就已经开始执行顶点着色器。注意到 waitStages 数组中的每一个记录都会与 pWaitSemaphores 数组中相同索引的记录相对应

再往后的两个参数指定了实际需要提交来执行的命令缓冲区。如上文所述,我们应当提交与从交换链中请求到的用作颜色附着的图像相对应的命令缓冲区

signalSemaphoreCount 和 pSignalSemaphores 参数指定了在命令缓冲区执行完毕后需要释放的信号量。这里我们将传入 renderFinishedSemaphore 这个信号量

最后调用 vkQueueSubmit 函数提交命令缓冲区到图形队列。这个函数以一个 VkSubmitInfo 数组为参数,这可以在工作量更大的时候提高性能。最后一个参数是一个可选的屏障,当这些命令缓冲区都执行完毕时将会释放。这里我们使用信号量来进行同步,因此传入 VK_NULL_HANDLE 就行了

展示

绘制一帧的最后一步就是把渲染结果提交给交换链,并使它最终展示到屏幕上。展示可以由 VkPresentInfoKHR 结构体进行配置

VkPresentInfoKHR presentInfo = {};
presentInfo.sType = VK_STRUCTURE_TYPE_PRESENT_INFO_KHR;
presentInfo.waitSemaphoreCount = 1;
presentInfo.pWaitSemaphores = signalSemaphores;
VkSwapchainKHR swapchains[] = {swapchain};
presentInfo.swapchainCount = 1;
presentInfo.pSwapchains = swapchains;
presentInfo.pImageIndices = &imageIndex;
presentInfo.pResults = nullptr;
vkQueuePresentKHR(graphicsAndPresentQueue, &presentInfo);

waitSemaphoreCount 和 pWaitSemaphores 这两个参数非常显然,表示需要等待的信号量

随后的 swapchainCount、pSwapchains、pImageIndices 分别表示交换链数量、所有的交换链、以及每个交换链需要展示对应的图像索引。当然一般只会传入一个交换链

最后一个 pResults 参数是可选的。它允许我们指定一个 VkResult 的数组来检查每一个交换链是否展示成功。当然使用一个交换链的时候这是不需要的,因为我们使用 vkQueuePresentKHR 这个函数调用的返回值就足够了

最后编译运行就能够看到这样的结果

Result

但是在关闭窗体的时候会发现程序崩溃,这是因为在 drawFrame 中的所有操作都是异步的,这意味着我们退出主循环的时候,渲染和展示操作可能依旧在进行。于是我们就会在 cleanup 中销毁正在被使用的资源,于是引发错误

于是为了解决这个问题,我们需要在退出主循环之前等待逻辑设备完成它当前的操作,修改 mainLoop 函数如下即可

void mainLoop() {
    while (!glfwWindowShouldClose(window)) {
        glfwPollEvents();
        drawFrame();
    }
    vkDeviceWaitIdle(device);
}