目录
16 顶点缓冲区
顶点着色器
顶点数据
管道顶点输入
17 顶点缓冲区创建
缓冲区创建
内存要求
内存分配
填充顶点缓冲区
18 暂存缓冲区
传输队列
使用暂存缓冲区
19 索引缓冲区
索引缓冲区创建
使用索引缓冲区
16 顶点缓冲区
我们将用内存中的顶点缓冲区替换顶点着色器中的硬编码顶点数据。我们将从最简单的方法开始,即创建 CPU 可见缓冲区并使用memcpy
直接将顶点数据复制到其中。
顶点着色器
首先更改顶点着色器,使其不再在着色器代码本身中包含顶点数据。顶点着色器使用关键字从顶点缓冲区获取输入 in
。
#version 450
//layout(location = x)注释将索引分配给我们稍后可以用来引用它们的输入
layout(location = 0) in vec2 inPosition;
//inPosition变量和inColor是顶点属性
layout(location = 1) in vec3 inColor;
layout(location = 0) out vec3 fragColor;
void main() {
gl_Position = vec4(inPosition, 0.0, 1.0);
fragColor = inColor;
}
顶点数据
我们将顶点数据从着色器代码移动到程序代码中的数组。首先包括 GLM 库,它为我们提供线性代数相关类型,如向量和矩阵。我们将使用这些类型来指定位置和颜色向量。
#include <glm/glm.hpp>
struct Vertex {
glm::vec2 pos;
glm::vec3 color;
//顶点绑定描述了在整个顶点中从内存加载数据的速率
static VkVertexInputBindingDescription getBindingDescription() {
VkVertexInputBindingDescription bindingDescription{};
//我们所有的逐顶点数据都打包在一个数组中,因此我们只需要一个绑定
bindingDescription.binding = 0;
bindingDescription.stride = sizeof(Vertex);
bindingDescription.inputRate = VK_VERTEX_INPUT_RATE_VERTEX;
return bindingDescription;
}
//另一个辅助函数来Vertex填充这些结构
static std::array<VkVertexInputAttributeDescription, 2> getAttributeDescriptions() {
std::array<VkVertexInputAttributeDescription, 2> attributeDescriptions{};
//属性描述结构描述了如何从源自绑定描述的顶点数据块中提取顶点属性。
//我们有两个属性,位置和颜色,所以我们需要两个属性描述结构。
attributeDescriptions[0].binding = 0;
attributeDescriptions[0].location = 0;
attributeDescriptions[0].format = VK_FORMAT_R32G32_SFLOAT;
attributeDescriptions[0].offset = offsetof(Vertex, pos);
attributeDescriptions[1].binding = 0;
attributeDescriptions[1].location = 1;
//该format参数描述属性的数据类型
attributeDescriptions[1].format = VK_FORMAT_R32G32B32_SFLOAT;
attributeDescriptions[1].offset = offsetof(Vertex, color);
return attributeDescriptions;
}
};
//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}}
};
该stride
参数指定从一个条目到下一个条目的字节数,该inputRate
参数可以具有以下值之一:
VK_VERTEX_INPUT_RATE_VERTEX
:移动到每个顶点后的下一个数据条目VK_VERTEX_INPUT_RATE_INSTANCE
:移动到每个实例后的下一个数据条目
管道顶点输入
我们现在需要设置图形管道以通过引用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();
17 顶点缓冲区创建
Vulkan 中的缓冲区是用于存储可由显卡读取的任意数据的内存区域。它们可以用来存储顶点数据.与我们目前处理的 Vulkan 对象不同,缓冲区不会自动为自己分配内存。
缓冲区创建
创建一个新函数createVertexBuffer
调用它。
void createVertexBuffer() {
//创建缓冲区需要我们填充一个VkBufferCreateInfo结构。
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!");
}
}
我们现在可以创建缓冲区了vkCreateBuffer。定义一个类成员来保存缓冲区句柄并调用它vertexBuffer
。
VkBuffer vertexBuffer;
vkDestroyBuffer(device, vertexBuffer, nullptr);
内存要求
缓冲区已创建,但实际上尚未分配任何内存。为缓冲区分配内存的第一步是使用适当命名的
VkMemoryRequirements memRequirements;
vkGetBufferMemoryRequirements(device, vertexBuffer, &memRequirements);
//缓冲区已创建,但实际上尚未分配任何内存。
//为缓冲区分配内存的第一步是使用适当命名的vkGetBufferMemoryRequirements 函数查询其内存需求。
该VkMemoryRequirements结构具有三个字段:
size
:所需内存量的大小(以字节为单位)可能与 不同bufferInfo.size
。alignment
: 缓冲区在分配的内存区域中开始的字节偏移量,取决于bufferInfo.usage
和bufferInfo.flags
。memoryTypeBits
:适合缓冲区的内存类型的位字段。
显卡可以提供不同类型的内存供分配。每种类型的内存在允许的操作和性能特征方面各不相同。我们需要结合缓冲区的要求和我们自己的应用程序要求来找到合适的内存类型来使用。让我们findMemoryType
为此创建一个新函数。
uint32_t findMemoryType(uint32_t typeFilter, VkMemoryPropertyFlags properties) {
VkPhysicalDeviceMemoryProperties memProperties;
vkGetPhysicalDeviceMemoryProperties(physicalDevice, &memProperties);
//结构有两个数组memoryTypes 和memoryHeaps。
//内存堆是不同的内存资源,例如专用 VRAM 和 RAM 中用于 VRAM 耗尽时的交换空间。
//这些堆中存在不同类型的内存。
for (uint32_t i = 0; i < memProperties.memoryTypeCount; i++) {
if ((typeFilter & (1 << i)) && (memProperties.memoryTypes[i].propertyFlags & properties) == properties) {
//该typeFilter参数将用于指定适合的内存类型的位域
return i;
}
}
throw std::runtime_error("failed to find suitable memory type!");
}
内存分配
我们现在有办法确定正确的内存类型,所以我们可以通过填充结构来实际分配内存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.
VkDeviceMemory vertexBufferMemory;
如果内存分配成功,那么我们现在可以使用以下方法将此内存与缓冲区相关联vkBindBufferMemory:前三个参数是不言自明的,第四个参数是内存区域内的偏移量。由于此内存是专门为此顶点缓冲区分配的,因此偏移量很简单0
。如果偏移量不为零,则它需要被 整除memRequirements.alignment
vkBindBufferMemory(device, vertexBuffer, vertexBufferMemory, 0);
填充顶点缓冲区
现在是将顶点数据复制到缓冲区的时候了。
void* data;
vkMapMemory(device, vertexBufferMemory, 0, bufferInfo.size, 0, &data);
//允许我们访问由偏移量和大小定义的指定内存资源区域。这里的偏移量和大小分别是0和 bufferInfo.size
//倒数第二个参数可用于指定标志,但当前 API 中尚无可用标志。它必须设置为值0。
//最后一个参数指定指向映射内存的指针的输出。
memcpy(data, vertices.data(), (size_t) bufferInfo.size);
//v\简单地memcpy将顶点数据存储到映射的内存中,然后使用vkUnmapMemor
vkUnmapMemory(device, vertexBufferMemory);
//在渲染操作期间绑定顶点缓冲区
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);
//调用更改为vkCmdDraw传递缓冲区中的顶点数,而不是硬编码的数字3。
18 暂存缓冲区
我们现在拥有的顶点缓冲区可以正常工作,但允许我们从 CPU 访问它的内存类型可能不是显卡本身读取的最佳内存类型。最佳内存有 VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT
标志,通常不能被专用显卡上的 CPU 访问。
传输队列
缓冲区复制命令需要一个支持传输操作的队列系列,使用 表示VK_QUEUE_TRANSFER_BIT
。任何具有VK_QUEUE_GRAPHICS_BIT
或VK_QUEUE_COMPUTE_BIT
功能的队列系列都已经隐含地支持VK_QUEUE_TRANSFER_BIT
操作。我们将创建两个顶点缓冲区。 CPU 可访问内存中的一个暂存缓冲区,用于将数据从顶点数组上传到,最后一个顶点缓冲区位于设备本地内存中。然后,我们将使用缓冲区复制命令将数据从暂存缓冲区移动到实际的顶点缓冲区。
- 修改
QueueFamilyIndices
并findQueueFamilies
显式查找带有 bit 的队列系列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
为仅使用主机可见缓冲区作为临时缓冲区,并使用设备本地缓冲区作为实际顶点缓冲区。
void createVertexBuffer() {
VkDeviceSize bufferSize = sizeof(vertices[0]) * vertices.size();
//使用新的stagingBuffer、stagingBufferMemory来映射和复制顶点数据
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);
}
VK_BUFFER_USAGE_TRANSFER_SRC_BIT
:缓冲区可以用作内存传输操作中的源。VK_BUFFER_USAGE_TRANSFER_DST_BIT
:缓冲区可用作内存传输操作中的目标。
vertexBuffer
从设备本地的内存类型分配的,这通常意味着我们无法使用vkMapMemory. 但是,我们可以将数据从 stagingBuffer
复制到vertexBuffer
。必须通过指定 stagingBuffer
的传输源标志和vertexBuffer
的传输目标标志以及顶点缓冲区使用标志来表明 。
我们现在要编写一个函数,将内容从一个缓冲区复制到另一个缓冲区,称为copyBuffer
.
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);
//只打算使用命令缓冲区一次,然后等待从函数返回,直到复制操作完成执行
VkBufferCopy copyRegion{};
copyRegion.srcOffset = 0; // Optional
copyRegion.dstOffset = 0; // Optional
copyRegion.size = size;
//使用命令传输缓冲区的内容vkCmdCopyBuffer。它以源缓冲区和目标缓冲区作为参数,以及要复制的区域数组。
vkCmdCopyBuffer(commandBuffer, srcBuffer, dstBuffer, 1, ©Region);
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);
vkFreeCommandBuffers(device, commandPool, 1, &commandBuffer);
}
我们现在可以调用copyBuffer
函数createVertexBuffer
将顶点数据移动到设备本地缓冲区,将数据从暂存缓冲区复制到设备缓冲区后,我们应该清理它:
copyBuffer(stagingBuffer, vertexBuffer, bufferSize);
vkDestroyBuffer(device, stagingBuffer, nullptr);
vkFreeMemory(device, stagingBufferMemory, nullptr);
19 索引缓冲区
在真实世界应用程序中渲染的 3D 网格通常会在多个三角形之间共享顶点。
绘制一个矩形需要两个三角形,这意味着我们需要一个有 6 个顶点的顶点缓冲区。问题是需要复制两个顶点的数据,导致 50% 的冗余。对于更复杂的网格,情况只会变得更糟,其中顶点在平均 3 个三角形中重复使用。这个问题的解决方案是使用索引缓冲区。
索引缓冲区本质上是指向顶点缓冲区的指针数组。它允许您重新排序顶点数据,并为多个顶点重用现有数据。上图演示了如果我们有一个包含四个唯一顶点中的每一个的顶点缓冲区,则索引缓冲区对于矩形来说会是什么样子。前三个索引定义右上三角形,后三个索引定义左下三角形的顶点。
索引缓冲区创建
修改顶点数据并添加索引数据以绘制图中所示的矩形。修改顶点数据以表示四个角:左上角是红色,右上角是绿色,右下角是蓝色,左下角是白色。
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
};
//定义两个新的类成员来保存索引缓冲区的资源:
VkBuffer vertexBuffer;
VkDeviceMemory vertexBufferMemory;
VkBuffer indexBuffer;
VkDeviceMemory indexBufferMemory;
void createIndexBuffer() {
VkDeviceSize bufferSize = sizeof(indices[0]) * indices.size();//索引数bufferSize乘以索引类型的大小
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);
//VK_BUFFER_USAGE_INDEX_BUFFER_BIT而不是 VK_BUFFER_USAGE_VERTEX_BUFFER_BIT
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);
}
使用索引缓冲区
使用索引缓冲区进行绘图涉及对 recordCommandBuffer
. 我们首先需要绑定索引缓冲区,就像我们为顶点缓冲区所做的那样。不同之处在于您只能有一个索引缓冲区。不幸的是,不可能为每个顶点属性使用不同的索引,因此即使只有一个属性发生变化,我们仍然必须完全复制顶点数据。
vkCmdBindIndexBuffer(commandBuffer, indexBuffer, 0, VK_INDEX_TYPE_UINT16);
仅绑定索引缓冲区还不会改变任何内容,我们还需要更改绘图命令以告知 Vulkan 使用索引缓冲区。删除该 vkCmdDraw行并将其替换为vkCmdDrawIndexed:
vkCmdDrawIndexed(commandBuffer, static_cast<uint32_t>(indices.size()), 1, 0, 0, 0);
对此函数的调用与vkCmdDraw. 前两个参数指定索引数和实例数。我们没有使用实例化,所以只需指定1
实例。索引数表示将传递给顶点着色器的顶点数。下一个参数指定索引缓冲区的偏移量,使用值1
将导致图形卡在第二个索引处开始读取。倒数第二个参数指定要添加到索引缓冲区中的索引的偏移量。最后一个参数指定实例化的偏移量,我们没有使用它。
现在运行你的程序,你应该看到以下内容: