Vulkan教程 – 15 索引缓冲

        顶点缓冲已经能正常工作了,但是让我们能够从CPU访问的内存类型可能对显卡本身读取来说不是最优的。最好的内存会有VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT标记,且通常在专用显卡上不可以用CPU访问。本章我们创建两个顶点缓冲,一个位于CPU可访问内存中的临时缓冲来上传来自顶点数组的数据,一个设备本地内存中的最终的顶点缓冲。我们使用缓冲复制命令来移动数据,从临时缓冲移动到实际顶点缓冲中。

        缓冲复制命令要求队列族支持转移操作,用VK_QUEUE_TRANSFER_BIT标记。一个好消息是,任意队列族,有VK_QUEUE_GRAPHICS_BIT或者VK_QUEUE_COMPUTE_BIT能力的话,其实已经隐式支持VK_QUEUE_TRANSFER_BIT操作了。这些情况下,实现并不要显式罗列到queueFlags中。

        如果你喜欢挑战自己,那么你仍然可以尝试使用一个不同的专门用于转移操作的队列族。它会要求你做以下修改:

        修改QueueFamilyIndices和findQueueFamilies以显式查找有VK_QUEUE_TRANSFER位的队列族,但不是VK_QUEUE_GRAPHICS_BIT;

        修改createLogicalDevice来获取转移队列句柄;

        为已经提交到转移队列族的命令缓冲创建一个次命令池;

        修改资源的sharingMode为VK_SHARING_MODE_CONCURRENT,并同时指定图形和转移队列族;

        提交任何转移命令如vkCmdCopyBuffer(本章我们也是用这个)到转移队列而不是图形队列。

        是有一些工作量,但是它会教你很多东西,就是关于资源如何在不同队列族间共享的内容。

        因为我们要创建多重缓冲,将缓冲创建移动到助手方法中是个不错的想法。创建一个新的方法createBuffer,移动createVertexBuffer中的代码(除了映射外)到它里面:

void createBuffer(VkDeviceSize size, VkBufferUsageFlags usage, VkMemoryPropertyFlags properties,
    VkBuffer& buffer, VkDeviceMemory& bufferMemory) {
    VkBufferCreateInfo bufferInfo = {};
    bufferInfo.sType = VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO;
    bufferInfo.size = size;
    bufferInfo.usage = usage;
    bufferInfo.sharingMode = VK_SHARING_MODE_EXCLUSIVE;

    if (vkCreateBuffer(device, &bufferInfo, nullptr, &buffer) != VK_SUCCESS) {
        throw std::runtime_error("failed to create buffer!");
    }

    VkMemoryRequirements memRequirements;
    vkGetBufferMemoryRequirements(device, buffer, &memRequirements);

    VkMemoryAllocateInfo allocInfo = {};
    allocInfo.sType = VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO;
    allocInfo.allocationSize = memRequirements.size;
    allocInfo.memoryTypeIndex = findMemoryType(memRequirements.memoryTypeBits, properties);

    if (vkAllocateMemory(device, &allocInfo, nullptr, &bufferMemory) != VK_SUCCESS) {
        throw std::runtime_error("failed to allocate buffer memory!");
    }

    vkBindBufferMemory(device, buffer, bufferMemory, 0);
}

        确保添加了缓冲大小,内存属性和使用方法等参数以便我们用该方法创建多个不同类型的缓冲。最后两个参数是输出变量,以便向其写入句柄。

        现在可以从createVertexBuffer中移除缓冲创建和内存分配的代码,然后调用createBuffer:

void createVertexBuffer() {
    VkDeviceSize bufferSize = sizeof(vertices[0]) * vertices.size();
    createBuffer(bufferSize, VK_BUFFER_USAGE_VERTEX_BUFFER_BIT,
        VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | VK_MEMORY_PROPERTY_HOST_COHERENT_BIT,
        vertexBuffer, vertexBufferMemory);

    void* data;
    vkMapMemory(device, vertexBufferMemory, 0, bufferSize, 0, &data);
    memcpy(data, vertices.data(), (size_t) bufferSize);
    vkUnmapMemory(device, vertexBufferMemory);
}

        运行下程序,确保顶点缓冲没有问题。

        我们现在打算修改createVertexBuffer,以仅仅使用一个可见缓冲作为临时缓冲,并使用设备本地的一个作为实际顶点缓冲。

