《游戏引擎实现原理》15.3 后处理链的性能预算

后处理链的代价可以提前算出来:每个全屏 pass 都要把纹理读一遍、写一遍,这就是带宽。本节在 helio 里实现一份带宽账本,把 pass 的读写字节按格式与分辨率算清楚,再在真实 WebGPU 设备上实测 1/5/13 抽样三个全屏 pass 的每帧耗时,看成本到底由「固定开销」还是「像素数」主导。

15.3 后处理链的性能预算

15.1 与 15.2 把后处理链接起来了,能跑、能出图。但每挂一个 pass 都要付一笔账:这个 pass 要把输入纹理从显存读一遍、把输出写一遍。链越长、分辨率越高、格式越宽,这笔账越大。这一节先在纸面上把账算清楚(带宽账本),再用真机实测对照,最后给出降本的方向。

带宽账本:把每个 pass 的读写算成字节

一个全屏 pass 的成本可以粗略拆成「固定开销 + 像素成本」。像素成本里最硬的一块是带宽:它只跟三件事有关——输出分辨率、每个纹理的每像素字节数、以及这个 pass 读了几张写了几张。

每像素字节数由格式决定:rgba8unorm 是 4 字节、rgba16float 是 8 字节、r11g11b10 是 4 字节、r8unorm 只有 1 字节。于是「读一张 HDR + 写一张 LDR」在 1920×1080 上就是 1920×1080×(8+4) = 24883200 字节 ≈ 24.88 MB/帧;60fps 下折合 1.49 GB/s。半分辨率的 Bloom 降采样只有四分之一像素,带宽直接砍到四分之一——这就是后处理普遍在半分辨率上做模糊的原因。

// file: src/render/postfx/postfx-budget.ts
// tier: L1
// verify: npx vitest run src/render/postfx/postfx-budget.test.ts
//
// helio 渲染卷 15.3:后处理链的带宽账本。
// 每个全屏 pass 都要把纹理从显存读一遍、写一遍,这就是它的带宽成本。
// 本模块按「格式字节/像素 × 分辨率 × 读写次数」把一条链的带宽算出来,
// 供帧预算决策:在什么分辨率下、跑几个 pass 才不超预算。

export type Format =
  | 'rgba8unorm' | 'rgba16float' | 'r11g11b10' | 'rgba32float' | 'r8unorm' | 'depth32float';

const BYTES_PER_PIXEL: Record<Format, number> = {
  rgba8unorm: 4,
  rgba16float: 8,
  r11g11b10: 4,
  rgba32float: 16,
  r8unorm: 1,
  depth32float: 4,
};

export function bytesPerPixel(fmt: Format): number { return BYTES_PER_PIXEL[fmt]; }

export interface BudgetPass {
  readonly name: string;
  readonly reads: readonly Format[];
  readonly write: Format;
  readonly scale: number; // 相对全屏的缩放(1=全分辨率,0.5=半分辨率)
}

/** 一个 pass 的字节数:输入像素之和 + 输出像素。 */
export function passBytes(p: BudgetPass, width: number, height: number): number {
  const pixels = Math.floor(width * p.scale) * Math.floor(height * p.scale);
  let bytes = pixels * bytesPerPixel(p.write);
  for (const f of p.reads) bytes += pixels * bytesPerPixel(f);
  return bytes;
}

export function totalBytes(passes: readonly BudgetPass[], width: number, height: number): number {
  return passes.reduce((s, p) => s + passBytes(p, width, height), 0);
}

/** 每秒带宽(GB/s):bytes × fps / 1e9。 */
export function bandwidthGBps(bytes: number, fps: number): number {
  return (bytes * fps) / 1e9;
}

/** 在给定每秒带宽预算下,这条链能跑到多少 fps。 */
export function budgetFps(bytes: number, budgetGBps: number): number {
  return (budgetGBps * 1e9) / bytes;
}

这份账本有 7 条单测:各格式的每像素字节、全分辨率 HDR→LDR 恰好 24883200 字节、半分辨率是四分之一、双输入合成相加、总字节等于各 pass 之和、60fps 折算 1.49 GB/s、100 GB/s 预算下约 4018 fps。跑法 npx vitest run src/render/postfx/postfx-budget.test.ts。

可运行产物:实测三个全屏 pass 的每帧耗时

