《游戏引擎实现原理》14.1 Pass 依赖与资源生命周期

帧图不是一张画出来的图,而是一组 Pass 与它们读写资源的声明。本节在 helio 里从零实现帧图的 IR:把「谁读什么、谁写什么」翻译成数据流依赖与三类 hazard,跑拓扑排序排出执行序,再算出每个资源的生命周期区间,为后面的内存规划与执行打底。

14.1 Pass 依赖与资源生命周期

到了第 14 章,前面十几节攒下的东西——RHI、资源、命令录制、剔除、批次、材质、光照、阴影——已经能画出一帧了。但它们是一句一句手写命令堆出来的:先录 shadow pass,再录 lighting pass,中间靠人脑记住「lighting 要用 shadowMap,所以 shadow pass 必须在前面」。Pass 一多,人脑就记不住,顺序写反、资源被覆盖、该用的没生成,全是这类错。

帧图(frame graph)要解决的就是这件事:让每个 Pass 只声明自己读什么、写什么,顺序和资源由框架推出来。本章分三节把它在 helio 上搭出来——14.1 做 IR 与依赖推导,14.2 讲 WebGPU 的约束差异,14.3 把帧图编译成可执行的计划。

本章与站内既有内容的分工

第 14 章最容易写成一篇「渲染图综述」。先划清边界:

既有文章它负责什么本节只做什么
/graphics-render-graph/渲染图的原理、资源抽象与自动屏障、内存别名算法、Unreal RDG / Unity SRP 案例只把「Pass 声明 → 依赖 → 顺序 → 生命周期」在 helio 上用 TS 从零实现并跑出结果
/graphics-gpu-driven-rendering-lod/GPU driven 的间接绘制与 LOD 选择不涉及,帧图只管 Pass 与资源的组织
/graphics-shader-compilation-pipeline/着色器编译与变体原理不涉及,着色器在本卷第 10–11 章已讲过

一句话:别人讲「渲染图为什么能自动排序、自动插屏障」,本章讲「在 helio 的 RHI 上怎么把这套东西搭出来、跑出什么」。

从零实现:帧图的 IR

帧图的输入是一组 Pass 节点,每个节点只有两件事:读哪些资源、写哪些资源。资源用字符串 ID 指代(shadowMap、hdr 之类),节点不关心它背后是纹理还是缓冲、多大、什么格式。先把这层 IR 和它的依赖推导写出来:

// file: src/render/framegraph/framegraph.ts
// tier: L1
// verify: npx vitest run src/render/framegraph/framegraph.test.ts
//
// helio 渲染卷 14.1:帧图的 IR、依赖推导与资源生命周期。
// 一个 Pass 只声明「读哪些资源、写哪些资源」。帧图据此做两件事:
//   1) dependencies():数据流真依赖(写者 → 读者),与声明顺序无关,
//      拓扑排序只认它;
//   2) hazards():按声明顺序分类出的三类 hazard(RAW / WAR / WAW),
//      用于屏障与别名推理。
// 再把每个资源的生命周期区间(首次使用 → 末次使用)算出来。全部在 CPU 侧。

export type PassId = string;
export type ResourceId = string;

/** 一个 Pass 节点:只声明「读写哪些资源」,不关心底层命令。 */
export interface PassNode {
  readonly name: PassId;
  readonly reads: readonly ResourceId[];
  readonly writes: readonly ResourceId[];
}

/** 三类数据依赖。RAW 是真依赖(写后读),WAR/WAW 是反依赖与输出依赖。 */
export type EdgeKind = 'RAW' | 'WAR' | 'WAW';

export interface Edge {
  readonly from: PassId;
  readonly to: PassId;
  readonly resource: ResourceId;
  readonly kind: EdgeKind;
}

/** 资源在拓扑序里的生命周期区间(下标都是拓扑序位置,闭区间)。 */
export interface Lifetime {
  readonly resource: ResourceId;
  /** 第一次被写的位置。 */
  readonly firstWrite: number;
  /** 第一次被读或被写的位置。 */
  readonly firstUse: number;
  /** 最后一次被读或被写的位置。 */
  readonly lastUse: number;
}

export class FrameGraphError extends Error {}

/** 帧图:按声明顺序收 Pass,再一次性推导依赖、顺序与生命周期。 */
export class FrameGraph {
  readonly #passes: PassNode[] = [];
  readonly #seen = new Set<PassId>();

