Vulkan Tutorial 教程翻译(八)顶点缓冲区

151 阅读24分钟

顶点缓冲

顶点输入描述

介绍

在后面的几个章节中,我们准备使用内存中的顶点缓冲去替换硬编码在顶点着色器中的顶点数据。首先我们使用最简单的,从CPU可见内存中利用 memcpy 直接拷贝数据的方式,之后我们会使用一个暂存缓冲区拷贝数据到高性能内存的方式。

顶点着色器

首先修改顶点着色器让它不再包含顶点数据,使用 in 关键字去接收顶点缓存数据.

#version 450

layout(location = 0) in vec2 inPosition;
layout(location = 1) in vec3 inColor;

layout(location = 0) out vec3 fragColor;

void main(){
    gl_Position = vec4(inPosition, 0.0 , 1.0);
    fragColor = inColor;
}

inPosition 和 inColor 变量是顶点属性,它们是在顶点缓冲区中逐顶点设置的属性,就像我们手动通过两个数组指定每个顶点的颜色和位置一样,确保重新编译了这个顶点着色器.

就像 fragColor,layout(location = x) 注解标注了出的索引,之后可以引用它们,有一点很重要,它们中的一些类型,例如 dvec3 代表64位的向量类型,会占用多个槽位,这意味着,这之后的索引必须至少递增2以上。

layout(location = 0) in dvec3 inPosition;
layout(location = 2) in vec3 inColor;

你可以在布局说明的文档中找到更多的信息 OpenGL wiki.

顶点数据

我们将着色器代码中的顶点数据移动到C++代码中,首先引入 GLM 库头文件,这会为我们提供与线性代数有关的向量和矩阵数据结构,我们会使用这些类型去实现位置和颜色向量.

#include <glm/glm.hpp>

创建一个 Vertex 结构体,包含两个我们在顶点着色器中使用的属性.

struct Vertex {
    glm::vec2 pos;
    glm::vec3 color;
};

GLM 可以为我们提供在着色器中数据类型在C++层的对应类型。

const std::vector<Vertex> vertices = {
    {{0.0f, -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}}
};

现在使用 Vertex 结构体去指定顶点数据,我们使用与之前shader中一样的位置和颜色数据,不过这次我们将其打包进了一个顶点数组中,这种方式称之为顶点数据的交错存储。

绑定描述

下一步,是告诉Vulkan,在数据被传输到GPU的显存后如何按需要的格式传递给顶点着色器。有两种结构体用来传达这些内容。

首先是 VkVertexInputBindingDescription ,我们为 Vertex 结构体添加一个成员函数,返回 VkVertexInputBindingDescription结构体

struct Vertex {
    glm::vec2 pos;
    glm::vec3 color;

    static VkVertexInputBindingDescription getBindingDescription() {
        VkVertexInputBindingDescription bindingDescription{};

        return bindingDescription;
    }
};

顶点绑定描述代表着在顶点着色阶段数据如何从内存中载入到顶点中,指定了数据之间的字节大小,以及是否可以每个顶点数据结束时自动移动到下一个数据。

VkVertexInputBindingDescription bindingDescription{};
bindingDescription.binding = 0;
bindingDescription.stride = sizeof(Vertex);
bindingDescription.inputRate = VK_VERTEX_INPUT_RATE_VERTEX;

我们所有的逐顶点数据都被打包在一个数组中,所以我们只准备使用一个绑定。binding参数指定了绑定数据的索引,stride 指定了从一个实体数据跳转到下一条实体数据的字节偏移, inputRate 代表着顶点的步进方式,可取以下的两个值.

  • VK_VERTEX_INPUT_RATE_VERTEX : 逐顶点步进
  • VK_VERTEX_INPUT_RATE_INSTANCE : 逐实例步进

我们并不使用实例渲染,所以这里选择 VK_VERTEX_INPUT_RATE_VERTEX 逐顶点数据.

属性描述

第二个用于描述如何处理顶点输入的结构体是 VkVertexInputAttributeDescription , 我们向 Vertex 结构体中添加一个帮助函数去填写这个结构体.

