Ch2-1 Rendering Loop

本节当中我们将构建一个最基本的渲染循环(rendering loop)。

就我的写法而言,一个最基本的渲染循环应该是:
1.通过等待一个栅栏,确保前一次的命令已执行完毕(以便接下来覆写同一命令缓冲区)
2.获取交换链图像索引,然后置位信号量A
3.录制命令缓冲区
4.提交命令缓冲区,等待信号量A,命令执行完后置位信号量B和栅栏
5.等待信号量B,确保本次的命令执行完毕后,呈现图像

创建栅栏和信号量

信号量(semaphore)和栅栏(fence)是用于同步的Vulkan对象。
参见Ch3-1 同步原语,我在该节中解释了何为栅栏和信号量,并对它们进行了简单封装,请先阅览该节中的FenceSemaphore两小节,并完成封装,请务必弄明白封装封装栅栏的类fence和信号量的类semaphore的使用方式(是很简单的C++代码,边抄边读一遍即可)。

在主函数中创建一个栅栏和两个信号量:

using namespace vulkan; //main.cpp里会写一堆vulkan命名空间下的类型,using命名空间以省事
int main() {
    if (!InitializeWindow({1280,720}))
        return -1;

    fence fence(VK_FENCE_CREATE_SIGNALED_BIT); //以置位状态创建栅栏
    semaphore semaphore_imageIsAvailable;
    semaphore semaphore_renderingIsOver;

    while (!glfwWindowShouldClose(pWindow)) {

        /*渲染过程,待填充*/

        glfwPollEvents();
        TitleFps();
    }
    TerminateWindow();
    return 0;
}
  • fence在渲染完成后被置位。

  • 我打算在渲染循环的开头等待栅栏被置位,因此以置位状态创建fence(为了在首次执行渲染循环时能完成等待)。

  • semaphore_imageIsAvailable在取得交换链图像后被置位,在执行命令前等待它。

  • semaphore_renderingIsOver在渲染完成后被置位,在呈现图像前等待它。

在渲染完成后置位fencesemaphore_renderingIsOver两个同步对象(而非一个)的原因在于它们用法不同。
开始录制命令缓冲区前需要在CPU一侧手动等待fence被置位以确保先前的命令已完成执行,虽然执行完命令后同样也会置位semaphore_renderingIsOver,但你不能在CPU一侧手动等待二值信号量。
而用vkQueuePresentKHR(...)呈现图像时,其参数中只能指定需要等待被置位的二值信号量而不能指定栅栏,因此才出现了一个操作后需要置位两个同步对象的情况。

为什么在获取下一张交换链图像前等待fence,而不是在录制命令缓冲区前?
如先前所言,等待fence被置位,是为确保先前的命令已完成执行,以便接下来覆写同一命令缓冲区。那么为什么不能将等待它这一步,延后到录制命令缓冲区前?
为了简化叙事,我在每一帧当中使用了同一套同步对象(你当然可以多创建几套)。
假设在获取交换链图像后等待fence,那么:考虑到“获取下一张交换链图像”这一步所需的同步对象是semaphore_imageIsAvailable,而如果前一帧的命令非常轻量,很快录制完毕的话,很可能在本次的“获取交换链图像”时,前一帧的“在执行命令前等待信号量”还没结束,即semaphore_imageIsAvailable可能仍被前一帧占用着。
等待fence最主要的目的是确保命令执行完毕,但稍做进一步推理,显然也会顺带确保semaphore_imageIsAvailablesemaphore_renderingIsOver已不再被前一帧占用。

你也可以以非置位状态创建fence,然后有两个选项:
1.在呈现图像后等待它,即从下一循环的开头移动到当前循环的最后,执行逻辑上是一致的,同步开销也没有差别。
2.在呈现图像前等待它,如此一来semaphore_renderingIsOver就是多余的。
等待栅栏并非异步(见后文),同步粒度比信号量粗(因为栅栏是在CPU一侧调用函数等待GPU发来信号嘛),这是否有可见的影响当然与硬件及Vulkan的底层实现有关,而这是否可忽略则取决于你的需求。

注意,“在GPU侧等待/置位信号量 ”和“在GPU侧置位栅栏”,这类行为是相对于CPU侧异步发生的,进一步推理,命令的执行也是异步的,这是Vulkan与OpenGL的主要不同点之一。而“等待栅栏”则是相对于CPU侧同步的(意味着要求CPU侧等待到栅栏被置位或超时,然后才能执行后续程序)。

如果你对上述说明中的异步还是没有确切概念,来么,先来更新主函数,以此为例:

using namespace vulkan;
int main() {
    if (!InitializeWindow({1280,720}))
        return -1;

    fence fence(VK_FENCE_CREATE_SIGNALED_BIT);
    semaphore semaphore_imageIsAvailable;
    semaphore semaphore_renderingIsOver;

    while (!glfwWindowShouldClose(pWindow)) {
        /*假定这里有同步执行的A部分代码*/

        //等待并重置fence
        fence.WaitAndReset();

        /*假定这里有异步执行的B部分代码*/

        glfwPollEvents();
        TitleFps();
    }
    TerminateWindow();
    return 0;
}

在CPU侧执行fence.WaitAndReset();前的A部分代码时,GPU侧可能正在做其后的B部分代码要求做的事。
比如,执行glfwWindowShouldClose(pWindow)时,GPU可能正在执行B部分代码中的绘制命令。
(太啰嗦吗?虽然我想看这个教程的人大多知道什么是异步,姑且以防万一)

获取交换链图像索引

vkAcquireNextImageKHR(...)获取下一张用于渲染的交换链图像的索引:

VkResult VKAPI_CALL vkAcquireNextImageKHR(...) 的参数说明

VkDevice device

逻辑设备的handle

VkSwapchainKHR swapchain

交换链的handle

uint64_t timeout

