写原生 Vulkan 的人大概都踩过同一个坑:改了 storage texture 的写入 pass,忘了在后面的读取 pass 前插 vkCmdPipelineBarrier,结果就是偶发花屏——不是必现,不是必不现,是驱动和硬件心情好就正常。Godot 的 RenderingDevice(RD)把这整类错误消掉了,做法是记录每个纹理的读写历史,在列表录制结束时自动推导屏障。这篇基于 4.x 源码讲这个机制怎么工作、推导不出来的场合它怎么暴露给你。
为什么屏障这么难写对
屏障的语义是"前一批命令对资源的访问,对后一批命令可见"。要插对一个屏障,你得同时答对四个问题:哪个资源(image 还是 buffer)、前后分别是什么访问类型(读/写、shader storage / color attachment / transfer)、在哪个 pipeline stage 之间、要不要跨 queue。Vulkan 把四个问题的答案全部塞给开发者,一个填错就是 undefined behavior。
RD 的观察是:这四个问题里前三个,调用序列里本来就有答案。texture_update() 写纹理、compute_list_dispatch() 里绑定的 uniform set 引用它、texture_get_data() 读它——资源怎么被用,全写在 RD 的 API 调用里。缺的是"记录",不是"信息"。
RD 的解法:给每个资源记一本账
看 servers/rendering/rendering_device.h 里 Texture 结构(4.x 各版本字段略有出入,核心不变):
// servers/rendering/rendering_device.h, 4.2
struct Texture {
RDD::TextureID driver_id;
TextureFormat format;
// ...
uint32_t usage_scope = 0; // 最近一次访问的"作用域"
};
usage_scope 就是账本:纹理最近一次被哪个 pipeline 阶段、以读还是写方式访问。每次你往列表里录一条命令,RD 把命令涉及的资源集合和它们当前的 usage_scope 扫一遍:
- 新访问和前次访问同作用域,无事发生;
- 读 → 读,大多数情况也无事发生(UAV 读读并发合法);
- 写 → 读 或 读 → 写 或 写 → 写,在这里插入一次 pipeline barrier,把
usage_scope更新为新值。
阶段掩码、访问掩码都从作用域枚举一一映射出来,映射表写在 rendering_device.cpp 里,是编译期常量。你要写的 vkCmdPipelineBarrier(srcStage, dstStage, srcAccess, dstAccess, ...) 六个参数,RD 从"这个纹理上一条命令是什么、这一条是什么"推导出五个,剩下一个 image layout 由纹理创建时的格式和用途决定。
整个推导依赖一个前提:列表里的命令顺序就是 GPU 执行顺序。RD 是单活动列表模型——compute_list_begin() 没 end() 就开第二个直接报错——前面那篇 RD 抽象层文章说过这是用约束换正确性,这里就是换来的东西。
列表结束时的集中结算
推导不是每录一条命令就调一次 vkCmdPipelineBarrier,那样驱动会被细粒度屏障拖死。RD 的策略是攒到列表结束:录制期间只更新账本(O(涉及资源数)),compute_list_end() / draw_list_end() 时一次性把积攒的转换翻译成最少量的屏障下发。效果等价于手写"把同一资源的多次转换合并",但不用你记得合并。
// 用户视角:两次 dispatch,中间隔着一次对同一张 storage texture 的读
compute_list_begin();
compute_list_bind_compute_pipeline(list, pipeline_write);
compute_list_bind_uniform_set(list, u_set_write, 0);
compute_list_dispatch(list, x, y, z); // 写 tex_a
compute_list_add_barrier(list); // ← 唯一要写的屏障
compute_list_bind_compute_pipeline(list, pipeline_read);
compute_list_bind_uniform_set(list, u_set_read, 0);
compute_list_dispatch(list, x, y, z); // 读 tex_a
compute_list_end(); // RD 结算所有延迟屏障
compute_list_add_barrier() 是留给"自动推导不够用"场合的显式口:它不等列表结束,立即对列表里已记录的全部资源转换下屏障。什么时候需要它?同一个列表内、同一资源、跨逻辑阶段——上面例子里,两个 dispatch 之间共享 tex_a,但第二次 dispatch 的 uniform set 在绑定那一刻 RD 还没法确定和前一次 dispatch 的顺序语义,或者你就是要保证 dispatch B 看到 dispatch A 的全部结果。显式屏障在这里的作用不是"补 RD 的漏",而是"把屏障位置从列表末尾提前到两个 dispatch 之间"。
三个显式同步口,各管一摊
RD 对外暴露的同步 API 一共三个,职责分得相当干净:
| API | 粒度 | 什么时候用 |
|---|---|---|
compute_list_add_barrier() |
列表内,资源级 | 同一 compute 列表里写后读,需要 B dispatch 在 A 之后 |
barrier(BarrierMask, sync_with_draw) |
设备级,全量 | 跨列表、跨 pass 的依赖,或不确定时用 BARRIER_MASK_ALL_BARRIERS 兜底 |
sync() |
时间线级 | CPU 必须等 GPU:读回 buffer、截图 |
barrier() 的 BarrierMask 是一组位标志(compute / raster / copy 各阶段),可以精确指定哪些阶段之间要栅栏,也可以全量兜底。注意它是全量资源的:不传 mask 的兜底屏障在桌面 GPU 上是微秒级以下开销,但在 tile-based 移动 GPU 上会强制把 tile 内存刷回主存,移动端要慎用全量屏障——这点 Mobile 渲染器那篇会展开。
一张图看懂自动屏障的位置
边界与代价
自动屏障不是零成本,它只是把成本从"写错即花屏的风险"换成了"每次资源使用一次的账簿更新"。账簿是 per-texture 的一个字段,列表结束时的推导是线性的——对几千个资源的场景,这部分 CPU 开销在 profiler 里基本看不见。真正的代价是保守:RD 按"最坏情况插屏障",比如你连续两个 dispatch 写同一张纹理的不同 mip,RD 依然插全量转换屏障,而手写的人可以论证出这两次写可以合并。Forward+ 渲染器里那种精心设计的 pass 间依赖,就是用显式 barrier() 手动编排的,自动机制兜底常规路径。
另一个边界:跨 queue 和跨设备(本地 RD 与主 RD 之间)的同步自动机制完全管不到,那是你自己的责任。texture_get_data() 这类读回接口内部会先 sync 再映射,所以它们在循环里用就是逐次全停——这是 RD 少有的"隐式同步陷阱",用之前看一眼文档里那句 "This function is slow and synchronizes"。
本文基于 Godot 4.2/4.3 源码,关键路径:servers/rendering/rendering_device.h 的 Texture::usage_scope 与 servers/rendering/rendering_device.cpp 的列表结算逻辑。