接下来几章,我们会使用内存中的顶点缓冲替换掉顶点着色器中的硬编码顶点数据。我们用最简单的方式开始,创建一个CPU可见的缓冲,使用memcpy来将顶点数据直接拷贝到它上面,之后我们会介绍如何使用临时缓冲来拷贝顶点数据到高性能内存中。
首先修改顶点着色器,不要再在着色器代码中包括顶点数据。顶点着色器使用in关键字接收来自顶点缓冲的输入:
#version 450
#extension GL_ARB_separate_shader_objects : enable
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;
我们将顶点数据从顶点着色器中移动到了我们自己程序的数组中,包含GLM库,它提供了线性代数有关的向量和矩阵。我们使用这些类型来指定位置和颜色向量。
#include
创建一个叫做Vertex的结构体,里面放两个属性,我们将会在顶点着色器中使用:
struct Vertex {
glm::vec2 pos;
glm::vec3 color;
};
GLM给我们提供了易于使用的C++类型,和着色器语言中的向量类型正好匹配:
const std::vector 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结构体来指定一组顶点数据。我们就使用和之前一样的位置及颜色,但是现在它们被绑定到一组顶点上了。这也就是交叉顶点属性。
下一步就是告诉Vulkan,一旦它被上传到GPU内存,如何将这个数据格式传递到顶点着色器。为了传递这个信息,需要两类结构体。第一个是VkVertexInputBindingDescription,我们会在Vertex中添加一个成员方法,以让它输入正确的数据。
struct Vertex {
glm::vec2 pos;
glm::vec3 color;
static VkVertexInputBindingDescription getBindingDescription() {
VkVertexInputBindingDescription bindingDescription = {};
return 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:每个实例处理后移动到下一个数据记录。
我们不会使用实例渲染,所以就还是用逐顶点数据。
处理顶点输入的第二个结构体是VkVertexInputAttributeDescription。我们要添加另一个助手函数到Vertex:
static std::array
getAttributeDescriptions() {
std::array
attributeDescriptions = {};
return attributeDescriptions;
}
注意要包含array头文件。
如函数原型所示,这里将会有两个这样的结构体。一个属性描述结构体描述如何从来自绑定描述的一堆顶点数据中提取一个顶点属性。我们有两个描述,位置和颜色,所以我们要两个属性描述结构体:
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位有符号整数组成的2组件向量;
uvec4:VK_FORMAT_R32G32B32A32_UINT,这是一个由32位无符号整数组成的4组件向量;
double:VK_FORMAT_R64_SFLOAT,双精度浮点数(64位)。
format参数隐式定义了属性数据的字节大小,offset参数指定了从逐顶点数据读取的起始的字节数。绑定就是一次加载一个Vertex结构体数据,描述信息(pos)是一个值位0的相对于该结构体开头的偏置。这个会使用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();
VkPipelineVertexInputStateCreateInfo vertexInputInfo = {};
vertexInputInfo.sType = VK_STRUCTURE_TYPE_PIPELINE_VERTEX_INPUT_STATE_CREATE_INFO;
vertexInputInfo.vertexBindingDescriptionCount = 1;
vertexInputInfo.vertexAttributeDescriptionCount = static_cast(attributeDescriptions.size());
vertexInputInfo.pVertexBindingDescriptions = &bindingDescription;
vertexInputInfo.pVertexAttributeDescriptions = attributeDescriptions.data();
现在管线已经准备好接收vertices容器中格式的顶点数据了。现在运行程序,会发现没用顶点缓冲绑定到该绑定:
下一步就是创建顶点缓冲并移动顶点数据到其中以便GPU能读取。
Vulkan中的缓冲是一些内存区域,用于存储显卡能去读的任意数据。它们可以用于存储顶点数据,也就是我们这一章要做的事情。但是它们也可以用于许多其他目的,这等以后再看。不像我们之前处理的Vulkan对象,缓冲不会自动为自己分配内存。
创建一个新的方法createVertexBuffer,从initVulkan中调用,就在createCommandBuffers之前。创建缓冲要填写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。
if (vkCreateBuffer(device, &bufferInfo, nullptr, &vertexBuffer) != VK_SUCCESS) {
throw std::runtime_error("failed to create vertex buffer!");
}
缓冲会在渲染命令中可用,直到程序结束,且它不依赖交换链,所以就在cleanup方法中清理交换链操作之后清理掉它。
vkDestroyBuffer(device, vertexBuffer, nullptr);
现在缓冲创建好了,但是它实际上还没有分配内存。给缓冲分配内存的第一步就是查询它所需的内存量:
VkMemoryRequirements memRequirements;
vkGetBufferMemoryRequirements(device, vertexBuffer, &memRequirements);
该结构体有以下字段:
size:以字节为单位的所需内存大小,可能和bufferInfo.size不一样;
alignment:以字节为单位的偏移量,缓冲起始于分配的内存区域,依赖于bufferInfo.usage和bufferInfo.flags;
memoryTypeBits:适用于缓冲的内存类型,这是一个位字段。
显卡可以提供不同类型的内存分配。每种根据允许的操作变换,每个内存类型根据可用的操作变化。我们将缓冲的要求和我们的应用结合起来,以找到正确的内存来使用。为我们创建一个新的方法findMemoryType:
uint32_t findMemoryType(uint32_t typeFilter, VkMemoryPropertyFlags properties) {
}
首先我们要查询可用内存类型信息,用的方法是vkGetPhysicalDeviceMemoryProperties:
VkPhysicalDeviceMemoryProperties memProperties;
vkGetPhysicalDeviceMemoryProperties(physicalDevice, &memProperties);
VkPhysicalDeviceMemoryProperties结构体有两组memoryTypes和memoryHeaps。内存堆是截然不同的内存资源,如同专用VRAM以及RAM的交换空间一样。这种不同类型的内存存在于这些堆之间。现在我们只关心这种内存而不关心它来自哪,但是你可以想象下这其实可以影响到性能的。
我们先找到适合该缓冲的内存类型:
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参数用于指定合适的内存类型的位域。这表明我们可以找到一个合适内存类型的索引,方法就是遍历它们,检查是否对应位设置为1。
但是我们仅关心适合于顶点缓冲的内存类型,我们要可以将我们的顶点数据写入内存。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;
}
}
我们可能有不止一个想要的属性,所以我们应该检查按位的或操作结果是否不仅仅非零,还要等于想要的属性位域。如果有一个内存类型适合该缓冲,而且也有所有我们想要的特性,那么我们应该返回它的索引,否则我们抛出异常。
我们现在可以确定正确的内存类型,所以我们可以分配内存了:
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);
内存分配现在和指定大小及类型一样简单了,二者都是来自顶点缓冲的内存要求和想要的属性。创建一个类成员来存储处理内存和分配的句柄,
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);
开始的三个参数不用解释,第四个是内存区域内的偏置。由于该内存是单独为该顶点缓冲分配的,该偏置自然就是0。如果该偏置不是0,那么它要求能被memRequirements.alignment整除。
当然,就和C++动态内存分配一样,内存应该在某个时候释放。绑定该缓冲对象的内存可能在缓冲一旦不使用的时候就被释放,所以我们在销毁缓冲后释放它:
vkFreeMemory(device, vertexBufferMemory, nullptr);
就在cleanup方法的vkDestroyBuffer之后调用。
现在是时候将顶点数据拷贝到缓冲中去了,就是使用vkMapMemory将缓冲内存映射到CPU可访问的内存:
void* data;
vkMapMemory(device, vertexBufferMemory, 0, bufferInfo.size, 0, &data);
该方法允许我们通过偏置和大小访问一片特定的内存资源,偏置和大小这里分别是0和bufferInfo.size。也可以指定特殊值VK_WHOLE_SIZE来映射所有内存。倒数第二个参数可以用于指定标记,但是当前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调用之前能够完成。
现在剩下的就是渲染操作过程中绑定顶点缓冲。我们扩展createCommandBuffers方法来实现:
vkCmdBindPipeline(commandBuffers[i], VK_PIPELINE_BIND_POINT_GRAPHICS, graphicsPipeline);
VkBuffer vertexBuffers[] = { vertexBuffer };
VkDeviceSize offsets[] = { 0 };
vkCmdBindVertexBuffers(commandBuffers[i], 0, 1, vertexBuffers, offsets);
vkCmdDraw(commandBuffers[i], static_cast(vertices.size()), 1, 0, 0);
vkCmdBindVertexBuffers方法用于绑定顶点缓冲到绑定上,就和我们在之前章节中建立的类似。开始的两个参数,除了命令缓冲,指定了偏置和我们将要指定顶点缓冲的绑定数量。最后两个参数指定了顶点缓冲数组以及开始读取顶点数据的字节偏置。你应该改变vkCmdDraw来传递缓冲中顶点的个数而不是原来硬编码的3。
现在运行代码就能看到熟悉的三角形了,尝试改变顶部的顶点为白色,修改vertices数组如下:
const std::vector 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}}
};
运行后三角形变成了下面这样:
下一章我们会用一个不同的方法拷贝顶点数据到顶点缓冲,也会有更好的性能,但是也会有更多工作量。