账本算的是「应该花多少带宽」,但真实耗时还受采样次数、驱动实现、甚至「固定开销」影响。下面用三个只差采样次数的全屏 pass 来测:fs_copy(1 次采样)、fs_blur5(5 抽样横向高斯)、fs_blur13(13 抽样)。三者读同一张源、写同尺寸输出,所以耗时差只能归因到采样次数。

// file: shaders/postfx-budget.wgsl
// tier: L2
// verify: node tools/run-l2.mjs src/render/postfx/postfx-budget-l2.ts
//
// helio 渲染卷 15.3:用于测耗时的三个全屏 pass。
// fs_copy 单次采样(下限),fs_blur5 是 5 抽样横向高斯,fs_blur13 是 13 抽样。
// 三者读同一张源、写同一尺寸输出,便于把耗时差归因到「采样次数」。

struct Uniforms {
  params: vec4<f32>, // x = 模糊半径(像素)
};

@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_copy(in: VSOut) -> @location(0) vec4<f32> {
  return vec4<f32>(textureSample(src, samp, in.uv).rgb, 1.0);
}

@fragment
fn fs_blur5(in: VSOut) -> @location(0) vec4<f32> {
  let texel = 1.0 / vec2<f32>(textureDimensions(src));
  let r = u.params.x;
  var acc = vec3<f32>(0.0);
  var wsum = 0.0;
  for (var i = -2; i <= 2; i = i + 1) {
    let w = exp(-f32(i * i) / 8.0);
    let off = vec2<f32>(f32(i) * r * texel.x, 0.0);
    acc = acc + textureSample(src, samp, in.uv + off).rgb * w;
    wsum = wsum + w;
  }
  return vec4<f32>(acc / wsum, 1.0);
}

@fragment
fn fs_blur13(in: VSOut) -> @location(0) vec4<f32> {
  let texel = 1.0 / vec2<f32>(textureDimensions(src));
  let r = u.params.x;
  var acc = vec3<f32>(0.0);
  var wsum = 0.0;
  for (var i = -6; i <= 6; i = i + 1) {
    let w = exp(-f32(i * i) / 18.0);
    let off = vec2<f32>(f32(i) * r * texel.x, 0.0);
    acc = acc + textureSample(src, samp, in.uv + off).rgb * w;
    wsum = wsum + w;
  }
  return vec4<f32>(acc / wsum, 1.0);
}

L2 脚本在 256² 与 512² 上各计时:预热后提交 N 帧、用 queue.onSubmittedWorkDone() 卡住再取 performance.now() 差值,重复多轮取最小值以压掉同机噪声。同时它顺手用上一节的账本把每帧字节换算成等效带宽。

// file: src/render/postfx/postfx-budget-l2.ts
// tier: L2
// verify: node tools/run-l2.mjs src/render/postfx/postfx-budget-l2.ts
//
// helio 渲染卷 15.3:实测三个全屏 pass 的每帧耗时,并用带宽账本换算等效带宽。
// fs_copy(1 采样) / fs_blur5(5 采样) / fs_blur13(13 采样) 在 256² 与 512² 上计时:
// 先卡住 queue.onSubmittedWorkDone() 预热,再提交 N 帧取 performance.now() 差值,
// 重复多轮取最小值以压掉同机负载噪声。结果来自本机 SwiftShader 软件光栅,
// 只用于「同机相对比较」,绝对值不可外推。需在浏览器中经本地 http 服务运行,
// 命令行不可复现。