void createVertexBuffer() {
    VkDeviceSize bufferSize = sizeof(vertices[0]) * vertices.size();
    VkBuffer stagingBuffer;
    VkDeviceMemory stagingBufferMemory;
    createBuffer(bufferSize, VK_BUFFER_USAGE_TRANSFER_SRC_BIT,
        VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | VK_MEMORY_PROPERTY_HOST_COHERENT_BIT,
        stagingBuffer, stagingBufferMemory);

    void* data;
    vkMapMemory(device, stagingBufferMemory, 0, bufferSize, 0, &data);
    memcpy(data, vertices.data(), (size_t)bufferSize);
    vkUnmapMemory(device, stagingBufferMemory);

    createBuffer(bufferSize, VK_BUFFER_USAGE_TRANSFER_DST_BIT |
        VK_BUFFER_USAGE_VERTEX_BUFFER_BIT, VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT,
        vertexBuffer, vertexBufferMemory);
}

        我们现在使用一个新的带stagingBufferMemory的stagingBuffer用于映射和拷贝顶点数据。本章中我们将会使用两个新的缓冲用法标记:

        VK_BUFFER_USAGE_TRANSFER_SRC_BIT:在内存转移操作中,缓冲可以用作源地址;

        VK_BUFFER_USAGE_TRANSFER_DST_BIT:在内存转移操作中,缓冲可以用作目的地。

        vertexBuffer现在从设备本地类型的内存中分配,一般表示我们无法使用vkMapMemory了。但是,我们可以从stagingBuffer中拷贝数据到vertexBuffer。我们必须通过指定stagingBuffer的转移源标记,vertexBuffer的转移目的地标记,以及顶点缓冲用法标记,来表示我们想要那么做。

        我们现在打算写一个方法来从一个缓冲拷贝内容到另一个:

void copyBuffer(VkBuffer srcBuffer, VkBuffer dstBuffer, VkDeviceSize size) {

}

        内存转移操作通过命令缓冲执行,就和绘制命令一样。因此我们必须首先分配一个临时命令缓冲。你可能希望能为这些短暂存在的缓冲创建一个单独的命令池,因为实现可能会应用于内存分配优化。在这种情况下,你应该在命令池生成过程中使用VK_COMMAND_POOL_CREATE_TRANSIENT_BIT标记。

void copyBuffer(VkBuffer srcBuffer, VkBuffer dstBuffer, VkDeviceSize size) {
    VkCommandBufferAllocateInfo allocInfo = {};
    allocInfo.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_ALLOCATE_INFO;
    allocInfo.level = VK_COMMAND_BUFFER_LEVEL_PRIMARY;
    allocInfo.commandPool = commandPool;
    allocInfo.commandBufferCount = 1;

    VkCommandBuffer commandBuffer;
    vkAllocateCommandBuffers(device, &allocInfo, &commandBuffer);
}

        然后立即开始记录命令缓冲:

VkCommandBufferBeginInfo beginInfo = {};
beginInfo.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO;
beginInfo.flags = VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT;

vkBeginCommandBuffer(commandBuffer, &beginInfo);

        我们为绘制命令缓冲使用过的VK_COMMAND_BUFFER_USAGE_SIMULTANEOUS_USE_BIT标记这里并不是必需的,因为我们只是打算使用一次命令缓冲,然后从方法中用返回来等待,直到复制操作已经完成。告诉驱动我们使用VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT的意图是一个比较好的做法。

VkBufferCopy copyRegion = {};
copyRegion.srcOffset = 0;  // optional
copyRegion.dstOffset = 0;  // optional
copyRegion.size = size;
vkCmdCopyBuffer(commandBuffer, srcBuffer, dstBuffer, 1, &copyRegion);

        缓冲的内容使用vkCmdCopyBuffer命令进行转移。它接收源和目的缓冲作为参数,以及一个要拷贝的区域数组。区域在VkBufferCopy结构体中定义,由一个源缓冲偏置,目的缓冲偏置和大小组成。不像是vkMapMemory命令,这里不能指定VK_WHOLE_SIZE。

vkEndCommandBuffer(commandBuffer);

        该命令缓冲只包含了复制命令,所以我们可以在此之后停止记录。现在执行命令缓冲来完成转移:

VkSubmitInfo submitInfo = {};
submitInfo.sType = VK_STRUCTURE_TYPE_SUBMIT_INFO;
submitInfo.commandBufferCount = 1;
submitInfo.pCommandBuffers = &commandBuffer;

