17.1 渲染线程模型与命令缓冲
到第 16 章为止,helio 的每一帧都在一个线程上跑完:算变换、剔除、排序、录命令、提交。这条路对《微光岛》这种量级够用,但只要主线程里多塞一点游戏逻辑——物理、AI、网络回调——渲染命令的录制就会被挤到帧尾,帧时间开始抖。这一章换一种组织方式:把「录命令」和「提交执行」拆到不同的线程上,让它们各跑各的。
本节先把最底层的骨架搭出来:一条主线程到渲染线程的命令缓冲通道。主线程是生产者,把「绑定管线、画多少顶点」这类命令写进缓冲;渲染线程是消费者,按帧把缓冲取走、交给 GPU。中间用一个双缓冲(或三缓冲)的环兜住两者速度差,并在「生产者想覆写消费者还没读完的缓冲」时直接抛错。
这条通道与卷一那套 Job System 是同一条脉络:卷一的线程池与任务队列 讲任务怎么排队,任务图与帧调度 讲任务怎么连成图,与主循环、渲染线程的同步 讲主循环与渲染线程怎么在帧边界对齐。本节把「同步」那一步落成一条可断言的命令缓冲环,第 17.3 节再把它接到 Job System 上。
本章与站内既有内容的分工
多线程渲染最容易写成「GPU 为什么快」的科普。先划清边界:
| 既有文章 | 它负责什么 | 本节只做什么 |
|---|---|---|
/graphics-render-graph/ | 帧图的原理与 Unreal RDG / Unity SRP 案例 | 不重推帧图,只讲它录出来的命令怎么跨线程传递与合并 |
/graphics-gpu-profiling-debugging/ | GPU 计时、帧耗时拆解、厂商调试工具 | 不重做 profiling,只用墙钟对比「录制在主线程」与「录制在渲染线程」 |
/graphics-gpu-driven-rendering-lod/ | GPU 驱动的剔除、LOD 与间接绘制 | 讲 CPU 侧把录制分块并行、主线程按顺序合并提交 |
一句话:别人讲「GPU 侧怎么并行」,本章讲「CPU 侧的录制怎么跨线程分工、又怎么保证结果和单线程一模一样」。
为什么要把录制与提交拆开
单线程帧循环里,录制和提交是绑死的:算完这一帧的所有绘制项,立刻录命令、立刻 submit,然后开始下一帧。问题出在 submit 之后的空档——GPU 跑这一帧的同时,主线程已经在为下一帧做逻辑,两件事抢同一个线程。逻辑一重,录制就被推迟,帧率开始掉。
拆线程之后,职责变成:
- 主线程(生产者):跑游戏逻辑,把「要画什么」翻译成命令,塞进缓冲,然后立刻返回去跑下一帧逻辑。
- 渲染线程(消费者):只做一件事——把缓冲里的命令取出来提交给 GPU,不掺任何游戏逻辑。
两者通过一个容量有限的环解耦。环的容量就是「允许同时在途的帧数」:容量 2 是双缓冲,容量 3 是三缓冲。主线程要开新一帧时,如果环里没有空闲槽位(渲染线程还没消费完),它就必须等——这就是背压(backpressure)。把背压做成一条明确的异常,比让它悄悄覆写一个还在读的缓冲、然后在屏幕上闪一帧要安全得多。这与第 3.3 节 uniform 三缓冲是同一个「写入前确认上一任已退役」的规则,只不过对象从 uniform buffer 换成了命令缓冲。
命令缓冲的生产/消费环
下面 src/render/thread/command-buffer.ts 是完整可编译文件,层级 L1。它用四个状态——free / recording / ready / consuming——把生产者与消费者的权限分开:生产者只能碰 recording 的槽位,消费者只能碰 consuming 的槽位,任何越界都会立刻抛错。
// file: src/render/thread/command-buffer.ts
// tier: L1
// verify: npx vitest run src/render/thread/command-buffer.test.ts
//
// helio 渲染卷 17.1:渲染线程模型的核心——命令缓冲的生产/消费环。
// 主线程是生产者:录一帧的命令到「录制中」的缓冲,帧末 publish 成「待消费」;
// 渲染线程是消费者:取走「待消费」的缓冲,执行完 release 回「空闲」。
// 环容量 = 允许同时在途的缓冲数;生产者找不到空闲缓冲时直接抛错,
// 把「渲染线程还没消费完、主线程又想录下一帧」这个竞态变成一条确定性异常。
/** 一条后端无关的命令。真实 RHI 里对应 setPipeline / draw / copy 等调用。 */
export type RenderCommand =
| { readonly op: 'setPipeline'; readonly name: string }
| { readonly op: 'setBindGroup'; readonly index: number; readonly name: string }
| { readonly op: 'draw'; readonly vertexCount: number };
export type BufferState = 'free' | 'recording' | 'ready' | 'consuming';
export interface SlotView {
readonly id: number;
readonly state: BufferState;
readonly count: number;
}
export class CommandBufferError extends Error {}
interface Slot {
readonly id: number;
state: BufferState;
commands: RenderCommand[];
}
/**
* 命令缓冲环。主线程与渲染线程各持一个句柄,互不越界:
* 生产者只能碰「录制中」的缓冲,消费者只能碰「待消费」的缓冲。
*/
export class CommandBufferRing {
readonly #slots: Slot[];
readonly #capacity: number;
constructor(capacity: number) {
if (capacity < 2) throw new CommandBufferError('双缓冲至少需要 2 个槽位');
this.#capacity = capacity;
this.#slots = [];
for (let i = 0; i < capacity; i++) {
this.#slots.push({ id: i, state: 'free', commands: [] });
}
}
get capacity(): number {
return this.#capacity;
}
/** 主线程:申请一个空闲槽位开始录制。无空闲槽位时报错(渲染线程仍占着)。 */
beginRecord(): number {
const slot = this.#slots.find((s) => s.state === 'free');
if (!slot) {
const busy = this.#slots.map((s) => `${s.id}:${s.state}`).join(',');
throw new CommandBufferError(`无空闲命令缓冲,渲染线程尚未消费完 [${busy}]`);
}
slot.state = 'recording';
slot.commands = [];
return slot.id;
}
/** 主线程:往正在录制的槽位追加一条命令。 */
record(id: number, cmd: RenderCommand): void {
const slot = this.#require(id, 'recording');
slot.commands.push(cmd);
}
/** 主线程:结束录制,把槽位置为「待消费」。 */
publish(id: number): void {
const slot = this.#require(id, 'recording');
slot.state = 'ready';
}
/** 渲染线程:取走最早的一个「待消费」槽位;没有则返回 -1。 */
acquireReady(): number {
const slot = this.#slots.find((s) => s.state === 'ready');
if (!slot) return -1;
slot.state = 'consuming';
return slot.id;
}
/** 渲染线程:读取正在消费的槽位的命令(拷贝,防止消费中被改写)。 */
readConsuming(id: number): readonly RenderCommand[] {
return this.#require(id, 'consuming').commands.slice();
}
/** 渲染线程:执行完毕,把槽位归还为空闲。 */
release(id: number): void {
const slot = this.#require(id, 'consuming');
slot.commands = [];
slot.state = 'free';
}
/** 诊断视图:每个槽位的 id / 状态 / 命令条数。 */
snapshot(): readonly SlotView[] {
return this.#slots.map((s) => ({ id: s.id, state: s.state, count: s.commands.length }));
}
#require(id: number, state: BufferState): Slot {
const slot = this.#slots[id];
if (!slot) throw new CommandBufferError(`非法槽位 ${id}`);
if (slot.state !== state) {
throw new CommandBufferError(`槽位 ${id} 处于 ${slot.state},期望 ${state}`);
}
return slot;
}
}
#require 是整段的核心:它把「这个操作合不合法」编码成一个状态断言,而不是靠调用方自觉。readConsuming 返回的是 slice() 拷贝,这样即使消费者在读,生产者也没法从另一个槽位改到它。
可运行产物:断言不覆盖在途缓冲
单测覆盖五个场景,跑法 npx vitest run src/render/thread/command-buffer.test.ts:
// file: src/render/thread/command-buffer.test.ts
// tier: L1
// verify: npx vitest run src/render/thread/command-buffer.test.ts
import { describe, expect, it } from 'vitest';
import { CommandBufferError, CommandBufferRing, type RenderCommand } from './command-buffer.ts';
const draw = (n: number): RenderCommand => ({ op: 'draw', vertexCount: n });
describe('CommandBufferRing', () => {
it('双缓冲:生产者录两帧、消费者依次取走,命令原样保留', () => {
const ring = new CommandBufferRing(2);
const a = ring.beginRecord();
ring.record(a, { op: 'setPipeline', name: 'forward' });
ring.record(a, draw(3));
ring.publish(a);
const b = ring.beginRecord();
ring.record(b, draw(6));
ring.publish(b);
const first = ring.acquireReady();
expect(ring.readConsuming(first)).toEqual([
{ op: 'setPipeline', name: 'forward' },
{ op: 'draw', vertexCount: 3 },
]);
ring.release(first);
const second = ring.acquireReady();
expect(ring.readConsuming(second)).toEqual([{ op: 'draw', vertexCount: 6 }]);
ring.release(second);
expect(ring.acquireReady()).toBe(-1);
});
it('两个槽位都被占满时,第三帧 beginRecord 报错(背压)', () => {
const ring = new CommandBufferRing(2);
const a = ring.beginRecord();
ring.publish(a);
const b = ring.beginRecord();
ring.publish(b);
expect(() => ring.beginRecord()).toThrow(/无空闲命令缓冲/);
});
it('消费者读到的命令不会被生产者下一帧改写(无撕裂)', () => {
const ring = new CommandBufferRing(2);
const a = ring.beginRecord();
ring.record(a, draw(10));
ring.publish(a);
const consuming = ring.acquireReady();
const taken = ring.readConsuming(consuming);
// 生产者在另一个槽位录下一帧,内容与上一帧完全不同。
const b = ring.beginRecord();
ring.record(b, draw(999));
ring.publish(b);
// 消费者手里的命令不受影响。
expect(taken).toEqual([{ op: 'draw', vertexCount: 10 }]);
expect(ring.readConsuming(consuming)).toEqual([{ op: 'draw', vertexCount: 10 }]);
});
it('release 后槽位可被复用,且旧命令被清空', () => {
const ring = new CommandBufferRing(2);
const a = ring.beginRecord();
ring.record(a, draw(1));
ring.publish(a);
const taken = ring.acquireReady();
ring.release(taken);
const reused = ring.beginRecord();
expect(reused).toBe(a);
expect(ring.snapshot()[a]!.count).toBe(0);
});
it('状态机越界会报错', () => {
const ring = new CommandBufferRing(2);
const a = ring.beginRecord();
expect(() => ring.publish(999)).toThrow(CommandBufferError);
expect(() => ring.release(a)).toThrow(/期望 consuming/);
ring.publish(a);
expect(() => ring.record(a, draw(1))).toThrow(/期望 recording/);
});
});
本机实跑:npx vitest run src/render/thread/command-buffer.test.ts 全绿,5 条断言通过,npx tsc --noEmit 对本章文件零错误。三条关键断言的实测结果最能说明问题:双缓冲下连续两帧的命令读回各自独立;两个槽位占满后第三次 beginRecord 抛 无空闲命令缓冲,渲染线程尚未消费完;消费者手里的 draw(10) 在生产者为下一帧录了 draw(999) 之后依然不变。
双缓冲、三缓冲与背压
容量该取几,取决于「渲染线程最多落后主线程多少帧」。下面 src/render/thread/command-buffer-demo.ts 用确定性事件循环模拟「主线程每帧都录、渲染线程每 2 帧才消费一帧」,对比容量 2 与容量 3 的背压时刻:
// file: src/render/thread/command-buffer-demo.ts
// tier: L1
// verify: node src/render/thread/command-buffer-demo.ts
//
// 用一个确定性事件循环模拟「主线程录帧、渲染线程慢半拍消费」:
// 主线程每帧都尝试录制并发布;渲染线程每 consumerPeriod 帧才消费一帧。
// 当两个槽位都在途时,主线程 beginRecord 会撞上背压异常。
import { CommandBufferRing } from './command-buffer.ts';
export interface Step {
readonly frame: number;
readonly consumed: number | null;
readonly recorded: number | null;
readonly backpressure: string | null;
}
/** 模拟 frames 帧:consumerPeriod 帧消费一次,主线程每帧都录。 */
export function simulate(capacity: number, frames: number, consumerPeriod: number): Step[] {
const ring = new CommandBufferRing(capacity);
const steps: Step[] = [];
for (let f = 0; f < frames; f++) {
let consumed: number | null = null;
if (f % consumerPeriod === 0) {
const taken = ring.acquireReady();
if (taken !== -1) {
ring.release(taken);
consumed = taken;
}
}
let recorded: number | null = null;
let backpressure: string | null = null;
try {
const id = ring.beginRecord();
ring.record(id, { op: 'draw', vertexCount: (f + 1) * 3 });
ring.publish(id);
recorded = id;
} catch (e) {
backpressure = (e as Error).message;
}
steps.push({ frame: f, consumed, recorded, backpressure });
}
return steps;
}
function report(capacity: number, frames: number, consumerPeriod: number): void {
const steps = simulate(capacity, frames, consumerPeriod);
console.log(`capacity=${capacity} consumerPeriod=${consumerPeriod}`);
for (const s of steps) {
const c = s.consumed === null ? '-' : `consume(${s.consumed})`;
const r = s.recorded === null ? `BACKPRESSURE: ${s.backpressure}` : `record(${s.recorded})`;
console.log(` frame ${s.frame}: ${c} ${r}`);
}
}
report(3, 8, 2); // 三缓冲:同样的慢消费者,背压来得更晚
report(2, 8, 2); // 双缓冲:同样的慢消费者,背压来得更早
node src/render/thread/command-buffer-demo.ts 的本机输出(同样的消费者速度,只改容量;这里摘出各自的背压帧):
capacity=3 consumerPeriod=2
frame 5: - BACKPRESSURE: 无空闲命令缓冲,渲染线程尚未消费完 [0:ready,1:ready,2:ready]
capacity=2 consumerPeriod=2
frame 3: - BACKPRESSURE: 无空闲命令缓冲,渲染线程尚未消费完 [0:ready,1:ready]
同样每 2 帧消费一次,双缓冲在第 3 帧就撞上背压,三缓冲推迟到第 5 帧。多出来的那一份槽位不是「更安全一点」,而是把可容忍的在途帧数从 2 抬到 3,正好覆盖常见的「录一帧、跑一帧、再排一帧」节奏。代价是每多一份槽位就多一份命令存储与一次提交机会,三缓冲是社区里被验证过的甜点值。
需要强调的是:背压不是 bug,是设计。容量有限意味着主线程最多只能领先渲染线程固定帧数,超了就必须等——这恰好把「渲染线程别落后太多、否则输入延迟爆炸」这条约束编码进了结构里。真正的 bug 是让生产者无界地写、把内存吃光,或者覆写一个还在读的缓冲、在屏幕上闪一帧。
小结
- 把录制与提交拆到渲染线程,是为了让主线程的游戏逻辑不再和渲染命令抢同一个线程。
- 命令缓冲环用
free / recording / ready / consuming四态把生产者与消费者的权限分开,越界即抛错。 - 环容量 = 最大在途帧数;双缓冲、三缓冲的差别是能容忍渲染线程落后几帧,超了就是背压。
- 与第 3.3 节的 uniform 三缓冲同一条规则:写入前必须确认上一任已退役。
有了这条通道,下一个问题是:一帧的命令能不能在多个线程上并行录,而不是只在渲染线程上串行录?这就要用到 OffscreenCanvas——它让渲染表面也能被搬到别的线程。
阅读导航:上一节:回到目录 · 下一节:17.2 OffscreenCanvas 与并行命令录制 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。