超时时间,单位为纳秒,若无限制,将其指定为UINT64_MAX

VkSemaphore semaphore

成功获取图像索引,且所获取的图像可被安全读写后,需被置位的二值信号量

VkFence fence

成功获取图像索引,且所获取的图像可被安全读写后,需被置位的栅栏

uint32_t* pImageIndex

若函数执行成功,将获取到的交换链图像索引写入*pImageIndex

  • semaphore和fence中至少有一个不得为VK_NULL_HANDLE

  • 函数执行成功时会立刻更新*pImageIndex,但呈现引擎可能还在读取所获取的图像,semaphore和fence会在呈现引擎不再使用该图像后(很可能在函数返回后)被置位。

  • 若在timeout指定的时限内无法取得任何一张可用于渲染的图像,则返回VK_TIMEOUT

  • 若在timeout指定为0且无法当即取得任何一张可用于渲染的图像,则返回VK_NOT_READY

  • 对于需要重建交换链的情况,可能返回VK_SUBOPTIMAL_KHR(窗口相关参数改变但仍能获取到图像)或VK_ERROR_OUT_OF_DATE_KHR(无法获取到图像)。

可同时提供栅栏和信号量,但只需一个即可。
若选择只提供栅栏,那么必须在提交命令前在CPU一侧手动等待栅栏被置位;若选择只提供信号量,则能在命令执行到特定阶段前等待信号量被置位,换言之信号量的同步粒度更精细,因为我选择只提供信号量。

需要注意的是,获取到的图像索引未必依序而来。
举例而言,如果交换链中一共有三张图像,vkAcquireNextImageKHR(...)可能总是按0、1、2的顺序获取图像,也可能总是按0、2、1的顺序。更极端的是,其顺序可能在每个周期(这里以交换链图像个数次渲染循环为一个周期)中不同,比如在先按0、1、2的顺序获取并呈现图像,然后在下一个周期中按0、2、1的顺序获取到图像,但这种情况理论上只在待获取图像可能超过一张的情况下发生。
因此,在渲染循环中总是应当使用graphicsBase::Base.CurrentImageIndex()来获取当前图像索引,不要使用一个总是从0到2(假定图像数量为3张)递增的变量。

于是在graphicsBase中加入以下内容:

private:
//当前取得的交换链图像索引
uint32_t currentImageIndex = 0;
public:
//Getter
uint32_t CurrentImageIndex() const { return currentImageIndex; }
//该函数用于获取交换链图像索引到currentImageIndex,以及在需要重建交换链时调用RecreateSwapchain()、重建交换链后销毁旧交换链
result_t SwapImage(VkSemaphore semaphore_imageIsAvailable) {
    //销毁旧交换链(若存在)
    if (swapchainCreateInfo.oldSwapchain &&
        swapchainCreateInfo.oldSwapchain != swapchain) {
        vkDestroySwapchainKHR(device, swapchainCreateInfo.oldSwapchain, nullptr);
        swapchainCreateInfo.oldSwapchain = VK_NULL_HANDLE;
    }
    //获取交换链图像索引
    while (VkResult result = vkAcquireNextImageKHR(device, swapchain, UINT64_MAX, semaphore_imageIsAvailable, VK_NULL_HANDLE, &currentImageIndex))
        switch (result) {
        case VK_SUBOPTIMAL_KHR:
        case VK_ERROR_OUT_OF_DATE_KHR:
            if (VkResult result = RecreateSwapchain())
                return result;
            break; //注意重建交换链后仍需要获取图像,通过break递归,再次执行while的条件判定语句
        default:
            outStream << std::format("[ graphicsBase ] ERROR\nFailed to acquire the next image!\nError code: {}\n", int32_t(result));
            return result;
        }
    return VK_SUCCESS;
}
  • RecreateSwapchain()中,旧交换链的handle被记录在swapchainCreateInfo.oldSwapchain,所以销毁旧交换链的首要条件是验证swapchainCreateInfo.oldSwapchain是否为空。旧交换链应该存活至创建新交换链成功为止,由于RecreateSwapchain()执行失败时不会覆写成员变量swapchain,因此通过判断swapchainCreateInfo.oldSwapchain是否等于swapchain,来判断前一帧中RecreateSwapchain()是否执行成功。两个条件中间添加&&来短路执行。

  • 由于超时时间被指定为UINT64_MAX,不需要考虑返回VK_TIMEOUTVK_NOT_READY的情况

如之前在Ch1-4 创建交换链中所说,不能在重建交换链后立刻销毁旧交换链。SwapImage(...)中的逻辑是,若在当前帧重建交换链,那么在下一帧销毁交换链(这也是为什么重建交换链后使用了break来再次执行while,而非递归地调用SwapImage(...))。
返回VK_SUBOPTIMAL_KHR的情况下,信号量可能会被置位,这会导致验证层在第二次调用vkAcquireNextImageKHR(...)时报错,但并不影响后续执行逻辑:因为是新创建的交换链图像,不需要等呈现引擎把它吐出来,已经置位的信号量就这么保留置位即可(要消掉这时的报错,代码改起来很烦,就不改了)。

主函数更新为:

int main() {
    if (!InitializeWindow({1280,720}))
        return -1;

    fence fence(VK_FENCE_CREATE_SIGNALED_BIT);
    semaphore semaphore_imageIsAvailable;
    semaphore semaphore_renderingIsOver;

    while (!glfwWindowShouldClose(pWindow)) {
        //等待并重置fence
        fence.WaitAndReset();
        //获取交换链图像索引
        graphicsBase::Base().SwapImage(semaphore_imageIsAvailable);

        /*待后续填充*/

        glfwPollEvents();
        TitleFps();
    }
    TerminateWindow();
    return 0;
}

创建命令池和命令缓冲区

