《游戏引擎实现原理》15.1 全屏 Pass 链

后处理是一串全屏 pass:每个 pass 读若干张纹理、写一张纹理。本节在 helio 上从零搭出全屏 Pass 链的数据模型——覆盖整个视口的全屏三角形、pass 描述符、链合法性校验,以及用「最后一次使用」回收中间纹理的物理纹理规划,再在真实 WebGPU 设备上跑一条两段链、回读像素逐通道验证。

15.1 全屏 Pass 链

14 章把帧图搭了起来,帧图负责「谁先谁后、谁依赖谁」。到了后处理这一步,帧图里的节点几乎都是同一种形状:一个覆盖整个视口的全屏三角形,读几张纹理,写一张纹理。这就是后处理的基本单元——全屏 pass。把若干个全屏 pass 按顺序串起来,就是本节要讲的 Pass 链。

这一章(15 章)讲后处理与全屏特效。15.1 搭出 Pass 链这个骨架,15.2 把色调映射与抗锯齿这两个最常挂的 pass 接进来,15.3 算一算这条链的性能预算。

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

站内 graphics/ 专题已经把后处理的原理讲透了,本卷不重推算法,只做「在 helio 的 RHI 上从零实现 + 实测」。

既有文章它负责什么本节只做什么
/graphics-post-processing/后处理全景:HDR 与 Bloom 降采样、ACES 色调映射、全屏 Quad 管线、MSAA/FXAA/TAA 的取舍在 helio 的 RHI 上把全屏 Pass 链的数据模型与中间纹理复用实现出来,并跑出像素
/graphics-anti-aliasing-methods/各类抗锯齿的算法与质量对比只提供「把 AA 当作链上一个 pass 接进来」的挂载点
/graphics-color-management-hdr-pipeline/色彩管线、HDR 与线性/伽马空间只处理「HDR 纹理作为链输入、LDR 纹理作为链输出」这一层接口

换句话说:算法看外链,本节只回答「怎么在 helio 上把它串成一条能跑、能回读、能复用显存的链」。

全屏三角形与 Pass 链的数据模型

后处理的每个 pass 都用全屏三角形而不是全屏四边形:三个 clip-space 顶点 (-1,-1) (3,-1) (-1,3) 就能覆盖整个视口,比四个顶点的四边形少一次光栅化接缝,也不需要顶点缓冲——顶点坐标写死在着色器里,draw(3) 即可。

一条链要能被引擎安全地执行,得先描述成数据再校验。每个 pass 声明自己读哪些纹理、写哪张纹理;链声明外部输入 source 与最终输出 output。校验规则很直接:每个输入必须由更早的 pass 产出(或就是 source),不能自反馈(同一次读写同一张),不能覆写外部输入,output 必须被写出且不被中间 pass 读取。

校验通过后还要解决显存问题:一条 5 段链如果每段都新开一张全分辨率纹理,显存会爆。做法是物理纹理复用——扫一遍链,某张中间纹理被最后一次读之后就不再需要,它的物理槽位可以回收给后面的 pass。线性链(每段只读上一段)的结果正好是两张 ping-pong 纹理。

// file: src/render/postfx/pass-chain.ts
// tier: L1
// verify: npx vitest run src/render/postfx/pass-chain.test.ts
//
// helio 渲染卷 15.1:全屏 Pass 链的 CPU 模型。
// 后处理是一串「读若干张纹理、写一张纹理」的全屏 pass。本模块把链描述成
// 数据(PostFxChain),做两件事:校验合法性(输入必须由更早的 pass 产出、
// 不得自反馈、输出必须被写出),以及用「最后一次使用」回收中间纹理,
// 把 N 个逻辑中间纹理压到少数物理纹理上(线性链正好退化成 ping-pong 两张)。

export interface PostFxPass {
  readonly name: string;
  readonly frag: string;
  readonly reads: readonly string[];
  readonly writes: string;
}
export interface PostFxChain {
  readonly source: string;
  readonly output: string;
  readonly passes: readonly PostFxPass[];
}

/** 全屏三角形:三个 clip-space 顶点覆盖整个视口,不需要顶点缓冲。 */
export function fullscreenTriangle(): Float32Array {
  return new Float32Array([-1, -1, 3, -1, -1, 3]);
}