  /** 声明一个 Pass。名字重复直接报错,避免依赖边指向歧义节点。 */
  addPass(node: PassNode): this {
    if (this.#seen.has(node.name)) {
      throw new FrameGraphError(`Pass 名字重复: ${node.name}`);
    }
    this.#seen.add(node.name);
    this.#passes.push(node);
    return this;
  }

  get passes(): readonly PassNode[] {
    return this.#passes;
  }

  /**
   * 数据流真依赖:对任意「A 写 R、B 读 R」的 Pass 对,生成一条 RAW 边 A→B,
   * 与二者谁先声明无关。这是拓扑排序唯一的输入。
   */
  dependencies(): Edge[] {
    const out: Edge[] = [];
    const seen = new Set<string>();
    for (const writer of this.#passes) {
      for (const reader of this.#passes) {
        if (writer.name === reader.name) continue;
        for (const res of intersect(writer.writes, reader.reads)) {
          const key = `${writer.name}->${reader.name}:${res}`;
          if (!seen.has(key)) {
            seen.add(key);
            out.push({ from: writer.name, to: reader.name, resource: res, kind: 'RAW' });
          }
        }
      }
    }
    return out;
  }

  /**
   * 按声明顺序(i < j)分类出的 hazard 列表:
   *   i 写、j 读同一资源 → RAW;i 读、j 写 → WAR;两边都写 → WAW。
   * 与 dependencies() 不同,它带方向语义(谁先谁后),供屏障/别名推理使用。
   */
  hazards(): Edge[] {
    const out: Edge[] = [];
    const n = this.#passes.length;
    for (let i = 0; i < n; i++) {
      for (let j = i + 1; j < n; j++) {
        const a = this.#passes[i]!;
        const b = this.#passes[j]!;
        for (const r of intersect(a.writes, b.reads)) out.push({ from: a.name, to: b.name, resource: r, kind: 'RAW' });
        for (const r of intersect(a.reads, b.writes)) out.push({ from: a.name, to: b.name, resource: r, kind: 'WAR' });
        for (const r of intersect(a.writes, b.writes)) out.push({ from: a.name, to: b.name, resource: r, kind: 'WAW' });
      }
    }
    return out;
  }

  /**
   * 拓扑序(Kahn 算法)。**只按真依赖(RAW)排序**:写者必须先于读者,
   * 即使读者声明在前。WAR/WAW 由声明序兜底,不参与排序。孤立 Pass 保持声明序。
   * 真依赖成环(两个 Pass 互相读写同一资源)时抛错——帧图不允许有环。
   */
  topologicalOrder(): PassId[] {
    const edges = this.dependencies();
    const indeg = new Map<PassId, number>();
    const adj = new Map<PassId, Set<PassId>>();
    for (const p of this.#passes) {
      indeg.set(p.name, 0);
      adj.set(p.name, new Set<PassId>());
    }
    for (const e of edges) {
      const set = adj.get(e.from)!;
      if (!set.has(e.to)) {
        set.add(e.to);
        indeg.set(e.to, (indeg.get(e.to) ?? 0) + 1);
      }
    }
    const queue: PassId[] = this.#passes.filter((p) => indeg.get(p.name) === 0).map((p) => p.name);
    const order: PassId[] = [];
    while (queue.length > 0) {
      const cur = queue.shift()!;
      order.push(cur);
      for (const next of adj.get(cur)!) {
        const d = (indeg.get(next) ?? 0) - 1;
        indeg.set(next, d);
        if (d === 0) queue.push(next);
      }
    }
    if (order.length !== this.#passes.length) {
      throw new FrameGraphError('帧图存在真依赖环,无法拓扑排序');
    }
    return order;
  }

  /**
   * 每个资源的生命周期。先取拓扑序,再把「资源 → 拓扑序下标」扫一遍。
   * 没被任何 Pass 触碰的资源不会出现。
   */
  lifetimes(): Lifetime[] {
    const order = this.topologicalOrder();
    const pos = new Map<PassId, number>();
    order.forEach((name, idx) => pos.set(name, idx));
    const acc = new Map<ResourceId, { firstWrite: number; firstUse: number; lastUse: number }>();
    const touch = (r: ResourceId, at: number, isWrite: boolean): void => {
      const cur = acc.get(r);
      if (cur === undefined) {
        acc.set(r, { firstWrite: isWrite ? at : Number.POSITIVE_INFINITY, firstUse: at, lastUse: at });
        return;
      }
      if (isWrite) cur.firstWrite = Math.min(cur.firstWrite, at);
      cur.firstUse = Math.min(cur.firstUse, at);
      cur.lastUse = Math.max(cur.lastUse, at);
    };
    for (const p of this.#passes) {
      const at = pos.get(p.name)!;
      for (const r of p.writes) touch(r, at, true);
      for (const r of p.reads) touch(r, at, false);
    }
    return [...acc.entries()].map(([resource, v]) => ({ resource, ...v }));
  }
}