同OpenGL或DirectX11这类API不同,DirectX12、Vulkan等底层API中的命令被录制在命令缓冲区(command buffer)中,而命令缓冲区从命令池(command pool)分配。
本节中还不会录制任何实际的命令,仅为完成渲染循环,需要先写好开始录制命令、结束录制命令、提交命令缓冲区用的代码。
参见Ch3-5 命令缓冲区,我在该节中对命令缓冲区和命令池做了简单封装,请先阅览该节(关于二级命令缓冲区的部分请暂且无视),并完成封装,请务必弄明白封装命令缓冲区的类commandBuffer和命令池的类commandPool的使用方式。

在主函数中创建一个命令池,并从中分配一个命令缓冲区:

int main() {
    if (!InitializeWindow({1280,720}))
        return -1;

    fence fence(VK_FENCE_CREATE_SIGNALED_BIT);
    semaphore semaphore_imageIsAvailable;
    semaphore semaphore_renderingIsOver;

    commandBuffer commandBuffer;
    commandPool commandPool(graphicsBase::Base().QueueFamilyIndex_Graphics(), VK_COMMAND_POOL_CREATE_RESET_COMMAND_BUFFER_BIT);
    commandPool.AllocateBuffers(commandBuffer);

    while (!glfwWindowShouldClose(pWindow)) {
        fence.WaitAndReset();
        graphicsBase::Base().SwapImage(semaphore_imageIsAvailable);

        /*待后续填充*/

        glfwPollEvents();
        TitleFps();
    }
    TerminateWindow();
    return 0;
}

录制及提交命令缓冲区

调用函数开始命令缓冲区的录制,中间渲染过程留待填充,然后结束命令缓冲区的录制:

int main() {
    if (!InitializeWindow({1280,720}))
        return -1;

    fence fence(VK_FENCE_CREATE_SIGNALED_BIT);
    semaphore semaphore_imageIsAvailable;
    semaphore semaphore_renderingIsOver;

    commandBuffer commandBuffer;
    commandPool commandPool(graphicsBase::Base().QueueFamilyIndex_Graphics(), VK_COMMAND_POOL_CREATE_RESET_COMMAND_BUFFER_BIT);
    commandPool.AllocateBuffers(commandBuffer);

    while (!glfwWindowShouldClose(pWindow)) {
        fence.WaitAndReset();
        graphicsBase::Base().SwapImage(semaphore_imageIsAvailable);

        commandBuffer.Begin(VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT);
        /*渲染命令,待填充*/
        commandBuffer.End();

        /*提交命令缓冲区,待后续填充*/
        /*呈现图像,待后续填充*/

        glfwPollEvents();
        TitleFps();
    }
    TerminateWindow();
    return 0;
}

vkQueueSubmit(...)提交命令缓冲区,相关参数已经在Ch3-5 命令缓冲区中叙述,现对其进行封装,在graphicsBase中加入多个成员函数:

public:
//该函数用于将命令缓冲区提交到用于图形的队列
result_t SubmitCommandBuffer_Graphics(VkSubmitInfo& submitInfo, VkFence fence = VK_NULL_HANDLE) const {
    submitInfo.sType = VK_STRUCTURE_TYPE_SUBMIT_INFO;
    VkResult result = vkQueueSubmit(queue_graphics, 1, &submitInfo, fence);
    if (result)
        outStream << std::format("[ graphicsBase ] ERROR\nFailed to submit the command buffer!\nError code: {}\n", int32_t(result));
    return result;
}
//该函数用于在渲染循环中将命令缓冲区提交到图形队列的常见情形
result_t SubmitCommandBuffer_Graphics(VkCommandBuffer commandBuffer,
    VkSemaphore semaphore_imageIsAvailable = VK_NULL_HANDLE, VkSemaphore semaphore_renderingIsOver = VK_NULL_HANDLE, VkFence fence = VK_NULL_HANDLE,
    VkPipelineStageFlags waitDstStage_imageIsAvailable = VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT) const {
    VkSubmitInfo submitInfo = {
        .commandBufferCount = 1,
        .pCommandBuffers = &commandBuffer
    };
    if (semaphore_imageIsAvailable)
        submitInfo.waitSemaphoreCount = 1,
        submitInfo.pWaitSemaphores = &semaphore_imageIsAvailable,
        submitInfo.pWaitDstStageMask = &waitDstStage_imageIsAvailable;
    if (semaphore_renderingIsOver)
        submitInfo.signalSemaphoreCount = 1,
        submitInfo.pSignalSemaphores = &semaphore_renderingIsOver;
    return SubmitCommandBuffer_Graphics(submitInfo, fence);
}
//该函数用于将命令缓冲区提交到用于图形的队列,且只使用栅栏的常见情形
result_t SubmitCommandBuffer_Graphics(VkCommandBuffer commandBuffer, VkFence fence = VK_NULL_HANDLE) const {
    VkSubmitInfo submitInfo = {
        .commandBufferCount = 1,
        .pCommandBuffers = &commandBuffer
    };
    return SubmitCommandBuffer_Graphics(submitInfo, fence);
}
//该函数用于将命令缓冲区提交到用于计算的队列
result_t SubmitCommandBuffer_Compute(VkSubmitInfo& submitInfo, VkFence fence = VK_NULL_HANDLE) const {
    submitInfo.sType = VK_STRUCTURE_TYPE_SUBMIT_INFO;
    VkResult result = vkQueueSubmit(queue_compute, 1, &submitInfo, fence);
    if (result)
        outStream << std::format("[ graphicsBase ] ERROR\nFailed to submit the command buffer!\nError code: {}\n", int32_t(result));
    return result;
}
//该函数用于将命令缓冲区提交到用于计算的队列,且只使用栅栏的常见情形
result_t SubmitCommandBuffer_Compute(VkCommandBuffer commandBuffer, VkFence fence = VK_NULL_HANDLE) const {
    VkSubmitInfo submitInfo = {
        .commandBufferCount = 1,
        .pCommandBuffers = &commandBuffer
    };
    return SubmitCommandBuffer_Compute(submitInfo, fence);
}
  • 在渲染循环中将命令缓冲区提交到图形队列时,若不需要做深度或模板测试,最迟可以在VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT阶段等待获取到交换链图像,渲染结果在该阶段被写入到交换链图像。

  • 包含图形命令的命令缓冲区可能不带任何信号量(在渲染循环之外完全有理由这么做),而只包含数据转移命令的话通常也不会带任何信号量(就使用情形而言多是在“加载”这一环节中),数据转移命令可以被提交给图形或计算队列。

