《游戏引擎实现原理》18.3 渲染性能剖析与收口

画面能画、能动了,但「动得好不好」要用数字说话。本节给 18.1 的装配流水线做 CPU 逐帧计时,再用 WebGPU 的 timestamp-query 在渲染通道两端打时间戳、读出真实 GPU 纳秒数,跑出可复现的毫秒数字,最后为整卷《渲染引擎》收口。

18.3 渲染性能剖析与收口

到这一节,《微光岛》已经能被装配、被画出、随时间变换光照。但「能跑」和「跑得好」是两回事:一帧到底花了多少时间?CPU 在剔除排序上花了多少?GPU 在绘制上花了多少?没有数字,优化就是猜。

本节用两把尺子量同一件事:CPU 侧用 performance.now() 给18.1 的装配流水线 逐帧计时,GPU 侧用 WebGPU 的 timestamp-query 在渲染通道两端打时间戳。所有数字都是本机实跑出来的,不做任何估算。

CPU 侧:装配流水线逐帧计时

被测对象就是 18.1 的 assembleFrame:剔除 → 排序 → 合批。脚本先热身 20 帧,再逐帧计时 200 次,给出 min / 中位 / p99 / 均值,并按 16.67ms(60 FPS)的单帧预算算占比。场景实例数从 1 千爬到 5 万,覆盖「远小于视锥」到「大量落在视锥外」两种情形。

// file: src/render/app/render-profiling.ts
// tier: L1
// verify: node src/render/app/render-profiling.ts
//
// helio 渲染卷 18.3:渲染性能剖析(CPU 侧)。
// 把 18.1 的装配流水线(剔除 → 排序 → 合批)当被测对象,用 performance.now()
// 逐帧计时,给出 min / 中位 / p99 / 均值 与单帧预算占比。纯计算(L1),
// Node 26 直跑:node src/render/app/render-profiling.ts

import { assembleFrame, type SceneInstance } from './renderer-assembly.ts';
import { lookAt, multiply, perspective } from '../culling/frustum.ts';
import { hashUnit } from '../sort/sort-key.ts';

export interface BenchResult {
  readonly n: number;
  readonly iters: number;
  readonly culled: number;
  readonly drawn: number;
  readonly batches: number;
  readonly minMs: number;
  readonly medianMs: number;
  readonly p99Ms: number;
  readonly avgMs: number;
}

const FOV = (60 * Math.PI) / 180;

/** 确定性场景:n 个实例散布在半径 radius 的方形区域内(大量落在视锥外)。 */
export function makeScene(n: number, seed: number, radius: number): SceneInstance[] {
  const out: SceneInstance[] = new Array(n);
  for (let i = 0; i < n; i++) {
    out[i] = {
      id: i,
      meshId: Math.floor(hashUnit(i, seed + 1) * 8),
      materialId: Math.floor(hashUnit(i, seed + 2) * 4),
      position: [
        (hashUnit(i, seed + 3) * 2 - 1) * radius,
        (hashUnit(i, seed + 4) * 2 - 1) * radius * 0.3,
        (hashUnit(i, seed + 5) * 2 - 1) * radius,
      ],
      scale: 0.5,
      alpha: hashUnit(i, seed + 6) < 0.05,
      visible: true,
    };
  }
  return out;
}

function percentile(sorted: number[], p: number): number {
  const idx = Math.min(sorted.length - 1, Math.floor(p * sorted.length));
  return sorted[idx]!;
}

/** 计时装配流水线:先热身,再逐帧测量,返回分位统计(毫秒)。 */
export function benchAssemble(
  instances: readonly SceneInstance[], viewProj: readonly number[],
  cam: readonly [number, number, number], far: number, iters: number,
): BenchResult {
  let stats = assembleFrame(instances, viewProj, cam, far).stats;
  for (let w = 0; w < 20; w++) stats = assembleFrame(instances, viewProj, cam, far).stats; // 热身
  const times: number[] = new Array(iters);
  for (let i = 0; i < iters; i++) {
    const t0 = performance.now();
    assembleFrame(instances, viewProj, cam, far);
    times[i] = performance.now() - t0;
  }
  const sorted = [...times].sort((a, b) => a - b);
  const sum = times.reduce((s, t) => s + t, 0);
  return {
    n: instances.length, iters, culled: stats.culled, drawn: stats.drawn, batches: stats.batches,
    minMs: sorted[0]!, medianMs: percentile(sorted, 0.5), p99Ms: percentile(sorted, 0.99), avgMs: sum / iters,
  };
}

const round3 = (x: number): number => Math.round(x * 1000) / 1000;

