命令缓冲区
在 Vulkan 中,绘图操作和内存传输等命令不是直接使用函数调用执行的。您必须将要执行的所有操作记录在命令缓冲区对象中。这样做的优势在于,当我们准备告诉 Vulkan 我们想要做什么时,所有命令都一起提交。Vulkan 可以更有效地处理命令,因为所有命令都一起可用。此外,这允许命令记录在多个线程中进行(如果需要的话)。
命令池
在创建命令缓冲区之前,我们必须创建一个命令池。命令池管理用于存储缓冲区的内存,命令缓冲区从中分配。添加一个新的类成员来存储 VkCommandPool:
vk::raii::CommandPool commandPool = nullptr;
然后创建一个新函数 createCommandPool 并在创建图形管线后从 initVulkan 调用它。
void initVulkan() {
createInstance();
setupDebugMessenger();
createSurface();
pickPhysicalDevice();
createLogicalDevice();
createSwapChain();
createImageViews();
createGraphicsPipeline();
createCommandPool();
}
...
void createCommandPool() {
}
命令池创建只需要两个参数:
vk::CommandPoolCreateInfo poolInfo{ .flags = vk::CommandPoolCreateFlagBits::eResetCommandBuffer, .queueFamilyIndex = graphicsIndex };
命令池有两个可能的标志:
-
VK_COMMAND_POOL_CREATE_TRANSIENT_BIT:提示命令缓冲区经常用新命令重新记录(可能会改变内存分配行为) -
VK_COMMAND_POOL_CREATE_RESET_COMMAND_BUFFER_BIT:允许单独重置命令缓冲区,没有此标志它们必须一起重置
我们将在每一帧记录一个命令缓冲区,因此我们希望能够重置并重新记录它。因此,我们需要为命令池设置 VK_COMMAND_POOL_CREATE_RESET_COMMAND_BUFFER_BIT 标志位。
命令缓冲区通过提交到设备队列之一来执行,例如我们检索的图形和呈现队列。每个命令池只能分配提交到单一类型队列的命令缓冲区。我们将记录用于绘制的命令,这就是为什么我们选择了图形队列族。
commandPool = vk::raii::CommandPool(device, poolInfo);
使用 vkCreateCommandPool 函数完成命令池的创建。它没有任何特殊参数。命令将在整个程序中用于在屏幕上绘制东西。
命令缓冲区分配
我们现在可以开始分配命令缓冲区了。
创建一个 VkCommandBuffer 对象作为类成员。当命令池被销毁时,命令缓冲区将自动释放,因此我们不需要显式清理。
vk::raii::CommandBuffer commandBuffer = nullptr;
我们现在将开始编写 createCommandBuffer 函数,从命令池中分配一个命令缓冲区。
void initVulkan() {
createInstance();
setupDebugMessenger();
createSurface();
pickPhysicalDevice();
createLogicalDevice();
createSwapChain();
createImageViews();
createGraphicsPipeline();
createCommandPool();
createCommandBuffer();
}
...
void createCommandBuffer() {
}
命令缓冲区使用 vkAllocateCommandBuffers 函数分配,该函数将 VkCommandBufferAllocateInfo 结构作为参数,指定命令池和要分配的缓冲区数量:
vk::CommandBufferAllocateInfo allocInfo{ .commandPool = commandPool, .level = vk::CommandBufferLevel::ePrimary, .commandBufferCount = 1 };
commandBuffer = std::move(vk::raii::CommandBuffers(device, allocInfo).front());
level 参数指定分配的命令缓冲区是主要命令缓冲区还是次要命令缓冲区。
-
VK_COMMAND_BUFFER_LEVEL_PRIMARY:可以提交到队列执行,但不能从其他命令缓冲区调用。 -
VK_COMMAND_BUFFER_LEVEL_SECONDARY:不能直接提交,但可以从主要命令缓冲区调用。
我们不会在这里使用次要命令缓冲区功能,但您可以想象,从主要命令缓冲区重用常见操作是有帮助的。
由于我们只分配一个命令缓冲区,commandBufferCount 参数只是 1。
命令缓冲区记录
我们现在将开始编写 recordCommandBuffer 函数,该函数将我们想要执行的命令写入命令缓冲区。使用的 VkCommandBuffer 将作为参数传入,以及我们想要写入的当前交换链图像的索引。
void recordCommandBuffer(uint32_t imageIndex) {
}
我们总是通过调用 vkBeginCommandBuffer 开始记录命令缓冲区,该函数以一个小的 VkCommandBufferBeginInfo 结构作为参数,指定有关此特定命令缓冲区使用的一些详细信息。
commandBuffer->begin( {} );
flags 参数指定我们将如何使用命令缓冲区。以下值可用:
-
VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT:命令缓冲区将在执行一次后立即重新记录。 -
VK_COMMAND_BUFFER_USAGE_RENDER_PASS_CONTINUE_BIT:这是一个次要命令缓冲区,将完全在单个渲染通道内。 -
VK_COMMAND_BUFFER_USAGE_SIMULTANEOUS_USE_BIT:命令缓冲区可以重新提交,同时它已经在等待执行。
现在这些标志对我们都不适用。
pInheritanceInfo 参数仅与次要命令缓冲区相关。它指定从调用主要命令缓冲区继承哪些状态。
如果命令缓冲区已经记录过一次,那么调用 vkBeginCommandBuffer 将隐式重置它。不可能在稍后时间向缓冲区附加命令。
图像布局转换
在我们开始渲染到图像之前,我们需要将其布局转换为适合渲染的布局。在 Vulkan 中,图像可以处于不同的布局中,这些布局针对不同的操作进行了优化。例如,图像可以处于适合呈现到屏幕的布局中,或者处于适合用作颜色附件的布局中。
我们将使用管线屏障将图像布局从 VK_IMAGE_LAYOUT_UNDEFINED 转换为 VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL:
void transition_image_layout(
uint32_t imageIndex,
vk::ImageLayout oldLayout,
vk::ImageLayout newLayout,
vk::AccessFlags2 srcAccessMask,
vk::AccessFlags2 dstAccessMask,
vk::PipelineStageFlags2 srcStageMask,
vk::PipelineStageFlags2 dstStageMask
) {
vk::ImageMemoryBarrier2 barrier = {
.srcStageMask = srcStageMask,
.srcAccessMask = srcAccessMask,
.dstStageMask = dstStageMask,
.dstAccessMask = dstAccessMask,
.oldLayout = oldLayout,
.newLayout = newLayout,
.srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED,
.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED,
.image = swapChainImages[imageIndex],
.subresourceRange = {
.aspectMask = vk::ImageAspectFlagBits::eColor,
.baseMipLevel = 0,
.levelCount = 1,
.baseArrayLayer = 0,
.layerCount = 1
}
};
vk::DependencyInfo dependencyInfo = {
.dependencyFlags = {},
.imageMemoryBarrierCount = 1,
.pImageMemoryBarriers = &barrier
};
commandBuffer.pipelineBarrier2(dependencyInfo);
}
这个函数将用于在渲染前后转换图像布局。
开始动态渲染
使用动态渲染,我们不需要创建渲染通道或帧缓冲区。相反,我们在开始渲染时直接指定附件:
// 在开始渲染之前,将交换链图像转换为 COLOR_ATTACHMENT_OPTIMAL
transition_image_layout(
imageIndex,
vk::ImageLayout::eUndefined,
vk::ImageLayout::eColorAttachmentOptimal,
{}, // srcAccessMask(不需要等待之前的操作)
vk::AccessFlagBits2::eColorAttachmentWrite, // dstAccessMask
vk::PipelineStageFlagBits2::eTopOfPipe, // srcStage
vk::PipelineStageFlagBits2::eColorAttachmentOutput // dstStage
);
首先,我们将图像布局转换为 VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL。然后,我们设置颜色附件:
vk::ClearValue clearColor = vk::ClearColorValue(0.0f, 0.0f, 0.0f, 1.0f);
vk::RenderingAttachmentInfo attachmentInfo = {
.imageView = swapChainImageViews[imageIndex],
.imageLayout = vk::ImageLayout::eColorAttachmentOptimal,
.loadOp = vk::AttachmentLoadOp::eClear,
.storeOp = vk::AttachmentStoreOp::eStore,
.clearValue = clearColor
};
imageView 参数指定要渲染到哪个图像视图。imageLayout 参数指定图像在渲染期间将处于的布局。loadOp 参数指定在渲染之前对图像做什么,storeOp 参数指定在渲染之后对图像做什么。我们使用 VK_ATTACHMENT_LOAD_OP_CLEAR 在渲染之前将图像清除为黑色,使用 VK_ATTACHMENT_STORE_OP_STORE 存储渲染的图像以供以后使用。
接下来,我们设置渲染信息:
vk::RenderingInfo renderingInfo = {
.renderArea = { .offset = { 0, 0 }, .extent = swapChainExtent },
.layerCount = 1,
.colorAttachmentCount = 1,
.pColorAttachments = &attachmentInfo
};
renderArea 参数定义渲染区域的大小,类似于渲染通道中的渲染区域。layerCount 参数指定要渲染到的层数,对于非分层图像为 1。colorAttachmentCount 和 pColorAttachments 参数指定要渲染到的颜色附件。
现在我们可以开始渲染:
commandBuffer.beginRendering(renderingInfo);
所有记录命令的函数都可以通过其 vkCmd 前缀识别。它们都返回 void,因此在我们完成记录之前不会有错误处理。
beginRendering 命令的参数是我们刚刚设置的渲染信息,它指定要渲染到的附件和渲染区域。
基本绘制命令
我们现在可以绑定图形管线:
commandBuffer.bindPipeline(vk::PipelineBindPoint::eGraphics, graphicsPipeline);
第二个参数指定管线对象是图形管线还是计算管线。我们现在已经告诉 Vulkan 在图形管线中执行哪些操作以及在片段着色器中使用哪个附件。
如 固定功能章节 中所述,我们确实指定了此管线的视口和剪刀状态为动态。因此,我们需要在发出绘制命令之前在命令缓冲区中设置它们:
commandBuffer.setViewport(0, vk::Viewport(0.0f, 0.0f, static_cast<float>(swapChainExtent.width), static_cast<float>(swapChainExtent.height), 0.0f, 1.0f));
commandBuffer.setScissor(0, vk::Rect2D(vk::Offset2D(0, 0), swapChainExtent));
现在我们准备发出三角形的绘制命令:
commandBuffer.draw(3, 1, 0, 0);
实际的 vkCmdDraw 函数有点平淡无奇,但它之所以如此简单,是因为我们提前指定了所有信息。除了命令缓冲区外,它还有以下参数:
-
vertexCount:尽管我们没有顶点缓冲区,但从技术上讲,我们仍然有 3 个顶点要绘制。 -
instanceCount:用于实例化渲染,如果不这样做,请使用1。 -
firstVertex:用作顶点缓冲区的偏移量,定义SV_VertexId的最低值。 -
firstInstance:用作实例化渲染的偏移量,定义SV_InstanceID的最低值。
完成
现在可以结束渲染:
commandBuffer.endRendering();
渲染后,我们需要将图像布局转换回 VK_IMAGE_LAYOUT_PRESENT_SRC_KHR,以便它可以呈现到屏幕上:
// 渲染后,将交换链图像转换为 PRESENT_SRC
transition_image_layout(
imageIndex,
vk::ImageLayout::eColorAttachmentOptimal,
vk::ImageLayout::ePresentSrcKHR,
vk::AccessFlagBits2::eColorAttachmentWrite, // srcAccessMask
{}, // dstAccessMask
vk::PipelineStageFlagBits2::eColorAttachmentOutput, // srcStage
vk::PipelineStageFlagBits2::eBottomOfPipe // dstStage
);
我们已经完成了命令缓冲区的记录:
commandBuffer.end();
在 下一章 中,我们将编写主循环的代码,该循环将从交换链获取图像,记录并执行命令缓冲区,然后将完成的图像返回到交换链。
C++ 代码 / Slang 着色器 / GLSL 顶点着色器 / GLSL 片段着色器