queue_presentation提交命令缓冲区的情况,仅限于用于呈现的队列族与用于图形的队列族不一致时,见本节的最后一小节。

主函数更新为:

int main() {
    if (!InitializeWindow({1280,720}))
        return -1;

    fence fence(VK_FENCE_CREATE_SIGNALED_BIT);
    semaphore semaphore_imageIsAvailable;
    semaphore semaphore_renderingIsOver;

    commandBuffer commandBuffer;
    commandPool commandPool(graphicsBase::Base().QueueFamilyIndex_Graphics(), VK_COMMAND_POOL_CREATE_RESET_COMMAND_BUFFER_BIT);
    commandPool.AllocateBuffers(commandBuffer);

    while (!glfwWindowShouldClose(pWindow)) {
        fence.WaitAndReset();
        graphicsBase::Base().SwapImage(semaphore_imageIsAvailable);

        commandBuffer.Begin(VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT);
        /*渲染命令,待填充*/
        commandBuffer.End();

        graphicsBase::Base().SubmitCommandBuffer_Graphics(commandBuffer, semaphore_imageIsAvailable, semaphore_renderingIsOver, fence);
        /*呈现图像,待填充*/

        glfwPollEvents();
        TitleFps();
    }
    TerminateWindow();
    return 0;
}

呈现图像

vkQueuePresentKHR(...)呈现图像:

VkResult VKAPI_CALL vkQueuePresentKHR(...) 的参数说明

VkQueue queue

队列的handle

const VkPresentInfoKHR* pPresentInfo

指向呈现信息

struct VkPresentInfoKHR 的成员说明

VkStructureType sType

结构体的类型,本处必须是VK_STRUCTURE_TYPE_PRESENT_INFO_KHR

const void* pNext

如有必要,指向一个用于扩展该结构体的结构体

uint32_t waitSemaphoreCount

所需等待被置位的信号量的个数

const VkSemaphore* pWaitSemaphores

指向所需等待被置位的信号量的数组

uint32_t swapchainCount

有图像需要被呈现的交换链的个数

const VkSwapchainKHR* pSwapchains

指向有图像需要被呈现的交换链的数组

const uint32_t* pImageIndices

指向各个交换链中需要被呈现的图像的索引构成的数组

VkResult* pResults

若非nullptr,将对各个交换链中各图像的呈现结果写入pResults所指数组

多交换链适用于程序有多个窗口的情况,由于我们的程序只有一个交换链,可以直接从vkQueuePresentKHR(...)的返回值得知其执行结果,pResults填写为nullptr
vkQueuePresentKHR(...)同vkAcquireNextImageKHR(...)一样,可能会遭遇需要重建交换链的情况,因此在graphicsBase中对其做如下封装:

result_t PresentImage(VkPresentInfoKHR& presentInfo) {
    presentInfo.sType = VK_STRUCTURE_TYPE_PRESENT_INFO_KHR;
    switch (VkResult result = vkQueuePresentKHR(queue_presentation, &presentInfo)) {
    case VK_SUCCESS:
        return VK_SUCCESS;
    case VK_SUBOPTIMAL_KHR:
    case VK_ERROR_OUT_OF_DATE_KHR:
        return RecreateSwapchain();
    default:
        outStream << std::format("[ graphicsBase ] ERROR\nFailed to queue the image for presentation!\nError code: {}\n", int32_t(result));
        return result;
    }
}
//该函数用于在渲染循环中呈现图像的常见情形
result_t PresentImage(VkSemaphore semaphore_renderingIsOver = VK_NULL_HANDLE) {
    VkPresentInfoKHR presentInfo = {
        .swapchainCount = 1,
        .pSwapchains = &swapchain,
        .pImageIndices = &currentImageIndex
    };
    if (semaphore_renderingIsOver)
        presentInfo.waitSemaphoreCount = 1,
        presentInfo.pWaitSemaphores = &semaphore_renderingIsOver;
    return PresentImage(presentInfo);
}
  • SwapImage(...)中的情形不同,PresentImage(...)在重建交换链后直接返回,这会导致必定丢1帧。要保留这1帧的话,得在重建交换链后再获取交换链图像、呈现图像,考虑到获取交换链图像时还要创建临时的同步对象,代码会写得比较麻烦,按下不表。

渲染循环更新为:

while (!glfwWindowShouldClose(pWindow)) {
    fence.WaitAndReset();
    graphicsBase::Base().SwapImage(semaphore_imageIsAvailable);

    commandBuffer.Begin(VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT);
    /*渲染命令,待填充*/
    commandBuffer.End();

    graphicsBase::Base().SubmitCommandBuffer_Graphics(commandBuffer, semaphore_imageIsAvailable, semaphore_renderingIsOver, fence);
    graphicsBase::Base().PresentImage(semaphore_renderingIsOver);

    glfwPollEvents();
    TitleFps();
}

在窗口最小化时暂停渲染循环

在窗口最小化到任务栏时继续渲染循环,可能收到来自验证层的报错信息:

