Vulkan教程 – 12 栅栏和信号量

        这一章所有东西都会整合到一起了。我们将会写一个drawFrame方法,它会被主循环调用,将三角形呈现到屏幕上。创建drawFrame方法在mainLoop的while内处理事件后调用:

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

        该方法会执行下面的操作:

        从交换链获取图像;

        用该图像作为帧缓冲中的附件来执行命令缓冲;

        将图像返回给交换链以便呈现。

        这些事件每一个都是使用单个方法调用来启动的,但是它们都是异步的。方法调用会在实际操作结束之前返回,且执行顺序也是不一定的。这就很不幸了,因为每个操作都依赖于之前的操作完成才行。

        有两种方式来同步交换链事件:栅栏和信号量。它们俩对象都能用于协调操作,方式就是设置一个操作信号,另一个操作等到一个栅栏或者信号量,然后从一个未标记的状态变成标记的状态。

        不同之处是栅栏状态可以从你的程序中通过类似vkWaitForFences的调用来访问,而信号量却不行。栅栏主要是设计用于同步你的应用和渲染操作的,然而信号量用于同步命令队列操作。我们想要同步绘制命令和呈现的序列操作,选用信号量最合适。

        我们需要一个信号量标记一个图像已经获取到且准备渲染就绪,另一个信号量用于标记渲染已经完成且可以呈现了。创建两个类成员来存储:

VkSemaphore imageAvailableSemaphore;
VkSemaphore renderFinishedSemaphore;

        为了创建信号量,我们还需要添加最后一个create方法,也就是createSemaphores,放在initVulkan最后。创建信号量需要填写VkSemaphoreCreateInfo,但是当前版本的API就只要求sType为必填项:

void createSemaphores() {
    VkSemaphoreCreateInfo semaphoreInfo = {};
    semaphoreInfo.sType = VK_STRUCTURE_TYPE_SEMAPHORE_CREATE_INFO;
}

        未来版本的Vulkan或者扩展可能为flags和pNext参数添加功能,就和别的结构体那样。创建信号量如下:

if (vkCreateSemaphore(device, &semaphoreInfo, nullptr, &imageAvailableSemaphore) != VK_SUCCESS ||
    vkCreateSemaphore(device, &semaphoreInfo, nullptr, &renderFinishedSemaphore) != VK_SUCCESS) {
    throw std::runtime_error("failed to create semaphores!");
}

        信号量要在程序结束的地方清理,这时所有命令都完成了且没有更多必要的同步要做:

vkDestroySemaphore(device, renderFinishedSemaphore, nullptr);
vkDestroySemaphore(device, imageAvailableSemaphore, nullptr);

        放在cleanup开头处。

        像前面提到的,drawFrame中第一个要做的事就是从交换链获取图像。回想下,交换链是扩展特性,所以我们必须用vk*KHR命名习惯:

void drawFrame() {
    uint32_t imageIndex;
    vkAcquireNextImageKHR(device, swapChain, std::numeric_limits<uint64_t>::max(),
        imageAvailableSemaphore, VK_NULL_HANDLE, &imageIndex);
}

        vkAcquireNextImageKHR前两个参数是逻辑设备和我们想要获取图像的交换链,第三个参数指定以纳秒为单位的图像可用性超时。使用64位无符号整数的最大值来禁用超时。

        接着两个参数指定当呈现引擎使用该图像完成任务时标记的同步对象。就是这个时间点,我们可以开始向它绘制东西。可以指定一个信号量,栅栏或者两者都有。我们这里使用imageAvailableSemaphore。

        最后一个参数指定一个变量输出可用的交换链图像索引。索引引用swapChainImages数组中的VkImage,我们会使用该索引来取得正确的命令缓冲。

        队列提交和同步通过VkSubmitInfo配置:

VkSemaphore waitSemaphores[] = { imageAvailableSemaphore };
VkPipelineStageFlags waitStages[] = { VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT };
submitInfo.waitSemaphoreCount = 1;
submitInfo.pWaitSemaphores = waitSemaphores;
submitInfo.pWaitDstStageMask = waitStages;

        最开始的三个参数指定开始执行前等待哪个信号量,以及管线在哪个阶段等待。在图像还不可用的时候,我们用写入颜色到图像的方式来进行等待,所以我们指定了写入到颜色附件的图形管线阶段。这意味着理论上当图像还不可用的时候,就开始执行顶点着色器了。waitStages的每个记录对应了pWaitSemaphores中的相同索引的信号量。

