在 Vulkan 中,像绘制操作和内存传输这样的命令不是直接使用函数调用来执行的。你必须在你想要执行的命令缓冲区对象中记录所有操作。这样做的好处是,当我们准备告诉 Vulkan 我们想要做什么时,所有命令都会一起提交,Vulkan 可以更有效地处理这些命令,因为所有命令都同时可用。此外,如果需要,这允许命令记录在多个线程中进行。

命令池

在我们可以创建命令缓冲区之前,我们必须先创建一个命令池。命令池管理用于存储缓冲区的内存,命令缓冲区从命令池中分配。添加一个新的类成员来存储一个 VkCommandPool

VkCommandPool commandPool;

然后创建一个新的函数 createCommandPool 并在帧缓冲创建之后从 initVulkan 中调用它。

void initVulkan() {
    createInstance();
    setupDebugMessenger();
    createSurface();
    pickPhysicalDevice();
    createLogicalDevice();
    createSwapChain();
    createImageViews();
    createRenderPass();
    createGraphicsPipeline();
    createFramebuffers();
    createCommandPool();
}

...

void createCommandPool() {

}

命令池创建只需要两个参数

QueueFamilyIndices queueFamilyIndices = findQueueFamilies(physicalDevice);

VkCommandPoolCreateInfo poolInfo{};
poolInfo.sType = VK_STRUCTURE_TYPE_COMMAND_POOL_CREATE_INFO;
poolInfo.flags = VK_COMMAND_POOL_CREATE_RESET_COMMAND_BUFFER_BIT;
poolInfo.queueFamilyIndex = queueFamilyIndices.graphicsFamily.value();

命令池有两个可能的标志

  • VK_COMMAND_POOL_CREATE_TRANSIENT_BIT:提示命令缓冲区非常频繁地用新命令重新记录(可能会改变内存分配行为)
  • VK_COMMAND_POOL_CREATE_RESET_COMMAND_BUFFER_BIT:允许单独重新记录命令缓冲区,如果没有此标志,则必须将它们全部一起重置

我们将每帧记录一个命令缓冲区,所以我们希望能够重置并重新记录它。因此,我们需要为我们的命令池设置 VK_COMMAND_POOL_CREATE_RESET_COMMAND_BUFFER_BIT 标志位。

命令缓冲区通过在设备队列之一(例如我们检索到的图形和呈现队列)上提交来执行。每个命令池只能分配在单一类型的队列上提交的命令缓冲区。我们将记录用于绘制的命令,这就是为什么我们选择了图形队列族。

if (vkCreateCommandPool(device, &poolInfo, nullptr, &commandPool) != VK_SUCCESS) {
    throw std::runtime_error("failed to create command pool!");
}

使用 vkCreateCommandPool 函数完成命令池的创建。它没有任何特殊参数。命令将在整个程序中使用,以在屏幕上绘制内容,因此命令池应该只在结束时销毁

void cleanup() {
    vkDestroyCommandPool(device, commandPool, nullptr);

    ...
}

命令缓冲区分配

我们现在可以开始分配命令缓冲区了。

创建一个 VkCommandBuffer 对象作为类成员。命令缓冲区将在其命令池销毁时自动释放,因此我们不需要显式清理。

VkCommandBuffer commandBuffer;

我们现在将开始处理 createCommandBuffer 函数,以从命令池中分配单个命令缓冲区。

void initVulkan() {
    createInstance();
    setupDebugMessenger();
    createSurface();
    pickPhysicalDevice();
    createLogicalDevice();
    createSwapChain();
    createImageViews();
    createRenderPass();
    createGraphicsPipeline();
    createFramebuffers();
    createCommandPool();
    createCommandBuffer();
}

...

void createCommandBuffer() {

}

命令缓冲区使用 vkAllocateCommandBuffers 函数分配,该函数接受一个 VkCommandBufferAllocateInfo 结构体作为参数,该结构体指定命令池和要分配的缓冲区数量

VkCommandBufferAllocateInfo allocInfo{};
allocInfo.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_ALLOCATE_INFO;
allocInfo.commandPool = commandPool;
allocInfo.level = VK_COMMAND_BUFFER_LEVEL_PRIMARY;
allocInfo.commandBufferCount = 1;

if (vkAllocateCommandBuffers(device, &allocInfo, &commandBuffer) != VK_SUCCESS) {
    throw std::runtime_error("failed to allocate command buffers!");
}

level 参数指定分配的命令缓冲区是主命令缓冲区还是辅助命令缓冲区。

  • VK_COMMAND_BUFFER_LEVEL_PRIMARY:可以提交到队列以执行,但不能从其他命令缓冲区调用。
  • VK_COMMAND_BUFFER_LEVEL_SECONDARY:不能直接提交,但可以从主命令缓冲区调用。

我们在这里不会使用辅助命令缓冲区功能,但你可以想象它有助于重用来自主命令缓冲区的常用操作。

由于我们只分配一个命令缓冲区,因此 commandBufferCount 参数仅为一。

命令缓冲区记录

我们现在将开始处理 recordCommandBuffer 函数,该函数将我们想要执行的命令写入命令缓冲区。使用的 VkCommandBuffer 将作为参数传入,以及我们想要写入的当前交换链图像的索引。

void recordCommandBuffer(VkCommandBuffer commandBuffer, uint32_t imageIndex) {

}