Validation Error: [ VUID-vkQueueSubmit-pWaitSemaphores-03238 ]
恢复窗口大小后会持续收到另一个报错信息:
Validation Error: [ VUID-vkAcquireNextImageKHR-swapchain-01802 ]
尽管上述情况可能不影响程序的正常执行,以防万一之外,也出于节省CPU和GPU占用的考量,有必要在窗口最小化时阻塞渲染循环。
这可以通过一串简单的代码来实现:

while (!glfwWindowShouldClose(pWindow)) {
    //新增------------------------------------
    while (glfwGetWindowAttrib(pWindow, GLFW_ICONIFIED))
        glfwWaitEvents();
    //----------------------------------------

    fence.WaitAndReset();
    graphicsBase::Base().SwapImage(semaphore_imageIsAvailable);

    commandBuffer.Begin(VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT);
    /*渲染命令,待填充*/
    commandBuffer.End();

    graphicsBase::Base().SubmitCommandBuffer_Graphics(commandBuffer, semaphore_imageIsAvailable, semaphore_renderingIsOver, fence);
    graphicsBase::Base().PresentImage(semaphore_renderingIsOver);

    glfwPollEvents();
    TitleFps();
}
  • 使用glfwGetWindowAttrib(GLFWWindow* window, int attrib)检查窗口是否具有某个性质,若具有性质则返回值非0。这里GLFW_ICONIFIED表示窗口是否被图标化(最小化到任务栏时只有图标)。

  • glfwWaitEvents()等待事件,因为接收到的事件不会只有“恢复窗口大小”一种(比如键鼠输入),所以需要while循环反复判定窗口是否被图标化,直到接收到恢复窗口大小的事件为止。

最后一件事:等待栅栏的时机

前面将“等待栅栏”放在渲染循环开头,是为了方便说明其与“获取交换链图像”的先后关系,及说明以置位状态创建栅栏的意义所在。
这么做有个缺陷,考虑如下情况:

while (!glfwWindowShouldClose(pWindow)) {
    while (glfwGetWindowAttrib(pWindow, GLFW_ICONIFIED))
        glfwWaitEvents();
    fence.WaitAndReset();

    /*在这里将上一帧的命令执行结果拷贝到CPU侧*/

    graphicsBase::Base().SwapImage(semaphore_imageIsAvailable);

    commandBuffer.Begin(VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT);
    /*命令*/
    commandBuffer.End();

    graphicsBase::Base().SubmitCommandBuffer_Graphics(commandBuffer, semaphore_imageIsAvailable, semaphore_renderingIsOver, fence);
    graphicsBase::Base().PresentImage(semaphore_renderingIsOver);

    glfwPollEvents();
    TitleFps();
}

要将上一帧的命令执行结果拷贝到CPU侧,当然得等上一帧的命令执行完,即等待到fence之后。
而在首次执行渲染循环时,若如上述代码这般在执行命令前进行拷贝,因为没有“上一帧”,要么无从拷贝,要么得在代码中对首次执行做特殊处理。
因此把fence.WaitAndReset();和得在命令执行完后的干的事,统统移动到循环末尾,在代码逻辑上会比较省事(但未必总是合适,见后文即时帧)。

于是,至此为止主函数为:

int main() {
    if (!InitializeWindow({1280,720}))
        return -1;

    fence fence; //以非置位状态创建栅栏
    semaphore semaphore_imageIsAvailable;
    semaphore semaphore_renderingIsOver;

    commandBuffer commandBuffer;
    commandPool commandPool(graphicsBase::Base().QueueFamilyIndex_Graphics(), VK_COMMAND_POOL_CREATE_RESET_COMMAND_BUFFER_BIT);
    commandPool.AllocateBuffers(commandBuffer);

    while (!glfwWindowShouldClose(pWindow)) {
        while (glfwGetWindowAttrib(pWindow, GLFW_ICONIFIED))
            glfwWaitEvents();

        graphicsBase::Base().SwapImage(semaphore_imageIsAvailable);

        commandBuffer.Begin(VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT);
        /*渲染命令,待填充*/
        commandBuffer.End();

        graphicsBase::Base().SubmitCommandBuffer_Graphics(commandBuffer, semaphore_imageIsAvailable, semaphore_renderingIsOver, fence);
        graphicsBase::Base().PresentImage(semaphore_renderingIsOver);

        glfwPollEvents();
        TitleFps();

        fence.WaitAndReset();
    }
    TerminateWindow();
    return 0;
}

接下来是非必要的选读内容。

即时帧

上述渲染循环代码中,每一帧所用的同步对象是相同的,这意味着必须渲染完上一帧才能渲染当前帧。
然而,考虑到交换链中存在多张图像,既然当前帧和上一帧所写入的图像不同,在渲染当前帧时何必等待渲染完上一帧呢?
通过给交换链中的每张图像创建一套专用的同步对象、命令缓冲区、帧缓冲,以及其他一切在循环的单帧中会被更新、写入的Vulkan对象,以此在渲染每一帧图像的过程中避免资源竞争,减少阻塞,这种做法叫做即时帧(frames in flight)。

即时帧的好处显而易见,对每张交换链图像的写入,只需发生在呈现引擎上一次读取同一图像之后即可,假设交互链图像有3张,那么便是与2帧之前的操作同步,相比与上一帧同步,大幅提升了并行度。
但是即时帧的设备内存开销也是成倍增加的,由于所有会被更新、写入的Vulkan对象都得创建与交换链图像相同的份数,在一些情况下会产生惊人的内存开销(比如延迟渲染)。而且即便应用了即时帧也未必能大幅提升帧数,如果每帧的渲染时间都很短的话,即时帧不会对帧数产生影响,换言之只应该在每帧渲染时间较长的情况下应用即时帧(起码应该要长于屏幕的垂直刷新间隔)。
后续教程中不会应用即时帧,仅在此略作知会。除了内存开销外,代码会被写更复杂难看也是个原因(会出现一堆用于存放Vulkan对象的vector和用来创建这些对象的循环代码,虽然可以通过封装每一套Vulkan对象或者某些更省事的奇怪手段来简化)。