submitInfo.commandBufferCount = 1;
submitInfo.pCommandBuffers = &commandBuffers[imageIndex];

        接着两个参数指定提交到哪个命令缓冲来执行。像之前提到的,我们应该提交绑定了我们刚获取的图像的命令缓冲作为颜色附件。

VkSemaphore signalSemaphores[] = { renderFinishedSemaphore };
submitInfo.signalSemaphoreCount = 1;
submitInfo.pSignalSemaphores = signalSemaphores;

        signalSemaphoreCount和pSignalSemaphores参数指定一旦命令缓冲执行完成后哪个信号量来标记。我们这里用的是renderFinishedSemaphore。

if (vkQueueSubmit(graphicsQueue, 1, &submitInfo, VK_NULL_HANDLE) != VK_SUCCESS) {
    throw std::runtime_error("failed to submit draw command buffer!");
}

        现在我们可以使用vkQueueSubmit提交命令缓冲到图形队列中了。该方法接收一个VkSubmitInfo结构体作为参数,为的是有很大负荷的时候能更高效。最后一个参数引用一个可选的栅栏,我们会在命令缓冲结束执行的时候给它标记。我们使用信号量进行同步,所以我们就传一个VK_NULL_HANDLE即可。

        记住,渲染通道中的子通道自动处理图像布局转移问题。这些转移是子通道依赖控制的,指定了子通道的内存和执行依赖。我们只有一个子通道,但是该子通道之前或之后紧挨着的操作也是隐形的子通道。有两个内置的依赖负责在渲染通道的开头和结尾处转移,但是前者不会在正确的时间发生。它假设转移发生在管线的起始处,但是我们那时候还没有获取图像呢。有两个办法来处理该问题,我们可以改变imageAvailableSemaphore的waitStages为VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT来确保渲染通道不会开始,直到图像可用;或者使渲染通道等待VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT阶段。我决定用第二种,因为可以更好观察子通道依赖和它们如何工作的:

        子通道依赖是VkSubpassDependency中设定的,在createRenderPass方法中添加一个:

VkSubpassDependency dependency = {};
dependency.srcSubpass = VK_SUBPASS_EXTERNAL;
dependency.dstSubpass = 0;

        开始的两个字段指定了依赖和被依赖子通道的索引。特殊值VK_SUBPASS_EXTERNAL指的是渲染通道之前或之后根据是否在srcSubpass或dstSubpass中要依赖的隐含子通道。索引0指的是我们的子通道,也是第一个和仅有的一个。dstSubpass一定要一直比srcSubpass高,以防止依赖图循环问题。

dependency.srcStageMask = VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT;
dependency.srcAccessMask = 0;

        接着两个字段指定了要等待的操作和这些操作在什么阶段产生。我们要等到交换链完成从图像的读取才能访问图像。这可以通过等待颜色附件输出阶段来做:

dependency.dstStageMask = VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT;
dependency.dstAccessMask = VK_ACCESS_COLOR_ATTACHMENT_READ_BIT |
    VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT;

        应该等待这个的操作是在颜色附件阶段,且涉及到读取和写入颜色附件。这些设置将会阻止转移的发生,直到有必要或者允许的时候,也就是我们想要开始向它写入颜色的时候。

renderPassInfo.dependencyCount = 1;
renderPassInfo.pDependencies = &dependency;

        VkRenderPassCreateInfo有两个字段指定一组依赖。

        绘制一帧的最后一步是提交结果到交换链,让它最终显示到屏幕上。呈现通过VkPresentInfoKHR配置,就在drawFrame方法的末尾处:

VkPresentInfoKHR presentInfo = {};
presentInfo.sType = VK_STRUCTURE_TYPE_PRESENT_INFO_KHR;

presentInfo.waitSemaphoreCount = 1;
presentInfo.pWaitSemaphores = signalSemaphores;

        前面两个参数指定了呈现开始之前等待哪个信号量,就和VkSubmitInfo一样。

VkSwapchainKHR swapChains[] = {swapChain};
presentInfo.swapchainCount = 1;
presentInfo.pSwapchains = swapChains;
presentInfo.pImageIndices = &imageIndex;

        后面两个参数指定了图像呈现的目的交换链,以及每个交换链图像索引。这基本上都是一个。

presentInfo.pResults = nullptr;  // optional

        还有最后一个可选参数pResults。它能让你指定一个VkResult数组值来检查每个交换链是否成功呈现。如果只用了一个交换链就不必检查,因为可以直接用呈现函数的返回值。

