在这一章里,所有的事情都要集中起来。我们将编写drawFrame函数,该函数将在主循环中被调用,以将三角形置于屏幕上。创建函数并从mainLoop调用它.
void mainLoop() {
while (!glfwWindowShouldClose(window)) {
glfwPollEvents();
drawFrame();
}
}
...
void drawFrame() {
}
drawFrame函数将执行以下操作:
这些事件都是使用单个函数调用设置的,但它们是异步执行的。函数调用将在操作实际完成之前返回,并且执行的顺序也未定义。因为每个操作都依赖于前一个完成,所以需要同步机制。
有两种同步交换链事件的方法:栅栏和信号量。
它们都是可以用于协调操作的对象,方法是让一个操作信号和另一个操作等待栅栏或信号量从无信号状态变为有信号状态。
不同的是,你可以通过vkWaitForFences来访问fences的状态,而信号量却不能。
fence主要用于通过呈现操作同步应用程序本身,而信号量用于在命令队列内或跨命令队列同步操作。我们想要同步draw命令和表示的队列操作,这使得信号量最适合。
信号量是一种同步原语,可以用来在提交给队列的批之间插入依赖关系。信号量有两种状态——有信号的和无信号的。一个信号量的状态可以在一批命令执行完成后发出信号。批处理可以在开始执行前等待信号量变成有信号的,也可以在批处理开始执行前等待信号量变成无信号的。
与Vulkan中的大多数对象一样,信号量是内部数据的接口,通常对应用程序是不透明的。这个内部数据被称为信号量的有效负载。但是,为了能够与当前设备之外的代理进行通信,必须能够将有效负载导出为一种普遍理解的格式,然后再从该格式导入。信号量的内部数据可以包括对任何资源的引用,以及与在该信号量对象上执行的信号或非信号操作相关的待定工作。
下面提供了向信号量导入和导出内部数据的机制。这些机制间接地使应用程序能够跨进程和API边界在两个或多个信号量和其他同步原语之间共享信号量状态。
信号量由VkSemaphore句柄表示:VK_DEFINE_NON_DISPATCHABLE_HANDLE(VkSemaphore)
VkResult vkCreateSemaphore(
VkDevice device,
const VkSemaphoreCreateInfo* pCreateInfo,
const VkAllocationCallbacks* pAllocator,
VkSemaphore* pSemaphore);
创建时,信号量处于无信号状态。
typedef struct VkSemaphoreCreateInfo {
VkStructureType sType;
const void* pNext;
VkSemaphoreCreateFlags flags;
} VkSemaphoreCreateInfo;
需要一个信号量来表示图像已经获得并准备好呈现,还需要另一个信号量来表示渲染已经完成并可以进行呈现。创建两个类成员来存储这些信号量对象:
VkSemaphore imageAvailableSemaphore;
VkSemaphore renderFinishedSemaphore;
创建信号量需要填写VkSemaphoreCreateInfo,但是在当前版本的API中,除了sType之外实际上没有任何必需的字段:
void initVulkan() {
...
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("failed to create semaphores!");
}
}
同理,信号量应该在程序结束时清除,当所有的命令都已经完成,不再需要更多的同步:
void cleanup() {
vkDestroySemaphore(device, renderFinishedSemaphore, nullptr);
vkDestroySemaphore(device, imageAvailableSemaphore, nullptr);
...
}
如前所述,在drawFrame函数中需要做的第一件事是从交换链中获取图像。回想一下,交换链是一个扩展特性,所以我们必须使用一个具有vk*KHR命名约定的函数:
void drawFrame() {
uint32_t imageIndex;
vkAcquireNextImageKHR(device, swapChain,
std::numeric_limits::max(),
imageAvailableSemaphore, VK_NULL_HANDLE, &imageIndex);
}
获取一个可用的可呈现图像使用,并检索该图像的索引,调用: vkAcquireNextImageKHR:
VkResult vkAcquireNextImageKHR(
VkDevice device,
VkSwapchainKHR swapchain,
uint64_t timeout,
VkSemaphore semaphore,
VkFence fence,
uint32_t* pImageIndex);
当成功时,vkAcquireNextImageKHR从swapchain获得一个应用程序可以使用的图像,并将pImageIndex设置为该图像在swapchain中的索引。表示引擎在获取图像时可能还没有完成对图像的读取,因此应用程序必须使用信号量和/或栅栏来确保图像布局和内容在表示引擎读取完成之前不会被修改。如果semaphore不是VK_NULL_HANDLE,应用程序可能会认为,一旦vkAcquireNextImageKHR返回,semaphore引用的信号量信号操作已经提交执行。图像获取的顺序取决于实现,并且可能与图像呈现的顺序不同。
如果timeout为0,则vkAcquireNextImageKHR不会等待,并且会成功获取镜像,或者失败并返回VK_NOT_READY,如果没有可用的镜像。如果指定的超时时间在获取镜像之前过期,vkAcquireNextImageKHR将返回VK_TIMEOUT。如果timeout是UINT64_MAX,超时时间被认为是无限的,vkAcquireNextImageKHR将阻塞直到一个图像被获取或一个错误发生。
如果应用程序当前获取的(但尚未呈现的)图像数量小于或等于swapchain中的图像数量与vksurfacecabiltieskhr::minImageCount值之间的差值,则最终会获得一个图像。如果当前获取的图像数量大于此值,则不应该调用vkAcquireNextImageKHR;如果是,timeout不能是UINT64_MAX。
如果一个图像成功获得,vkAcquireNextImageKHR必须要么返回VK_SUCCESS,要么返回VK_SUBOPTIMAL_KHR,如果交换链不再完全匹配表面属性,但仍然可以用于表示。
队列提交和同步是通过VkSubmitInfo结构中的参数配置的。
typedef struct VkSubmitInfo {
VkStructureType sType;
const void* pNext;
uint32_t waitSemaphoreCount;
const VkSemaphore* pWaitSemaphores;
const VkPipelineStageFlags* pWaitDstStageMask;
uint32_t commandBufferCount;
const VkCommandBuffer* pCommandBuffers;
uint32_t signalSemaphoreCount;
const VkSemaphore* pSignalSemaphores;
} VkSubmitInfo;
命令缓冲区在pCommandBuffers中出现的顺序用于确定提交顺序,因此所有的隐式排序都保证遵守它。除了这些隐式排序保证和任何显式同步原语之外,这些命令缓冲区可能会重叠或以其他方式乱序执行。
// 前三个参数指定在执行开始之前等待哪些信号量,以及在管道的哪个阶段等待。
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;
// 理论上已经可以开始执行顶点着色器,而还没有可用图像。
// waitStages数组中的每一项都对应于在pwaitsemaphres中具有相同索引的信号量。
submitInfo.pWaitDstStageMask = waitStages;
// 指定实际提交哪些命令缓冲区以执行
submitInfo.commandBufferCount = 1;
submitInfo.pCommandBuffers = &commandBuffers[imageIndex];
// 指定在命令缓冲区完成执行后要发送哪些信号量
VkSemaphore signalSemaphores[] = {renderFinishedSemaphore};
submitInfo.signalSemaphoreCount = 1;
submitInfo.pSignalSemaphores = signalSemaphores;
// 使用vkqueuessubmit将命令缓冲区提交到图形队列
if (vkQueueSubmit(graphicsQueue, 1, &submitInfo, VK_NULL_HANDLE) != VK_SUCCESS) {
throw std::runtime_error("failed to submit draw command buffer!");
}
使用vkqueuessubmit将命令缓冲区提交到图形队列。当工作负载更大时,该函数接受一个VkSubmitInfo结构数组作为效率参数。
最后一个参数引用一个可选的fence,该fence将在命令缓冲区完成执行时发出信号。我们使用信号量进行同步,所以我们将传递一个VK_NULL_HANDLE。
VkResult vkQueueSubmit(
VkQueue queue,
uint32_t submitCount,
const VkSubmitInfo* pSubmits,
VkFence fence);
提交可能是一个高开销的操作,应用程序应该尽可能少的调用vkqueuessubmit来批量处理。
渲染通道中的子通道会自动处理图像布局的转换。这些转换由子传递依赖项控制,子传递依赖项指定子传递之间的内存和执行依赖项。
我们现在只有一个Subpass,但是在这个Subpass之前和之后的操作也被算作隐式的“Subpasses”。
有两个内置的依赖关系负责渲染通道开始和结束的转换,但前者没有在正确的时间发生。它假设转换发生在管道的开始,但是我们在那一点还没有获得图像!
有两种方法来处理这个问题:
在这里使用第二种方法,因为这是一个很好的方式来了解子传递依赖项及其工作方式。Subpass依赖在VkSubpassDependency结构中指定。
在createRenderPass函数中添加一个:
VkSubpassDependency dependency = {};
// 指定依赖项
dependency.srcSubpass = VK_SUBPASS_EXTERNAL;
// 从属子传递的索引
dependency.dstSubpass = 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;
特殊值VK_SUBPASS_EXTERNAL指的是在渲染传递之前或之后的隐式子传递,这取决于它是在srcSubpass还是dstSubpass中指定的。
索引0指向我们的子通道,它是第一个也是唯一一个。dstSubpass必须始终高于srcSubpass,以防止依赖关系图中的循环。
绘制框架的最后一步是将结果提交回交换链,使其最终显示在屏幕上。
在应用程序可以呈现一个图像之前,图像的布局必须转换为VK_IMAGE_LAYOUT_PRESENT_SRC_KHR布局,或者对于一个共享的可呈现图像,必须转换为VK_IMAGE_LAYOUT_SHARED_PRESENT_KHR布局。
typedef struct VkPresentInfoKHR {
VkStructureType sType;
const void* pNext;
uint32_t waitSemaphoreCount;
const VkSemaphore* pWaitSemaphores;
uint32_t swapchainCount;
const VkSwapchainKHR* pSwapchains;
const uint32_t* pImageIndices;
VkResult* pResults;
} VkPresentInfoKHR;
通过drawFrame函数末尾的VkPresentInfoKHR结构来配置显示相关设置:
VkPresentInfoKHR presentInfo = {};
presentInfo.sType = VK_STRUCTURE_TYPE_PRESENT_INFO_KHR;
// 指定在表示发生之前等待哪些信号量,就像VkSubmitInfo一样
presentInfo.waitSemaphoreCount = 1;
presentInfo.pWaitSemaphores = signalSemaphores;
// 指定要向其显示图像的交换链,以及每个交换链的图像索引。
VkSwapchainKHR swapChains[] = {swapChain};
presentInfo.swapchainCount = 1;
presentInfo.pSwapchains = swapChains;
presentInfo.pImageIndices = &imageIndex;
// 指定一个VkResult值数组,以便在表示成功时检查每个交换链。
// 只使用单个交换链,就不需要,因为可以简单地使用当前函数的返回值。
presentInfo.pResults = nullptr; // Optional
// vkQueuePresentKHR函数提交请求,以向交换链请求一个图像
vkQueuePresentKHR(presentQueue, &presentInfo);
现在编译运行一下我们的程序:
ohhhh!!!整整一千多行的代码,终于不是黑糊糊的窗口了。
当启用验证层时,程序在关闭时就会崩溃。从debugCallback打印到终端的消息告诉我们为什么:
记住,drawFrame中的所有操作都是异步的。这意味着当我们退出mainLoop中的循环时,绘图和表示操作可能仍然在进行。当这种情况发生时,清理资源就可能带来异常。
要解决这个问题,我们应该等待逻辑设备完成操作,然后退出mainLoop并销毁窗口:
void mainLoop() {
while (!glfwWindowShouldClose(window)) {
glfwPollEvents();
drawFrame();
}
}
vkDeviceWaitIdle(device);
在将所有渲染命令排队并将图像转换到正确的布局后,要将图像排队显示,调用:
VkResult vkQueuePresentKHR(
VkQueue queue,
const VkPresentInfoKHR* pPresentInfo);
应用程序不需要按照获取图像的顺序来呈现图像——应用程序可以任意地呈现当前获取的任何图像。
如果在启用了验证层的情况下运行应用程序,并且监视应用程序的内存使用情况,则可能会注意到它正在缓慢增长。
原因是应用程序正在使用drawFrame函数快速提交工作,但实际上并没有检查是否有任何工作完成。如果CPU提交工作的速度快于GPU不能跟上的工作,那么队列将缓慢地填满工作。 更糟糕的是,我们同时对多个帧重用了imageAvailableSemaphore和renderFinishedSemaphore。
解决此问题的简单方法是提交后等待工作完成,例如使用vkQueueWaitIdle:
void drawFrame() {
...
vkQueuePresentKHR(presentQueue, &presentInfo);
vkQueueWaitIdle(presentQueue);
}
但是,我们可能无法以这种方式最佳地使用GPU,因为整个图形流水线现在一次只能使用一帧。 当前帧已经经过的阶段是空闲的,可能已经用于下一帧。 现在,我们将扩展我们的应用程序,以允许在运行多个frame的同时仍限制堆积的工作量。
首先在程序顶部添加一个常量,该常量定义应同时处理多少帧, 以及每个frame应具有自己的一组信号:
const int MAX_FRAMES_IN_FLIGHT = 2;
std::vector imageAvailableSemaphores;
std::vector renderFinishedSemaphores;
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);
}
...
}
同理,drawFrame也需要修改:
void drawFrame() {
vkAcquireNextImageKHR(device, swapChain, std::numeric_limits::max(),
imageAvailableSemaphores[currentFrame], VK_NULL_HANDLE, &imageIndex);
...
VkSemaphore waitSemaphores[] = {imageAvailableSemaphores[currentFrame]};
...
VkSemaphore signalSemaphores[] = {renderFinishedSemaphores[currentFrame]};
...
}
这里的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提供了第二种类型的同步原语,称为fences。 在可以发信号并等待信号的意义上,fence与信号相似,但是这次我们实际上在自己的代码中等待信号。 我们首先为每个框架创建一个fence:
std::vector imageAvailableSemaphores;
std::vector renderFinishedSemaphores;
std::vector inFlightFences;
size_t currentFrame = 0;
因为fence也是同步机制,所以最好把同步对象的创建放在一起,吧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!");
}
}
}
也要记得销毁fence.
void cleanup() {
// 释放信号量和fence
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);
}
}
现在使用fence进行同步。vkqueuessubmit调用包含一个可选参数,用于传递一个fence,当命令缓冲区执行完毕时,该fence应该被通知。我们可以用它来表示一个帧已经完成。
void drawFrame() {
// 等待当前帧fence完成
vkWaitForFences(device, 1, &inFlightFences[currentFrame], VK_TRUE, std::numeric_limits::max());
vkResetFences(device, 1, &inFlightFences[currentFrame]);
...
// VkQueue是Vulkan中应用程序向GPU提交命令的唯一途径
if (vkQueueSubmit(graphicsQueue, 1, &submitInfo, inFlightFences[currentFrame]) != VK_SUCCESS) {
throw std::runtime_error("failed to submit draw command buffer!");
}
...
}
vkWaitForFences函数接受一个fences数组,在返回之前等待其中任何一个或者所有的栅栏被通知。我们在这里传递的VK_TRUE表示我们希望等待所有的fence,但在单个fence的情况下,这显然无关紧要。就像vkAcquireNextImageKHR一样,这个函数也是需要一个超时。
与信号量不同,我们需要通过vkResetFences调用来手动将栅栏恢复到无信号状态。如果你现在运行这个程序,你会注意到一些奇怪的东西。应用程序似乎不再呈现任何东西。
这是因为在等一个还没被提交的fence! 这里的问题是,在默认情况下,fence是在无信号状态下创建的。这意味着如果我们以前没有用过fence,vkWaitForFences将会永远等下去。为了解决这个问题,我们可以改变fence的创建,在有信号的状态下初始化它,就像我们已经完成了初始帧的渲染一样:
VkFenceCreateInfo fenceInfo = {};
fenceInfo.sType = VK_STRUCTURE_TYPE_FENCE_CREATE_INFO;
fenceInfo.flags = VK_FENCE_CREATE_SIGNALED_BIT; // 初始化fence
程序现在应该可以正常工作了,内存泄漏也消失了! 我们已经实现了所有需要的同步,以确保排队的工作不超过两个帧。请注意,代码的其他部分,如最终的清理,可以依赖于更粗糙的同步,如vkDeviceWaitIdle,应该根据性能需求决定使用哪种方法。
Vulkan管道的框图
现在我们已经写了一千多行的代码,总算把Vulkan的这一套简单的过了一遍。在继续后续学习之前,有必要先总结一下,巩固基础。
首先Vulkan是什么:Vulkan是一个低开销、跨平台的二维、三维图形与计算的应用程序接口(API)。本身是一个与平台无关的API,所以不包括用于创建显示渲染结果的窗口的工具。所以借助 GLFW (当然也可以是其他库如SDL)创建窗口。
下面是一个Vuklan应用一般流程的简述:
好的,现在我们又加深了一遍印象,这其中诸多细节我们后续挖掘。