vkQueueSubmit(graphicsQueue, 1, &submitInfo, VK_NULL_HANDLE);
vkQueueWaitIdle(graphicsQueue);

        不像是绘制命令,我们不用等待事件。我们就是想要立即完成缓冲上的转移。还是有两种方式来等待该缓冲完成。我们可以通过vkWaitForFences使用一个栅栏,或者简单地用vkQueueWaitIdle等待转移队列变空闲。栅栏会允许你同时计划多个转移,并等待所有都完成,而不是一次只能执行一个。这样也给驱动更多机会优化。

vkFreeCommandBuffers(device, commandPool, 1, &commandBuffer);

        别忘记清理用于转移操作的命令缓冲。现在我们可以从createVertexBuffer中调用copyBuffer来将顶点数据移动到设备本地缓冲中:

createBuffer(bufferSize, VK_BUFFER_USAGE_TRANSFER_DST_BIT |
    VK_BUFFER_USAGE_VERTEX_BUFFER_BIT, VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT,
    vertexBuffer, vertexBufferMemory);

copyBuffer(stagingBuffer, vertexBuffer, bufferSize);

        从临时缓冲拷贝数据到设备缓冲后,我们应该将其清理掉:

copyBuffer(stagingBuffer, vertexBuffer, bufferSize);

vkDestroyBuffer(device, stagingBuffer, nullptr);
vkFreeMemory(device, stagingBufferMemory, nullptr);

        运行程序确保能看到原来熟悉的三角形。现在可能还看不到我们的改进,但是现在顶点数据是从高性能内存中加载的。当我们渲染更复杂几何对象的时候会有影响。

        要注意的是,真实的程序中不应该对每个缓冲调用vkAllocateMemory。内存分配数量最大值由物理设备maxMemoryAllocationCount限制,可能在高端显卡如1080上也仅有4096而已。对大量对象分配内存的正确方法是创建一个自定义的分配器,将多个不同物体的一个分配操作使用offset参数进行切分。

        你要渲染在真实程序中的3D网格常常会在多个三角形中共享顶点。就是很简单的东西如绘制一个矩形就会发生这种事情:

        绘制一个矩形需要两个三角形,意味着我们需要一个有6个顶点的顶点缓冲。问题是,两个顶点的数据需要重复,导致50%的冗余。对于更复杂的网格表现会更糟,解决办法就是使用索引缓冲。

        索引缓冲实际上是一组指向顶点缓冲的指针。它允许你记录顶点数据,对多个顶点重用已有的数据。上面的插图表明了矩形的索引缓冲看起来会是什么样子,如果我们有一个顶点缓冲包含了所有四个不同顶点的话。第一组三个顶点定义了右上三角形,后面三个顶点定义了左下的三角形。

        本章我们要修改顶点数据,添加索引数据来绘制矩形。修改顶点数据来表示四个角:

const std::vector<Vertex> vertices = {
    {{-0.5f, -0.5f}, {1.0f, 0.0f, 0.0f}},
    {{0.5f, -0.5f}, {0.0f, 1.0f, 0.0f}},
    {{0.5f, 0.5f}, {0.0f, 0.0f, 1.0f}},
    {{-0.5f, 0.5f}, {1.0f, 1.0f, 1.0f}}
};

        左上角是红色,右上角是绿色,右下角是蓝色,左下角是白色。我们添加一个新的数组indices来表示索引缓冲的内容。它应该和插图中绘制右上和左下三角形的索引匹配:

const std::vector<uint16_t> indices = {
    0, 1, 2, 2, 3, 0
};

        索引缓冲可以使用uint16_t或者uint32_t,这取决于vertices中记录的个数。我们还是用uint16_t,因为我们使用的互不相同的顶点少于65535。

        就和顶点数据一样,索引需要加载到VkBuffer以便GPU能访问。定义两个新的类成员来存储索引缓冲资源:

VkCommandPool commandPool;
VkBuffer vertexBuffer;
VkDeviceMemory vertexBufferMemory;
VkBuffer indexBuffer;
VkDeviceMemory indexBufferMemory;

        我们将要添加的createIndexBuffer方法就和createVertexBuffer基本一样:

