一帧画面的诞生要经过几十甚至上百个渲染步骤:阴影贴图、G-Buffer、光照、透明排序、后处理链、UI 合成。每一步都读写若干纹理与缓冲,步与步之间既有顺序依赖,也有资源状态的转换(从渲染目标切到着色器读取)。在 Vulkan、D3D12 这类显式 API 里,这些状态转换必须由开发者手工写屏障,一处遗漏就是花屏或验证层报错,一处多余就是全管线停顿。
渲染图(Render Graph / Frame Graph)正是为治理这种复杂度而生的架构:开发者只声明「这个 Pass 读哪些资源、写哪些资源」,框架负责推导依赖顺序、插入正确的屏障、安排资源的内存、甚至在可能时把工作丢给异步计算队列并行执行。本文拆解这套机制从资源抽象到帧调度的完整链路。
一、渲染图解决什么问题
一句话:渲染图把「命令式地录制一长串绘制调用」变成「声明式地描述 Pass 与资源的依赖关系」,把屏障、内存与调度的正确性从人脑转移到框架。
1.1 手工管线的三大痛点
以传统 Vulkan 录制为例,一个后处理链的代码会长成这个样子:
// 手工录制:每一步都要自己管状态转换
vkCmdBeginRenderPass(cmd, &shadowRP, ...); // 写 shadowMap
vkCmdEndRenderPass(cmd);
transition(cmd, shadowMap, RENDER_TARGET, SHADER_READ); // 手写屏障 1
vkCmdBeginRenderPass(cmd, &gBufferRP, ...); // 写 albedo/normal/depth
vkCmdEndRenderPass(cmd);
transition(cmd, albedo, RENDER_TARGET, SHADER_READ); // 手写屏障 2
transition(cmd, normal, RENDER_TARGET, SHADER_READ); // 手写屏障 3
vkCmdBeginRenderPass(cmd, &lightingRP, ...); // 读 G-Buffer 写 HDR
vkCmdEndRenderPass(cmd);
问题集中在三处:
| 痛点 | 具体表现 | 后果 |
|---|---|---|
| 屏障手工维护 | 每个资源在每次用途切换时都要 transition | 漏写→花屏;多写→停顿 |
| 资源生命周期混乱 | 中间纹理何时创建、何时释放靠人记 | 显存峰值高、泄漏 |
| 并行机会丢失 | 哪两个 Pass 无依赖可并行靠人判断 | 异步计算队列闲置 |
1.2 渲染图的核心思想
渲染图把这套流程建模为一张有向无环图(DAG):节点是 Pass,边是资源依赖。开发者只写:
// 声明式:只说读什么、写什么
auto shadow = graph.createTexture("ShadowMap", shadowDesc);
auto gbuffer = graph.createGBuffer();
graph.addPass("ShadowPass")
.write(shadow)
.execute([](PassContext& ctx) { renderShadowMap(ctx); });
graph.addPass("LightingPass")
.read(shadow)
.read(gbuffer)
.write(hdrTarget)
.execute([](PassContext& ctx) { doLighting(ctx); });
框架在编译阶段做三件事:拓扑排序确定执行顺序、扫描资源用途生成屏障、分析每个资源的生命周期决定内存复用。
1.3 编译期的三个产物
一次 graph.compile() 通常产出:
- 执行列表:拓扑排序后的 Pass 序列,附带可并行的分组;
- 屏障列表:每个 Pass 执行前需要的
vkCmdPipelineBarrier; - 资源分配表:每个瞬态资源占用的显存偏移与别名关系。
理解这三个产物,就理解了渲染图的全部价值。
1.4 与命令缓冲的关系
需要强调:渲染图不替代命令缓冲,而是命令缓冲的生成器。渲染图的 Pass 在 execute 回调里仍然录制真实的 vkCmdDraw*,只是「录制」这一步被延迟到编译之后。它带来的额外好处是命令缓冲的分帧录制:可以把不相邻的 Pass 录到不同命令缓冲里,由任务线程并行录制,最后按依赖顺序提交,这与 Vulkan 命令缓冲的多线程录制模型天然契合。
二、资源抽象:瞬态与持久资源
一句话:资源分两类——跨帧存活的「持久资源」(Persistent)与仅一帧内有效的「瞬态资源」(Transient),前者由开发者管理,后者由渲染图分配与回收。
2.1 资源描述符
渲染图不直接持有 VkImage,而是持有一个轻量的描述符,记录用途、格式、尺寸与访问标记:
struct TextureDesc {
const char* name;
VkFormat format = VK_FORMAT_R16G16B16A16_SFLOAT;
uint32_t width, height, depth = 1;
uint32_t mipLevels = 1, arrayLayers = 1;
VkImageUsageFlags usage = VK_IMAGE_USAGE_SAMPLED_BIT
| VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT;
bool transient = true; // 关键:是否允许别名复用
};
transient = true 的资源不分配独立显存,而是从渲染图的瞬态内存池中按生命周期切片分配。
2.2 生命周期与首次/末次使用
框架通过扫描所有 Pass 的读写声明,计算每个资源的:
firstUse:第一次被读或被写的 Pass 序号;lastUse:最后一次被引用的 Pass 序号;lifetime = [firstUse, lastUse]。
只要两个资源的生命周期区间不重叠,它们就可以共享同一块显存——这就是**内存别名(Memory Aliasing)**的基础。
2.3 资源的读改写语义
一个 Pass 对资源的访问方式决定了屏障类型:
| 访问类型 | 声明方法 | 屏障需求 |
|---|---|---|
| 只读 | .read(tex) | 确保写者已完成(Read-After-Write) |
| 只写 | .write(tex) | 确保旧读者已退出(Write-After-Read) |
| 读写 | .readWrite(tex) | 双向依赖,最严格 |
| 保留内容 | .preserve(tex) | 仅做布局转换,不清空 |
2.4 句柄、引用计数与延迟创建
渲染图对外暴露的应是**句柄(Handle)**而非裸资源:
struct TextureHandle { uint32_t id; }; // 轻量,可随意拷贝
struct BufferHandle { uint32_t id; };
// 只有编译后句柄才解析为真实资源
VkImageView resolve(TextureHandle h) const { return m_textures[h.id].view; }
句柄的价值在于「声明期与执行期分离」:createTexture 只登记描述,真正的 vkCreateImage 推迟到编译期按实际用途合并。例如某纹理既作颜色附件又被采样,框架可在创建时一次性置好 usage 位,避免运行时重建。
2.5 导入外部资源
持久资源(交换链图像、上一帧的历史缓冲、外部导入的共享纹理)通过 import 接口进入渲染图:
auto backbuffer = graph.importTexture("Backbuffer", swapchainImage);
graph.addPass("Present")
.readWrite(backbuffer)
.execute(...);
导入的资源不参与别名,其生命周期完全由外部管理,渲染图只跟踪它的状态转换。
三、依赖推导与自动屏障
一句话:框架根据资源在相邻 Pass 间的访问方式推导依赖类型,再翻译成
VkImageMemoryBarrier,把开发者从手写状态机中解放出来。
3.1 三类数据依赖
对同一资源 R,若 Pass A 在 Pass B 之前访问 R,依赖类型按 A→B 的访问组合判定:
- RAW(Read-After-Write):A 写、B 读 → 最常见的生产者-消费者依赖;
- WAR(Write-After-Read):A 读、B 写 → 必须等 A 读完才能覆写;
- WAW(Write-After-Write):A 写、B 写 → 两次写之间的顺序。
Vulkan 的管线屏障用 srcStageMask/dstStageMask 表达「谁等谁」,用 srcAccessMask/dstAccessMask 表达「什么内存可见」。
3.2 从用途推导屏障
框架内部维护一张「资源当前状态表」,Pass 执行前对比目标状态与当前状态,生成最小屏障:
VkImageMemoryBarrier barrier{};
barrier.oldLayout = currentLayout; // 从状态表读出
barrier.newLayout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL;
barrier.srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED;
barrier.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED;
barrier.image = image;
barrier.subresourceRange = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 1, 0, 1};
barrier.srcAccessMask = VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT; // 上一用途
barrier.dstAccessMask = VK_ACCESS_SHADER_READ_BIT; // 当前用途
vkCmdPipelineBarrier(cmd,
VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT, // srcStage
VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT, // dstStage
0, 0, nullptr, 0, nullptr, 1, &barrier);
关键纪律:屏障要「刚好够」。把 srcStageMask 写成 ALL_COMMANDS 虽然安全,却会让整个管线空转,是移动端和低端 GPU 上的头号性能杀手。
3.3 状态跟踪表
一个健壮的实现会为每个资源维护:
ResourceState {
VkImageLayout layout; // 当前布局
VkAccessFlags access; // 上次访问类型
VkPipelineStageFlags stage; // 上次使用的管线阶段
uint32_t lastWritePass; // 最后写入的 Pass(用于 RAW)
}
每次 Pass 执行后更新这张表,下一次屏障就基于它做增量推导。这也是校验层(Validation Layer)判断「是否缺屏障」的依据。
3.4 屏障合并与批处理
逐 Pass 生成屏障会产生大量小屏障调用,每个 vkCmdPipelineBarrier 都有 CPU 开销。成熟实现会做两步优化:
- 同 Pass 合并:同一 Pass 内对多个资源的转换合并进一次
vkCmdPipelineBarrier,用pImageMemoryBarriers数组一次性提交; - 跨 Pass 前瞻:若某资源在两个 Pass 之间没有任何访问,可把两次转换合并为一次直达目标布局。
// 合并多个资源的屏障为一次调用
std::vector<VkImageMemoryBarrier> barriers;
for (auto& res : pass.transitions) barriers.push_back(makeBarrier(res));
vkCmdPipelineBarrier(cmd, srcStage, dstStage, 0,
0, nullptr, 0, nullptr,
(uint32_t)barriers.size(), barriers.data());
3.5 队列族所有权转移
当资源在不同队列族之间流转(如计算队列写的纹理要给图形队列读),除布局转换外还需所有权转移(Queue Family Ownership Transfer):源队列释放所有权(srcQueueFamilyIndex = computeFamily、dstQueueFamilyIndex = IGNORED),目标队列获取所有权(反向)。漏掉这一步,跨队列读写就是未定义行为。
四、内存别名与瞬态资源分配
一句话:内存别名让生命周期不重叠的瞬态纹理共享同一块显存,是大场景显存预算的关键杠杆。
4.1 别名原理
假设一帧内有三个瞬态纹理:
ShadowMap : Pass 1 → Pass 3
GBufferA : Pass 2 → Pass 4
BloomChain : Pass 5 → Pass 8
ShadowMap 与 BloomChain 的生命周期完全不重叠,可以占用同一块显存。渲染图在编译期做一次区间着色(Interval Coloring),得到最小堆叠高度。
4.2 分配算法
简化版流程:
- 收集所有
transient资源及其[firstUse, lastUse]区间; - 按
firstUse排序,用贪心把当前资源塞进第一个能容纳它的已有堆块; - 每个堆块的实际大小 = 块内所有资源需求的最大值(取最大尺寸与对齐);
- 为每个堆块创建一块
VkDeviceMemory,各资源用vkBindImageMemory绑定到不同偏移。
// 伪代码:贪心堆叠
std::vector<Heap> heaps;
for (auto& res : sortedByFirstUse) {
Heap* target = nullptr;
for (auto& h : heaps) {
if (h.freeAt <= res.firstUse && h.size >= res.size) { target = &h; break; }
}
if (!target) { heaps.push_back(Heap{res.size, res.lastUse}); }
else { target->freeAt = res.lastUse; }
}
4.3 别名的限制与代价
| 限制 | 原因 | 规避 |
|---|---|---|
| 同一别名区不能跨帧存活 | 显存会被下一帧覆写 | 跨帧资源标 transient = false |
需要 VK_IMAGE_CREATE_ALIAS_BIT | 驱动需知道存在别名 | 创建时显式置位 |
| 布局必须一致或显式转换 | 别名的两资源布局独立 | 切换时插入完整屏障 |
| 调试困难 | 同一地址两个名字 | 帧捕获工具中按名字过滤 |
4.4 显存预算实测
一个 1080p、含完整 G-Buffer 与后处理链的渲染器,瞬态资源原始需求与别名后对比:
| 资源 | 尺寸 | 格式 | 单块占用 | 生命周期 |
|---|---|---|---|---|
| ShadowMap | 2048² | D32 | 16 MB | Pass 1–3 |
| GBuffer Albedo | 1920×1080 | RGBA8 | 8 MB | Pass 4–6 |
| GBuffer Normal | 1920×1080 | RGBA16F | 16 MB | Pass 4–6 |
| GBuffer Depth | 1920×1080 | D32 | 8 MB | Pass 4–8 |
| HDR Color | 1920×1080 | RGBA16F | 16 MB | Pass 7–9 |
| Bloom Chain | 6 级金字塔 | RGBA16F | 21 MB | Pass 10–13 |
原始总和约 85 MB;别名后按区间着色压到约 3 块堆(最大块 ~21 MB),峰值降到 ~40 MB,节省近一半。移动端 tile 显存更紧张时,这个比例只会更显著。相关纹理压缩与格式选型可参考 纹理采样与 GPU 内存优化 。
五、帧调度与异步计算
一句话:依赖图一旦确定,渲染图就能识别出互不依赖的 Pass,把它们分派到图形队列、计算队列、拷贝队列上并行执行。
5.1 队列拓扑
现代 GPU 通常提供三类队列族:
- Graphics Queue:绘制 + 光栅;
- Compute Queue:纯计算(可独立于图形队列推进);
- Transfer Queue:DMA 拷贝。
渲染图会为每个 Pass 标注目标队列族,然后按依赖关系生成队列间信号量。
5.2 异步计算的分派条件
一个 Pass 可以走异步计算队列,需同时满足:
- 只使用计算着色器(无光栅化、无渲染目标);
- 与图形队列上的相邻 Pass 无强依赖,或依赖可通过信号量解耦;
- 不与图形队列争抢同一个引擎(如都吃满 ROP)。
典型受益场景:SSAO、屏幕空间反射、Bloom 降采样、粒子模拟、后处理降噪。
5.3 时序示意
图形队列: [Shadow]──[GBuffer]──[Lighting]──────[ToneMap][UI]
计算队列: └──[SSAO]──┘ └──[Bloom]──┘
▲ ▲
semaphore semaphore
渲染图需要为每条跨队列的依赖插入 VkSemaphore,并在提交时用 vkQueueSubmit 的 pWaitSemaphores/pSignalSemaphores 表达。调度器还会做带宽预算:如果两个 Pass 都把显存带宽吃满,强行并行反而更慢,此时应串行。
5.4 与任务系统的衔接
帧级调度之上还有 CPU 侧的任务调度:Pass 的录制、资源上传、PSO 编译都可以丢给工作线程。这部分与引擎的 Job System 直接对接,可参考 任务调度器设计 中的工程实践。
5.5 多帧并行与资源环
为了让 CPU 不空等 GPU,引擎通常维持 2~3 帧「在飞(in flight)」。这意味着每帧的瞬态资源与同步对象都要有帧环(Frame Ring):
constexpr int kFramesInFlight = 2;
struct FrameContext {
VkCommandPool pool;
VkFence fence;
VkSemaphore imageAvailable, renderFinished;
};
FrameContext frames[kFramesInFlight];
// 每帧轮转:CPU 录制第 N 帧时,GPU 可能仍在执行第 N-1 帧
渲染图必须显式建模这个环:跨帧存活的资源(历史深度、TAA 历史色、时序累积缓冲)要按帧号分配 N 份,否则第 N 帧会覆写第 N-1 帧仍在读的数据。
六、引擎落地案例
一句话:三大引擎的渲染图实现各有取舍——Unreal 偏运行时灵活,Unity 偏可组合性,Frostbite 是学术意义上的 FrameGraph 原型。
6.1 Unreal RDG(Render Dependency Graph)
RDG 在运行时构建图,资源用 FRDGTextureRef 句柄表示,Pass 用 FRDGBuilder::AddPass 注册。特点是即时模式与延迟执行混合:图在 GraphBuilder.Execute() 时统一编译与提交,支持异步计算与 RHI 抽象层。
6.2 Unity SRP RenderGraph
Unity 的 SRP(Scriptable Render Pipeline)在 URP/HDRP 中引入 RenderGraph,资源通过 RenderGraph.CreateTexture 创建,Pass 用 AddRenderPasses 注册,并支持 AllowPassCulling 与 AllowGlobalStateModification 等开关。它的优势是可组合的 Renderer Feature:第三方扩展可以声明式地插入自己的 Pass。
6.3 Frostbite FrameGraph
Frostbite 在 SIGGRAPH 上发表的 FrameGraph 是这一模式的学术原型,强调「图即数据」:所有资源与 Pass 都是纯数据,编译期完成全部屏障与内存决策,运行时只是遍历执行列表。它的瞬态内存别名策略被后续实现广泛借鉴。
6.4 选型建议
| 场景 | 建议 |
|---|---|
| 自研引擎、追求极致控制 | 自建轻量渲染图,只做屏障 + 别名 |
| 已有大量命令式代码 | 渐进式迁移:先包一层资源跟踪,再引入图 |
| 移动端、带宽敏感 | 必须启用瞬态别名,且禁用过度并行的异步计算 |
| 需要第三方扩展 | 选支持可组合 Renderer Feature 的框架(如 Unity SRP) |
若引擎还需异构卸载(如把部分 Pass 交给 CPU 或专用硬件),可结合 GPU 异构卸载编译技术 的思路设计卸载边界。
6.5 五个常见陷阱
- 把渲染图当成银弹:它解决正确性,不自动解决性能——一个 Pass 写成带宽灾难,图再漂亮也救不回;
- 过度并行:把所有计算 Pass 都丢异步队列,结果两条队列争抢带宽,总时间反而变长;
- 别名区跨帧复用:忘了标
transient = false,导致 TAA 历史被覆写,出现闪烁; - 屏障粒度过粗:一律
ALL_COMMANDS,在 TBDR 移动 GPU 上会打断 tile 流水,代价极高; - 忽略队列所有权:跨队列资源只做布局转换不做所有权转移,验证层可能不报,但真机上花屏。
小结
渲染图的价值不在「少写几行代码」,而在把三类正确性问题系统化解决:
- 屏障正确性:用资源用途推导取代手工状态机,杜绝漏写与冗余;
- 显存效率:用生命周期分析驱动内存别名,把显存峰值压到理论下界附近;
- 调度效率:用依赖图识别并行机会,把异步计算队列真正用起来。
落地时的优先级建议是:先做屏障与资源跟踪,再做内存别名,最后做异步调度。前两步带来的是稳定性收益,第三步才需要更精细的性能调优。调试渲染图时,务必配合 计算机图形渲染管线理论 与 Vulkan 命令缓冲与同步 中的同步语义检查,才能确认框架生成的屏障既正确又最小。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。