命令在 Vulkan 中,比如绘图操作和内存传递,并不能直接经过函数调用来执行。我们必须要把所有需要执行的操作记录到命令缓冲区对象中。这样做的优势在于配置绘图命令的复杂工作能够提前且多线程进行。这之后,我们只需要在主循环中通知 Vulkan 执行这些命令

命令池

在创建命令缓冲区之前,我们必须先创建一个命令池。命令池能够管理用于存储缓冲区的内存,命令缓冲区就从命令池中进行分配。我们添加一个成员变量用于保存 VkCommandPool 对象

VkCommandPool commandPool;

然后还是添加一个成员函数并在 initVulkan 中调用它

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

void createCommandPool() {
    uint32_t queueFamilyIndex = findQueueFamily(physicalDevice).value();
    VkCommandPoolCreateInfo poolInfo = {};
    poolInfo.sType = VK_STRUCTURE_TYPE_COMMAND_POOL_CREATE_INFO;
    poolInfo.queueFamilyIndex = queueFamilyIndex;
    poolInfo.flags = 0;
    if (vkCreateCommandPool(device, &poolInfo, nullptr, &commandPool) != VK_SUCCESS)
        throw std::runtime_error("failed to create command pool");
}

执行命令缓冲区,其实就是把其中的命令提交到某个设备队列中,比如图形队列和展示的队列。每一个命令池中分配的命令缓冲区都只能被提交到同一种队列。而我们之前选择的队列支持图形,因此使用它的队列族索引,就能分配用于绘制图像的命令缓冲区

对于 flags 参数,有两种可能的标记

  • VK_COMMAND_POOL_CREATE_TRANSIENT_BIT

    表示命令缓冲区将会频繁地用于记录新的命令(可能会改变内存分配行为)

  • VK_COMMAND_POOL_CREATE_RESET_COMMAND_BUFFER_BIT

    允许命令缓冲区对象之间互相独立,没有这个标记的话它们将会被一起重置

我们只有在程序开始时记录命令,之后会在主线程中多次执行,所以不需要这些标记

最后在 cleanup 中销毁命令池

void cleanup() {
    vkDestroyCommandPool(device, commandPool, nullptr);
    for (auto framebuffer : swapchainFramebuffers)
        vkDestroyFramebuffer(device, framebuffer, nullptr);
    vkDestroyPipeline(device, graphicsPipeline, nullptr);
    vkDestroyPipelineLayout(device, pipelineLayout, nullptr);
    vkDestroyRenderPass(device, renderPass, nullptr);
    for (auto imageView : swapchainImageViews)
        vkDestroyImageView(device, imageView, nullptr);
    vkDestroySwapchainKHR(device, swapchain, nullptr);
    vkDestroyDevice(device, nullptr);
    vkDestroySurfaceKHR(instance, surface, nullptr);
    vkDestroyInstance(instance, nullptr);

    glfwDestroyWindow(window);
    glfwTerminate();
}

命令缓冲区分配

我们现在可以开始分配命令缓冲区并在其中记录绘制命令了。因为有一个绘制命令涉及到绑定一个帧缓冲区,所以我们实际上必须要为交换链中的每一个图像都记录一个命令缓冲区。最后创建一个命令缓冲区的数组作为成员变量。命令缓冲区会在命令池销毁时自动销毁,因此不需要我们显式地销毁它们

std::vector<VkCommandBuffer> commandBuffers;

同样添加一个成员函数并在 initVulkan 中调用它

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

void createCommandBuffers() {

}

然后我们来填充它。使用 VkCommandBufferAllocateInfo 结构体来指定命令池以及缓冲区的数量,再调用 vkAllocateCommandBuffers 函数一次性创建缓冲区

commandBuffers.resize(swapchainFramebuffers.size());
VkCommandBufferAllocateInfo allocInfo = {};
allocInfo.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_ALLOCATE_INFO;
allocInfo.commandPool = commandPool;
allocInfo.level = VK_COMMAND_BUFFER_LEVEL_PRIMARY;
allocInfo.commandBufferCount = (uint32_t) commandBuffers.size();
if (vkAllocateCommandBuffers(device, &allocInfo, commandBuffers.data()) != VK_SUCCESS)
    throw std::runtime_error("failed to allocate command buffer");

level 参数用于指定分配的命令缓冲区是主要命令缓冲区还是次要命令缓冲区

  • VK_COMMAND_BUFFER_LEVEL_PRIMARY

    能够被提交到队列来执行,但不能被其他命令缓冲区调用

  • VK_COMMAND_BUFFER_LEVEL_SECONDARY

    不能被直接执行,但是可以被主要缓冲区调用

当然我们不需要使用次要缓冲区的功能,但你可以想象到,它对于复用一些常用操作会很有帮助

记录命令缓冲区

于是我们来记录命令缓冲区

for (size_t i = 0; i < commandBuffers.size(); i++) {
    VkCommandBufferBeginInfo beginInfo = {};
    beginInfo.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO;
    if (vkBeginCommandBuffer(commandBuffers[i], &beginInfo) != VK_SUCCESS)
        throw std::runtime_error("failed to begin recording command buffer!");

    VkRenderPassBeginInfo renderPassInfo = {};
    renderPassInfo.sType = VK_STRUCTURE_TYPE_RENDER_PASS_BEGIN_INFO;
    renderPassInfo.renderPass = renderPass;
    renderPassInfo.framebuffer = swapchainFramebuffers[i];
    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;
    vkCmdBeginRenderPass(commandBuffers[i], &renderPassInfo, VK_SUBPASS_CONTENTS_INLINE);
    vkCmdBindPipeline(commandBuffers[i], VK_PIPELINE_BIND_POINT_GRAPHICS, graphicsPipeline);
    vkCmdDraw(commandBuffers[i], 3, 1, 0, 0);
    vkCmdEndRenderPass(commandBuffers[i]);

    if (vkEndCommandBuffer(commandBuffers[i]) != VK_SUCCESS)
        throw std::runtime_error("failed to record command buffer!");
}

其实代码非常地直观,就是先开启记录,然后记录命令,最后结束记录

在记录过程中,需要先开启渲染路径,绑定渲染管线,开始绘制,最后结束渲染路径。注意到所有的命令都是以 vkCmd 为前缀

着重介绍一下绘制命令 vkCmdDraw 的后四个参数

  • vertexCount

    尽管我们没有使用顶点缓冲区,但是我们把顶点数据硬编码在着色器中,因此还是有三个顶点需要绘制

  • instanceCount

    如果不是用于实例渲染,应当设为一

  • firstVertex

    用作顶点缓冲区的偏移量,定义了 gl_VertexIndex 的起始值

  • firstInstance

    用作实例渲染中的偏移量,定义了 gl_InstanceIndex 的起始值

我们现在已经创建了命令缓冲区并在其中填入了相应的命令,下一章中我们将为主循环填写代码,从交换链中获取图像,执行正确的命令缓冲区,再把图像返还给交换链