#include <array>
...
static std::array<VkVertexInputAttributeDescription, 2> getAttributeDescriptions() {
    std::array<VkVertexInputAttributeDescription, 2> attributeDescriptions{};

    return attributeDescriptions;
}

如定义的函数原型所示,我们准备返回两个结构体构成的数组.一个属性描述结构体代表着如何从原始的顶点数据中提取出顶点属性,我们现在有两个属性,位置和颜色,所以这里需要返回两个顶点描述结构体。

attributeDescriptions[0].binding = 0;
attributeDescriptions[0].location = 0;
attributeDescriptions[0].format = VK_FORMAT_R32G32_SFLOAT;
attributeDescriptions[0].offset = offsetof(Vertex, pos);

binding 参数告诉 Vulkan 使用哪个顶点数据的绑定.location 参数直接对应着着色器中的 location 数据位置,输入的顶点着色器 location = 0,代表位置数据,是两个32位的浮点数.

format 描述了数据的类型,一个可能会让人困惑的点是,类型的指定可能用的是颜色的格式.通常使用以下的类型对应关系:

  • float : VK_FORMAT_R32_SFLOAT
  • vec2 : VK_FORMAT_R32G32_SFLOAT
  • vec3 : VK_FORMAT_R32G32B32_SFLOAT
  • vec4 : VK_FORMAT_R32G32B32A32_SFLOAT

应该选择与颜色通道相匹配的格式类型,允许使用比着色器中更多的通道数量,这些会被直接丢弃,如果通道的数量低于组件的数量,BGA通道将会使用默认的值(0, 0, 1),颜色的值类型(SFLOAT,UINT,SINT)和二进制比特的位数,也需要匹配着色器中的类型,以下是一些示例:

  • ivec2 : VK_FORMAT_R32G32_SINT , 二维向量,元素是32位的有符号整型
  • uvec4 : VK_FORMAT_R32G32B32A32_UINT , 四维向量,元素是32位的无符号整型
  • double : VK_FORMAT_R64_SFLOAT,一个双精度的浮点型

format 参数隐式地定义了数据的比特位数,offset 指定了每个顶点此段数据的偏移量,我们使用 offsetof 这个宏定义来自动计算.

attributeDescriptions[1].binding = 0;
attributeDescriptions[1].location = 1;
attributeDescriptions[1].format = VK_FORMAT_R32G32B32_SFLOAT;
attributeDescriptions[1].offset = offsetof(Vertex, color);

颜色属性也是相似的设置。

管线的顶点输入

现在需要在 createGraphicsPipeline 函数中,安装图形管线,按我们的期望去接收顶点数据,找到 vertexInputInfo 结构体,修改其中的两个描述字段:

auto bindingDescription = Vertex::getBindingDescription();
auto attributeDescriptions = Vertex::getAttributeDescriptions();

vertexInputInfo.vertexBindingDescriptionCount = 1;
vertexInputInfo.vertexAttributeDescriptionCount = static_cast<uint32_t>(attributeDescriptions.size());
vertexInputInfo.pVertexBindingDescriptions = &bindingDescription;
vertexInputInfo.pVertexAttributeDescriptions = attributeDescriptions.data();

管线以及准备好按照顶点容器的格式去接收顶点数据并将它们传输到顶点着色器中了。如果现在运行程序,验证层将会报错:未绑定顶点缓冲,下一步我们就要去创建顶点缓冲,讲数据传输到顶点缓冲区,让GPU可以访问到它们。

创建顶点缓冲区

概述

Vulkan 中的缓冲(Buffer) 指的是一块可以被显卡读取的用于存储各种数据的内存区域,它可以被用于存储顶点数据,我们本章便会演示,也可以用来存储其他类型的数据,后面也会演示.与Vulkan对象不同,缓冲区并不会自动从内存中分配,前几章已经表明了,Vulkan会让程序员控制所有的东西,内存的管理便是其中之一。

创建缓存

新建函数 createVertexBuffer ,在initVulkan 中调用

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

...

void createVertexBuffer() {

}