/** 校验一条 pass 链:输入产出顺序、自反馈、输出唯一性。 */
export function validateChain(chain: PostFxChain): { ok: boolean; errors: string[] } {
  const errors: string[] = [];
  if (chain.passes.length === 0) errors.push('链为空');
  const produced = new Set<string>([chain.source]);
  const names = new Set<string>();
  chain.passes.forEach((p, i) => {
    if (names.has(p.name)) errors.push(`pass ${i} 名称重复: ${p.name}`);
    names.add(p.name);
    if (p.reads.length === 0) errors.push(`pass ${i} (${p.name}) 没有输入`);
    for (const r of p.reads) {
      if (!produced.has(r)) errors.push(`pass ${i} (${p.name}) 读取了尚未产出的 ${r}`);
      if (r === p.writes) errors.push(`pass ${i} (${p.name}) 自反馈: 同时读写 ${r}`);
    }
    if (p.writes === chain.source) errors.push(`pass ${i} (${p.name}) 覆写了外部输入 ${chain.source}`);
    produced.add(p.writes);
  });
  if (!produced.has(chain.output)) errors.push(`最终输出 ${chain.output} 从未被写出`);
  if (chain.passes.some((p) => p.reads.includes(chain.output))) {
    errors.push(`最终输出 ${chain.output} 被中间 pass 读取`);
  }
  return { ok: errors.length === 0, errors };
}

export interface ResourcePlan {
  readonly physicalTextures: number;
  readonly reuseCount: number;
  readonly slotOf: ReadonlyMap<string, number>;
}

/** 「最后一次使用」规划器:某纹理最后一次被读之后即可回收给后续 pass 复用。 */
export function planResources(chain: PostFxChain): ResourcePlan {
  const lastUse = new Map<string, number>();
  chain.passes.forEach((p, i) => { for (const r of p.reads) lastUse.set(r, i); });
  const live = new Map<string, number>();
  const assigned = new Map<string, number>();
  const free: number[] = [];
  let highWater = 0;
  let reuseCount = 0;
  chain.passes.forEach((p, i) => {
    for (const [name, lu] of lastUse) {
      if (lu < i && name !== chain.output) {
        const s = live.get(name);
        if (s !== undefined) { free.push(s); live.delete(name); }
      }
    }
    const reused = free.shift();
    let slot: number;
    if (reused !== undefined) { slot = reused; reuseCount++; }
    else { slot = highWater++; }
    live.set(p.writes, slot);
    assigned.set(p.writes, slot);
  });
  return { physicalTextures: highWater, reuseCount, slotOf: assigned };
}

这份实现有 9 条单测:全屏三角形三个顶点、合法链通过、读取未产出纹理被拒、自反馈被拒、输出未写出被拒、覆写外部输入被拒、名称重复被拒、线性三连链压成两张 ping-pong 纹理、双输入合成 pass 需要第三张物理纹理。跑法 npx vitest run src/render/postfx/pass-chain.test.ts。

可运行产物:在 GPU 上跑一条两段链

模型只是纸面,真正要证明的是「按这份描述在 RHI 上发命令,能跑出对的像素」。下面这条链是 source -> fs_invert -> t0 -> fs_scale(0.5) -> present:先反相,再整体乘 0.5。着色器给一个顶点入口和两个片元入口;为了让 layout: 'auto' 推出的绑定组一致,两个片元都读同一个 uniform(反相 pass 用它的 .a 当反相强度)。

// file: shaders/postfx-chain.wgsl
// tier: L2
// verify: node tools/run-l2.mjs src/render/postfx/pass-chain-l2.ts
//
// helio 渲染卷 15.1:全屏 Pass 链的着色器。
// 一个顶点入口生成覆盖全屏的三角形(无顶点缓冲),两个片元入口构成一条
// 两段链:fs_invert 反相 -> 中间纹理 -> fs_scale 缩放 -> 输出。
// 两个片元都读同一个 uniform(fs_invert 用 .a 当反相强度),这样 auto
// layout 的绑定组一致。

struct Uniforms {
  scale: vec4<f32>, // rgb = 缩放系数,a = 反相强度(1.0 = 完全反相)
};

@group(0) @binding(0) var src: texture_2d<f32>;
@group(0) @binding(1) var samp: sampler;
@group(0) @binding(2) var<uniform> u: Uniforms;

struct VSOut {
  @builtin(position) pos: vec4<f32>,
  @location(0) uv: vec2<f32>,
};

@vertex
fn vs_fullscreen(@builtin(vertex_index) i: u32) -> VSOut {
  var p = array<vec2<f32>, 3>(
    vec2<f32>(-1.0, -1.0),
    vec2<f32>(3.0, -1.0),
    vec2<f32>(-1.0, 3.0),
  );
  var o: VSOut;
  o.pos = vec4<f32>(p[i], 0.0, 1.0);
  o.uv = vec2<f32>((p[i].x + 1.0) * 0.5, 1.0 - (p[i].y + 1.0) * 0.5);
  return o;
}

