需要预先了解:


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 的具体实现。