创建缓存需要我们去填写 VkBufferCreateInfo 结构体.

VkBufferCreateInfo bufferInfo{};
bufferInfo.sType = VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO;
bufferInfo.size = sizeof(vertices[0]) * vertices.size();

size 代表缓存的字节数量,计算字节数量很简单直接使用 sizeof,

bufferInfo.usage = VK_BUFFER_USAGE_VERTEX_BUFFER_BIT;

第二个字段 usage , 代表缓存中的数据将被用来做什么,可以通过位运算 | 来指定多个用途.我们目前是用于顶点缓冲区的。

bufferInfo.sharingMode = VK_SHARING_MODE_EXCLUSIVE;

与交换链中的图像一样,缓冲区也可能在同一时间被其他的队列簇所共享,这个缓冲区仅被用于图形队列,所以这里坚持使用排他模式。

flags 参数可以被用于配置稀疏类型的缓存内存,目前还用不上所以直接设置为0.

现在可以通过 vkCreateBuffer 创建缓冲区了,定义一个名为 vertexBuffer 的类成员去存储缓冲的句柄。

VkBuffer vertexBuffer;

...

void createVertexBuffer() {
    VkBufferCreateInfo bufferInfo{};
    bufferInfo.sType = VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO;
    bufferInfo.size = sizeof(vertices[0]) * vertices.size();
    bufferInfo.usage = VK_BUFFER_USAGE_VERTEX_BUFFER_BIT;
    bufferInfo.sharingMode = VK_SHARING_MODE_EXCLUSIVE;

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

直到程序结束,缓冲区应该一直都可被渲染命令所使用,并且它并不依赖于交换链,所以在原始的 cleanup 函数中清理它.

void cleanup() {
    cleanupSwapChain();

    vkDestroyBuffer(device, vertexBuffer, nullptr);

    ...
}

内存需求

缓冲区已经被创建出来了,但是还没有任何的内存被赋值给它,为缓冲区分配内存的第一步是去查询内存的需求,我们这里使用函数 vkGetBufferMemoryRequirements。

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

VkMemoryRequirements 结构体有三个成员

  • size : 内存的数量(字节为单位),也许会与bufferInfo.size不一样
  • alignment : 缓冲区开始的内存范围的偏移,依赖于 bufferInfo.usgae 和 bufferInfo.flags.
  • memoryTypeBits : 比特标志位,对这个缓冲区合适的内存类型

显卡可以提供并分配不同类型的内存,不同的内存有着不同的操作和效率上的特点,我们需要结合缓冲区的需求和应用自身的需求选择正确的内存类型.让我们创建一个新函数 findMemoryType

uint32_t findMemoryType(uint32_t typeFilter,VkMemoryPropertyFlags properties){

}

首先需要使用 vkGetPhysicalDeviceMemoryProperties 查询可用的内存类型,

VkPhysicalDeviceMemoryProperties memProperties;
vkGetPhysicalDeviceMemoryProperties(physicalDevice, &memProperties);

VkPhysicalDeviceMemoryProperties 有两个数组成员变量 memoryTypes 和 memoryHeaps ,堆内存是不同的内存资源,如独立的显存资源,以及在显存资源耗尽时可用的交换空间等。这些堆上存在着不同的内存类型,现在我们仅聚焦于自己的内存,而不是堆内存,但是你要对这些影响性能的内存留下一个印象.

现在找到一个适合缓冲区的内存类型:

for(uint32_t i = 0 ; i < memProperties.memoryTypeCount ; i++){
    if(typeFilter & (1 << i) ){
        return i;
    }
}

throw std::runtime_error("failed to find suitable memory type!");

typeFilter 参数会使用二进制运算组合需要请求的内存类型,我们可以通过简单的与运算找到合适的内存类型。 然而,我们并不仅仅对适用于顶点缓冲区的内存类型感兴趣,也需要这些内存可以写入我们提供的顶点数据,memoryTypes类型由 VkMemoryType的数组构成,它代表着堆和每一种内存的类型。定义了特定用途的内存类型,例如可以将它映射到CPU操作的内存空间,其代表常量是 VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT,我们也需要 VK_MEMORY_PROPERTY_HOST_COHERENT_BIT 这个属性,会看到为何需要这个内存属性。

现在可以修改循环中的检查条件

for (uint32_t i = 0; i < memProperties.memoryTypeCount; i++) {
    if ((typeFilter & (1 << i)) 
        && (memProperties.memoryTypes[i].propertyFlags & properties) == properties) {
        return i;
    }
}

我们也许会请求多个需要支持的内存属性,所以需要通过 & 操作符检查结果,如果这种内存类型满足所有我们期望的操作就返回在memoryTypes结构中的索引,否则抛出异常。

内存分配

现在已经有方法去找到正确的内存类型了,可以通过 VkMemoryAllocateInfo 来实际分配内存了.

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

内存的分配现在只需要指定大小和类型了,它们都是从内存的类型查询和期望使用的内存属性中得到的。创建类成员vkAllocateMemory去存储分配的内存。

VkBuffer vertexBuffer;
VkDeviceMemory vertexBufferMemory;

...

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

内存分配成功后,就可以使用 vkBindBufferMemory 将其与缓冲区关联在一起了.

vkBindBufferMemory(device, vertexBuffer , vertexBufferMemory, 0);

前三个参数意义很明显,第四个参数是内存的偏移,由于这块内存是专门为了顶点缓冲分配的,所以 offset值直接设置成了0,如果offset 是非零值,那么它需要根据 memRequirements.alignment 参数进行切割处理.

当然,就像C++的内存动态分配一样,这里的内存也需要在合适的时机被释放掉,绑定到缓冲区的内存在缓冲区不再被使用时就需要被释放掉了,所以这里我们在缓冲区被销毁后,也清理掉关联的内存。

void cleanup() {
    cleanupSwapChain();

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

    ... 
}

填充顶点缓冲

现在可以将顶点的数据拷贝到缓冲区了,通过使用内存映射技术,将显卡内存映射为CPU可访问的内存。以上通过 vkMapMemory 函数来实现。

void* data;
vkMapMemory(device, vertexBufferMemory, 0, bufferInfo.size, 0, &data);

此函数允许我们通过提供大小和偏移来访问一块指定的内存区域。偏移和大小在这里设置为 0 和 bufferInfo.size。也可以设置 VK_WHOLE_SIZE 特殊值来映射整段的内存区域。倒数第二个参数被用来指定flags,但是目前的API版本还不起作用,所以一定是0.最后一个参数则指定了映射内存的输出指针.

void* data;
vkMapMemory(device, vertexBufferMemory, 0, bufferInfo.size, 0, &data);
    memcpy(data, vertices.data() , (size_t)bufferInfo.size);
vkUnMapMemory(device, vertexBufferMemory);

现在可以使用 memcpy 函数拷贝顶点数据到映射的内存中,最后使用 vkUnmapMemory 解绑映射关系.不幸的是,驱动可能并不会立刻将数据拷贝到映射的缓冲区中,例如由于缓存原因,也有可能写入的内存对于映射的内存来说,是不可见的。有两种方式去处理这个问题:

  • 使用与主机一致的内存类型,标志位为 VK_MEMORY_PROPERTY_HOST_COHERENT_BIT
  • 在做完内存映射后立刻调用 vkFlushMappedMemoryRanges ,然后在读取映射内存前调用 vkInvalidateMappedMemoryRanges 。

我们这里选择第一种方案,它会确保映射的内存总是与缓冲区中的内容是一致的。需要重视的一点是,与显式刷新相比,这个方案的性能更差。

刷新内存或者使用一致性内存意味着驱动可以感知到我们给缓冲区写入了内容,但这并不意味着它们是在GPU上实际可见的,把数据传输给GPU是一个发生在后台的操作,规范仅仅是限制了,在下一次的 vkQueueSubmit 操作前数据的传输一定会完成。

绑定顶点缓冲区

最后,需要在渲染操作的时候绑定顶点缓冲区,现在在 recordCommandBuffer 中完成这一步操作

vkCmdBindPipeline(commandBuffer, VK_PIPELINE_BIND_POINT_GRAPHICS, graphicsPipeline);

VkBuffer vertexBuffers[] = {vertexBuffer};
VkDeviceSize offsets[] = {0};
vkCmdBindVertexBuffers(commandBuffer, 0, 1, vertexBuffers, offsets);

vkCmdDraw(commandBuffer, static_cast<uint32_t>(vertices.size()), 1, 0, 0);

vkCmdBindVertexBuffers 函数用于绑定顶点缓冲,commandBuffer后面的两个参数,指定了偏移和绑定的数量,后两个参数指定了顶点缓冲区的数组和从这个数组偏移多少取值。vkCmdDraw 的调用也从之前的硬编码3改为了实际的顶点数量.

现在运行程序会看到与之前一样的三角形:

triangle.png

试着在 vertices 变量中修改顶部顶点的颜色为白色

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

再次运行程序,会看到如下结果:

triangle_white.png

在下一节,我们会采用另外一种性能更好但是更加麻烦的方式来拷贝顶点数据送入顶点缓冲区.

暂存缓冲

介绍

我们已经让顶点缓冲正确工作了,但是这个允许我们从CPU访问的内存类型也许对GPU的读取并不是最优的。最优内存的标记位是 VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT ,这通常不能被CPU直接访问到,因为此内存存在于GPU上.本章,我们会创建两个顶点缓冲区,一个是CPU可访问的暂存缓冲区,用于从顶点数组中上传数据,另外一个顶点缓冲是设备内存上的缓冲区,我们会使用一个缓冲拷贝命令来将数据从暂存区域移动到实际的顶点缓冲区.

传输队列

拷贝命令需要一个支持传输操作的队列簇,标志位为 VK_QUEUE_TRANSFER_BIT . 好消息是任何支持 VK_QUEUE_GRAPHICS_BIT 或 VK_QUEUE_COMPUTE_BIT 的队列簇都是隐式支持 VK_QUEUE_TRANSFER_BIT 的,并不需要在 queueFlags 标志位上将其显式地标明出来.

如果你喜欢挑战,也可以试着去使用不同的队列簇去完成传输操作,你的程序需要做如下的修改:

  • 修改 QueueFamilyIndices 和 findQueueFamilies 去显式地找到拥有 VK_QUEUE_TRANSFER_BIT 标志位的队列簇.而不是之前的 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 ,使用一个CPU可见的内存作为临时缓存,再创建一个设备本地内存作为实际的顶点缓冲区.

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);
}

