渲染图与帧调度架构:从 Pass 依赖到自动屏障与异步计算

渲染图(Render Graph)把一帧的渲染流程抽象成 Pass 与资源的依赖图,由框架自动推导屏障、管理瞬态资源生命周期、做内存别名复用并调度异步计算队列。本文从手工管线的痛点讲起,剖析资源抽象、依赖推导、屏障生成、瞬态内存别名与帧调度机制,并对比 Unreal RDG、Unity SRP 与 Frostbite FrameGraph 的落地差异。

一帧画面的诞生要经过几十甚至上百个渲染步骤:阴影贴图、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() 通常产出:

  1. 执行列表:拓扑排序后的 Pass 序列,附带可并行的分组;
  2. 屏障列表:每个 Pass 执行前需要的 vkCmdPipelineBarrier;
  3. 资源分配表:每个瞬态资源占用的显存偏移与别名关系。

理解这三个产物,就理解了渲染图的全部价值。

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 开销。成熟实现会做两步优化:

  1. 同 Pass 合并:同一 Pass 内对多个资源的转换合并进一次 vkCmdPipelineBarrier,用 pImageMemoryBarriers 数组一次性提交;
  2. 跨 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 分配算法

简化版流程:

  1. 收集所有 transient 资源及其 [firstUse, lastUse] 区间;
  2. 按 firstUse 排序,用贪心把当前资源塞进第一个能容纳它的已有堆块;
  3. 每个堆块的实际大小 = 块内所有资源需求的最大值(取最大尺寸与对齐);
  4. 为每个堆块创建一块 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 与后处理链的渲染器,瞬态资源原始需求与别名后对比:

资源尺寸格式单块占用生命周期
ShadowMap2048²D3216 MBPass 1–3
GBuffer Albedo1920×1080RGBA88 MBPass 4–6
GBuffer Normal1920×1080RGBA16F16 MBPass 4–6
GBuffer Depth1920×1080D328 MBPass 4–8
HDR Color1920×1080RGBA16F16 MBPass 7–9
Bloom Chain6 级金字塔RGBA16F21 MBPass 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 可以走异步计算队列,需同时满足:

  1. 只使用计算着色器(无光栅化、无渲染目标);
  2. 与图形队列上的相邻 Pass 无强依赖,或依赖可通过信号量解耦;
  3. 不与图形队列争抢同一个引擎(如都吃满 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 五个常见陷阱

  1. 把渲染图当成银弹:它解决正确性,不自动解决性能——一个 Pass 写成带宽灾难,图再漂亮也救不回;
  2. 过度并行:把所有计算 Pass 都丢异步队列,结果两条队列争抢带宽,总时间反而变长;
  3. 别名区跨帧复用:忘了标 transient = false,导致 TAA 历史被覆写,出现闪烁;
  4. 屏障粒度过粗:一律 ALL_COMMANDS,在 TBDR 移动 GPU 上会打断 tile 流水,代价极高;
  5. 忽略队列所有权:跨队列资源只做布局转换不做所有权转移,验证层可能不报,但真机上花屏。

小结

渲染图的价值不在「少写几行代码」,而在把三类正确性问题系统化解决:

  • 屏障正确性:用资源用途推导取代手工状态机,杜绝漏写与冗余;
  • 显存效率:用生命周期分析驱动内存别名,把显存峰值压到理论下界附近;
  • 调度效率:用依赖图识别并行机会,把异步计算队列真正用起来。

落地时的优先级建议是:先做屏障与资源跟踪,再做内存别名,最后做异步调度。前两步带来的是稳定性收益,第三步才需要更精细的性能调优。调试渲染图时,务必配合 计算机图形渲染管线理论 与 Vulkan 命令缓冲与同步 中的同步语义检查,才能确认框架生成的屏障既正确又最小。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「计算机图形学」更多文章

  1. 移动端渲染与功耗优化:TBDR、带宽与热节流治理
  2. SDF 与距离场渲染:从 Raymarching 到字体、阴影与碰撞
  3. 集群前向渲染与光源管理:Forward+、Tiled 与 Clustered 光照