如果你想应用即时帧,那么渲染循环应被改成:

int main() {
    if (!InitializeWindow({1280,720}))
        return -1;

    struct perFrameObjects_t {
        fence fence = { VK_FENCE_CREATE_SIGNALED_BIT }; //直接 = VK_FENCE_CREATE_SIGNALED_BIT也行,这里按照我的编程习惯在初始化类/结构体时保留花括号
        semaphore semaphore_imageIsAvailable;
        semaphore semaphore_renderingIsOver;
        commandBuffer commandBuffer;
    };
    std::vector<perFrameObjects_t> perFrameObjects(graphicsBase::Base().SwapchainImageCount());
    commandPool commandPool(graphicsBase::Base().QueueFamilyIndex_Graphics(), VK_COMMAND_POOL_CREATE_RESET_COMMAND_BUFFER_BIT);
    for (auto& i : perFrameObjects)
        commandPool.AllocateBuffers(i.commandBuffer);
    uint32_t i = 0;

    while (!glfwWindowShouldClose(pWindow)) {
        while (glfwGetWindowAttrib(pWindow, GLFW_ICONIFIED))
            glfwWaitEvents();

        const auto& [fence, semaphore_imageIsAvailable, semaphore_renderingIsOver, commandBuffer] = perFrameObjects[i];
        i = (i + 1) % graphicsBase::Base().SwapchainImageCount();

        fence.WaitAndReset(); //在渲染循环开头等待与当前交换链图像配套的栅栏(在渲染循环末尾等待的话,下一帧必须等待当前帧渲染完,即时帧的做法便没了意义)
        graphicsBase::Base().SwapImage(semaphore_imageIsAvailable);

        commandBuffer.Begin(VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT);
        /*渲染命令*/
        commandBuffer.End();

        graphicsBase::Base().SubmitCommandBuffer_Graphics(commandBuffer, semaphore_imageIsAvailable, semaphore_renderingIsOver, fence);
        graphicsBase::Base().PresentImage(semaphore_renderingIsOver);

        glfwPollEvents();
        TitleFps();
    }
    TerminateWindow();
    return 0;
}

假定获取到的交换链图像索引在每个周期中有固定顺序,那么虽然这里的i可能跟当前图像索引不同(如果获取到的交换链图像索引总是0、1、2依序而来,那么i跟当前图像索引一致),但i总是跟图像索引一一对应(也就是一种映射关系)。

如先前获取交换链图像索引所述,Vulkan标准对交换链图像索引在每个周期中的顺序并无要求。那么上述代码是否还能确保执行逻辑不出错呢?
考虑比较极端的情况:如果连续两次获取到索引为0的交换链图像,而你的同步对象按0、1的索引依序使用。既然是连续两次渲染到同一交换链图像,后一次得等待前一次命令执行完成,可前一次点亮的是0号栅栏,后一次等待的为1号栅栏,这么一来同步不就出问题了吗!?
上述情况没有什么值得你担心的:
只有在将图像交还给呈现引擎后(此时已通过信号量确保已完成了先前在该图像上的渲染),vkAcquireNextImageKHR(...)才能获取到与该图像对应的索引。
因此,前文的代码在获取到的交换链图像索引是乱序时也不会有问题,只是也难以减少阻塞。

队列族所有权转移

若这一小节里有任何看不懂的地方,请看过往后的章节后再回过来看。

这套教程中不会做队列族所有权转移(日后更新说不准),因为实际上你总能找到一个同时支持图形、呈现、计算的队列族(虽然我不敢100%肯定,但至少用来开发图形程序的PC不应该糟糕到连这都无法满足),仅在这里简单演示一下,从用于图形的队列将所有权转移到用于呈现的队列。

通常而言,出于对资源访问效率的考虑,资源在某一时刻只应被单一队列族独占访问,并在创建资源时将sharingMode指定为VK_SHARING_MODE_EXCLUSIVE。当这样的资源要被另一队列族访问时,应当事先显式地告知Vulkan。
队列所有权转移的必要性为同时满足以下两点:
1.一块物理设备内存区域先后被两个不同队列族中的队列使用
2.后使用该资源的队列需要保留其中的数据
举例而言,若用于图形的队列QG与用于呈现的队列QP不同,从QG到QP需要做队列族所有权转移,因为呈现需要读取已渲染完的图像数据,而从QP到QG则不需要,因为渲染一张新图像时,不需要保留先前的内容。

队列族所有权转移是内存屏障的功能之一,内存屏障参见Pipeline Barrier
只要内存屏障的参数中,srcQueueFamilyIndex和dstQueueFamilyIndex不同,那么资源的队列族所有权就会被转移。

首先来看下Vulkan官方示例中的做法:
以下内存屏障录制在被提交到图形队列的命令缓冲区末尾,使得图形队列释放图像所有权:

uint32_t currentImageIndex = graphicsBase::Base().CurrentImageIndex();
VkImageMemoryBarrier imageMemoryBarrier_g2p_release = {
    .sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER,
    .srcAccessMask = VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT,
    .dstAccessMask = 0,//因为vkCmdPipelineBarrier(...)的参数中dstStage是BOTTOM_OF_PIPE,不需要dstAccessMask
    .oldLayout = VK_IMAGE_LAYOUT_PRESENT_SRC_KHR,//内存布局已经在渲染通道结束时转换(这是下一节的内容),此处oldLayout和newLayout相同,不发生转变
    .newLayout = VK_IMAGE_LAYOUT_PRESENT_SRC_KHR,
    .srcQueueFamilyIndex = graphicsBase::Base().QueueFamilyIndex_Graphics(),
    .dstQueueFamilyIndex = graphicsBase::Base().QueueFamilyIndex_Presentation(),
    .image = graphicsBase::Base().SwapchainImage(currentImageIndex),
    .subresourceRange = { VK_IMAGE_ASPECT_COLOR_BIT, 0, 1, 0, 1 }
};
vkCmdPipelineBarrier(commandBuffer, VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT, VK_PIPELINE_STAGE_BOTTOM_OF_PIPE_BIT, 0,
    0, nullptr, 0, nullptr, 1, &imageMemoryBarrier_g2p_release);
  • 这里使用的srcStage是VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT,srcAccessMask是VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT,说明释放所有权必须发生在将渲染结果输出到图像之后。