import { bandwidthGBps, passBytes, type BudgetPass } from './postfx-budget.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 SIZES: readonly number[] = [256, 512];
const FMT = 'rgba8unorm';

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 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-budget.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}`);
  push('wgsl 0 error', errors.length === 0, errors.length);

  const sampler = device.createSampler({ magFilter: 'nearest', minFilter: 'nearest' });
  const uniform = device.createBuffer({ size: 16, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST });
  device.queue.writeBuffer(uniform, 0, new Float32Array([4.0, 0, 0, 0]));
  const pipe = (entry: string): GPURenderPipeline => device.createRenderPipeline({
    layout: 'auto',
    vertex: { module, entryPoint: 'vs_fullscreen' },
    fragment: { module, entryPoint: entry, targets: [{ format: FMT }] },
    primitive: { topology: 'triangle-list' },
  });
  const mkSrc = (size: number): GPUTexture => {
    const src = device.createTexture({
      size: [size, size], format: FMT,
      usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.COPY_DST,
    });
    const d = new Uint8Array(size * size * 4);
    for (let y = 0; y < size; y++) for (let x = 0; x < size; x++) {
      const v = x < size / 2 ? 0 : 255; // 左黑右白,模糊后边界变灰
      const o = (y * size + x) * 4;
      d[o] = d[o + 1] = d[o + 2] = v;
      d[o + 3] = 255;
    }
    device.queue.writeTexture({ texture: src }, d, { bytesPerRow: size * 4 }, [size, size]);
    return src;
  };
  const bg = (p: GPURenderPipeline, src: GPUTexture): GPUBindGroup => device.createBindGroup({
    layout: p.getBindGroupLayout(0),
    entries: [
      { binding: 0, resource: src.createView() },
      { binding: 1, resource: sampler },
      { binding: 2, resource: { buffer: uniform } },
    ],
  });
  const encodePass = (p: GPURenderPipeline, view: GPUTextureView, group: GPUBindGroup): GPUCommandBuffer => {
    const enc = device.createCommandEncoder();
    const pass = enc.beginRenderPass({ colorAttachments: [{
      view, loadOp: 'clear', storeOp: 'store', clearValue: { r: 0, g: 0, b: 0, a: 1 },
    }] });
    pass.setPipeline(p);
    pass.setBindGroup(0, group);
    pass.draw(3);
    pass.end();
    return enc.finish();
  };
  // 每帧编码全新命令缓冲(同一缓冲重复提交会被驱动缓存);重复多轮取最小值。
  const timeAt = async (p: GPURenderPipeline, size: number, frames: number, repeats = 5): Promise<number> => {
    const src = mkSrc(size);
    const target = device.createTexture({
      size: [size, size], format: FMT,
      usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.COPY_SRC,
    });
    const view = target.createView();
    const group = bg(p, src);
    let best = Infinity;
    for (let r = 0; r < repeats; r++) {
      device.queue.submit([encodePass(p, view, group)]);
      await device.queue.onSubmittedWorkDone(); // 预热
      const t0 = performance.now();
      for (let f = 0; f < frames; f++) device.queue.submit([encodePass(p, view, group)]);
      await device.queue.onSubmittedWorkDone();
      best = Math.min(best, (performance.now() - t0) / frames);
    }
    return best;
  };

  const copyPipe = pipe('fs_copy');
  const blur5Pipe = pipe('fs_blur5');
  const blur13Pipe = pipe('fs_blur13');
  const copyMs: Record<number, number> = {};
  const blurMs: Record<number, number> = {};
  for (const s of SIZES) copyMs[s] = await timeAt(copyPipe, s, 100);
  const blur5Ms = await timeAt(blur5Pipe, 512, 30);
  for (const s of SIZES) blurMs[s] = await timeAt(blur13Pipe, s, 20);

  // 用带宽账本换算 512² 的等效带宽(一个 pass 读 1 张 + 写 1 张 rgba8unorm)
  const budgetPass: BudgetPass = { name: 'p', reads: [FMT], write: FMT, scale: 1 };
  const bytesPerFrame = passBytes(budgetPass, 512, 512);
  data.copyMsPerFrame = { 256: +copyMs[256]!.toFixed(4), 512: +copyMs[512]!.toFixed(4) };
  data.blur5MsPerFrame512 = +blur5Ms.toFixed(3);
  data.blurMsPerFrame = { 256: +blurMs[256]!.toFixed(3), 512: +blurMs[512]!.toFixed(3) };
  data.impliedGBps = {
    copy: +bandwidthGBps(bytesPerFrame, 1000 / copyMs[512]!).toFixed(1),
    blur13: +bandwidthGBps(bytesPerFrame, 1000 / blurMs[512]!).toFixed(2),
  };
  push('三个 pass 都跑完', copyMs[512]! > 0 && blurMs[512]! > 0,
    `copy=${copyMs[512]!.toFixed(4)} blur5=${blur5Ms.toFixed(3)} blur13=${blurMs[512]!.toFixed(3)} ms/帧`);
  push('blur 耗时随像素数增长(512 > 256)', blurMs[512]! > blurMs[256]!,
    `${blurMs[256]!.toFixed(3)} -> ${blurMs[512]!.toFixed(3)} ms/帧`);
  push('耗时随抽样次数单调增长', copyMs[512]! < blur5Ms && blur5Ms < blurMs[512]!,
    `1tap=${copyMs[512]!.toFixed(4)} 5tap=${blur5Ms.toFixed(3)} 13tap=${blurMs[512]!.toFixed(3)} ms`);

  // 读回 512² 模糊结果的中心像素:边界处应出现中间灰
  const src = mkSrc(512);
  const target = device.createTexture({
    size: [512, 512], format: FMT,
    usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.COPY_SRC,
  });
  device.queue.submit([encodePass(blur13Pipe, target.createView(), bg(blur13Pipe, src))]);
  const bpr = 512 * 4; // 2048,已是 256 的倍数
  const rb = device.createBuffer({ size: bpr * 512, usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ });
  const enc = device.createCommandEncoder();
  enc.copyTextureToBuffer({ texture: target }, { buffer: rb, bytesPerRow: bpr }, [512, 512]);
  device.queue.submit([enc.finish()]);
  await rb.mapAsync(GPUMapMode.READ);
  const px = new Uint8Array(rb.getMappedRange().slice(0));
  rb.unmap();
  const center = Array.from(px.slice(256 * bpr + 256 * 4, 256 * bpr + 256 * 4 + 3));
  push('blur 后边界出现中间灰', center[0]! > 0 && center[0]! < 255, center[0]);

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

在 headless Chromium 里跑,本机一次完整运行的数据:

PASS  wgsl 0 error  0
PASS  三个 pass 都跑完  copy=0.0152 blur5=1.238 blur13=2.428 ms/帧
PASS  blur 耗时随像素数增长(512 > 256)  0.709 -> 2.428 ms/帧
PASS  耗时随抽样次数单调增长  1tap=0.0152 5tap=1.238 13tap=2.428 ms
PASS  blur 后边界出现中间灰  145
DATA  copyMsPerFrame {256: 0.015, 512: 0.0152}
      blurMsPerFrame {256: 0.709, 512: 2.428}
      impliedGBps {copy: 138.4, blur13: 0.86}

从数据到预算:两类成本与三种降本手段

数据里藏着两件事。第一,copy 的耗时几乎不随分辨率变(256² 的 0.015 ms 与 512² 的 0.0152 ms 基本相等),说明一次采样的全屏 pass 几乎全是固定开销——提交命令、驱动往返、onSubmittedWorkDone() 的同步,真正的像素成本小到测不出来。第二,blur13 从 256² 到 512² 像素翻了 4 倍、耗时从 0.709 ms 涨到 2.428 ms(约 3.4 倍),这才是像素成本主导的 pass;而 5 抽样到 13 抽样(采样次数 2.6 倍)耗时涨到约 2.0 倍,也大致同比例。

换句话说:小分辨率、简单 pass 的成本由「有几个 pass」决定;大分辨率、重 pass 的成本由「像素数 × 采样次数」决定。 前者对应三种降本手段——(1) 合并 pass,能一次算完的不要拆两次(一个 pass 就少一次读写);(2) 降到半分辨率,Bloom/SSAO 这类低频效果在半分辨率上做,带宽直接四分之一;(3) 用更窄的格式,中间纹理能用 r11g11b10 就别用 rgba16float。后者的解法是减少采样次数——分离式高斯把 2D 卷积拆成横竖两遍,13×13 从 169 次采样降到 26 次。

(本节数字来自本机 SwiftShader 软件光栅的一次运行,只用于同机相对比较,绝对值不可外推;多次运行会因同机负载出现明显抖动。src/render/postfx/postfx-budget-l2.ts 需在浏览器中运行,命令行不可复现。)

到这里,helio 的渲染主干(RHI → 资源 → 场景 → 剔除 → 材质 → 光照 → 帧图 → 后处理)就通了。接下来换一条线:2D 与 UI——精灵批次、SDF 文字与矢量 UI、以及 2D/3D 的合成顺序。

阅读导航:上一节:15.2 色调映射与抗锯齿接入 · 下一节:16.1 精灵批次与图集 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「engine」更多文章

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