RenderPass与RenderGraph
需要预先了解:
1. RenderPass与RenderGraph
一帧画面不是"把物体画上去"一句话能概括的:先从光源视角渲染阴影贴图,再画不透明物体,然后天空盒、透明物体,最后后处理。每一步都是一个RenderPass(渲染工序);这些工序之间有依赖——透明混合必须等不透明物体和天空都画完才能开始。手动维护这个顺序,Pass 一多就容易错。
IDGameEngine 的做法是把这些工序和它们的依赖组织成一张图:RenderGraph(渲染图)。Pass 声明自己读写哪些资源,图负责推导依赖、排出执行顺序、剔除无用的 Pass。这篇笔记梳理这套机制的实现。
1.1 RenderPass:渲染工序的抽象
RenderPass 是一个抽象基类,子类只需要关心两件事:
class RenderPass
{
public:
RenderPass(const std::string& name = "RenderPass");
// 声明阶段:声明本 Pass 读/写哪些资源(add_pass 时由 RenderGraph 调用)
// 禁止在此做任何 GPU 操作——只做依赖声明
virtual void setup(RenderPassBuilder& builder) { }
// 执行阶段:子类必须实现,完成一次渲染
virtual void execute(RenderContext& ctx) = 0;
virtual void on_begin_frame() { }
virtual void on_end_frame() { }
};
setup 和 execute 的分离是整套设计的核心:声明期只记录"我需要什么、我产出什么",执行期才真正碰 GPU。声明期收集的信息,是 RenderGraph 排序和剔除的全部依据。
基类还提供三个静态工具函数,供各 Pass 复用:
| 函数 | 职责 |
|---|---|
draw_batch |
画一个批次:绑定材质 → 帧级 uniform → 阴影 → 物体级 uniform → draw |
set_frame_uniforms |
帧级 uniform:view / proj / camera_pos / ambient / time / 光源数组 |
set_object_uniforms |
物体级 uniform:u_mvp / u_model |
1.2 RenderContext:帧级共享数据
RenderContext 是传给每个 Pass 的执行上下文,一帧构造一次,全体 Pass 共享:
struct RenderContext
{
const Camera& camera; // 相机
uint32_t window_width, window_height;
float time; // 时间(shader 动效用)
std::vector<ModelSE>& opaque_batches; // 不透明批次(已排序)
std::vector<ModelSE>& transparent_batches; // 透明批次(已排序)
std::vector<LightSE>& lights; // 光源列表
RendererStatistics* statistics; // 帧统计(可空)
FrameBufferID gbuffer_fb; // G-Buffer(延迟路径)
FrameBufferID scene_fb; // HDR 场景颜色
FrameBufferID viewport_fb; // 最终显示目标
FrameBufferID shadow_fb; // 阴影贴图 FBO
TextureID shadow_depth_array; // 阴影深度 array 纹理
SamplerID shadow_sampler_raw; // raw 采样器(blocker search 用)
SamplerID shadow_sampler_cmp; // cmp 采样器(硬件 PCF)
UniformBufferID shadow_ubo; // ShadowBlock UBO
bool shadow_enabled; // 本帧是否启用阴影
// CSM 级联数据:层数、各层光源 VP、各层远边界、各层 bias 缩放
uint32_t cascade_count;
Mat4 light_view_projs[MAX_CASCADES];
// ...
};
其中三个 FBO 构成了整条渲染链的"中转站":几何渲染写进 scene_fb(RGBA16F,HDR),后处理读它、写 viewport_fb(RGBA8,供窗口显示);gbuffer_fb 只在延迟路径使用。
注意 ShadowPass 相关字段的模式:生产者写、消费者读。ShadowPass 执行时把阴影贴图句柄、采样器、UBO 全部写进 ctx,后面的 ForwardPass / LightingPass 直接从 ctx 取用——Pass 之间不直接引用,全靠这份共享上下文传递。
1.3 RenderGraph:节点与边
RenderGraph 的定义一句话就能说完:渲染图 = 节点(Pass)+ 边(资源依赖)。Pass 在 setup 里声明读/写哪些资源,边由图按规则自动推导,执行顺序由编译时的拓扑排序决定。
语义资源槽位
目前资源用一组语义槽位表达,与 RenderContext 的字段一一对应:
enum class RGResourceName : uint8_t
{
ShadowMap, // ctx.shadow_fb(ShadowPass 写入)
GBuffer, // ctx.gbuffer_fb(GBufferPass 写入,LightingPass 读取)
SceneColor, // ctx.scene_fb(Forward/Lighting 写入,Skybox/Transparent 读改写)
ViewportTarget, // ctx.viewport_fb(PostProcess 写入,最终呈现目标)
Count
};
比起"任意命名瞬态资源"的完整 RenderGraph(TODO 里提到了),这套设计轻量得多——但依赖推导、剔除、拓扑排序的核心逻辑是完全一样的。
版本句柄 RGHandle
每个槽位维护一条版本时间线:每次有 Pass 写入,版本号 +1。声明读取时记录的是"当前版本",写入时记录"当前版本 + 1":
struct RGHandle
{
uint32_t slot = 0xFFFFFFFF; // RGResourceName 槽位
uint32_t version = 0; // 版本号(SSA 式)
};
这套 SSA(Static Single Assignment,静态单赋值)式的版本追踪精确表达了"你读的是哪一版":两个 Pass 都写 SceneColor,后面读的那个依赖的是后写的那版,边就指向后写的 Pass。
声明 API
Pass 在 setup 里通过 RenderPassBuilder 声明依赖:
| API | 语义 | 生成的边 |
|---|---|---|
reads(res) |
只读 | RAW:依赖当前版本写入者 |
writes(res) |
只写,版本 +1 | WAW(依赖前一版写入者)+ WAR(依赖旧版读者) |
read_writes(res) |
读改写(= reads + writes) | 上述两者之和 |
after(pass) |
显式顺序(逃生舱) | Explicit |
requires_pass<T>() |
前置依赖 Pass,缺失时自动补加 | —— |
一句话概括声明语义:想让别人读到你的输出,就必须在别人之前声明——声明时刻决定读到哪个版本。
requires_pass 参考"前置组件"的模式:如果图里还没加被依赖的 Pass,会自动用默认构造补加一个(物理上插到当前 Pass 之前,保证版本线正确),并留日志提示。它只用于硬依赖——比如 LightingPass 依赖 GBufferPass,缺了光照无从谈起;可选增强(Forward 之于 Shadow)不能声明,否则"关闭阴影"这类开关语义就失效了。
1.4 依赖边的推导
add_pass 时图会立即调用 Pass 的 setup 收集声明,然后按三条规则建边(derive_edges):
读资源(RAW):
- 依赖该资源当前版本的写入者——写的人必须先跑;
- 还要对之前的读者加保序边(Order)——虽然读不改数据,但保持声明序可以让画面行为稳定。
写资源(WAW + WAR):
- WAW:依赖前一版本的写入者——后来的写必须排在前面;
- WAR:依赖旧版本的所有读者——不能覆盖别人正在读的数据。
每条边带类型标注(RGEdgeType:RAW / WAW / WAR / Order / Explicit),导出 GraphViz 时会画在边上,调试时一眼能看出"这条边为什么存在"。
声明完成后,更新资源时间线:写者接管新版本、读者列表清空后登记新读者。整个过程只动内存里的图结构,没有任何 GPU 调用。
1.5 compile:拓扑排序与剔除
结构稳定后调用 compile(),做四件事:
1. Kahn 拓扑排序
经典算法:入度为 0 的节点进就绪集合,每次取一个输出到执行序,并把它指向的节点入度减一。这里就绪集合用的是 std::set——每次取注册序最小的就绪节点,保证调度是确定性的:同一份装配,永远得到同一份执行序。
2. 环检测
如果排序结束时还有节点没输出(入度不为 0),说明存在依赖环。此时清空执行序、打错误日志(列出环上的 Pass 名)、拒绝执行,保留上一帧画面——宁可画面冻结也不能乱序渲染。m_dirty 同时置 false,避免每帧重复刷同一条错误。
3. 死 Pass 剔除
从最终输出(ViewportTarget 的写入者,即 PostProcessPass)出发,沿前驱边反向做可达性分析。走不到的 Pass 被标记 culled,不再执行:
- 典型例子:图里只有 ShadowPass(比如误装配),它的输出没有任何人读、也不通向最终输出——剔除,日志里标注"输出未被引用";
- 显式
after()边也算前驱,所以"after 链可达的副作用节点"会被自动保留。
没有 Pass 写 ViewportTarget 时保守跳过剔除(未声明输出的装配不剔除任何 Pass)。
4. 悬空依赖警告
遍历未剔除 Pass 的读声明:读的槽位如果没有任何写入者(比如图里没有 ShadowPass,却有 Pass 读 ShadowMap),输出警告。ForwardPass 读 ShadowMap 而阴影开关关闭时,这条警告就是预期的第二道保险——提醒你"你声明了读阴影,但这帧没人生产它"。
编译产物固化成 m_execution_order(Pass 名列表)并输出一条编译日志,形如:
RenderGraph: 编译完成,执行序(5 个 Pass):[0.ShadowPass -> 1.ForwardPass -> 2.SkyboxPass -> 3.TransparentPass -> 4.PostProcessPass]
1.6 execute:按序执行
void RenderGraph::execute(RenderContext& ctx)
{
if (m_dirty && !compile()) // 结构有变动时先编译;失败(环等)则拒绝执行
return;
for (uint32_t idx : m_order) // 拓扑序
{
RGPassNode& node = m_nodes[idx];
if (node.culled) continue;
node.pass->on_begin_frame();
node.pass->execute(ctx);
node.pass->on_end_frame();
}
}
执行本身很薄:编译已经把顺序、剔除都定好了,这里只是按序调 execute。m_dirty 机制保证结构不变时跳过重复编译——装配是低频操作(初始化、切换渲染路径),每帧编译就太浪费了。
另外 add_pass 有个细节:同类型 Pass 唯一。往图里重复 add 同一类型的 Pass 会直接复用已有实例并返回引用(参考组件的单例模式),这也让"前置依赖自动补加的实例"可以被后续显式 add 拿到引用去做配置。
1.7 引擎中的装配
引擎本体通过 rebuild_pipeline 按当前配置装配整张图:
void rebuild_pipeline(bool shadow, bool skybox, bool post_process)
{
RenderGraph& graph = Renderer::get_render_graph();
graph.clear();
if (shadow)
graph.add_pass<ShadowPass>();
if (render_path_state() == RenderPath::Deferred)
{
graph.add_pass<GBufferPass>(); // 几何 → G-Buffer
graph.add_pass<LightingPass>(Vec3(1.0f, 1.0f, 1.0f)); // 光照 → scene_fb
}
else
{
ForwardPass& forward = graph.add_pass<ForwardPass>();
forward.set_use_scene_fb(true); // 渲染到场景 HDR FBO
forward.set_render_transparent(!skybox); // 有天空盒时透明拆到 TransparentPass
}
if (skybox)
{
graph.add_pass<SkyboxPass>();
graph.add_pass<TransparentPass>();
}
if (post_process)
graph.add_pass<PostProcessPass>(); // 输出 → viewport_fb
graph.compile(); // 装配期立即验证 + 编译日志
}
set_visual_pipeline(shadow, skybox, post_process) 和 set_render_path(Forward/Deferred) 共用这个入口:改配置 → clear → 重新 add → 立即 compile 验证。热插拔 = 清空重建。
当前引擎里全部 Pass 的依赖声明一览:
| Pass | 读 | 写 | 职责 |
|---|---|---|---|
| ShadowPass | —— | ShadowMap | 光源视角渲染级联深度 |
| ForwardPass | ShadowMap | SceneColor | 前向:不透明 +(可选)透明批次 |
| GBufferPass | —— | GBuffer | 延迟几何阶段:MRT 写 G-Buffer |
| LightingPass | GBuffer, ShadowMap | SceneColor | 延迟光照阶段:全屏重组光照 |
| SkyboxPass | SceneColor(读改写) | SceneColor | 深度 LessEqual 填充背景 |
| TransparentPass | SceneColor(读改写), ShadowMap | SceneColor | 透明批次混合 |
| PostProcessPass | SceneColor | ViewportTarget | Bloom / ToneMapping / Gamma |
对照这张表可以手动验证拓扑排序的结果:ShadowPass 与 GBufferPass 互不相干(一个只写 ShadowMap、一个只写 GBuffer),理论顺序不唯一,但因为 Kahn 取注册序最小的就绪节点,两者必然按 add 顺序执行。
注:LightingPass 里还调用了
builder.requires_pass<GBufferPass>()——即使装配代码漏加了 GBufferPass,图也会自动补加,这就是前置依赖机制的实际用例。
1.8 调试支持
RenderGraph 内建两套可视化输出:
export_graphviz:导出 DOT 格式,节点标名、边标类型(RAW/WAW/…),被剔除的节点灰显虚线,拿去 GraphViz 画出来就是完整的依赖图;build_view:导出一份只读快照(RGGraphView,含全部节点、边、执行序),引擎的 DevGUI 里有个节点编辑器(RenderGraphEditorWidget)直接消费它,编辑器里能看到每个 Pass 的读写槽位和最终执行序。
对一个渲染架构来说,"能把依赖关系画出来"和"依赖正确"几乎同等重要——配错了边,排查起来比业务 bug 隐蔽得多。
2. 小结
RenderPass 与 RenderGraph 的分工可以概括为:
- RenderPass 负责"一道工序怎么做":setup 声明依赖,execute 执行渲染,共享数据走 RenderContext;
- RenderGraph 负责"工序之间怎么排":版本化资源时间线 → RAW/WAW/WAR 自动建边 → Kahn 确定性拓扑排序 → 死 Pass 剔除 → 悬空警告。
装配代码因此变得非常直白:想要什么工序就 add 什么 Pass,顺序交给图去推导。后面的光照、阴影、后处理笔记,都会回到这张图里看各 Pass 的具体实现。