以下内存屏障录制在被提交到呈现队列的命令缓冲区中,使得呈现队列获得图像所有权:

//这个VkImageMemoryBarrier可以跟上文的一模一样
VkImageMemoryBarrier imageMemoryBarrier_g2p_acquire = {
    .sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER,
    //因为srcStage是VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT,不需要srcAccessMask
    //因为dstStage是VK_PIPELINE_STAGE_BOTTOM_OF_PIPE_BIT,不需要dstAccessMask
    .oldLayout = VK_IMAGE_LAYOUT_PRESENT_SRC_KHR,
    .newLayout = VK_IMAGE_LAYOUT_PRESENT_SRC_KHR,
    .srcQueueFamilyIndex = graphicsBase::Base().QueueFamilyIndex_Graphics(),
    .dstQueueFamilyIndex = graphicsBase::Base().QueueFamilyIndex_Presentation(),
    .image = graphicsBase::Base().SwapchainImage(currentImageIndex),
    .subresourceRange = { VK_IMAGE_ASPECT_COLOR_BIT, 0, 1, 0, 1 }
};
vkCmdPipelineBarrier(commandBuffer, VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT, VK_PIPELINE_STAGE_BOTTOM_OF_PIPE_BIT, 0,
    0, nullptr, 0, nullptr, 1, &imageMemoryBarrier_g2p_acquire);
  • 队列族所有权转移的典型做法,是在两个不同的队列中使用两个参数完全一致的内存屏障。
    然而这里使用的srcStage是VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT,srcAccessMask是0,与CmdReleaseImageOwnership_Graphics(...)中的不一致,这是因为有信号量这一粒度更粗的同步手段,使得CmdAcquireImageOwnership_Presentation(...)中的内存屏障必然在渲染命令及其附属的内存屏障之后执行,因此这里的srcStage也就不再重要了。

如果使用以上两个内存屏障进行交换链图像的队列族所有权转移,那么主函数改为:

int main() {
    if (!InitializeWindow({1280,720}))
        return -1;

    fence fence(VK_FENCE_CREATE_SIGNALED_BIT);
    semaphore semaphore_imageIsAvailable;
    semaphore semaphore_renderingIsOver;
    semaphore semaphore_ownershipIsTransfered;

    commandBuffer commandBuffer_graphics;
    commandBuffer commandBuffer_presentation;
    commandPool commandPool_graphics(graphicsBase::Base().QueueFamilyIndex_Graphics(), VK_COMMAND_POOL_CREATE_RESET_COMMAND_BUFFER_BIT);
    commandPool commandPool_presentation(VK_COMMAND_POOL_CREATE_RESET_COMMAND_BUFFER_BIT, graphicsBase::Base().QueueFamilyIndex_Presentation());
    commandPool_graphics.AllocateBuffers(commandBuffer_graphics);
    commandPool_presentation.AllocateBuffers(commandBuffer_presentation);

    while (!glfwWindowShouldClose(pWindow)) {
        while (glfwGetWindowAttrib(pWindow, GLFW_ICONIFIED))
            glfwWaitEvents();

        graphicsBase::Base().SwapImage(semaphore_imageIsAvailable);

        //提交到图形队列的命令缓冲区
        commandBuffer_graphics.Begin(VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT);
        /*渲染命令,待填充*/
        VkImageMemoryBarrier imageMemoryBarrier_g2p_release = {
            .sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER,
            .srcAccessMask = VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT,
            .oldLayout = VK_IMAGE_LAYOUT_PRESENT_SRC_KHR,
            .newLayout = VK_IMAGE_LAYOUT_PRESENT_SRC_KHR,
            .srcQueueFamilyIndex = graphicsBase::Base().QueueFamilyIndex_Graphics(),
            .dstQueueFamilyIndex = graphicsBase::Base().QueueFamilyIndex_Presentation(),
            .image = graphicsBase::Base().SwapchainImage(currentImageIndex),
            .subresourceRange = { VK_IMAGE_ASPECT_COLOR_BIT, 0, 1, 0, 1 }
        };
        vkCmdPipelineBarrier(commandBuffer, VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT, VK_PIPELINE_STAGE_BOTTOM_OF_PIPE_BIT, 0,
            0, nullptr, 0, nullptr, 1, &imageMemoryBarrier_g2p_release);
        commandBuffer_graphics.End();
        graphicsBase::Base().SubmitCommandBuffer_Graphics(commandBuffer_graphics, semaphore_imageIsAvailable, semaphore_renderingIsOver);

        //提交到呈现队列的命令缓冲区
        commandBuffer_presentation.Begin(VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT);
        VkImageMemoryBarrier imageMemoryBarrier_g2p_acquire = {
            .sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER,
            .oldLayout = VK_IMAGE_LAYOUT_PRESENT_SRC_KHR,
            .newLayout = VK_IMAGE_LAYOUT_PRESENT_SRC_KHR,
            .srcQueueFamilyIndex = graphicsBase::Base().QueueFamilyIndex_Graphics(),
            .dstQueueFamilyIndex = graphicsBase::Base().QueueFamilyIndex_Presentation(),
            .image = graphicsBase::Base().SwapchainImage(currentImageIndex),
            .subresourceRange = { VK_IMAGE_ASPECT_COLOR_BIT, 0, 1, 0, 1 }
        };
        vkCmdPipelineBarrier(commandBuffer, VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT, VK_PIPELINE_STAGE_BOTTOM_OF_PIPE_BIT, 0,
            0, nullptr, 0, nullptr, 1, &imageMemoryBarrier_g2p_acquire);
        commandBuffer_presentation.End();
        graphicsBase::Base().SubmitCommandBuffer_Presentation(commandBuffer_presentation, semaphore_renderingIsOver, semaphore_ownershipIsTransfered, fence);

        graphicsBase::Base().PresentImage(semaphore_ownershipIsTransfered);

        glfwPollEvents();
        TitleFps();

        fence.WaitAndReset();
    }
    TerminateWindow();
    return 0;
}

