技术博客

descriptor set 与 UniformSet 的缓存策略:绑定资源时的哈希查表

第 44 / 149 篇 · 2026-06-19 · godot · 渲染Vulkan源码分析

Vulkan 原生接口里,每次绑定资源都要 vkAllocateDescriptorSets + vkUpdateDescriptorSets,后者是写描述符的 CPU 操作,上千次 draw call 的场景下能吃掉一整毫秒。RD 的 uniform_set_create 把这套操作包了一层缓存:内容相同就复用,核心是一次对绑定内容的全量哈希。读懂这个缓存,才能理解为什么"同样的 uniform set 反复创建"在 Godot 里几乎免费,而"每次创建内容都变"比 Vulkan 原生还贵。

缓存的键:绑定内容的全量哈希

RenderingDevice::uniform_set_create()(servers/rendering/rendering_device.cpp)的第一步不是碰 Vulkan,而是把传入的 RDUniform 数组序列化进一个哈希键:

// 示意:键的构成(4.2.2,有删减)
struct UniformSetKey {
    RID shader;                    // 属于哪个 shader
    uint32_t set_index;            // set = 0 / 1 / 2...
    // 每个 uniform:类型 + binding 号 + 资源 RID 列表
};

哈希值相同的两次调用,返回同一个 RID——背后是同一个 VkDescriptorSet。所以"每帧 uniform_set_create 几十次"的代码并不慢:前几个 frame 建好后,后续全部走哈希命中,一次 vkAllocateDescriptorSets 都没有。

这个设计有一个反直觉的后果:哈希键里含着 shader 的 RID。同一个缓冲集合在 shader A 下创建的 uniform set,拿到 shader B 下不能用——set 布局由 shader 的 SPIR-V 反射决定(三-8 篇),换 shader 等于换布局,缓存天然隔离。所以共享 uniform set 的正确姿势是先创建、再在不同 pipeline 间复用同一 RID,而不是指望跨 shader 自动命中。

未命中时的真实成本

哈希查不到时,RD 做的事比 Vulkan 原生多三步:分配 key 内存、写入缓存表、再调驱动的 descriptor set 分配。单次未命中比原生贵一点,换来的是命中路径几乎零成本。这套"读多写少、写入摊薄"的假设来自渲染器的使用模式:材质实例化后,每帧反复用的是同一批绑定。

uniform_set_is_valid 的存在也是缓存语义的产物:缓存里那份对应的底层资源(纹理、缓冲)被释放后,RID 还在表里但已是僵尸,这个 API 就是让你确认缓存条目还有效。

为什么不让驱动层缓存

4.3 拆 RDD 之后(三-2 篇),descriptor set 的分配在驱动层,但缓存在 RD 层——这个分工是有意的。RD 层知道完整的资源 RID 和 shader 布局信息,能算出内容级哈希;驱动层只看得见底层句柄,做不了"逻辑等价"判断。同理 barrier 推导在驱动层、资源簿记在 RD 层:每段逻辑放它信息最全的那一侧。

实战含义

三条从缓存机制直接推出的写法:材质用到的 uniform set 在 _ready 创建一次存成成员变量,别在 _process 里反复 uniform_set_create(命中虽便宜,序列化哈希键的循环本身不免费);storage buffer 内容每帧都变的场景,变化走 buffer_update 写数据而不是重建 set——键里没有缓冲内容,只有 RID,缓冲更新了缓存照样命中;多 shader 共享同一批资源就共享 uniform set RID,引擎不会替你做跨 shader 的合并。

uniform_set_create RDUniform 数组 哈希键计算 shader RID + set 号 + 资源 RID 缓存表查 RID UniformSetCache 哈希表 命中:直接返回已有 RID 零 Vulkan 调用 未命中:RDD 分配 VkDescriptorSet 写缓存表后返回新 RID 键不含缓冲内容只含 RID:buffer_update 改数据不破坏缓存命中

本文代码引自 Godot 4.2.2-stable 的 servers/rendering/rendering_device.cpp,键结构为示意简化。