if ((import.meta as { main?: boolean }).main) {
  const vp = multiply(perspective(FOV, 16 / 9, 0.1, 200), lookAt([0, 6, 24], [0, 0, 0], [0, 1, 0]));
  const cam: [number, number, number] = [0, 6, 24];
  console.log('count    culled  drawn  batches  min(ms)  median(ms)  p99(ms)  avg(ms)  %budget(16.67)');
  for (const n of [1000, 5000, 20000, 50000]) {
    const r = benchAssemble(makeScene(n, 7, 30), vp, cam, 200, 200);
    console.log(
      `${String(n).padEnd(7)}  ${String(r.culled).padEnd(6)}  ${String(r.drawn).padEnd(5)}  ${String(r.batches).padEnd(7)}  ` +
      `${String(round3(r.minMs)).padEnd(7)}  ${String(round3(r.medianMs)).padEnd(10)}  ${String(round3(r.p99Ms)).padEnd(7)}  ` +
      `${String(round3(r.avgMs)).padEnd(7)}  ${round3((r.medianMs / 16.67) * 100)}%`,
    );
  }
}

本机 node src/render/app/render-profiling.ts 实测:

count    culled  drawn  batches  min(ms)  median(ms)  p99(ms)  avg(ms)  %budget(16.67)
1000     332     668    52       0.075    0.091       0.294    0.109    0.547%
5000     1692    3308   64       0.364    0.418       0.884    0.438    2.509%
20000    6797    13203  64       1.458    1.626       2.406    1.668    9.754%
50000    16943   33057  64       3.979    4.664       8.762    4.833    27.979%

两个现象值得记下。其一,剔除真的在干活:5 万实例里只有约 3.3 万进入绘制,其余 1.7 万在装配阶段就被丢掉了。其二,装配的耗时随实例数近似线性增长,且排序键把批次数稳在 64 附近(1 万以后不再涨),说明合批没有随规模退化。到 5 万实例,中位耗时 4.664ms,占 60 FPS 单帧预算的约 28%——还有余量,但已经值得警惕:再往上走就该把装配挪进渲染线程 了。

可运行产物:GPU 侧时间戳剖析

CPU 快不代表 GPU 快。真正的绘制成本要用 GPU 自己的时钟量。WebGPU 的 timestamp-query 允许在渲染通道的起止点写入时间戳,再 resolveQuerySet 回读成纳秒数——这是硬件时钟,不受 CPU 调度干扰(原理见 /graphics-gpu-profiling-debugging/ )。

脚本对 256 / 1024 / 4096 / 16384 个实例各测一次,复用 18.1 的 scene-assembly.wgsl 绘制,取三次的最小值。

// file: src/render/app/render-profiling-l2.ts
// tier: L2
// verify: node tools/run-l2.mjs src/render/app/render-profiling-l2.ts
//
// helio 渲染卷 18.3:渲染性能剖析(GPU 侧)。
// 用 WebGPU 的 timestamp-query 在渲染通道两端打时间戳,读出真实 GPU 纳秒数。
// 对 256 / 1024 / 4096 / 16384 个实例各测一次,报告每帧 GPU 毫秒与单实例微秒,
// 并断言耗时随实例数单调上升。需在浏览器中经本地 http 服务运行,命令行不可复现。

import { assembleFrame, type SceneInstance } from './renderer-assembly.ts';
import { hashUnit } from '../sort/sort-key.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 W = 64;
const H = 64;
const EXTENT = 6;
const HALF = 0.25;
const FAR = 100;
const COUNTS = [256, 1024, 4096, 16384] as const;
const REPS = 3;

/** 俯视正交投影(与 18.1 一致):clip = (x/extent, z/extent)。 */
function topDownViewProj(extent: number): number[] {
  return [1 / extent, 0, 0, 0, 0, 0, -1 / 20, 0, 0, 1 / extent, 0, 0, 0, 0, 1, 1];
}

