多帧并行渲染

如果读者开启校验层后运行程序,观察应用程序的内存使用情况,可以发现我们的应用程序的内存使用量一直在慢慢增加。这是由于我们的drawFrame函数以很快地速度提交指令,但却没有在下一次指令提交时检查上一次提交的指令是否已经执行结束。也就是说CPU提交指令快过GPU对指令的处理速度,造成GPU需要处理的指令大量堆积。更糟糕的是这种情况下,我们实际上对多个帧同时使用了相同的imageAvailableSemaphore和renderFinishedSemaphore信号量。

最简单的解决上面这一问题的方法是使用vkQueueWaitIdle函数来等待上一次提交的指令结束执行,再提交下一帧的指令:

void drawFrame() {
        ...

    vkQueuePresentKHR(presentQueue, &presentInfo);

    vkQueueWaitIdle(presentQueue);
}

但这样做,是对GPU计算资源的大大浪费。图形管线可能大部分时间都处于空闲状态。为了充分利用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 for a frame!");
        }
    }
}

在应用程序结束前,我们需要清除为每一帧创建的信号量:

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

        ...
}

我们添加一个叫做currentFrame的变量来追踪当前渲染的是哪一帧。之后,我们通过这一变量来选择当前帧应该使用的信号量:

size_t currentFrame = 0;

修改drawFrame函数,使用正确的信号量对象:

void 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变量:

void drawFrame() {
        ...

    currentFrame = (currentFrame + 1) % MAX_FRAMES_IN_FLIGHT;
}

上面代码,我们使用模运算(%)来使currentFrame变量的值在0到MAX_FRAMES_IN_FLIGHT-1之间进行循环。 我们还需要使用栅栏(fence)来进行CPU和GPU之间的同步,来防止有超过MAX_FRAMES_IN_FLIGHT帧的指令同时被提交执行。栅栏(fence)和信号量(semaphore)类似,可以用来发出信号和等待信号。我们为每一帧创建一个VkFence对象:

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

在应用程序结束前,清除我们创建的VkFence对象:

void cleanup() {
    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);
    }

        ...
}

修改drawFrame函数使用我们创建的栅栏(fence)对象进行同步。vkQueueSubmit函数有一个可选的参数可以用来指定在指令缓冲执行结束后需要发起信号的栅栏(fence)对象。我们通过它来发起一帧结束执行的信号。

void drawFrame() {
        ...

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

现在需要我们修改drawFrame函数来等待我们当前帧所使用的指令缓冲结束执行:

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

        ...
}

vkWaitForFences函数可以用来等待一组栅栏(fence)中的一个或全部栅栏(fence)发出信号。上面代码中我们对它使用的VK_TRUE参数用来指定它等待所有在数组中指定的栅栏(fence)。我们现在只有一个栅栏(fence)需要等待,所以不使用VK_TRUE也是可以的。和vkAcquireNextImageKHR函数一样,vkWaitForFences函数也有一个超时参数。和信号量不同,等待栅栏发出信号后,我们需要调用vkResetFences函数手动将栅栏(fence)重置为未发出信号的状态。

现在编译运行程序,读者可能会感到奇怪。应用程序没有呈现出我们渲染的三角形。启用校验层后运行程序,我们在控制台窗口看到下面这样的信息:

image

出现这一问题的原因是,默认情况下,栅栏(fence)对象在创建后是未发出信号的状态。这就意味着如果我们没有在vkWaitForFences函数调用之前发出栅栏(fence)信号,vkWaitForFences函数调用将会一直处于等待状态。我们可以在创建栅栏(fence)对象时,设置它的初始状态为已发出信号来解决这一问题:

void createSyncObjects() {
        ...

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

        ...
}

现在可以重新编译运行程序,内存泄漏问题应该已经不见了!我们已经通过同步机制确保不会有超过我们设定数量的帧会被异步执行。对于其它需要同步的地方,比如cleanup函数,使用vkDeviceWaitIdle函数的效果也足够好,不需要使用栅栏或信号量。