我们现在使用一个新的 stagingBuffer 和 stagingBufferMemory 映射和拷贝进来的顶点数据,在本章我们准备使用两个新的内存使用标记位.

  • VK_BUFFER_USAGE_TRANSFER_SRC_BIT : 缓冲区可以被用作内存传输的源
  • VK_BUFFER_USAGE_TRANSFER_DST_BIT : 缓冲区可被用作内存传输的目的地

vertexBuffer 现在分配的内存类型是本地设备类型,这种类型意味着它不能被用作 vkMapMemory 进行映射,但是我们可以从 stageBuffer拷贝数据到 vertexBuffer.我们需要在 usgae 为stagingBuffer标识成源,为 vertexBuffer 标识为目的地.

现在去实现一个函数 copyBuffer ,将缓冲区对象从一个缓冲区移动到另一个.

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_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 的结构体数组,由 srcOffset,dstOffset ,size 组成,在这里不能像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);

不要忘记去清理掉传输指令运行的命令缓冲(这里应该不是必须的,因为程序结束会清理CommandPool,仅仅是尽早释放内存)

现在在 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 物理参数限制的,至在一些性能很高的硬件 如 NVIDIA GTX 1080 上,这个值也许会低于 4096 ,正确的使用内存分配的方式是,一次分配一个较大数量的内存,再创建一个自定义的二次分配器,结合offset参数完成内存的实际分配.