其中graphicsBase的成员函数,SubmitCommandBuffer_Presentation(...)的定义如下:

public:
result_t SubmitCommandBuffer_Presentation(VkCommandBuffer commandBuffer,
    VkSemaphore semaphore_renderingIsOver = VK_NULL_HANDLE, VkSemaphore semaphore_ownershipIsTransfered = VK_NULL_HANDLE, VkFence fence = VK_NULL_HANDLE) const {
    static constexpr VkPipelineStageFlags waitDstStage = VK_PIPELINE_STAGE_ALL_COMMANDS_BIT;
    VkSubmitInfo submitInfo = {
        .sType = VK_STRUCTURE_TYPE_SUBMIT_INFO,
        .commandBufferCount = 1,
        .pCommandBuffers = &commandBuffer
    };
    if (semaphore_renderingIsOver)
        submitInfo.waitSemaphoreCount = 1,
        submitInfo.pWaitSemaphores = &semaphore_renderingIsOver,
        submitInfo.pWaitDstStageMask = &waitDstStage;
    if (semaphore_ownershipIsTransfered)
        submitInfo.signalSemaphoreCount = 1,
        submitInfo.pSignalSemaphores = &semaphore_ownershipIsTransfered;
    VkResult result = vkQueueSubmit(queue_presentation, 1, &submitInfo, fence);
    if (result)
        outStream << std::format("[ graphicsBase ] ERROR\nFailed to submit the presentation command buffer!\nError code: {}\n", int32_t(result));
    return result;
}
  • 在呈现队列上任何操作开始执行前等待渲染完成,因此submitInfo.pWaitDstStageMask指向的值为VK_PIPELINE_STAGE_ALL_COMMANDS_BIT

如果使用完全一致的内存屏障,那么中间可以省去一个信号量。
这里将所用的内存屏障封装为graphicsBase的成员函数:

public:
void CmdTransferImageOwnership(VkCommandBuffer commandBuffer) const {
    VkImageMemoryBarrier imageMemoryBarrier_g2p = {
        .sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER,
        .srcAccessMask = VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT,
        .dstAccessMask = 0,
        .oldLayout = VK_IMAGE_LAYOUT_PRESENT_SRC_KHR,
        .newLayout = VK_IMAGE_LAYOUT_PRESENT_SRC_KHR,
        .srcQueueFamilyIndex = queueFamilyIndex_graphics,
        .dstQueueFamilyIndex = queueFamilyIndex_presentation,
        .image = swapchainImages[currentImageIndex],
        .subresourceRange = { VK_IMAGE_ASPECT_COLOR_BIT, 0, 1, 0, 1 }
    };
    vkCmdPipelineBarrier(commandBuffer, VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT, VK_PIPELINE_STAGE_BOTTOM_OF_PIPE_BIT, 0,
        0, nullptr, 0, nullptr, 1, &imageMemoryBarrier_g2p);
}

然后主函数改为:

int main() {
    if (!InitializeWindow({1280,720}))
        return -1;

    fence fence(VK_FENCE_CREATE_SIGNALED_BIT);
    semaphore semaphore_imageIsAvailable;
    semaphore semaphore_ownershipIsTransfered;

    commandBuffer commandBuffer_graphics;
    commandBuffer commandBuffer_presentation;
    commandPool commandPool_graphics(graphicsBase::Base().QueueFamilyIndex_Graphics(), VK_COMMAND_POOL_CREATE_RESET_COMMAND_BUFFER_BIT);
    commandPool commandPool_presentation(VK_COMMAND_POOL_CREATE_RESET_COMMAND_BUFFER_BIT, graphicsBase::Base().QueueFamilyIndex_Presentation());
    commandPool_graphics.AllocateBuffers(commandBuffer_graphics);
    commandPool_presentation.AllocateBuffers(commandBuffer_presentation);

    while (!glfwWindowShouldClose(pWindow)) {
        while (glfwGetWindowAttrib(pWindow, GLFW_ICONIFIED))
            glfwWaitEvents();

        graphicsBase::Base().SwapImage(semaphore_imageIsAvailable);

        //提交到图形队列的命令缓冲区
        commandBuffer_graphics.Begin(VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT);
        /*渲染命令,待填充*/
        graphicsBase::Base().CmdTransferImageOwnership(commandBuffer_graphics);
        commandBuffer_graphics.End();
        graphicsBase::Base().SubmitCommandBuffer_Graphics(commandBuffer_graphics, semaphore_imageIsAvailable);

        //提交到呈现队列的命令缓冲区
        commandBuffer_presentation.Begin(VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT);
        graphicsBase::Base().CmdTransferImageOwnership(commandBuffer_presentation);
        commandBuffer_presentation.End();
        graphicsBase::Base().SubmitCommandBuffer_Presentation(commandBuffer_presentation, VK_NULL_HANDLE, semaphore_ownershipIsTransfered, fence);

        graphicsBase::Base().PresentImage(semaphore_ownershipIsTransfered);

        glfwPollEvents();
        TitleFps();

        fence.WaitAndReset();
    }
    TerminateWindow();
    return 0;
}