简述
接下来,我们将用内存中的顶点缓冲区替换顶点着色器中的硬编码顶点数据。我们将从创建CPU可见缓冲区的最简单方法开始,并使用memcpy将顶点数据直接复制到其中,然后我们将看到如何使用分段缓冲区将顶点数据复制到高性能内存。
首先修改顶点着色器不再包含顶点数据在着色器代码本身, 顶点着色器使用in关键字从顶点缓冲区获取输入。
1 |
|
inPosition和inColor变量是顶点属性。 它们是在顶点缓冲区中为每个顶点指定的属性,就像我们使用两个数组为每个顶点手动指定位置和颜色一样。 更改后记得重新编译顶点着色器!
像fragColor一样,layout(location = x)批注为输入分配索引,我们之后可以使用索引来引用它们。 重要的是要知道某些类型(例如dvec3 64位向量)使用多个插槽。 这意味着之后的索引必须至少高2倍(这里没搞懂, 需要学习一下GLSL的语法:https://www.khronos.org/opengl/wiki/Layout_Qualifier_(GLSL)):
1 | layout(location = 0) in dvec3 inPosition; |
一. 顶点数据
将顶点数据从着色器代码移动到程序代码中的数组中。需要引入GLM库,它为我们提供了与线性代数相关的类型,如向量和矩阵, 有与着色器语言中使用的向量类型完全匹配的c++类型。我们将使用这些类型来指定位置和颜色向量。
1 |
|
创建一个名为Vertex的新结构,内有两个属性,我们将在其内部的顶点着色器中使用. 使用顶点结构来指定顶点数据的数组。我们使用和之前完全相同的位置和颜色值,但现在它们被组合到一个顶点数组中, 这就是所谓的交错顶点(interleaving vertex)属性。
接下来是告诉Vulkan,一旦数据格式被上传到GPU内存,如何将其传递到顶点着色器。而传达这个信息需要有两种类型的结构: VkVertexInputBindingDescription和VkVertexInputAttributeDescription.
1.1 绑定描述
第一个结构是VkVertexInputBindingDescription,我们将向顶点结构添加一个成员函数,用正确的数据填充它。
1 | struct Vertex { |
1.1.1 VkVertexInputBindingDescription
1 | typedef struct VkVertexInputBindingDescription { |
- binding: 该结构描述的绑定号
- stride : 是缓冲区中两个连续元素之间的距离(以字节为单位)
- inputRate: 是一个VkVertexInputRate值,指定顶点属性寻址是顶点索引还是实例索引的函数
- VK_VERTEX_INPUT_RATE_VERTEX: 指定顶点属性寻址是顶点索引的函数,即移动到每个顶点后的下一个数据项
- VK_VERTEX_INPUT_RATE_INSTANCE: 指定顶点属性寻址是实例索引的函数,即移到每个实例之后的下一个数据项
顶点绑定描述在所有顶点中从内存加载数据的速率。它指定数据条目之间的字节数,以及是在每个顶点之后还是在每个实例之后移动到下一个数据条目。
1.1.2 绑定
所有的顶点数据都打包在一个数组中,所以我们只需要一个绑定:
1 | static VkVertexInputBindingDescription getBindingDescription() { |
1.2 属性描述
第二个描述如何处理顶点输入的结构是VkVertexInputAttributeDescription。我们将添加另一个辅助函数到顶点来填充这些结构体。
1 |
|
正如函数原型所表明的,有两个VkVertexInputAttributeDescription,分别代表位置和颜色。
属性描述结构描述如何从源自绑定描述的顶点数据块中提取顶点属性。
1.2.1 VkVertexInputAttributeDescription
1 | typedef struct VkVertexInputAttributeDescription { |
- location: 属性的着色器绑定位置号
- binding: 该属性获取其数据的绑定号
- format: 指顶点属性数据的大小和类型, 应使用颜色通道数量与着色器数据类型中的组件数量相匹配的格式
- float: VK_FORMAT_R32_SFLOAT
- vec2: VK_FORMAT_R32G32_SFLOAT
- vec3: VK_FORMAT_R32G32B32_SFLOAT
- vec4: VK_FORMAT_R32G32B32A32_SFLOAT
- offset: 该属性相对于顶点输入绑定中元素开始的字节偏移量
1.2.2 绑定
1 | static std::array<VkVertexInputAttributeDescription, 2> getAttributeDescriptions() { |
这里offsetof函数是用来获取偏移量的。
1.3 管道输入顶点
现在需要通过引用createGraphicsPipeline中的结构来设置图形管道以接受这种格式的顶点数据。找到vertexInputInfo结构体并修改它以引用以下两种描述:
1 | auto bindingDescription = Vertex::getBindingDescription(); |
管道现在已经准备好接受顶点容器格式的顶点数据,并将其传递给顶点着色器。
如果在启用验证层的情况下运行程序,将报出没有顶点缓冲区绑定到绑定。下一步是创建一个顶点缓冲区,并将顶点数据移动到其中,以便GPU能够访问它。
二. 顶点缓冲区
Vulkan中的缓冲区是用于存储任意数据的内存区域,这些数据可以被显卡读取,通过描述符集或特定命令将它们绑定到图形或计算管道,或者直接将它们指定为特定命令的参数。它们可以用来存储顶点数据,但也可以用于许多其他目的,以后中探讨。
与我们目前处理的Vulkan对象不同,缓冲区不会自动为自己分配内存, Vulkan API让程序员控制了几乎所有的事情,内存管理就是其中之一。
2.1 创建缓冲区
创建一个新的函数createVertexBuffer,并在createCommandBuffers之前从initVulkan调用它:
1 | void initVulkan() { |
创建顶点缓冲区需要填充VkBufferCreateInfo结构。
2.1.1 VkBufferCreateInfo
1 | typedef struct VkBufferCreateInfo { |
- sType: 结构体类型, VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO
- pNext: 为空或指向特定于扩展的结构的指针
- flags: VkBufferCreateFlagBits的位掩码,指定缓冲区的附加参数, 用于配置稀疏缓冲区内存
- size: 是要创建的缓冲区的大小(以字节为单位)
- usage: VkBufferUsageFlagBits的位掩码,指定缓冲区允许的用法
- sharingMode: VkSharingMode值,指定当多个队列族访问缓冲区时,缓冲区的共享模式
- VK_SHARING_MODE_EXCLUSIVE: 指定对对象的任何范围或图像子资源的访问一次只能由单个队列族独占
- VK_SHARING_MODE_CONCURRENT: 指定支持对来自多个队列族的对象的任何范围或映像子资源的并发访问
- queueFamilyIndexCount: 是pQueueFamilyIndices数组中的数量
- pQueueFamilyIndices: 将访问这个缓冲区的队列族列表(如果shareingmode不是VK_SHARING_MODE_CONCURRENT则忽略)
2.1.2 vkCreateBuffer
1 | VkResult vkCreateBuffer( |
- device: 创建缓冲区对象的逻辑设备
- pCreateInfo: 指向VkBufferCreateInfo结构的指针,该结构包含影响缓冲区创建的参数
- pAllocator: 控制主机内存分配
- pBuffer: 指向VkBuffer句柄的指针,在该句柄中返回结果缓冲区对象
VkBuffer缓冲区表示用于各种目的的数据的线性数组,通过描述符集或特定命令将它们绑定到图形或计算管道,或者直接将它们指定为特定命令的参数。
缓冲区由VkBuffer句柄表示:
2.1.3 createVertexBuffer
1 | VkBuffer vertexBuffer; |
创建后不需要时应该手动销毁:
1 | void cleanup() { |
2.2 给顶点缓冲区分配内存
缓冲区已创建,但实际上尚未分配任何内存。为缓冲区分配内存的第一步是使用vkGetBufferMemoryRequirements函数查询其内存需求.
1 | VkMemoryRequirements memRequirements; |
2.2.1 vkGetBufferMemoryRequirements
1 | void vkGetBufferMemoryRequirements( |
- device: 创建缓冲区对象的逻辑设备
- buffer: 待请求所需内存大小的缓冲区
- pMemoryRequirements: 指向VkMemoryRequirements结构的指针,在该结构中返回缓冲区对象的内存需求
2.2.2 VkMemoryRequirements
1 | typedef struct VkMemoryRequirements { |
- size: 资源所需的内存分配的大小(以字节为单位)
- alignment: 资源所需的分配内偏移量的对齐(以字节为单位),即缓冲区在分配的内存区域中开始的偏移量
- memoryTypeBits: 适合缓冲区的内存类型的位字段
2.2.3 findMemoryType
图形显卡可以提供不同类型的内存进行分配。每种类型的内存在允许的操作和性能特性方面都有所不同。
我们需要结合缓冲区的需求和我们自己的应用程序需求来找到合适的内存类型。为此,我们创建一个新函数findMemoryType:
1 | uint32_t findMemoryType(uint32_t typeFilter, VkMemoryPropertyFlags properties) { |
typeFilter参数用于指定适合的内存类型的位字段。
2.2.3.1 VkPhysicalDeviceMemoryProperties
1 | typedef struct VkPhysicalDeviceMemoryProperties { |
- memoryTypeCount: memoryTypes数组中的有效元素数。
- memoryTypes: VK_MAX_MEMORY_TYPES_Vk MemoryType结构数组,描述可用于访问从memoryHeaps指定的堆中分配的内存的内存类型。
- memoryHeapCount: memoryHeaps数组中的有效元素数。
- memoryHeaps: VK_MAX_MEMORY_HEAPS VkMemoryHeap结构的数组,描述可以从中分配内存的内存堆。
VkPhysicalDeviceMemoryProperties结构描述了许多内存堆以及一些内存类型,这些内存类型可用于访问这些堆中分配的内存。每个堆描述特定大小的内存资源,每个内存类型描述一组内存属性(例如,主机缓存与未缓存),这些属性可以与给定内存堆一起使用。使用特定内存类型的分配将消耗该内存类型的堆索引指示的堆中的资源。多个内存类型可以共享每个堆,堆和内存类型提供了一种机制,以宣告物理内存资源的精确大小,同时允许将内存与各种不同的属性一起使用。
2.2.4 分配内存
通过findMemoryType, 现在可以获取正确的内存类型,接下来就是给VkBuffer分配内存了。
不同类型的内存具有不同的属性。一些类型的内存可以被CPU访问,一些不可以。一些类型可以在GPU和CPU间保持数据一致性、一些类型可以被CPU缓存使用等等。可以通过查询物理设备获取这些信息。我们可以根据需要使用不同的内存类型,比如对于暂存资源,我们需要使用可以被CPU访问的内存类型。对于用于渲染的图像、顶点数据,我们通常为其分配GPU内存。
内存分配现在只需指定大小和类型就可以了,这两种类型都来自于顶点缓冲区的内存需求和所需的属性。创建一个类成员来将句柄存储到内存中,并用vkallocatemory分配它。
2.2.4.1 VkMemoryAllocateInfo
1 | typedef struct VkMemoryAllocateInfo { |
- sType: 结构体类型, VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO
- pNext: 为空或指向特定于扩展的结构的指针
- allocationSize: 内存分配大小,以字节为单位
- memoryTypeIndex: 内存类型的索引,VkPhysicalDeviceMemoryProperties结构的memoryTypes数组中的数据
2.2.4.2 内存分配
1 | void createVertexBuffer() { |
内存对象对设备内存中的数据进行操作可以使用vkAllocateMemory函数:
1 | VkResult vkAllocateMemory( |
- device: 是拥有内存的逻辑设备
- pAllocateInfo: 指向描述分配参数的VkMemoryAllocateInfo结构的指针。成功返回的分配必须使用请求的参数 - 实现不允许替换。
- pAllocator: 控制内存分配
- pMemory: 指向VkDeviceMemory句柄的指针,在该句柄中返回有关已分配内存的信息
vkallocatemory返回的分配保证满足实现的任何对齐要求。例如,如果一个实现需要128字节的图像对齐和64字节的缓冲区对齐,那么通过这个机制返回的设备内存将是128字节对齐的。这确保了应用程序可以在同一内存对象中正确地子分配不同类型的对象(具有可能不同的对齐要求)。
2.2.4.3 内存与缓冲区关联
如果内存分配成功,那么可以使用vkBindBufferMemory将此内存与缓冲区关联:
1 | vkBindBufferMemory(device, vertexBuffer, vertexBufferMemory, 0); |
要将内存附加到缓冲区对象可以调用vkBindBufferMemory函数:
1 | VkResult vkBindBufferMemory( |
- device: 是拥有内存的逻辑设备
- buffer: 要附加到内存的缓冲区
- memory: 描述要附加的设备内存的VkDeviceMemory对象
- memoryOffset: 要绑定到缓冲区的内存区域的起始偏移量。因为这个内存被专门分配给这个顶点缓冲区,所以偏移量是0。如果偏移量不为零,则要求它可以被整除内存memRequirements.alignment.
一旦缓冲区不再使用,绑定到缓冲区对象的内存可能会被释放,因此让我们在缓冲区被销毁后释放它:
1 | // 在 vulkan 中推荐在创建的资源不需要后主动释放 |
2.3 填充顶点缓冲区
现在是时候将顶点数据复制到缓冲区了。可以通过使用vkMapMemory将缓冲内存映射到CPU可访问内存来实现:
1 | VkResult vkMapMemory( |
- device: 拥有内存的逻辑设备
- memory: 要映射的VkDeviceMemory对象
- offset: 从内存对象开始的以零为基础的字节偏移量
- size: 映射的内存范围的大小,或者是要从偏移量映射到分配末尾的VK_WHOLE_SIZE大小
- flags: 保留供将来使用
- ppData: 指向void*变量的指针,指向映射内存的指针的输出。在该变量中返回指向映射范围开头的主机可访问指针。此指针减去偏移量必须至少与VkPhysicalDeviceLimits::minMemoryMapAlignment对齐
此函数允许我们访问由偏移量和大小定义的指定内存资源区域:
1 | void* data; |
填充顶点数据到缓冲区内存的方式就是先映射然后拷贝,最后解映射。
但是驱动程序可能不会立即将数据复制到缓冲区内存中,例如因为缓存(Cache)机制。有两种方法可以解决这个问题:
- 缓存的内存类型使用主机相关的内存堆,用VK_MEMORY_PROPERTY_HOST_COHERENT_BIT表示
- 在写入映射内存后调用vkFlushMappedMemoryRanges以及在从映射内存读取之前调用vkInvalidateMappedMemoryRanges
对于CPU可以访问的内存类型,可以使用vkMapMemory/vkUnmapMemory函数对其进行映射。这一映射是持久化的,只要进行了正确的同步,可以在GPU使用这一内存区域时访问它。
vkMapMemory函数返回的指针可以被保存使用,只要进行了正确的同步,甚至可以在GPU使用这一内存区域时对其进行写入操作,同步规则可以保证CPU不会写入数据到GPU正在使用的那部分内存。
这里我们采用第一个方式实现,确保映射内存始终与分配内存的内容匹配。
刷新内存范围或使用一致的内存堆意味着驱动程序将知道我们对缓冲区的写入,但这并不意味着它们在GPU上实际上是可见的。将数据传输到GPU是一个在后台发生的操作,规范简单地告诉我们,它保证在下一次调用vkQueueSubmit时完成。
2.4 绑定顶点缓冲区
现在我们有了顶点缓冲区,也分配了内存并填充了顶点数据,就剩下在渲染操作期间绑定顶点缓冲区。
通过扩展createCommandBuffers函数来实现:
1 | vkCmdBindPipeline(commandBuffers[i], VK_PIPELINE_BIND_POINT_GRAPHICS, graphicsPipeline); |
将顶点缓冲区绑定到命令缓冲区,以便在后续绘制命令中使用,需要使用vkCmdBindVertexBuffers:
1 | void vkCmdBindVertexBuffers( |
- commandBuffer: 记录命令的命令缓冲区。
- firstBinding: 第一个顶点输入绑定的索引,其状态由命令更新。
- bindingCount: 状态由命令更新的顶点输入绑定数
- pBuffers: 指向缓冲区句柄数组的指针。
- pOffsets: 指向缓冲区偏移量数组的指针。
从pBuffers和poffset的元素i获取的值替换了顶点输入绑定firstBinding+i的当前状态,即[0,bindingCount]中的i。顶点输入绑定将更新为从缓冲区pBuffers[i]开始的由pOffsets[i]指示的偏移处开始。所有使用这些绑定的顶点输入属性都将在后续绘制命令的地址计算中使用这些更新的地址。
三. 根据鼠标移动变化颜色
现在我们完成了从硬编码的顶点输入转为程序内的顶点数据输入, 更改vertices就可以看到颜色有变化,比如:
1 | // 顶点数据 |
对应的图形就是:
现在我们动手做根据鼠标移动动态变化颜色:
1 | void initWindow() { |
最后的结果:
哈哈,有那么回事了,第一个顶点会跟随鼠标移动而移动,并且三角形也会变幻颜色, 不过这里只是简单的处理,甚至没有考虑同步问题。