vkQueuePresentKHR(presentQueue, &presentInfo);

        vkQueuePresentKHR方法提交请求呈现图像给交换链。我们给vkAcquireNextImageKHR和vkQueuePresentKHR添加错误处理,因为它们的问题不一定要让程序停止,这并不像我们之前看到的那些方法。

        如果你到目前为止把所有事情都做好了,那么运行的时候应该能看到类似这样的东西:

        对!不幸的是,启用了验证层,当你关闭程序的时候他就崩溃了。终端中debugCallback给出的信息如下:

        drawFrame中所有的操作都是异步的,这意味着当我们退出mainLoop的时候,绘制和呈现操作可能还在进行中,这时候就清理资源就不是个好办法。

        为了解决这个问题,我们要等逻辑设备结束操作的时候,且在退出mainLoop之前,销毁该窗口:

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

    vkDeviceWaitIdle(device);
}

        也可以用vkQueueWaitIdle在一个特定的命令队列中等待操作完成,这些方法可以用于原始的同步方式。此时可以看到退出的时候就不报错了:

        最终我们成功显示了一个三角形:

        文档已经看到145页了,才绘制出一个三角形,Vulkan太高效了呀!

        如果你开启了验证层后运行程序,且你监视内存使用的话,你可能会注意到它会慢慢增加,原因是该程序在drawFrame方法中快速地提交工作,但是实际上却不检查这些工作是否完成了。如果CPU提交的工作比GPU能处理的快,那么队列会慢慢被工作填充满。更糟糕的是,我们同时对多个帧重用imageAvailableSemaphore和renderFinishedSemaphore信号量。

        解决该问题的简单方法是在drawFrame末尾提交后就等待工作完成:

vkQueuePresentKHR(presentQueue, &presentInfo);
vkQueueWaitIdle(presentQueue);

        但是这样就不是高效使用GPU的方式了,因为一个时间点整个图形管线就只能被一个帧使用。当前帧已经处理过的阶段空闲着,可能已经被下一帧用了。我们现在扩展下以允许多个帧在绑定的一定工作量增长的时候还能继续准备。

        在程序开始的时候添加一个常数,以定义帧并发量:

const int MAX_FRAMES_IN_FLIGHT = 2;

        每一帧应该有它自己的一套信号量:

std::vector<VkSemaphore> imageAvailableSemaphores;
std::vector<VkSemaphore> renderFinishedSemaphores;

        createSemaphores方法也要改成创建所有这些信号量:

void createSemaphores() {
    imageAvailableSemaphores.resize(MAX_FRAMES_IN_FLIGHT);
    renderFinishedSemaphores.resize(MAX_FRAMES_IN_FLIGHT);

    VkSemaphoreCreateInfo semaphoreInfo = {};
    semaphoreInfo.sType = VK_STRUCTURE_TYPE_SEMAPHORE_CREATE_INFO;

    for (size_t i = 0; i < MAX_FRAMES_IN_FLIGHT; i++) {
        if (vkCreateSemaphore(device, &semaphoreInfo, nullptr,
                &imageAvailableSemaphores[i]) != VK_SUCCESS ||
            vkCreateSemaphore(device, &semaphoreInfo, nullptr,
                &renderFinishedSemaphores[i]) != VK_SUCCESS) {
            throw std::runtime_error("failed to create semaphores!");
        }
    }
}

        类似的,在cleanup中清理:

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

        为了每次使用正确配对的信号量;我们要跟踪当前帧,这里设置一个帧索引:

size_t currentFrame = 0;

        drawFrame方法现在就修改成这个样子以使用正确的对象:

vkAcquireNextImageKHR(device, swapChain, std::numeric_limits<uint64_t>::max(),
    imageAvailableSemaphores[currentFrame], VK_NULL_HANDLE, &imageIndex);
...
VkSemaphore waitSemaphores[] = { imageAvailableSemaphores[currentFrame] };
...
VkSemaphore signalSemaphores[] = { renderFinishedSemaphores[currentFrame] };

        当然,不要忘记每次都将其推到下一帧:

currentFrame = (currentFrame + 1) % MAX_FRAMES_IN_FLIGHT;

        通过使用取模运算符,我们确保了帧索引每当MAX_FRAMES_IN_FLIGHT个帧入队时循环一次。

        尽管现在我们设置了需要的对象来促进同时使用多帧,我们还是没有防止有多余MAX_FRAMES_IN_FLIGHT的工作提交。当前只有GPU-GPU同步而没有CPU-GPU同步以跟踪任务如何进行。我们可能在0号帧还未准备好的时候用0号帧的对象。

        为了进行CPU-GPU同步,Vulkan提供了第二种同步原语叫做栅栏。栅栏和信号量类似,它们可以被标记和等待,但是这次我们实际上在自己的代码中等待。我们先为每一个帧创建一个栅栏:

std::vector<VkSemaphore> imageAvailableSemaphores;
std::vector<VkSemaphore> renderFinishedSemaphores;
std::vector<VkFence> inFlightFences;
size_t currentFrame = 0;

        我决定和创建信号量和栅栏放在一起,然后把createSemaphores命名为createSyncObjects:

void createSyncObjects() {
    imageAvailableSemaphores.resize(MAX_FRAMES_IN_FLIGHT);
    renderFinishedSemaphores.resize(MAX_FRAMES_IN_FLIGHT);
    inFlightFences.resize(MAX_FRAMES_IN_FLIGHT);

    VkSemaphoreCreateInfo semaphoreInfo = {};
    semaphoreInfo.sType = VK_STRUCTURE_TYPE_SEMAPHORE_CREATE_INFO;

    VkFenceCreateInfo fenceInfo = {};
    fenceInfo.sType = VK_STRUCTURE_TYPE_FENCE_CREATE_INFO;

    for (size_t i = 0; i < MAX_FRAMES_IN_FLIGHT; i++) {
        if (vkCreateSemaphore(device, &semaphoreInfo, nullptr,
                &imageAvailableSemaphores[i]) != VK_SUCCESS ||
            vkCreateSemaphore(device, &semaphoreInfo, nullptr,
                &renderFinishedSemaphores[i]) != VK_SUCCESS ||
            vkCreateFence(device, &fenceInfo, nullptr,
                &inFlightFences[i]) != VK_SUCCESS) {
            throw std::runtime_error("failed to create synchronization objects for a frame!");
        }
    }
}

        栅栏创建和信号量类似,也要在cleanup中清理:

vkDestroyFence(device, inFlightFences[i], nullptr);

        现在改一下drawFrame来用栅栏进行同步。vkQueueSubmit调用包括了一个可选参数来传递当命令缓冲完成执行的时候应该标记的栅栏。我们可以用这个来标记一个帧已经完成了:

if (vkQueueSubmit(graphicsQueue, 1, &submitInfo, inFlightFences[currentFrame]) != VK_SUCCESS) {
    throw std::runtime_error("failed to submit draw command buffer!");
}

        现在唯一剩下的事情就是修改drawFrame开头来等待帧完成:

vkWaitForFences(device, 1, &inFlightFences[currentFrame],
    VK_TRUE, std::numeric_limits<uint64_t>::max());
vkResetFences(device, 1, &inFlightFences[currentFrame]);

        vkWaitForFences方法接收一个栅栏数组变量,返回之前等待它们中有一些或全部都标记好了。VK_TRUE表示我们想要等待所有的栅栏,但是单个的情况下自然就没什么影响了。和vkAcquireNextImageKHR一样,本方法也接收一个超时参数。但是不像信号量那样,我们需要手动存储栅栏以通过调用vkResetFences重置来解除标记。

        如果现在运行程序会发现没有东西渲染出来,验证层给出的信息如下:

        意思是我们在等待一个还未提交的栅栏。这里的问题是,栅栏默认创建的时候是未标记状态。也就是说vkWaitForFences将会一直等待,如果我们都还没使用过该栅栏的话。为了解决该问题,我们可以修改栅栏的创建信息,初始化为signaled状态,如同我们渲染了一个已完成的初始帧:

VkFenceCreateInfo fenceInfo = {};
fenceInfo.sType = VK_STRUCTURE_TYPE_FENCE_CREATE_INFO;
fenceInfo.flags = VK_FENCE_CREATE_SIGNALED_BIT;

        现在程序应该正常工作了,内存泄露问题也没了。我们已经实现了所有需要的同步操作以保证不会有多余两个工作帧入队。

        至此,将内容展现到屏幕上的所有阶段我们都经历过了,九百多行代码画出了一个三角形。Vulkan给你很多的控制能力,各项设置都要明确给出,现在最好回想下这些代码都是干什么的,下章还要做一些额外的工作来让该程序成为以后开发构建的更好的根基。

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享
评论 抢沙发
头像
欢迎您留下宝贵的见解!
提交
头像

昵称

取消
昵称表情代码图片