3.4 描述符布局、池与集合 本节摘要:描述符是着色器访问资源的点名册:布局声明册子格式,池是批量供料的仓库,集合是按册子填好的具体名单。本节讲清三层对象的创建与配对规则、更新的正确姿势,以及「每帧覆盖更新」与帧并发之间的时序陷阱。 为什么渲染命令里看不到任何纹理指针?因为指针在 Vulkan 里换了一种存在形式——描述符。着色器写的 sampler2D、uniform buffer,运行时都要靠描述符集(descriptor set)把它们与具体资源对上号。本节是第 3 章的柜台环节:资产(3.2)与库房(3.1)都备好了,现在办理把它们递交给着色器的手续。 一、三层对象,各管一摊 描述符系统的三层结构值得先立起来。
本节摘要:描述符是着色器访问资源的点名册:布局声明册子格式,池是批量供料的仓库,集合是按册子填好的具体名单。本节讲清三层对象的创建与配对规则、更新的正确姿势,以及「每帧覆盖更新」与帧并发之间的时序陷阱。
为什么渲染命令里看不到任何纹理指针?因为指针在 Vulkan 里换了一种存在形式——描述符。着色器写的 sampler2D、uniform buffer,运行时都要靠描述符集(descriptor set)把它们与具体资源对上号。本节是第 3 章的柜台环节:资产(3.2)与库房(3.1)都备好了,现在办理把它们递交给着色器的手续。
描述符系统的三层结构值得先立起来。VkDescriptorSetLayout 是册子模板:声明有哪些条目、各是什么类型、绑在哪个槽位、可见于哪些着色阶段。VkDescriptorPool 是供料仓库:创建时申报总量(多少个集合、每种类型多少条),之后从池里批量领取集合。VkDescriptorSet 是按模板填好的具体名单:每个槽位指向一个缓冲视图或图像视图(3.2 节的取景框在此派上用场)。
三层的约束关系是一条链:管线布局引用集合布局(第 4 章组装管线时),集合必须从支持对应类型的池里领取,集合的每个槽位写入与布局声明严丝合缝。验证层对这条链的核对极其严格——槽位号差一、类型不匹配、没写就绑,都会当场退件。
// 册子模板:一条 uniform 缓冲(顶点着色器可见)加一条组合图像采样器(片段可见) VkDescriptorSetLayoutBinding binds[2] = {}; binds[0].binding = 0; binds[0].descriptorType = VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER; binds[0].descriptorCount = 1; binds[0].stageFlags = VK_SHADER_STAGE_VERTEX_BIT; binds[1].binding = 1; binds[1].descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER; binds[1].descriptorCount = 1; binds[1].stageFlags = VK_SHADER_STAGE_FRAGMENT_BIT; VkDescriptorSetLayoutCreateInfo lci = {VK_STRUCTURE_TYPE_DESCRIPTOR_SET_LAYOUT_CREATE_INFO}; lci.bindingCount = 2; lci.pBindings = binds; VkDescriptorSetLayout layout; vkCreateDescriptorSetLayout(device, &lci, NULL, &layout); // 供料仓库:申报要撑起整个会话的量 VkDescriptorPoolSize sizes[2] = {}; sizes[0].type = VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER; sizes[0].descriptorCount = 1024; sizes[1].type = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER; sizes[1].descriptorCount = 1024; VkDescriptorPoolCreateInfo pci = {VK_STRUCTURE_TYPE_DESCRIPTOR_POOL_CREATE_INFO}; pci.flags = VK_DESCRIPTOR_POOL_CREATE_FREE_DESCRIPTOR_SET_BIT; // 允许单册归还 pci.maxSets = 1024; pci.poolSizeCount = 2; pci.pPoolSizes = sizes; VkDescriptorPool pool; vkCreateDescriptorPool(device, &pci, NULL, &pool);
集合从池里按布局领取,然后用一次写入调用填好所有槽位。填册的核心是 vkUpdateDescriptorSets,它按「写信息数组」批量执行:
VkDescriptorSetAllocateInfo ai = {VK_STRUCTURE_TYPE_DESCRIPTOR_SET_ALLOCATE_INFO}; ai.descriptorPool = pool; ai.descriptorSetCount = 1; ai.pSetLayouts = &layout; VkDescriptorSet set; vkAllocateDescriptorSets(device, &ai, &set); VkDescriptorBufferInfo bufInfo = { perObjectUBO, 0, sizeof(ObjectParams) }; VkDescriptorImageInfo imgInfo = { sampler, texView, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL }; VkWriteDescriptorSet writes[2] = {}; writes[0].sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET; writes[0].dstSet = set; writes[0].dstBinding = 0; writes[0].descriptorType = VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER; writes[0].descriptorCount = 1; writes[0].pBufferInfo = &bufInfo; writes[1] = writes[0]; writes[1].dstBinding = 1; writes[1].descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER; writes[1].pImageInfo = &imgInfo; vkUpdateDescriptorSets(device, 2, writes, 0, NULL); // 交册:绑定到命令缓冲,槽位号与管线布局对齐 vkCmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, pipelineLayout, 0, 1, &set, 0, NULL);
两个规则值得强调。其一,图像描述符必须携带布局:写入时的 imageLayout 要与资源实际状态一致(通常 SHADER_READ_ONLY_OPTIMAL),状态对不上是「采样到垃圾数据」类疑难案的常见根源。其二,更新与在途使用的关系:规范允许更新「未被待执行命令引用」的槽位;一个集合正被 GPU 使用时改写它,行为未定义——这个坑值得单独展开。

背景:按「每帧为每个对象写一次 uniform 描述符」实现后,画面上随机出现「A 物体顶着 B 物体的变换」串号现象,且越忙越频繁。
操作:排查更新时序后发现根因:CPU 已经跑到第 N 加二帧改写集合,而第 N 帧的渲染命令还在 GPU 队列里没执行——改写发生在「集合被待执行命令引用」期间,正是规范未定义区。改造方案按在途帧数分册:每个在途帧一个集合(2.5 节帧栅栏轮换的同一套框架),CPU 永远只写「对应帧的栅栏未触发就跳过」的那一册;对象数据本体也按帧分槽(3.1 的方案 B 直写内存同样分槽)。
结果:串号消失。在途帧数从二调到三时同步对象随之加套,框架无需再改。
解读:这是 Vulkan 与老 API 心智差异最大的地方之一:老 API 的常量更新是即时语义,Vulkan 的更新与执行是异步的两条时间线。「谁还在用这份名单」的答案不由直觉给,由栅栏给——这也预告了第 6 章的中心议题。
变式:对象数量大时「每对象一册」会让集合数爆炸,正确解法是把变换数据合并成一条大缓冲,用动态偏移(vkCmdBindDescriptorSets 的 pDynamicOffsets)在绑定层选槽,全场景共享一册。偏移的对齐要求来自设备的 minUniformBufferOffsetAlignment,照例查询取得。