《游戏引擎实现原理》9.2 状态缓存与冗余剔除

写一个后端无关的状态缓存,记住当前管线、绑定组、顶点与索引缓冲,只在真正变化时下发;在 20 万次绘制的乱序与排序两种流上跑出「应用 / 跳过」两路计数,看排序能把管线切换从 199157 次砍到 512 次、又砍不掉什么,为下一节的实例化合批埋下引子。

9.2 状态缓存与冗余剔除

9.1 把绘制列表排成了「同状态的项挨在一起」。但挨在一起只是必要条件,不是充分条件——如果命令编码器每次都把管线、绑定组、缓冲无脑重设一遍,排序带来的局部性就白费了。这一节写一个状态缓存:它记住当前状态,只在值真的变化时才下发,并统计「应用」与「跳过」两路计数。

本节产出一个完整可跑的 src/render/sort/state-cache.ts(L1,Node 直跑)。文中所有计数与耗时都来自本机实跑,数值不可移植。

后端为什么怕「重复设置状态」

在 WebGPU 里,setPipeline 会触发一次真正的管线切换:驱动要校验兼容性、可能重编内部状态、刷新常量缓存。在 WebGL2 里更糟——每个 useProgram、bindBuffer、bindVertexArray 都是一次跨边界的胶水调用,浏览器还要把它翻译成驱动命令。两边的共同点是:设置一个和当前完全相同的状态,是纯浪费。

这正是状态缓存要消灭的东西。它的逻辑简单到近乎笨:保存一份「当前状态」,每个 setter 先比较、相同就返回 false(跳过)、不同就写入并返回 true(应用)。整个缓存的复杂度不来自算法,而来自它必须和后端的状态语义严丝合缝:少记一个状态,画面就会错;多记一个,缓存就会假跳。所以本节把「每个 render pass 开始时必须 reset()」写死进注释——WebGPU 的 pass 不跨 pass 保留状态,WebGL2 靠一次隐式重置,两边都要求「进 pass 先当空」。

绑定组用一张 Map<number, number> 而不是单个字段,是因为绑定组是按槽位绑的:槽 0 换了不代表槽 1 也换。缓存必须逐槽记录,否则「槽 0 设了 A、槽 1 设了 A、下一项槽 0 又要设 A」会被错判成冗余。管线、顶点缓冲、索引缓冲则是「当前唯一」的,各用一个字段就够。这四种状态的粒度差异,正是缓存实现里唯一需要动脑的地方。

本节的缓存刻意做成「后端无关」的:它不 import 任何 WebGPU 或 WebGL2 类型,只维护 id 与计数。真实后端把它包在自己的 setPipeline 里——命中就 return,未命中才调 pass.setPipeline(...)。这样同一份去重逻辑能被三个后端共用,也才能在 Node 里脱离 GPU 单独测。

可运行产物:状态缓存与两路计数

下面是完整的 src/render/sort/state-cache.ts。它定义一个 DrawCmd(管线 + 材质绑定组 + 顶点/索引缓冲),一个记住当前状态的 StateCache,以及一个把绘制流跑一遍、返回统计的驱动函数。

// file: src/render/sort/state-cache.ts
// tier: L1
// verify: node src/render/sort/state-cache.ts
//
// helio 渲染卷 9.2:状态缓存与冗余剔除。
// 渲染后端(尤其 WebGL2)每次「设置状态」都要过一遍校验与命令重放;
// 若连续两次设置的值相同,第二次就是纯冗余。StateCache 记住当前状态,
// 只在真正变化时才下发,并统计「应用 / 跳过」两路计数。
// 纯计算(L1),Node 26 直跑:node src/render/sort/state-cache.ts
import { SortMode, packKey, radixSortKeys, makeItems } from './sort-key.ts';
import type { RenderItem } from './sort-key.ts';

/** 一次绘制需要的后端状态:管线 + 材质绑定组 + 顶点/索引缓冲。 */
export type DrawCmd = {
  readonly pipelineId: number;
  readonly bindGroupId: number;
  readonly vertexBufferId: number;
  readonly indexBufferId: number;
};

export type CacheStats = {
  pipeline: { applied: number; skipped: number };
  bindGroup: { applied: number; skipped: number };
  vertexBuffer: { applied: number; skipped: number };
  indexBuffer: { applied: number; skipped: number };
  totalApplied: number;
  totalSkipped: number;
};

type Counter = { applied: number; skipped: number };
const newCounter = (): Counter => ({ applied: 0, skipped: 0 });

/**
 * 后端无关的状态缓存。每个 render pass 开始时必须 reset()——WebGPU 的
 * render pass 不跨 pass 保留状态,WebGL2 则靠一次 draw 后的隐式重置,
 * 两边的语义都要求「进 pass 先当空」。
 */