@fragment
fn fs_invert(in: VSOut) -> @location(0) vec4<f32> {
  let c = textureSample(src, samp, in.uv).rgb;
  let k = clamp(u.scale.a, 0.0, 1.0);
  return vec4<f32>(mix(c, 1.0 - c, k), 1.0);
}

@fragment
fn fs_scale(in: VSOut) -> @location(0) vec4<f32> {
  let c = textureSample(src, samp, in.uv).rgb;
  return vec4<f32>(c * u.scale.rgb, 1.0);
}

下面的 L2 脚本用 4×4 纯色 (64,128,192) 当 source,跑完两段链后分别回读中间纹理 t0 与最终 present,与 CPU 期望逐通道比对(容差 1)。它同时也把 planResources 的结果打出来,验证两段链真的只用两张物理纹理。

// file: src/render/postfx/pass-chain-l2.ts
// tier: L2
// verify: node tools/run-l2.mjs src/render/postfx/pass-chain-l2.ts
//
// helio 渲染卷 15.1:在真实 WebGPU 设备上跑一条两段全屏 Pass 链。
// source(4x4 纯色) -> fs_invert -> t0 -> fs_scale(0.5) -> present。
// 回读中间纹理 t0 与最终 present,逐通道与 CPU 期望比对(容差 1)。
// 需在浏览器中经本地 http 服务运行,命令行不可复现。

import { planResources, validateChain, type PostFxChain } from './pass-chain.ts';

export interface L2Check { readonly name: string; readonly ok: boolean; readonly detail: string }
export interface L2Result { readonly checks: L2Check[]; readonly data: Record<string, unknown> }

const CHAIN: PostFxChain = {
  source: 'scene', output: 'present',
  passes: [
    { name: 'invert', frag: 'fs_invert', reads: ['scene'], writes: 't0' },
    { name: 'scale', frag: 'fs_scale', reads: ['t0'], writes: 'present' },
  ],
};
const W = 4, H = 4, BPR = 256;
const SRC: readonly [number, number, number] = [64, 128, 192];