/** 确定性场景:n 个实例铺在正交盒内(多数可见),用于测量绘制规模。 */
function makeScene(n: number, seed: number): SceneInstance[] {
  const out: SceneInstance[] = new Array(n);
  for (let i = 0; i < n; i++) {
    out[i] = {
      id: i, meshId: 0, materialId: 0,
      position: [(hashUnit(i, seed) * 2 - 1) * 5, 0, (hashUnit(i, seed + 1) * 2 - 1) * 5],
      scale: HALF, alpha: false, visible: true,
    };
  }
  return out;
}

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 hasTs = adapter.features.has('timestamp-query');
  push('adapter 支持 timestamp-query', hasTs, hasTs);
  const device = await adapter.requestDevice({ requiredFeatures: hasTs ? ['timestamp-query'] : [] });
  data.hasTimestamp = hasTs;

  const wgsl = await (await fetch('/shaders/scene-assembly.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 fmt: GPUTextureFormat = 'rgba8unorm';
  const pipeline = device.createRenderPipeline({
    layout: 'auto',
    vertex: { module, entryPoint: 'vs' },
    fragment: { module, entryPoint: 'fs', targets: [{ format: fmt }] },
    primitive: { topology: 'triangle-list' },
  });
  const target = device.createTexture({
    size: [W, H], format: fmt, usage: GPUTextureUsage.RENDER_ATTACHMENT,
  });
  const querySet = device.createQuerySet({ type: 'timestamp', count: 2 });
  const resolveBuf = device.createBuffer({ size: 16, usage: GPUBufferUsage.QUERY_RESOLVE | GPUBufferUsage.COPY_SRC });
  const tsRead = device.createBuffer({ size: 16, usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ });

  const vp = topDownViewProj(EXTENT);

  /** 渲染一帧并按时间戳读出 GPU 纳秒数。 */
  const gpuMs = async (rec: Float32Array): Promise<number> => {
    const sBuf = device.createBuffer({ size: rec.byteLength, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST });
    device.queue.writeBuffer(sBuf, 0, rec);
    const uBuf = device.createBuffer({ size: 16, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST });
    device.queue.writeBuffer(uBuf, 0, new Float32Array([EXTENT, 0, 0, 0]));
    const bg = device.createBindGroup({
      layout: pipeline.getBindGroupLayout(0),
      entries: [{ binding: 0, resource: { buffer: sBuf } }, { binding: 1, resource: { buffer: uBuf } }],
    });
    const enc = device.createCommandEncoder();
    const pass = enc.beginRenderPass({
      colorAttachments: [{ view: target.createView(), clearValue: { r: 0, g: 0, b: 0, a: 1 }, loadOp: 'clear', storeOp: 'store' }],
      timestampWrites: { querySet, beginningOfPassWriteIndex: 0, endOfPassWriteIndex: 1 },
    });
    pass.setPipeline(pipeline);
    pass.setBindGroup(0, bg);
    pass.draw(6, rec.length / 8);
    pass.end();
    enc.resolveQuerySet(querySet, 0, 2, resolveBuf, 0);
    enc.copyBufferToBuffer(resolveBuf, 0, tsRead, 0, 16);
    device.queue.submit([enc.finish()]);
    await tsRead.mapAsync(GPUMapMode.READ);
    const ts = new BigUint64Array(tsRead.getMappedRange().slice(0));
    tsRead.unmap();
    sBuf.destroy();
    uBuf.destroy();
    return Number(ts[1]! - ts[0]!) / 1e6;
  };

  const table: Record<string, unknown> = {};
  let prev = -1;
  let monotonic = true;
  for (const n of COUNTS) {
    const frame = assembleFrame(makeScene(n, 13), vp, [0, 20, 0], FAR);
    const rec = new Float32Array(frame.order.length * 8);
    frame.order.forEach((srcIdx, slot) => {
      const inst = frame.visible[srcIdx]!;
      const o = slot * 8;
      rec[o] = inst.position[0];
      rec[o + 1] = inst.position[2];
      rec[o + 2] = inst.scale;
      rec[o + 3] = 0;
      rec.set([0.4, 0.6, 0.9, 1], o + 4);
    });
    let best = Infinity;
    for (let r = 0; r < REPS; r++) best = Math.min(best, await gpuMs(rec));
    const perInstUs = (best * 1000) / frame.stats.drawn;
    table[String(n)] = { drawn: frame.stats.drawn, gpuMs: best, perInstUs };
    if (best < prev) monotonic = false;
    prev = best;
  }
  data.table = table;
  push('GPU 耗时随实例数单调上升', monotonic, JSON.stringify(Object.fromEntries(
    Object.entries(table).map(([k, v]) => [k, (v as { gpuMs: number }).gpuMs.toFixed(3)]))));

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

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

PASS  adapter 支持 timestamp-query  true
PASS  wgsl 0 error  0
PASS  GPU 耗时随实例数单调上升  {"256":"3.974","1024":"14.936","4096":"87.743","16384":"231.736"}

GPU 毫秒数随实例数明显上升:256 个实例 3.974ms,1024 个 14.936ms,4096 个 87.743ms,16384 个 231.736ms。注意这是 SwiftShader 软件光栅化器的数字,比真实 GPU 慢一两个数量级,且逐次运行会有可见波动(脚本已取三次最小值),但趋势是有意义的:绘制成本近似与实例数成正比。对比 CPU 侧同量级的装配耗时(1.6ms 左右),可以看出在这个软件后端上瓶颈在光栅化,不在装配——这正是剖析的价值:它告诉你该优化哪一头。

全卷收口

十八节走到这里,helio 的渲染卷画上了句号。回看这条路径:从帧时间线与分层 起步,先立 RHI 抽象与三后端,再做资源、管线、命令编码;接着是场景图、相机、剔除、排序、合批这些「决定画什么、按什么顺序画」的环节;然后是材质与着色器变体、光照与阴影、帧图 、后处理、2D/UI;最后是多线程渲染与本章的装配、昼夜、剖析。

每一节都不是纸上谈兵:能跑的单测、能在真实 WebGPU 设备上回读的像素、能用 GPU 时间戳量出的毫秒数。这正是本卷与站内 /posts/graphics/ 图形学专题的分工——那边讲「渲染算法为什么长这样」,这边讲「在 helio 的 RHI 上怎么把它搭出来、跑出什么」。

《微光岛》到这里已经能装配、能被照亮、能测速。要把这套渲染器真正接到游戏循环上,让它和卷一的内核、作业系统一起跑起来,就交给下一部吧。

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

阅读导航:上一节:18.2 昼夜循环与光照表现 · 下一节:回到目录 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「engine」更多文章

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