export class StateCache {
  private pipeline = -1;
  private readonly bindGroups = new Map<number, number>();
  private vertexBuffer = -1;
  private indexBuffer = -1;
  readonly stats: CacheStats = {
    pipeline: newCounter(),
    bindGroup: newCounter(),
    vertexBuffer: newCounter(),
    indexBuffer: newCounter(),
    totalApplied: 0,
    totalSkipped: 0,
  };

  reset(): void {
    this.pipeline = -1;
    this.bindGroups.clear();
    this.vertexBuffer = -1;
    this.indexBuffer = -1;
  }

  setPipeline(id: number): boolean {
    if (this.pipeline === id) {
      this.stats.pipeline.skipped++;
      this.stats.totalSkipped++;
      return false;
    }
    this.pipeline = id;
    this.stats.pipeline.applied++;
    this.stats.totalApplied++;
    return true;
  }

  setBindGroup(slot: number, id: number): boolean {
    if (this.bindGroups.get(slot) === id) {
      this.stats.bindGroup.skipped++;
      this.stats.totalSkipped++;
      return false;
    }
    this.bindGroups.set(slot, id);
    this.stats.bindGroup.applied++;
    this.stats.totalApplied++;
    return true;
  }

  setVertexBuffer(id: number): boolean {
    if (this.vertexBuffer === id) {
      this.stats.vertexBuffer.skipped++;
      this.stats.totalSkipped++;
      return false;
    }
    this.vertexBuffer = id;
    this.stats.vertexBuffer.applied++;
    this.stats.totalApplied++;
    return true;
  }

  setIndexBuffer(id: number): boolean {
    if (this.indexBuffer === id) {
      this.stats.indexBuffer.skipped++;
      this.stats.totalSkipped++;
      return false;
    }
    this.indexBuffer = id;
    this.stats.indexBuffer.applied++;
    this.stats.totalApplied++;
    return true;
  }

  /** 下发一条绘制命令的完整状态,返回本次真正应用了几项。 */
  apply(cmd: DrawCmd): number {
    let applied = 0;
    if (this.setPipeline(cmd.pipelineId)) applied++;
    if (this.setBindGroup(0, cmd.bindGroupId)) applied++;
    if (this.setVertexBuffer(cmd.vertexBufferId)) applied++;
    if (this.setIndexBuffer(cmd.indexBufferId)) applied++;
    return applied;
  }
}

/** 把渲染项翻译成绘制命令:材质→绑定组 0,网格→顶点/索引缓冲。 */
export function toDrawCmd(it: RenderItem): DrawCmd {
  return {
    pipelineId: it.pipelineId,
    bindGroupId: it.materialId,
    vertexBufferId: it.meshId,
    indexBufferId: it.meshId,
  };
}

/** 按给定下标顺序把绘制命令重排成连续数组。 */
export function reorder(cmds: readonly DrawCmd[], order: Uint32Array): DrawCmd[] {
  const out: DrawCmd[] = new Array(order.length);
  for (let i = 0; i < order.length; i++) out[i] = cmds[order[i]!]!;
  return out;
}

/** 顺序跑一遍状态缓存,返回统计。 */
export function runStream(cmds: readonly DrawCmd[]): CacheStats {
  const cache = new StateCache();
  for (let i = 0; i < cmds.length; i++) cache.apply(cmds[i]!);
  return cache.stats;
}

if ((import.meta as { main?: boolean }).main) {
  const N = 200000;
  const items = makeItems(N, 11);
  const cmds: DrawCmd[] = items.map(toDrawCmd);

  // 未排序:pipeline 在每一项之间乱跳,缓存几乎每次都「应用」。
  const unsortedOrder = new Uint32Array(N);
  for (let i = 0; i < N; i++) unsortedOrder[i] = i;
  const unsortedCmds = reorder(cmds, unsortedOrder);

  // 已排序:按 32 位键基数排序后,重新分配成堆上连续的数组——
  // 直接重排原对象会让指针乱跳、测出的是缓存缺失而非状态缓存的开销。
  const keys = Uint32Array.from(items, packKey);
  const sortedOrder = radixSortKeys(keys, N);
  const sortedCmds: DrawCmd[] = new Array(N);
  for (let i = 0; i < N; i++) {
    const c = cmds[sortedOrder[i]!]!;
    sortedCmds[i] = {
      pipelineId: c.pipelineId, bindGroupId: c.bindGroupId,
      vertexBufferId: c.vertexBufferId, indexBufferId: c.indexBufferId,
    };
  }

  const time = (f: () => CacheStats): { stats: CacheStats; ms: number } => {
    let best = Infinity;
    let stats = f();
    for (let r = 0; r < 7; r++) {
      const t0 = performance.now();
      stats = f();
      best = Math.min(best, performance.now() - t0);
    }
    return { stats, ms: best };
  };

  const a = time(() => runStream(unsortedCmds));
  const b = time(() => runStream(sortedCmds));

  const row = (name: string, s: CacheStats): string =>
    `${name.padEnd(9)} pipeline ${String(s.pipeline.applied).padStart(7)}  ` +
    `bindGroup ${String(s.bindGroup.applied).padStart(7)}  ` +
    `vtx ${String(s.vertexBuffer.applied).padStart(7)}  ` +
    `idx ${String(s.indexBuffer.applied).padStart(7)}  ` +
    `applied ${String(s.totalApplied).padStart(7)}  skipped ${String(s.totalSkipped).padStart(7)}`;

  console.log(`draws        ${N}`);
  console.log(row('unsorted', a.stats) + `  loop ${a.ms.toFixed(2)} ms`);
  console.log(row('sorted', b.stats) + `  loop ${b.ms.toFixed(2)} ms`);
  const cut = 1 - b.stats.totalApplied / a.stats.totalApplied;
  console.log(`pipeline sets ${a.stats.pipeline.applied} -> ${b.stats.pipeline.applied}`);
  console.log(`applied cut   ${(cut * 100).toFixed(1)}%`);
  void SortMode;
}