export async function runL2(): Promise<L2Result> {
  const checks: L2Check[] = [];
  const data: Record<string, unknown> = {};
  const push = (name: string, ok: boolean, detail: unknown): void => {
    checks.push({ name, ok, detail: String(detail) });
  };

  const v = validateChain(CHAIN);
  push('链校验通过', v.ok, v.errors.join('; ') || 'ok');
  const plan = planResources(CHAIN);
  data.physicalTextures = plan.physicalTextures;
  data.reuseCount = plan.reuseCount;
  push('两段链物理纹理=2', plan.physicalTextures === 2, plan.physicalTextures);
  push('两段链无复用(t0 在链中仍活)', plan.reuseCount === 0, plan.reuseCount);

  const adapter = await navigator.gpu.requestAdapter();
  if (!adapter) throw new Error('requestAdapter() returned null');
  const device = await adapter.requestDevice();

  const wgsl = await (await fetch('/shaders/postfx-chain.wgsl')).text();
  const module = device.createShaderModule({ code: wgsl });
  const info = await module.getCompilationInfo();
  const errors = info.messages.filter((m) => m.type === 'error')
    .map((m) => `${m.lineNum}:${m.linePos} ${m.message}`);
  data.wgslErrors = errors;
  push('wgsl 0 error', errors.length === 0, errors.length);

  const fmt = 'rgba8unorm';
  const mkTex = (): GPUTexture => device.createTexture({
    size: [W, H], format: fmt,
    usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.RENDER_ATTACHMENT
      | GPUTextureUsage.COPY_DST | GPUTextureUsage.COPY_SRC,
  });
  const sampler = device.createSampler({ magFilter: 'nearest', minFilter: 'nearest' });
  const scene = mkTex();
  const srcData = new Uint8Array(W * H * 4);
  for (let i = 0; i < W * H; i++) srcData.set([SRC[0], SRC[1], SRC[2], 255], i * 4);
  device.queue.writeTexture({ texture: scene }, srcData, { bytesPerRow: W * 4 }, [W, H]);

  const uniform = device.createBuffer({ size: 16, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST });
  device.queue.writeBuffer(uniform, 0, new Float32Array([0.5, 0.5, 0.5, 1.0]));

  const pipeline = (entry: string): GPURenderPipeline => device.createRenderPipeline({
    layout: 'auto',
    vertex: { module, entryPoint: 'vs_fullscreen' },
    fragment: { module, entryPoint: entry, targets: [{ format: fmt }] },
    primitive: { topology: 'triangle-list' },
  });
  const pipeInv = pipeline('fs_invert');
  const pipeScale = pipeline('fs_scale');
  const bg = (pipe: GPURenderPipeline, input: GPUTexture): GPUBindGroup => device.createBindGroup({
    layout: pipe.getBindGroupLayout(0),
    entries: [
      { binding: 0, resource: input.createView() },
      { binding: 1, resource: sampler },
      { binding: 2, resource: { buffer: uniform } },
    ],
  });
  const runPass = (pipe: GPURenderPipeline, input: GPUTexture, target: GPUTexture): void => {
    const enc = device.createCommandEncoder();
    const pass = enc.beginRenderPass({ colorAttachments: [{
      view: target.createView(), loadOp: 'clear', storeOp: 'store', clearValue: { r: 0, g: 0, b: 0, a: 1 },
    }] });
    pass.setPipeline(pipe);
    pass.setBindGroup(0, bg(pipe, input));
    pass.draw(3);
    pass.end();
    device.queue.submit([enc.finish()]);
  };
  const readPixel = async (tex: GPUTexture, px: number, py: number): Promise<number[]> => {
    const rb = device.createBuffer({ size: BPR * H, usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ });
    const enc = device.createCommandEncoder();
    enc.copyTextureToBuffer({ texture: tex }, { buffer: rb, bytesPerRow: BPR }, [W, H]);
    device.queue.submit([enc.finish()]);
    await rb.mapAsync(GPUMapMode.READ);
    const px8 = new Uint8Array(rb.getMappedRange().slice(0));
    rb.unmap();
    const o = py * BPR + px * 4;
    return Array.from(px8.slice(o, o + 3));
  };

  const t0 = mkTex();
  const present = mkTex();
  runPass(pipeInv, scene, t0);
  runPass(pipeScale, t0, present);
  await device.queue.onSubmittedWorkDone();

  const mid = await readPixel(t0, 2, 2);
  const fin = await readPixel(present, 2, 2);
  const expMid = SRC.map((c) => 255 - c);
  const expFin = expMid.map((c) => Math.round(c * 0.5));
  data.mid = mid; data.fin = fin; data.expMid = expMid; data.expFin = expFin;
  const close = (a: number[], b: number[], tol = 1): boolean => a.every((x, i) => Math.abs(x - b[i]!) <= tol);
  push('中间纹理=反相', close(mid, expMid), `${mid} vs ${expMid}`);
  push('最终=反相*0.5', close(fin, expFin), `${fin} vs ${expFin}`);
  push('链尾 != 链中', fin.join() !== mid.join(), `${fin} vs ${mid}`);

  device.destroy();
  return { checks, data };
}

在 headless Chromium 里跑(本地 http 服务 + --enable-unsafe-webgpu),本机实测:

PASS  链校验通过  ok
PASS  两段链物理纹理=2  2
PASS  两段链无复用(t0 在链中仍活)  0
PASS  wgsl 0 error  0
PASS  中间纹理=反相  191,127,63 vs 191,127,63
PASS  最终=反相*0.5  96,64,32 vs 96,64,32
PASS  链尾 != 链中  96,64,32 vs 191,127,63

源色 (64,128,192) 反相后是 (191,127,63),再乘 0.5 是 (96,64,32)——中间纹理与最终输出都逐字节命中,说明「按链的描述发两遍全屏 draw、中间纹理真的被当作下一段的输入」这件事在 helio 的 RHI 上成立了。planResources 也确认两段链只需要两张物理纹理:t0 在第 2 段仍被读,所以两段之间没有复用(reuseCount = 0),但物理纹理数仍是 2——这正是 ping-pong 的定义。链更长时复用才会出现:15.1 的单测里那条三连链就是 physicalTextures = 2 / reuseCount = 1。

(本节所有数字均来自本机实跑,运行环境见附录 C;src/render/postfx/pass-chain-l2.ts 需在浏览器中运行,命令行不可复现。)

有了这条能跑、能回读、能省显存的骨架,接下来只要往里塞具体的 pass 就行。最常挂的两个是色调映射和抗锯齿——15.2 把它们接进来。

阅读导航:上一节:回到目录 · 下一节:15.2 色调映射与抗锯齿接入 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「engine」更多文章

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