你既可以自行实现这个内存分配器,也可以使用 VulkanMemoryAllocator 库,本教程为了简便依然是为每一个资源都使用一个单独的分配器.

下一章,我们会学习索引缓冲区的使用。

索引缓冲区

介绍

在实际的应用程序中,你渲染的3D网格通常会在多个三角形之间共享顶点.甚至在仅绘制一个简单的矩形时,这种情况也会发生:

vertex_vs_index.svg

绘制一个矩形需要输入两个三角形,这意味着我们需要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 ,取决于顶点的数量.因为这里我们的顶点数量少于 65535 个,所以坚持使用 uint16_t.

与顶点数据一样 ,索引数据也需要打包上传到VkBuffer ,让GPU可以访问到.定义两个新的类成员去存储索引缓冲的资源句柄。

VkBuffer vertexBuffer;
VkDeviceMemory vertexBufferMemory;
VkBuffer indexBuffer;
VkDeviceMemory indexBufferMemory;

createIndexBuffer 函数与 createVertexBuffer 很相似:

void initVulkan() {
    ...
    createVertexBuffer();
    createIndexBuffer();
    ...
}

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);
}

有两个显著的不同,bufferSize 现在是索引的类型大小乘以索引的数量,usage参数使用 VK_BUFFER_USAGE_INDEX_BUFFER_BIT 代替 VK_BUFFER_USGAE_INDEX_BUFFER_BIT,除了这两点,其它几乎一样,我们创建了一个临时缓冲区拷贝了索引的内容到设备本地索引缓冲区。

