Render Graph 没有统一 API。本文介绍一种保守的通用设计,尝试回答一个核心问题:Render Graph 如何把 Pass 对逻辑资源的声明,转换为可执行的依赖顺序、Vulkan barrier 和物理资源规划?RAW、WAR 与 WAW 的完整推导已经在上一篇文章中展开,这里只保留理解编译过程所需的部分。
1. 为什么需要 Render Graph
在实时渲染中,一帧通常由多个 Pass 共同完成。一个 Pass 可能写入颜色、深度或法线 image,后续 Pass 再将它们作为 attachment、sampled image 或 storage image 使用。随着 Pass 数量增加,如果每个 Pass 分别创建资源、维护 image layout、插入 barrier 并管理生命周期,资源依赖和同步逻辑就会分散在渲染代码中,修改渲染流程时也容易遗漏状态转换。
Render Graph 提供了一种声明式组织方式:Pass 只描述自己读取和写入哪些逻辑资源,以及如何使用它们;图在编译阶段推导依赖、执行顺序和资源生命周期,在执行阶段将 barrier 与渲染命令录制到 command buffer。command buffer 被提交后,GPU 才真正开始执行。
2. 三层资源与一帧时间线
理解 Render Graph 之前,需要先区分逻辑资源、VkImage 和 device-memory allocation。它们分别处在渲染架构、Vulkan 对象和实际存储三个层次。
逻辑资源是 Render Graph 用来表达数据身份和依赖关系的抽象。一个名字或 handle 可以代表 Lighting、FilteredLighting 或 FinalColor,Pass 通过它声明自己读取和写入的数据。逻辑资源还可以保存尺寸、format、usage 等描述,但在 Setup 阶段,它不一定已经拥有对应的 Vulkan image 或 GPU memory。
VkImage 是 Vulkan image object 的不透明 handle。创建它时需要指定尺寸、VkFormat、sample count、usage flags 和 mip levels 等信息;随后通过 VkImageView 才能作为 attachment 或 descriptor 中的 image 使用。VkImage 描述一张 GPU image 应该如何被解释和访问,但它本身不等于存放像素的显存。在原生 Vulkan 中,创建 image 后还需要选择 device memory 并完成绑定;VMA 等分配器可以把这些步骤包装在一起。
Device-memory allocation 才是承载 image 内容的实际 GPU 存储。通常一个逻辑资源对应一个 VkImage,再由一个 allocation 为它提供内存;但这不是唯一映射。只要生命周期和创建参数满足约束,不同逻辑资源仍然可以保留各自独立的 VkImage 和 image view,并在不同时间绑定同一块 allocation。这正是后文 transient image aliasing 的基础。
1 | Logical resource A → VkImage A ─┐ |
区分这三层之后,一帧可以概括为下面的时间线:
1 | Setup |
Setup、Compile 和 Execute 都首先发生在 CPU 端。Setup 收集 Pass 声明;Compile 根据声明建立依赖、计算生命周期并规划物理资源;Execute 将 barrier、draw、dispatch 或 trace 命令写入 command buffer。调用 queue submit 仍然是 CPU 操作,但提交之后,GPU 才可能异步执行这些命令。因此,RenderGraph::Execute 返回不代表本帧的 GPU 工作已经完成。
3. Pass 声明如何编译成依赖图
Render Graph 的输入不是一串立即执行的渲染命令,而是一组 Pass 声明。可以先考虑一条简化的数据链:
1 | Pass A |
在 Setup 阶段,这些 Read 和 Write 只会登记逻辑资源 handle、访问类型以及可能需要的 shader binding;它们不会立刻运行 shader,也不会让 GPU 开始渲染。等所有 Pass 都完成声明后,Compile 才能看到一帧完整的 producer/consumer 关系。
在这个例子中,Pass A 是 Lighting 的 producer,Pass B 是它的 consumer。因为 B 必须读取 A 写入的结果,图中需要一条从 A 指向 B 的 RAW(read after write)edge。同理,FilteredLighting 建立了 B 到 C 的 edge:
1 | Pass A ──Lighting──▶ Pass B ──FilteredLighting──▶ Pass C |
edge 表达的是必须满足的逻辑先后关系,而不是只依赖 CPU 循环恰好采用的调用顺序;在这种实现中,Pass 的声明顺序仍会参与判断某次读写属于哪个资源版本。除了 RAW,新的 writer 还需要考虑此前的 reader 和 writer,从而处理 WAR 与 WAW;本文只保留与 Render Graph 编译过程直接相关的结论。
依赖建立后,可以通过拓扑排序把 DAG 划分为 execution layer。在上面的线性关系中,A、B、C 分别位于连续的三个 layer。若另一个 Pass D 不依赖 A、B、C,它可以和同样没有前驱的 A 被放入第一层。同 layer 只表示依赖图没有要求它们互相等待:具体实现仍可能把命令顺序录制到同一个 command buffer,GPU 是否产生重叠还取决于命令类型、barrier、queue 和硬件调度。
因此,Compile 的结果不是“GPU 已经执行了一张图”,而是一份后续录制命令所需的计划:哪些 Pass 必须先执行、哪些资源连接 producer 与 consumer,以及每个资源从哪个 layer 存活到哪个 layer。依赖图解决逻辑排序,真正约束 GPU 执行和内存访问的工作还需要下一节的 Vulkan synchronization。
4. 从逻辑依赖到 Vulkan 同步
依赖图解决的是逻辑排序,但一条从 Pass A 指向 Pass B 的 edge 只是 CPU 端的数据结构。它不会自动出现在 command buffer 中,也不能单独保证 GPU 等待。Render Graph 在 Execute 阶段还需要把资源用途转换为 Vulkan synchronization 信息:
1 | Resource usage |
stage 选择 barrier 要约束哪些 GPU 工作,例如 compute shader 或 fragment shader;access 进一步选择这些 stage 中的哪类内存读写;layout 则描述 image 当前适合哪种访问方式。三者必须与资源的真实使用方式匹配:范围过窄可能遗漏 hazard,范围始终使用 ALL_COMMANDS 虽然更保守,却也可能阻塞无关工作。
假设 Pass A 通过 compute shader 写入一张 storage image,Pass B 随后在 fragment shader 中采样它。两次使用可以概括为:
| 使用者 | Stage | Access | Layout |
|---|---|---|---|
| Pass A:compute 写入 | COMPUTE_SHADER |
SHADER_WRITE |
GENERAL |
| Pass B:fragment 采样 | FRAGMENT_SHADER |
SHADER_READ |
SHADER_READ_ONLY_OPTIMAL |
在两次访问之间,image barrier 的 source scope 覆盖 A 的 compute write,destination scope 覆盖 B 的 fragment read。它同时承担三个相关但不同的职责:
- Execution dependency:B 中被约束的 fragment 工作不能早于 A 中被约束的 compute 工作完成。
- Memory dependency:A 的写入结果被设为 available,并对 B 的读取变得 visible,B 才能看到正确内容。
- Layout transition:image 从适合 storage 访问的
GENERAL转换到适合采样的SHADER_READ_ONLY_OPTIMAL。
1 | Compute shader writes image |
如果只有 CPU 录制顺序而没有正确的 dependency,前后命令的相关 pipeline stage 仍可能在 GPU 上重叠;即使后一个 shader 等到了前一个 shader 执行结束,没有合适的 memory dependency,它也不一定能够看见前一次写入。反过来,只做内存可见性而没有覆盖正确的 execution scope,同样无法解决并发访问。
vkCmdPipelineBarrier2 的调用本身仍然发生在 CPU 录制阶段。它只是把 barrier 命令写入 command buffer;等 command buffer 被提交后,GPU 执行到这个位置时,barrier 才真正约束前后的工作。也正因为如此,graph layer、CPU 循环顺序和 Vulkan barrier 分别解决不同层面的问题,不能互相替代。
上面的 image barrier 针对某个 image 及其 subresource。后文的 image aliasing 会引入另一种情况:前后访问属于两个不同的 VkImage,但它们共享同一块 allocation。此时除了新 image 自己的 layout transition,还需要覆盖共享内存访问的 memory barrier。
为了聚焦基本机制,本文后续采用按整张 image、单 graphics queue 跟踪状态的保守设计;它不是完整的 subresource 或多队列同步调度器。
5. 从生命周期推导 transient image aliasing
Render Graph 在 Compile 阶段已经知道一帧内所有 Pass 的资源声明,因此不仅可以推导依赖,还可以计算临时资源的生命周期。对于一张 transient image,可以用第一次和最后一次出现的 execution layer 表示它的使用区间:
1 | Resource A: [firstLayer = 1, lastLayer = 3] |
若 A 的最后使用层严格早于 B 的首次使用层:
1 | A.lastLayer < B.firstLayer |
那么图中不存在同时访问 A 和 B 的 Pass,它们就具备复用底层内存的时间条件。这里必须使用严格小于;若 A.lastLayer == B.firstLayer,两张 image 仍在同一个 layer 中存活,而同 layer 的 Pass 没有彼此等待的依赖,GPU 访问可能重叠。
共享的是 allocation,不是资源身份
Memory aliasing 指多个 Vulkan resource 绑定到重叠的 VkDeviceMemory 范围。A 和 B 仍然是两个逻辑资源,也分别拥有自己的 VkImage 和 VkImageView;它们只是在不同时间使用同一块 allocation:
1 | Logical A → VkImage A ─┐ A 的生命周期内使用 |
这种复用可能降低应用的 device-memory footprint,但不会自动带来更高帧率。Vulkan 允许更一般的 aliasing:只要 allocation 的 size、alignment 和 memory type 等要求能够同时满足,参数不同的资源也可能共享内存。VMA 的 resource aliasing 文档给出了这种计算方式。为了缩小实现和验证范围,本文采用更保守的规则,只组合 width、height、format、sample count 和 usage 都相同的 image。
旧内容必须视为无效
当 A 和 B 使用同一内存范围时,A 的写入会改变 B 背后的那些字节。除非满足 Vulkan 对一致解释 alias contents 的特殊条件,否则不能把 A 的结果当作 B 的初始内容。本文采用的保守方案不尝试跨 alias 保留数据,而是把 B 当作一张未初始化的新 image:它的第一次使用必须写入或清除完整的有效区域,不能先读取旧 allocation 中的内容。
因此,history、persistent 和 external image 不适合参与这种帧内 transient aliasing。history 需要下一帧继续读取旧内容;persistent resource 的生命周期跨越多帧;external resource 的内存和所有权也不由当前图独占管理。把它们加入 alias group 可能在仍有消费者时覆盖有效数据。Render Graph 可以检查 B 的首次声明不是读取,却无法仅凭 Write 声明证明 shader 一定覆盖所有后续会读取的像素;完整写入或清除仍然是 Pass 必须遵守的契约。
从 A 切换到 B 时需要同步
生命周期在逻辑图上不重叠,并不意味着 GPU 已经自动完成 A 的访问。Vulkan 规范要求:若对重叠内存范围的 alias 使用中至少有一次写入,两次使用之间必须存在覆盖相应 access types 的 memory dependency。Vulkan Memory Aliasing
一种直接的实现可以把切换拆成两个职责:
| Barrier | 作用 |
|---|---|
Global VkMemoryBarrier2 |
在前一个 alias 的实际 stage/access 与新 alias 的首次访问之间建立 execution/memory dependency。 |
B 的 VkImageMemoryBarrier2 |
以 oldLayout = UNDEFINED 抛弃旧内容,转换到 B 首次用途需要的 layout。 |
之所以不能只依赖 B 的 image barrier,是因为 A 和 B 是两个不同的 VkImage;B 自己的 layout transition 不能完整表达 A 对共享 allocation 的历史访问。反过来,global memory barrier 也不负责设置 B 的 image layout,所以两个职责缺一不可。
VkMemoryBarrier2 本身不引用某个 image 或 allocation,它会作用于其 stage/access scope 匹配的内存操作;这里通过精确记录前后 alias 的实际用途来缩小同步范围,而不是用资源 handle 将 barrier 限定到这块 allocation。
alias group 还可能在切换前被多个互不依赖的 Pass 使用。例如 A 同时被 compute shader 和 fragment shader 读取,CPU 后录制的 Pass 不一定在 GPU 上最后完成。实现需要累计 A 实际使用过的 stage/access,切换时用它们作为 source scope,再把 B 的首次 stage/access 作为 destination scope。这样 B 写入共享 allocation 之前,所有仍可能访问 A 的 GPU 工作都被纳入依赖。
最后还要区分 image object 和 allocation 的所有权。alias child 拥有自己的 VkImage 与 view,但不能释放共享 allocation;allocation owner 才负责最终释放显存。销毁时必须先销毁所有 child image,再释放 owner 所持有的 allocation,并确保 GPU 已经不再使用这些对象。