/** 两个资源列表的交集(去重,保留 a 的顺序)。 */
function intersect(a: readonly ResourceId[], b: readonly ResourceId[]): ResourceId[] {
  const setB = new Set(b);
  const out: ResourceId[] = [];
  const seen = new Set<ResourceId>();
  for (const r of a) {
    if (setB.has(r) && !seen.has(r)) {
      seen.add(r);
      out.push(r);
    }
  }
  return out;
}

这里有个容易踩的设计点:拓扑排序只认真依赖(RAW)。如果把 WAR/WAW 也当排序约束,那么「消费者声明在生产之前」这种完全合法的写法(比如先写 tonemap 再写 scene)会被判成环——因为 tonemap 读 hdr、scene 写 hdr,两者互相制约。真依赖只看「谁生产、谁消费」,方向单一,不会有这种假环;WAR/WAW 交给声明序兜底即可。

一条典型帧图(阴影 → G-buffer → 光照 → 后处理 → 呈现)声明出来就是这样,每个 Pass 只写它「知道」的那几个资源:

shadow    writes=[shadowMap]
gbuffer   writes=[normal, albedo]
lighting  reads=[shadowMap, normal, albedo]   writes=[hdr]
post      reads=[hdr]                          writes=[ldr]
present   reads=[ldr]

注意 lighting 同时读三个上游资源——它的依赖边是三条,帧图要能把它们都排到 lighting 之前。这正是手写顺序容易漏的地方:少等一个 shadowMap,画面就是错的;而帧图不会漏。

可运行产物:依赖、顺序与生命周期

framegraph.ts 有 7 条单测护着:数据流依赖只出 RAW 边、hazard 分类出三类、拓扑序把写者排在读者前、互相读写同一资源报环、生命周期覆盖正确的区间等。跑法:

$ npx vitest run src/render/framegraph/framegraph.test.ts
 ✓ 数据流依赖只有 RAW,方向是写者到读者
 ✓ hazard 按声明顺序分成 RAW / WAR / WAW 三类
 ✓ 拓扑序把写者排在读者之前(即使读者先声明)
 ✓ 互相读写同一资源形成真依赖环时抛错
 ✓ Pass 名字重复直接拒绝
 ✓ 跨两个 Pass 的纹理生命周期覆盖两段
 ✓ 只写不读的资源末次使用等于首次写
 Test Files  1 passed (1)
      Tests  7 passed (7)

其中最有价值的一条是「拓扑序把写者排在读者之前」:输入按 tonemap, scene, present 的顺序声明,但 scene 才是 hdr 的生产者,编译器输出的执行序是 scene, tonemap, present——声明序与执行序被解耦了。这正是帧图存在的理由:写 Pass 的人不必记住先后,顺序是推出来的。

生命周期那条也直接可用:gbuffer pass 写 normal,light pass 读 normal,于是 normal 的生命周期是 [0, 1](拓扑序下标 0 到 1);hdr 由 light 写、post 读,生命周期 [1, 2]。这些区间就是下一节讨论内存复用的输入。

(本节所有数字均来自本机实跑,运行环境见附录 C。)

小结

到这里,帧图的第一层——IR 与依赖推导——已经能跑了:Pass 声明读写、数据流依赖、拓扑序、生命周期区间,全在 CPU 侧、纯 TS、可单测。它还不知道底层是 WebGPU 还是 Vulkan。但底层差异恰恰是帧图设计的关键:WebGPU 既没有 Vulkan 那套需要手动插入的屏障,也不允许瞬态资源别名。下一节就把这个约束差异写透。

阅读导航:上一节:回到目录 · 下一节:14.2 WebGPU 约束下的帧图 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「engine」更多文章

  1. 《游戏引擎实现原理》工程与实战 目录
  2. 《游戏引擎实现原理》9.3 选型与自研的判断标准
  3. 《游戏引擎实现原理》9.2 同一个特性在不同引擎里的实现路径