1.1 一帧都发生了什么(CPU / GPU 时间线)
很多人第一次写渲染器时,脑子里的一帧是这样的:算出物体位置、把它们画出来、屏幕就更新了。真实的一帧不是一条直线,而是两条同时在跑的流水线——CPU 在准备「下一帧要提交什么」,GPU 在执行「上一帧已经提交的东西」。这两条线不共享时钟,也不一定同步。
渲染器的几乎所有性能问题,归根到底都是这两条线的配合问题:CPU 提交得太慢,GPU 就空转;GPU 执行得太慢,CPU 就得等它把缓冲腾出来。所以在写任何一行渲染代码之前,我们先把一帧拆成时间线,把「谁在等谁」这件事量化出来。
本节是卷二的开篇,先把地基讲清楚:一帧里 CPU 和 GPU 各自做什么、双缓冲与三缓冲如何让两者重叠、为什么「CPU 帧」和「GPU 帧」不是同一件事。渲染算法本身(光栅化、光照、后处理)不在这里重推,见分工表。
本章与站内既有内容的分工
第 1 章是「渲染器总览」,最容易写成一篇泛泛的架构综述。为避免和站内既有内容打架,先把分工写清楚:
| 既有文章 | 它负责什么 | 本节只做什么 |
|---|---|---|
/graphics-render-graph/ | 帧图的原理、Pass 依赖与自动屏障 | 只把「一帧的时间线」画出来并量化,不实现帧图 |
/graphics-gpu-profiling-debugging/ | GPU 剖析工具链、帧捕获与计数器 | 用一段最小编码量出 CPU 提交与 GPU 排空耗时,不展开工具 |
/graphics-culling-visibility-systems/ | 视锥、BVH、Hi-Z 与 GPU Driven 剔除 | 本节不碰剔除,只关心「提交之后 CPU 与 GPU 怎么重叠」 |
一句话:别人讲「渲染管线的原理」,本节讲「从提交到呈现这一段时间到底花在哪」。
CPU 帧与 GPU 帧不是同一件事
一个新手最常见的误解是:renderFrame() 返回时,画面就画好了。事实是 renderFrame() 只是把命令提交出去,函数返回的那一刻,GPU 很可能一条命令都还没执行。CPU 提交一帧、GPU 执行一帧,是两个独立的过程:
- CPU 帧:遍历场景、做剔除、排序、构建命令缓冲、把命令压给图形 API。这段时间 CPU 满载,GPU 闲着。
- GPU 帧:光栅化、跑顶点/片元着色器、写回缓冲、present。这段时间 GPU 满载,CPU 可能已经在准备下一帧。
如果只有一块可写的缓冲(单缓冲),CPU 必须等 GPU 把上一帧执行完,才能往里写下一帧——两者被迫串行,一帧的周期就是 cpuMs + gpuMs。这就是为什么早期游戏一加后处理就掉帧:不是 GPU 慢,而是 CPU 和 GPU 根本没法同时干活。
解决办法是给交换链加缓冲:双缓冲让 CPU 写 A 缓冲时 GPU 读 B 缓冲,两者终于能重叠,帧周期降到 max(cpuMs, gpuMs);三缓冲再多一块,让 CPU 能领先 GPU 两帧,代价是流水线更深、延迟更高。这几种配置的差别到底有多大?我们用代码算出来。
可运行产物:一个 CPU / GPU 时间线模型
下面这个模型把上面的话变成可断言的数字。它用离散事件模拟两条流水线:CPU 每帧把命令写进一个交换链缓冲,写完立刻提交;GPU 按提交顺序执行;一个缓冲从 CPU 开始写、到 GPU 执行完才释放。核心规则就两条:
- CPU 开始第 i 帧 =
max(上一帧提交时刻, 第 i-buffers 帧的 GPU 结束时刻); - GPU 开始第 i 帧 =
max(第 i 帧提交时刻, 第 i-1 帧 GPU 结束时刻)。
buffers = 1 时这两条规则让 CPU 与 GPU 被迫串行,buffers >= 2 时才有重叠。
// file: src/render/frame-timeline.ts
// tier: L1
// verify: npx vitest run src/render/frame-timeline.test.ts
//
// helio 渲染:CPU / GPU 时间线模型。
// 用离散事件模拟「CPU 构建并提交命令」与「GPU 执行命令」两条流水线:
// 给定每帧 CPU 耗时、GPU 耗时与交换链缓冲数,算出稳态帧率、延迟与 CPU 阻塞。
// 它回答一个最基本的问题:一帧里 CPU 和 GPU 到底谁在等谁。
export interface TimelineConfig {
/** 每帧 CPU 侧构建命令并提交给 GPU 的耗时(毫秒)。 */
readonly cpuMs: number;
/** 每帧 GPU 侧执行上一帧命令的耗时(毫秒)。 */
readonly gpuMs: number;
/** 交换链缓冲数:1 = 不重叠,2 = 双缓冲,3 = 三缓冲。 */
readonly buffers: number;
/** 模拟多少帧。 */
readonly frames: number;
/** 在第几帧制造一次 GPU 尖峰(可选)。 */
readonly gpuSpikeAt?: number;
/** 尖峰那一帧的 GPU 耗时(毫秒,可选)。 */
readonly gpuSpikeMs?: number;
}
/** 一帧的时间线切片:四个关键时刻 + 一次 CPU 阻塞。 */
export interface FrameSample {
readonly index: number;
/** CPU 开始构建该帧。 */
readonly cpuStartMs: number;
/** 命令提交给 GPU(buffer 已就绪)。 */
readonly submitMs: number;
/** GPU 开始执行该帧命令。 */
readonly gpuStartMs: number;
/** GPU 执行结束,约等于 present。 */
readonly gpuEndMs: number;
/** CPU 因为等一个空闲 buffer 而空转的时间。 */
readonly cpuStallMs: number;
}
export interface TimelineResult {
readonly samples: readonly FrameSample[];
/** 后半段(稳态)帧率。 */
readonly steadyFps: number;
/** 稳态平均「CPU 开始 → GPU 呈现」延迟。 */
readonly avgLatencyMs: number;
/** 稳态平均每帧 CPU 阻塞时间。 */
readonly cpuStallPerFrameMs: number;
}
/**
* 模拟 CPU / GPU 两条流水线。
*
* 规则:CPU 每帧把命令写进一个交换链 buffer,写完立刻提交;GPU 按提交顺序执行。
* 一个 buffer 从 CPU 开始写、到 GPU 执行完才释放。所以:
* - CPU 开始第 i 帧 = max(上一帧提交时刻, 第 i-buffers 帧的 GPU 结束时刻);
* - GPU 开始第 i 帧 = max(第 i 帧提交时刻, 第 i-1 帧 GPU 结束时刻)。
* buffers = 1 时二者被迫串行,buffers >= 2 时才有重叠。
*/
export function simulateTimeline(cfg: TimelineConfig): TimelineResult {
const { cpuMs, gpuMs, buffers, frames } = cfg;
if (!Number.isInteger(buffers) || buffers < 1) {
throw new RangeError(`buffers 必须是 >= 1 的整数,收到 ${buffers}`);
}
if (!(cpuMs > 0) || !(gpuMs > 0) || !Number.isInteger(frames) || frames < 2) {
throw new RangeError('cpuMs/gpuMs 必须为正,frames 必须 >= 2');
}
const bufferFreeAt = new Array<number>(buffers).fill(Number.NEGATIVE_INFINITY);
const samples: FrameSample[] = [];
let cpuCursor = 0; // CPU 何时有空开始下一帧
let gpuFreeAt = 0; // GPU 何时空闲
for (let i = 0; i < frames; i++) {
const slot = i % buffers;
const cpuStart = Math.max(cpuCursor, bufferFreeAt[slot]);
const cpuStall = Math.max(0, cpuStart - cpuCursor);
const submit = cpuStart + cpuMs;
const gpuStart = Math.max(submit, gpuFreeAt);
const gpuCost = cfg.gpuSpikeAt === i ? (cfg.gpuSpikeMs ?? gpuMs) : gpuMs;
const gpuEnd = gpuStart + gpuCost;
samples.push({ index: i, cpuStartMs: cpuStart, submitMs: submit, gpuStartMs: gpuStart, gpuEndMs: gpuEnd, cpuStallMs: cpuStall });
cpuCursor = submit;
gpuFreeAt = gpuEnd;
bufferFreeAt[slot] = gpuEnd;
}
const mid = Math.floor(frames / 2);
const tail = samples.slice(mid);
const spanMs = tail[tail.length - 1]!.gpuEndMs - samples[mid - 1]!.gpuEndMs;
const steadyFps = spanMs > 0 ? (tail.length / spanMs) * 1000 : 0;
const avgLatencyMs = tail.reduce((s, f) => s + (f.gpuEndMs - f.cpuStartMs), 0) / tail.length;
const cpuStallPerFrameMs = tail.reduce((s, f) => s + f.cpuStallMs, 0) / tail.length;
return { samples, steadyFps, avgLatencyMs, cpuStallPerFrameMs };
}
用四种典型配置跑一遍(cpu = 6ms、gpu = 10ms,共 240 帧),本机实测输出如下:
单缓冲 cpu=6 gpu=10 FPS= 62.5 延迟= 16.0ms CPU 阻塞/帧=10.0ms
双缓冲 cpu=6 gpu=10 FPS=100.0 延迟= 20.0ms CPU 阻塞/帧= 4.0ms
三缓冲 cpu=6 gpu=10 FPS=100.0 延迟= 30.0ms CPU 阻塞/帧= 4.0ms
三缓冲 cpu=12 gpu=10 FPS= 83.3 延迟= 22.0ms CPU 阻塞/帧= 0.0ms
这张表把三件事说清楚了:
- 单缓冲必须串行:帧周期
6 + 10 = 16ms,帧率只有 62.5,且 CPU 每帧要空转 10ms 等 GPU。这就是「CPU 在等 GPU」的代价。 - 双缓冲让两者重叠:帧率跳到 100,正好等于
1000 / gpu——GPU 成了瓶颈,CPU 的 6ms 被完全藏进 GPU 的 10ms 里。CPU 阻塞从 10ms 降到 4ms。 - 三缓冲换延迟:稳态帧率仍是 100(GPU 仍瓶颈),但延迟从 20ms 涨到 30ms,正好多出一个 GPU 帧——这就是「多一块缓冲 = 多一帧流水线深度」的代价。
最后一行是另一个方向的验证:把 CPU 耗时提到 12ms,帧率降到 83.3,正好等于 1000 / cpu。谁慢谁定帧率,缓冲数只决定「能不能重叠」,不改变这个上限。
可运行产物:在真实 WebGPU 设备上量一帧
上面的模型是算出来的,可能有人怀疑它是自说自话。我们再到真实设备上量一次:连续提交 64 个 compute 命令,分别测「CPU 提交总耗时」与「GPU 排空耗时」。下面先是那段制造 GPU 负载的着色器。
// file: shaders/frame-timing.wgsl
// tier: L2
//
// helio 渲染:给 1.1 的 CPU/GPU 时间线测量用的 compute 着色器。
// 每个线程对数组元素做 2048 次整数混沌迭代,制造一段可控的 GPU 负载。
@group(0) @binding(0) var<storage, read_write> data: array<u32>;
@compute @workgroup_size(64)
fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
let i = gid.x;
if (i >= arrayLength(&data)) { return; }
var acc: u32 = data[i];
for (var k: u32 = 0u; k < 2048u; k = k + 1u) {
acc = (acc * 1664525u + 1013904223u) ^ (acc >> 13u);
}
data[i] = acc;
}
计时逻辑在下面这个文件里。关键点是用 performance.now() 夹住「提交循环」得到 CPU 时间,再用 queue.onSubmittedWorkDone() 等 GPU 排空得到总时间,两者之差就是 GPU 还在跑的时间。
// file: src/render/gpu-frame-timing.ts
// tier: L2
// verify: node tools/run-l2.mjs src/render/gpu-frame-timing.ts
//
// helio 渲染:在真实 WebGPU 设备上量一帧的 CPU / GPU 时间。
// 连续提交 frames 个 compute 命令,分别测「CPU 提交总耗时」与「GPU 排空耗时」,
// 用真实数字证明:CPU 帧和 GPU 帧不是同一件事,二者在时间上重叠。
// 需在浏览器中经本地 http 服务运行,命令行不可复现。
export interface GpuFrameTiming {
readonly frames: number;
readonly cpuSubmitMs: number;
readonly gpuDrainMs: number;
readonly totalMs: number;
readonly throughputFps: number;
}
const WORKGROUP = 64;
const ELEMENTS = 1 << 16;
export async function measureGpuFrameTiming(shaderCode: string, frames = 64): Promise<GpuFrameTiming> {
const adapter = await navigator.gpu.requestAdapter();
if (!adapter) throw new Error('拿不到 WebGPU adapter');
const device = await adapter.requestDevice();
const buf = device.createBuffer({
size: ELEMENTS * 4,
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
});
device.queue.writeBuffer(buf, 0, new Uint32Array(ELEMENTS));
const module = device.createShaderModule({ code: shaderCode });
const info = await module.getCompilationInfo();
const errors = info.messages.filter((m) => m.type === 'error');
if (errors.length > 0) throw new Error(`WGSL 编译失败:${errors[0]!.message}`);
const pipeline = device.createComputePipeline({ layout: 'auto', compute: { module, entryPoint: 'main' } });
const bindGroup = device.createBindGroup({
layout: pipeline.getBindGroupLayout(0),
entries: [{ binding: 0, resource: { buffer: buf } }],
});
const groups = ELEMENTS / WORKGROUP;
const submitOne = (): void => {
const enc = device.createCommandEncoder();
const pass = enc.beginComputePass();
pass.setPipeline(pipeline);
pass.setBindGroup(0, bindGroup);
pass.dispatchWorkgroups(groups);
pass.end();
device.queue.submit([enc.finish()]);
};
for (let i = 0; i < 4; i++) submitOne(); // 预热,跳过首次管线编译
await device.queue.onSubmittedWorkDone();
const t0 = performance.now();
for (let i = 0; i < frames; i++) submitOne(); // 只测 CPU 侧构建 + 提交
const cpuSubmitMs = performance.now() - t0;
await device.queue.onSubmittedWorkDone(); // 等 GPU 把队列排空
const totalMs = performance.now() - t0;
device.destroy();
return { frames, cpuSubmitMs, gpuDrainMs: totalMs - cpuSubmitMs, totalMs, throughputFps: (frames / totalMs) * 1000 };
}
在 headless Chromium(经本地 http 服务、带 --enable-unsafe-webgpu)里跑,本机实测结果如下(软件光栅器 SwiftShader,仅作同机对比):
adapterInfo: google/swiftshader
wgsl errors: []
frames: 64
cpuSubmitMs: 0.59
gpuDrainMs: 1122.07
totalMs: 1122.66
throughputFps: 57.0
数字最能说明问题:CPU 提交 64 帧只花了 0.59ms(平均每帧不到 0.01ms),而 GPU 把这 64 帧排空花了 1122ms。也就是说,CPU 在 0.59ms 内就把 64 帧全丢给了 GPU,然后眼睁睁看着 GPU 一帧一帧慢慢跑——CPU 帧和 GPU 帧在时间上差了三个数量级。这正是「CPU 提交完就返回、GPU 还在后面排队」的实证。
(本节所有数字均来自本机实跑,运行环境见附录 C;src/render/gpu-frame-timing.ts 需在浏览器中运行,命令行不可复现。)
搞清楚了「一帧里 CPU 和 GPU 谁在等谁」,下一个问题自然是:渲染器本身该怎么分层,才能把这种流水线管理得井井有条?下一节我们把渲染器拆成 RHI / 渲染器 / 场景三层。
阅读导航:上一节:回到目录 · 下一节:1.2 渲染器的分层:RHI / 渲染器 / 场景 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。