描述符集和统一缓冲
在这一篇文章中,我们开始学习描述符集和统一缓冲。
统一缓冲(Uniform Buffer)的本质是显存中开辟的一块物理空间,存储着我们定义的 MVP 矩阵、光照参数等常量。
描述符集(Descriptor Set)是一个资源包句柄,它包含了 Shader 所需的一组资源引用,不仅仅是统一缓冲,还可以是贴图、采样器等。它解决了 Shader 去哪里找数据的问题。整体运作流程如下:
1. 定义合约(Layout):VkDescriptorSetLayout 规定 Shader 里 "binding = 0" 的位置存放什么。
2. 准备空间(Pool):VkDescriptorPool 提前向 GPU 申请一块专门管理描述符的内存。
3. 实例化(Set):vkAllocateDescriptorSets 从池子里领一个具体的资源包。
4. 绑定数据(Update):vkUpdateDescriptorSets 将物理 VkBuffer 的地址填进资源包。
5. 告知 GPU(Bind):vkCmdBindDescriptorSets 在渲染时把整个包传递给管线。
本章的代码记录在 Commit 7f4fe28。如图 1 所示,我们会得到一个旋转的图形。
如代码清单 1 所示,我们在顶点着色器中引入了统一变量,包含 MVP 变换矩阵。
- #version 450 // Use GLSL 4.5
- layout(location = 0) in vec3 pos;
- layout(location = 1) in vec3 col;
- layout(binding = 0) uniform MVP {
- mat4 projection;
- mat4 view;
- mat4 model;
- } mvp;
- layout(location = 0) out vec3 fragColor;
- void main()
- {
- gl_Position = mvp.projection * mvp.view * mvp.model * vec4(pos, 1.0);
- fragColor = col;
- }
注意,在 Vulkan 中我们需要显式指定 binding。在 OpenGL 中,我们可以不写 binding,然后通过 glGetUniformLocation() 在运行时动态查询。但在 Vulkan 中,着色器和资源的连接关系必须在流水线创建时就确定。
在 Vulkan 中,binding 是着色器代码和 C++ 代码之间的约定索引。通过显式指定 binding,驱动程序不需要在运行时去查找字符串名称,直接通过索引就能找到资源,减少了 CPU 开销。
binding 也是描述符集(Descriptor Set)体系的一部分。比如 "binding = 0" 的意思是,需要在当前描述符集里寻找编号为 0 的槽位。
代码清单 1 中的写法其实是一个 Uniform Buffer Object (UBO)。在 OpenGL 4.x+ 中也有类似的写法,但在 Vulkan 里是强制的。
Vulkan 不鼓励一个一个的传递 float 或 int。它要求我们把此处所有的变换矩阵打包成一个结构体,一次性传给 GPU。它在内存中是一个连续的块。在 C++ 端,我们也需要定义一个一模一样的结构体,然后通过 memcpy 直接把数据拷贝进显存。
在 Vulkan 中,要让代码清单 1 中的 MVP 流程跑起来,我们需要在 C++ 代码里完成以下链路:
1. Descriptor Set Layout:它告诉 GPU,我们打算用一个包含 1 个统一缓冲的集合。
2. Descriptor Pool:提前申请一大块内存,用来存放这些“票据”(描述符)。
3. Descriptor Set:具体的实例。它会把我们的 VkBuffer(实际存放数据的地方)和着色器里的 "binding = 0" 绑定在一起。
4. Pipeline Layout:在创建流水线时,把这些布局信息填写进去。
如果把 Vulkan 的资源连接比作“插座”和“插头”,那么 VkDescriptorSetLayoutBinding 结构体就是在定义“插座”的规格。
在 Vulkan 中,我们不能把 Buffer 丢给 Shader,我们需要先定义一个布局,告诉 GPU:在 binding 0 号位置,我们准备放一个 Uniform Buffer,而且是给顶点着色器用的。结构体的核心字段如下:
1. binding:指定槽位索引,必须和我们 GLSL 代码里面的 binding 完全对应。
2. descriptorType:指定描述符类型。此处我们指定为 VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER,表示普通的 Uniform 块。
3. descriptorCount:指定描述符数量。通常填 1。如果我们想在 Shader 里传入一个数组,比如 "layout(binding = 0) uniform MyBuffer { ... } data[10];",那么此处就填写 10。
4. stageFlags:指定失效阶段。此处我们指定为 VK_SHADER_STAGE_VERTEX_BIT,表示只有顶点着色器能用。
我们已经定义好了单个“插座”的规格(VkDescriptorSetLayoutBinding),下一步就是把这些插座打包,做成一整块“插线板”。
整合通过 VkDescriptorSetLayoutCreateInfo 结构体。bindingCount 和 pBindings 是最关键的字段。
然后我们通过 vkCreateDescriptorSetLayout() 函数,生成布局句柄。其函数原型为:
- VKAPI_ATTR VkResult VKAPI_CALL vkCreateDescriptorSetLayout(
- VkDevice device,
- const VkDescriptorSetLayoutCreateInfo* pCreateInfo,
- const VkAllocationCallbacks* pAllocator,
- VkDescriptorSetLayout* pSetLayout);
其中 pSetLayout 参数就是返回的布局句柄,类型为 VkDescriptorSetLayout。
我们重温一下 VkPipelineLayoutCreateInfo 结构体,它的作用是告诉图形流水线:在这个渲染任务中,我们一共要用到多少个描述符集,以及是否有一些动态常量。
需要关注的字段是 setLayoutCount 和 pSetLayouts。在 GLSL 中,我们可以看到比如 "layout(set = 0, binding = 0)" 和 "layout(set = 1, binding = 0)"。这里的 pSetLayouts 数组索引,就直接对应的 Shader 里的 "set = N"。
我们再重温一下之前创建 Buffer 的流程,它涉及资源定义和内存分配两个阶段:
1. vkCreateBuffer:创建一个“空壳”对象。它定义了大小和用途,但此时并没有真正的物理内存。
2. vkGetBufferMemoryRequirements:询问 GPU,这个 Buffer 需要多少内存,对齐要求是什么。
3. vkAllocateMemory:根据要求,从显存中切出一块“蛋糕”。
4. vkBindBufferMemory:把“空壳”和“蛋糕”粘在一起。
此处我们需要创建 Uniform Buffer,它的用途设置为 VK_BUFFER_USAGE_UNIFORM_BUFFER_BIT。属性设置为 VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT 和 VK_MEMORY_PROPERTY_HOST_COHERENT_BIT,因为后续我们需要使用 vkMapMemory() 把 CPU 端的矩阵数据拷贝进去。
在 Vulkan 中,描述符不是凭空产生的,它们必须从一个池子里分配出来。VkDescriptorPoolSize 结构体告诉 Vulkan,这个池子里要准备多少个某种类型的插槽。它的字段含义如下:
1. type:指定描述符的类型。必须和之前在 VkDescriptorSetLayoutBinding 中定义的一致,此处我们定义为 VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER。
2. descriptorCount:指定该类型描述符的总配额。这是最容易产生误解的地方。这个数字不是指 Set 的数量,而是该类型描述符的总消耗量。比如我们有 3 帧缓冲,每一帧都需要 1 个 Uniform Buffer,那么此处的数量为 3。
VkDescriptorPoolCreateInfo 结构体定义了整个描述符池的容量上限和行为准则。它的核心字段含义如下:
1. maxSets:指定该池子允许分配的 VkDescriptorSet 实例总数。这个值通常等于“帧数 × 每帧用到的 Set 数量”。
2. poolSizeCount 和 pPoolSizes:指定指向 VkDescriptorPoolSize 的数组。
vkCreateDescriptorPool() 函数用于在 GPU 显存中开辟出那块专门用于存放描述符的区域。其函数原型为:
- VKAPI_ATTR VkResult VKAPI_CALL vkCreateDescriptorPool(
- VkDevice device,
- const VkDescriptorPoolCreateInfo* pCreateInfo,
- const VkAllocationCallbacks* pAllocator,
- VkDescriptorPool* pDescriptorPool);
目前我们的“插线板模板”(VkDescriptorSetLayout)和“物资仓库”( VkDescriptorPool)都已经就位。
VkDescriptorSetAllocateInfo 结构体为我们切分出真正可以使用的 VkDescriptorSet 实例。它的核心字段如下:
1. descriptorPool:指定从哪个池子里领取。
2. descriptorSetCount:指定打算领取多少个 Set。
3. pSetLayouts:指定按照哪些模板来领取。如果我们要分配 3 个结构完全一样的 Set,比如此处我们给三缓冲使用,我们需要提供一个包含 3 个相同 Layout 句柄的数组。
vkAllocateDescriptorSets() 函数将抽象的布局转化成真实的、可操作的描述符集实例。其函数原型为:
- VKAPI_ATTR VkResult VKAPI_CALL vkAllocateDescriptorSets(
- VkDevice device,
- const VkDescriptorSetAllocateInfo* pAllocateInfo,
- VkDescriptorSet* pDescriptorSets);
当我们已经分配好了 Descriptor Set 并创建了 Uniform Buffer,我们需要两者之间的“导线”。即我们要哪个 Buffer 里的哪一段内存,连接到描述符插槽上。
VkDescriptorBufferInfo 结构体解决第一个问题,即要哪个 Buffer 里的哪一段内存。它的字段含义如下:
1. buffer:指定实际存储数据的 Buffer 句柄。
2. offset:指定数据在该 Buffer 中的起始偏移量。
3. range:指定要读取的数据长度。
VkWriteDescriptorSet 结构体继续解决第二个问题,即把准备好的资源送到哪个 Set 的哪个 binding 槽位里。它的核心字段如下:
1. dstSet:指定更新哪一个描述符集。
2. dstBinding:指定更新哪一个槽位,对应 GLSL 代码里的 "binding = N"。
3. descriptorCount:通常填 1。如果 Shader 里面定义的是数组,则填写元素数量。
4. pBufferInfo 和 pImageInfo:两者是互斥的。此处我们更新的是 Uniform Buffer,给 pBufferInfo 传指针,pImageInfo 设置为 NULL。
vkUpdateDescriptorSets() 函数负责真正执行任务,把 Buffer 或 Image 的物理地址录入到描述符集。其函数原型为:
- VKAPI_ATTR void VKAPI_CALL vkUpdateDescriptorSets(
- VkDevice device,
- uint32_t descriptorWriteCount,
- const VkWriteDescriptorSet* pDescriptorWrites,
- uint32_t descriptorCopyCount,
- const VkCopyDescriptorSet* pDescriptorCopies);
我们可以发现参数分为 Write 和 Copy 两部分:
Write 是最常用的方式。通过 VkWriteDescriptorSet 将新资源绑定到 Set。
Copy 的用法是,如果我们有一个填好的 Set A,想要把它的内容原封不动的拷贝给 Set B 的某个位置,可以用 VkDescriptorCopy。
这个函数不像 vkCmd 系列函数那样录入命令缓冲,它是立即执行的:驱动程序会读取 pDescriptorWrites,检查目标 Set 是否合法,确认 Buffer 范围是否在显存。然后将 VkBuffer 的句柄和偏移量信息直接写入到 VkDescriptorSet 对应的显存区域。
在完成了创建 Layout、分配 Pool、写入 Buffer 这些复杂的准备工作之后,vkCmdBindDescriptorSets() 函数就是最后的绑定操作。它在录制命令缓冲时,告诉 GPU:接下来的渲染请使用这组特定的资源包。其函数原型为:
- VKAPI_ATTR void VKAPI_CALL vkCmdBindDescriptorSets(
- VkCommandBuffer commandBuffer,
- VkPipelineBindPoint pipelineBindPoint,
- VkPipelineLayout layout,
- uint32_t firstSet,
- uint32_t descriptorSetCount,
- const VkDescriptorSet* pDescriptorSets,
- uint32_t dynamicOffsetCount,
- const uint32_t* pDynamicOffsets);
其中,commandBuffer 参数指定正在录制的命令缓冲;pipelineBindPoint 参数指定绑定点,此处指定为 VK_PIPELINE_BIND_POINT_GRAPHICS,表示用于普通渲染;layout 指定为整个管线的布局。
firstSet 参数指定从第几个 Set 索引开始绑定;descriptorSetCount 参数指定绑定多少个 Set;pDescriptorSets 参数指定具体的 Set 句柄数组。
dynamicOffsetCount 和 pDynamicOffsets 是针对 VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER_DYNAMIC 这种特殊类型的。
目前剩下的任务就是更新 MVP 矩阵的内容了。之前我们使用 vkUpdateDescriptorSets() 时,实际上是在 GPU 的“地址簿”里进行了记录,比如 binding 0 指向显存地址 0x12345678。所以这个操作只需要做一次。
后续我们直接使用 vkMapMemory 并 memcpy,就能直接修改数据。还有需要考虑更新的时机,我们可以紧接着 vkAcquireNextImageKHR() 进行。
投影矩阵需要注意:我们使用 glm::perspective() 生成投影矩阵,但是它是为 OpenGL 设计的。在 OpenGL 中,Y 轴向上为正;而在 Vulkan 中,Y 轴向下为主。
我们有一个 hack 一点的方法,因为投影矩阵只有第二行第二列的元素作用到 Y 值,所以我们将其取反。
- mvp.projection = glm::perspective(glm::radians(45.0f), (float)swapChainExtent.width / (float)swapChainExtent.height, 0.1f, 100.0f);
- mvp.projection[1][1] *= -1;