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 昼夜循环与光照表现 · 下一节:回到目录 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。