在 /tmp/enginebook 里 node src/render/sort/state-cache.ts(本机实测,数值不可移植):

draws        200000
unsorted  pipeline  199157  bindGroup  199251  vtx  199217  idx  199217  applied  796842  skipped    3158  loop 2.52 ms
sorted    pipeline     512  bindGroup   70923  vtx  199193  idx  199193  applied  469821  skipped  330179  loop 2.79 ms
pipeline sets 199157 -> 512
applied cut   41.0%

乱序流里 pipeline 被应用了 199157 次——几乎每一项都要切一次管线,跳过只有 3158 次。排序流把它压到 512 次:这正是 9.1 那把键的回报,因为键的高位就是管线。绑定组从 199251 降到 70923,同样是排序把同材质的项聚到了一起。总的应用次数从 796842 降到 469821,砍掉了 41.0%。

注意 loop 一列:两种流几乎一样(2.52 ms vs 2.79 ms)。这不是矛盾——状态缓存的收益不在这个 JS 循环里,而在它替后端省掉的调用次数;循环本身的耗时被 Map 查找与计数淹没,所以两条流打平。要衡量缓存的价值,看的是 applied 那一列,不是 loop。

还有一点值得强调:applied cut 41.0% 这个数字只对这份合成数据成立。真实场景里,管线的分布、材质的分布、每个网格被复用的次数都会让这个比例上下浮动。它想说明的不是「一定省 41%」,而是「排序把管线切换从 O(N) 拉到了 O(管线数)」——199157 → 512 才是这条结论的形状,41.0% 只是它在本次输入下的具体刻度。

缓存砍掉了什么、砍不掉什么

把两行并排看,会发现一个刺眼的事实:vtx 与 idx 几乎没动,都是 199193 上下。也就是说,顶点缓冲和索引缓冲几乎每次都变。原因很简单——同一个材质下,网格是挨个不同的,而每个网格有自己的顶点缓冲;排序键按「材质」分组,但没有按「网格」分组。

这恰好指出了下一步。状态缓存只能消灭「相邻两次恰好相同」的冗余;要让顶点缓冲真正复用,得让共享同一网格的多个对象合并成一次绘制——也就是实例化。下一节换一条把网格提到高位的键,看它能把 20 万次绘制压到多少。

换个角度说:状态缓存是「被动去重」,它只回答「这一次设置和上一次一样吗」;它无法把两个不相邻、但状态相同的项拉到一起。把项拉到一起是排序的活;而把多项塞进一次绘制、让它们共享同一次状态设置,则是合批的活。三者一层套一层:排序提供局部性,缓存吃掉残差,合批把局部性变成真正的「一次调用画很多」。本节站在中间那一层。

小结

这一节把状态缓存做成了两个计数器:applied 与 skipped。它验证了 9.1 排序的直接价值——管线切换从 199157 次降到 512 次,总状态设置砍掉 41.0%;也诚实暴露了它的边界——顶点/索引缓冲的切换纹丝不动。缓存能做的只有「相邻去重」,跨项复用得靠更激进的合批。

从工程角度看,状态缓存的另一个好处是可观测:applied / skipped 本身就是一份诊断数据。如果某一帧的 skipped 突然塌下来,往往意味着排序退化了、或某个系统打乱了绘制列表;把它接到调试 HUD 上,比盯着帧时间猜原因要直接得多。

也因此,状态缓存几乎是所有渲染后端的标配:它便宜、正确性可单测、收益随场景规模放大。本节的实现只维护四个字段加一张按槽位的小 Map,正是这个「便宜」的写照。

阅读导航:上一节:9.1 排序键与前后绘制 · 下一节:9.3 实例化与合批 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「engine」更多文章

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