索引缓冲区也需要在程序结束时被清理。

void cleanup() {
    cleanupSwapChain();

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

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

    ...
}

使用索引缓冲

使用索引缓冲区,涉及到对 recordCommandBuffer 函数的两处修改,首先需要绑定索引缓冲,就像顶点缓冲那样,不同点是,你只可以有一个索引缓冲,不可能为每一个顶点属性使用不同的顶点缓冲,所以仍然会有重复的顶点属性存在.

vkCmdBindVertexBuffers(commandBuffer, 0, 1, vertexBuffers, offsets);

vkCmdBindIndexBuffer(commandBuffer, indexBuffer, 0, VK_INDEX_TYPE_UINT16);

索引缓冲通过函数 vkCmdBindIndexBuffer 完成绑定,它接收索引缓冲 ,字节为单位的偏移量,以及一个索引数据的类型,如前面提到的,这个值可能是 VK_INDEX_TYPE_UINT16 或者 VK_INDEX_TYPE_UINT32.

绑定了索引缓冲并不会改变任何东西 ,我们还需要修改绘制指令告诉Vulkan使用索引缓冲完成绘制, 使用 vkCmdDrawIndexed 替代原来的 vkCmdDraw

vkCmdDrawIndexed(commandBuffer, static_cast<uint32_t>(indices.size()), 1, 0, 0, 0);

这个函数的调用与 vkCmdDraw 很像,前两个参数指定了索引和实例的数量,我们并不使用实例,所以这里设置为1,索引的数据实际代表着传递给顶点着色器的数量,接下来的参数代表着索引缓冲区的偏移,倒数第二个参数代表在索引缓冲区进入顶点着色器之前顶点的索引值 (gl_VertexIndex),最后一个参数代表实例的偏移,这里不使用设置为0.

现在运行程序可以看到如下结果:

indexed_rectangle.png

现在已经了解了如何通过索引缓冲区来节省内存,这将在未来的章节我们导入复杂的3D模型时,很重要.

前面的章节已经提到你可以从单个的内存分配器中分配多个资源,例如缓冲区,但事实上,可以更进一步,驱动的开发者建议你在使用多个缓冲区的时候,例如顶点缓冲和索引缓冲,将多个缓冲区放入一个 VkBuffer 中,在提交命令时使用 偏移量(offset)来进行区分.好处是你的数据在这种场景下更加的缓存友好,因为它们排列的更加紧密,甚至可以针对没有使用相同渲染操作的数据,重用一些块的资源,这被称之为混叠,在一些Vulkan函数中,你需要显式地指定。

下一章,我们会学习如何传递一些频繁改变的参数给GPU。

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