我们总是通过调用 vkBeginCommandBuffer 开始记录命令缓冲区,并使用一个小的 VkCommandBufferBeginInfo 结构体作为参数,该结构体指定有关此特定命令缓冲区用法的一些详细信息。

VkCommandBufferBeginInfo beginInfo{};
beginInfo.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO;
beginInfo.flags = 0; // Optional
beginInfo.pInheritanceInfo = nullptr; // Optional

if (vkBeginCommandBuffer(commandBuffer, &beginInfo) != VK_SUCCESS) {
    throw std::runtime_error("failed to begin recording command buffer!");
}

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 将隐式地重置它。稍后无法将命令附加到缓冲区。

开始渲染通道

绘制从使用 vkCmdBeginRenderPass 开始渲染通道开始。渲染通道使用 VkRenderPassBeginInfo 结构体中的一些参数进行配置。

VkRenderPassBeginInfo renderPassInfo{};
renderPassInfo.sType = VK_STRUCTURE_TYPE_RENDER_PASS_BEGIN_INFO;
renderPassInfo.renderPass = renderPass;
renderPassInfo.framebuffer = swapChainFramebuffers[imageIndex];

第一个参数是渲染通道本身和要绑定的附件。我们为每个交换链图像创建了一个帧缓冲,其中它被指定为颜色附件。因此,我们需要为我们想要绘制到的交换链图像绑定帧缓冲。使用传入的 imageIndex 参数,我们可以为当前交换链图像选择正确的帧缓冲。

renderPassInfo.renderArea.offset = {0, 0};
renderPassInfo.renderArea.extent = swapChainExtent;

接下来的两个参数定义了渲染区域的大小。渲染区域定义了着色器加载和存储将发生的位置。此区域之外的像素将具有未定义的值。它应该与附件的大小匹配以获得最佳性能。

VkClearValue clearColor = {{{0.0f, 0.0f, 0.0f, 1.0f}}};
renderPassInfo.clearValueCount = 1;
renderPassInfo.pClearValues = &clearColor;

最后两个参数定义了用于 VK_ATTACHMENT_LOAD_OP_CLEAR 的清除值,我们将其用作颜色附件的加载操作。我已将清除颜色定义为简单的黑色,不透明度为 100%。

vkCmdBeginRenderPass(commandBuffer, &renderPassInfo, VK_SUBPASS_CONTENTS_INLINE);

渲染通道现在可以开始了。所有记录命令的函数都可以通过它们的 vkCmd 前缀来识别。它们都返回 void,因此在我们完成记录之前不会有任何错误处理。

每个命令的第一个参数始终是要记录命令的命令缓冲区。第二个参数指定了我们刚刚提供的渲染通道的详细信息。最后一个参数控制渲染通道内的绘制命令将如何提供。它可以具有以下两个值之一

  • VK_SUBPASS_CONTENTS_INLINE:渲染通道命令将嵌入在主命令缓冲区本身中,并且不会执行辅助命令缓冲区。
  • VK_SUBPASS_CONTENTS_SECONDARY_COMMAND_BUFFERS:渲染通道命令将从辅助命令缓冲区执行。

我们将不使用辅助命令缓冲区,所以我们将选择第一个选项。

基本绘制命令

我们现在可以绑定图形管线了

vkCmdBindPipeline(commandBuffer, VK_PIPELINE_BIND_POINT_GRAPHICS, graphicsPipeline);

第二个参数指定管线对象是图形管线还是计算管线。我们现在已经告诉 Vulkan 在图形管线中执行哪些操作,以及在片段着色器中使用哪个附件。

正如在 固定功能章节 中指出的那样,我们确实为这个管线指定了视口和裁剪状态为动态状态。因此,我们需要在发出绘制命令之前在命令缓冲区中设置它们

VkViewport viewport{};
viewport.x = 0.0f;
viewport.y = 0.0f;
viewport.width = static_cast<float>(swapChainExtent.width);
viewport.height = static_cast<float>(swapChainExtent.height);
viewport.minDepth = 0.0f;
viewport.maxDepth = 1.0f;
vkCmdSetViewport(commandBuffer, 0, 1, &viewport);

VkRect2D scissor{};
scissor.offset = {0, 0};
scissor.extent = swapChainExtent;
vkCmdSetScissor(commandBuffer, 0, 1, &scissor);

现在我们准备好为三角形发出绘制命令了

vkCmdDraw(commandBuffer, 3, 1, 0, 0);

实际的 vkCmdDraw 函数有点平淡无奇,但它如此简单是因为我们预先指定的所有信息。除了命令缓冲区之外,它还具有以下参数

  • vertexCount:即使我们没有顶点缓冲区,我们在技术上仍然有 3 个顶点要绘制。
  • instanceCount:用于实例化渲染,如果您不这样做,请使用 1
  • firstVertex:用作顶点缓冲区的偏移量,定义了 gl_VertexIndex 的最小值。
  • firstInstance:用作实例化渲染的偏移量,定义了 gl_InstanceIndex 的最小值。

完成

渲染通道现在可以结束了

vkCmdEndRenderPass(commandBuffer);

我们已经完成了命令缓冲区的记录

if (vkEndCommandBuffer(commandBuffer) != VK_SUCCESS) {
    throw std::runtime_error("failed to record command buffer!");
}

在下一章中,我们将编写主循环的代码,它将从交换链获取图像,记录和执行命令缓冲区,然后将完成的图像返回到交换链。

C++ 代码 / 顶点着色器 / 片段着色器