void createIndexBuffer() {
    VkDeviceSize bufferSize = sizeof(indices[0]) * indices.size();
    VkBuffer stagingBuffer;
    VkDeviceMemory stagingBufferMemory;
    createBuffer(bufferSize, VK_BUFFER_USAGE_TRANSFER_SRC_BIT,
        VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | VK_MEMORY_PROPERTY_HOST_COHERENT_BIT,
        stagingBuffer, stagingBufferMemory);

    void* data;
    vkMapMemory(device, stagingBufferMemory, 0, bufferSize, 0, &data);
    memcpy(data, indices.data(), (size_t)bufferSize);
    vkUnmapMemory(device, stagingBufferMemory);

    createBuffer(bufferSize, VK_BUFFER_USAGE_TRANSFER_DST_BIT |
        VK_BUFFER_USAGE_INDEX_BUFFER_BIT, VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT,
        indexBuffer, indexBufferMemory);

    copyBuffer(stagingBuffer, indexBuffer, bufferSize);

    vkDestroyBuffer(device, stagingBuffer, nullptr);
    vkFreeMemory(device, stagingBufferMemory, nullptr);
}

        该方法在initVulkan的createVertexBuffer后调用。

        只有两处不同。一处是bufferSize现在等于索引个数乘以索引类型大小,大小就是uint16_t或者uint32_t。indexBuffer用法应该是VK_BUFFER_USAGE_INDEX_BUFFER_BIT而不是VK_BUFFER_USAGE_VERTEX_BUFFER_BIT了。除此之外,处理都是一样的。我们创建一个临时缓冲以便向其拷贝索引内容,然后将它拷贝到最终设备本地索引缓冲中。

        索引缓冲应该在程序结尾清理掉,就和顶点缓冲一样:

cleanupSwapChain();

vkDestroyBuffer(device, indexBuffer, nullptr);
vkFreeMemory(device, indexBufferMemory, nullptr);

vkDestroyBuffer(device, vertexBuffer, nullptr);
vkFreeMemory(device, vertexBufferMemory, nullptr);

        绘制的时候使用索引缓冲涉及到对createCommandBuffers的两处修改。我们首先需要绑定索引缓冲,就和我们之前对顶点缓冲做的工作一样。不同之处是你只能有一个索引缓冲。很不幸,不能为每个顶点属性使用不同索引,所以我们还是完全复制顶点数据,即使它就有一个属性不同。

vkCmdBindVertexBuffers(commandBuffers[i], 0, 1, vertexBuffers, offsets);
vkCmdBindIndexBuffer(commandBuffers[i], indexBuffer, 0, VK_INDEX_TYPE_UINT16);

        索引缓冲用vkCmdBindIndexBuffer绑定,参数有索引缓冲,字节偏移量,索引数据类型。

        只是绑定索引缓冲并不会改变什么,我们还要修改绘制命令,告诉Vulkan使用索引缓冲。删除vkCmdDraw,替换为vkCmdDrawIndexed:

vkCmdDrawIndexed(commandBuffers[i], static_cast<uint32_t>(indices.size()), 1, 0, 0, 0);

        该方法的调用和vkCmdDraw类似。头两个参数指定了索引个数和实例个数。我们不用实例,所以就是1。索引个数表示将要传递到顶点缓冲上的顶点的个数。下一个参数指定索引缓冲偏置,使用1会导致显卡开始从第二个索引读取。倒数第二个参数指定了在索引缓冲中添加索引的时候的偏移量。最后的参数指定了实例的偏置,这里我们不用。

        现在运行程序看到如下的矩形:

        你现在知道如何通过顶点缓冲重用顶点来节省内存了,这在将来加载复杂3D模型的时候尤其重要。

        之前的章节已经提到,你应该分配多个资源,如同来自单个内存分配的缓冲那样,但实际上还要多进一步。驱动开发者建议你也要存储多个缓冲到单个VkBuffer并在类似vkCmdBindVertexBuffers的命令中使用偏置,就和顶点和索引缓冲一样。其优势是你的数据会更方便缓存,因为它们更接近在一起。甚至可以对多个资源重用相同块的内存,如果它们不是相同的渲染操作中使用,当然也要保证它们的数据是刷新过的。这就是混叠,一些Vulkan方法有明确的标记来让你指定想要这么做。

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享
评论 抢沙发
头像
欢迎您留下宝贵的见解!
提交
头像

昵称

取消
昵称表情代码图片