3.3 三缓冲与资源生命周期
上一节我们让 CPU 最多超前 GPU 三帧。这带来一个直接后果:同一块 uniform buffer 会被多帧同时「惦记」。帧 N 的 GPU 还在读它的相机矩阵,帧 N+1 的 CPU 已经要把新矩阵写进去了。如果它们共用一块 buffer,GPU 读到的就可能是写了一半的数据——画面上表现为抖动、错位,甚至整帧闪一下。
这一节解决的就是这个资源生命周期问题:为什么需要多份 uniform buffer、为什么是 2~3 份、以及怎么用一个环形池保证「复用槽位时不会覆盖仍在飞的资源」。
为什么一份 buffer 不够
先看单缓冲会怎么错。CPU 在第 N 帧把相机矩阵写进 ubo,submit,然后立刻开始录第 N+1 帧。因为上节说过 submit 不阻塞,此时 GPU 可能刚拿到第 N 帧的命令、还没读 ubo。CPU 却已经在为第 N+1 帧覆写 ubo 了。GPU 读到的可能是第 N 帧的矩阵、第 N+1 帧的矩阵,或者两者的混合——取决于它读到哪一半,这正是最典型的 use-after-write 竞态。
两份(双缓冲)能挡住「相邻两帧」的冲突:帧 N 用槽位 0,帧 N+1 用槽位 1,互不干扰。但如果 GPU 慢了半拍、帧 N 还没读完槽位 0,而 CPU 已经绕回来要写帧 N+2 的槽位 0——冲突又出现了。**三份(三缓冲)**留出一个缓冲垫:当 CPU 要复用槽位 0 时,占用它的帧 N 通常早已退役。K 份槽位能容纳的最大在飞帧数就是 K,这和上一节 FrameRing 的容量是同一个数。
同样的道理适用于所有「每帧写、GPU 读」的资源:uniform buffer、bind group(它引用 buffer,也得按帧各建一份)、动态 vertex buffer、甚至每帧重建的 shadow map。它们的生命周期都遵循同一条规则:写入前,必须确认这个槽位的上一任占用者已经退役。
环形分配的三缓冲池
下面 src/render/resource/uniform-ring.ts 是完整可编译文件,层级 L1。它给每帧一份独立槽位,槽位号 = frame % capacity;acquire() 在「该槽位仍被在飞帧占用」时直接抛错,把上面那条规则变成运行期强约束。
// file: src/render/resource/uniform-ring.ts
// tier: L1
// verify: npx vitest run src/render/resource/uniform-ring.test.ts
//
// helio 资源层:环形分配的三缓冲 uniform 池。
// GPU 可能还在读帧 N 的 uniform,而 CPU 已在写帧 N+1。环形池给每帧
// 一份独立槽位,并在「复用仍在飞的槽位」时直接报错,把覆盖在飞资源
// 这个竞态从线上黑屏变成一条确定性的异常。
export class UniformRingError extends Error {}
export class UniformRing {
readonly capacity: number;
readonly slotFloats: number;
readonly #store: Float32Array;
readonly #slotFrame: number[];
readonly #inflight = new Set<number>();
constructor(capacity: number, slotFloats: number) {
if (capacity < 2) throw new UniformRingError('三缓冲至少需要 2 份槽位');
if (slotFloats < 1) throw new UniformRingError('slotFloats 至少为 1');
this.capacity = capacity;
this.slotFloats = slotFloats;
this.#store = new Float32Array(capacity * slotFloats);
this.#slotFrame = new Array<number>(capacity).fill(-1);
}
/** 取帧 frame 的槽位;若该槽位仍被在飞帧占用则报错。 */
acquire(frame: number): number {
const slot = frame % this.capacity;
const occupant = this.#slotFrame[slot];
if (occupant !== -1 && this.#inflight.has(occupant)) {
throw new UniformRingError(
`槽位 ${slot} 仍被在飞帧 ${occupant} 占用,帧 ${frame} 若写入会覆盖它`,
);
}
this.#slotFrame[slot] = frame;
return slot;
}
/** 写入该槽位。写入前必须先 acquire,且槽位属于 frame。 */
write(frame: number, values: readonly number[]): void {
const slot = frame % this.capacity;
if (this.#slotFrame[slot] !== frame) {
throw new UniformRingError(`帧 ${frame} 尚未 acquire 槽位 ${slot}`);
}
if (values.length !== this.slotFloats) {
throw new UniformRingError(`期望 ${this.slotFloats} 个 float,实得 ${values.length}`);
}
this.#store.set(values, slot * this.slotFloats);
}
/** 读回某帧的槽位内容(拷贝),用于断言。 */
read(frame: number): Float32Array {
const slot = frame % this.capacity;
const base = slot * this.slotFloats;
return this.#store.slice(base, base + this.slotFloats);
}
markSubmitted(frame: number): void {
this.#inflight.add(frame);
}
markRetired(frame: number): void {
if (!this.#inflight.delete(frame)) {
throw new UniformRingError(`帧 ${frame} 不在飞,无法退役`);
}
}
get inFlight(): number {
return this.#inflight.size;
}
}
真实 RHI 里,#store 对应一块上传 buffer,write 对应 queue.writeBuffer(ubo, offset, data),acquire 返回的槽位号同时决定绑定哪一份 bind group。这里用 Float32Array 代替 GPU buffer,是为了让整套生命周期逻辑能在 Node 里纯 CPU 跑通、可断言。
可运行产物:断言不覆盖在飞资源
下面 uniform-ring.test.ts 覆盖三种情形:三份槽位互不覆盖、两份槽位在第 3 帧撞车、退役后可安全复用。
// file: src/render/resource/uniform-ring.test.ts
// tier: L1
// verify: npx vitest run src/render/resource/uniform-ring.test.ts
import { describe, expect, it } from 'vitest';
import { UniformRing, UniformRingError } from './uniform-ring.ts';
describe('UniformRing', () => {
it('三份槽位让连续三帧互不覆盖', () => {
const ring = new UniformRing(3, 2);
for (let f = 0; f < 3; f++) {
ring.acquire(f);
ring.write(f, [f, f * 10]);
ring.markSubmitted(f);
}
expect(Array.from(ring.read(0))).toEqual([0, 0]);
expect(Array.from(ring.read(1))).toEqual([1, 10]);
expect(Array.from(ring.read(2))).toEqual([2, 20]);
expect(ring.inFlight).toBe(3);
});
it('两份槽位不够:第 3 帧会覆盖仍在飞的第 1 帧', () => {
const ring = new UniformRing(2, 1);
ring.acquire(0); ring.write(0, [100]); ring.markSubmitted(0);
ring.acquire(1); ring.write(1, [200]); ring.markSubmitted(1);
// 帧 2 想复用槽位 0,但帧 0 仍在飞。
expect(() => ring.acquire(2)).toThrow(/仍被在飞帧 0 占用/);
});
it('退役后可安全复用,且不影响其它在飞槽位', () => {
const ring = new UniformRing(3, 1);
ring.acquire(0); ring.write(0, [1]); ring.markSubmitted(0);
ring.acquire(1); ring.write(1, [2]); ring.markSubmitted(1);
ring.acquire(2); ring.write(2, [3]); ring.markSubmitted(2);
ring.markRetired(0); // 帧 0 的 GPU 读完,槽位 0 可复用
const slot = ring.acquire(3); // frame 3 % 3 == 0
expect(slot).toBe(0);
ring.write(3, [99]);
expect(Array.from(ring.read(3))).toEqual([99]);
// 帧 1、2 的数据未被动过。
expect(Array.from(ring.read(1))).toEqual([2]);
expect(Array.from(ring.read(2))).toEqual([3]);
});
it('未 acquire 就写入会报错', () => {
const ring = new UniformRing(3, 1);
expect(() => ring.write(0, [1])).toThrow(UniformRingError);
});
it('写入长度不符会报错', () => {
const ring = new UniformRing(3, 4);
ring.acquire(0);
expect(() => ring.write(0, [1, 2])).toThrow(/期望 4 个 float/);
});
it('退役未在飞的帧会报错', () => {
const ring = new UniformRing(3, 1);
expect(() => ring.markRetired(7)).toThrow(/不在飞/);
});
});
本机实跑:npx vitest run src/render/resource/uniform-ring.test.ts 全绿,贡献 6 条断言,npx tsc --noEmit 零错误。两条关键断言的实跑输出最能说明问题:
- 容量 3 时连续三帧写完后,帧 0/1/2 的槽位读回分别是
[0,0]、[1,10]、[2,20],互不覆盖。 - 容量 2 时帧 2 想复用槽位 0,断言抛出
槽位 0 仍被在飞帧 0 占用,帧 2 若写入会覆盖它——竞态被提前成一条确定性的异常,而不是线上的一帧闪烁。
双缓冲还是三缓冲
容量该取几,取决于「GPU 最多可能落后 CPU 多少帧」。如果 K=2,当 GPU 落后 2 帧时,CPU 绕回槽位 0 的瞬间帧 0 可能还没读完——双缓冲在这里就漏了。三缓冲多出的那一份不是「更安全一点」,而是把可容忍的在飞帧数从 2 抬到 3,正好覆盖常见的「录一帧、跑一帧、再排一帧」节奏。
代价也要算清楚:每多一份槽位,就多一份显存、多一份 bind group、多一次 queue.writeBuffer。三缓冲是社区里被验证过的甜点值,绝大多数情况不必更多;只有在 GPU 明显慢于 CPU、且你把 K 和输入延迟一起调大时才需要重新评估。
把三缓冲接进帧循环
下面 uniform-ring-demo.ts 把环形池接进一个模拟帧循环:每帧开始先按 fence 退役最老的帧,再 acquire + write 本帧槽位。它用两个场景对比「容量够」与「容量不够」。
// file: src/render/resource/uniform-ring-demo.ts
// tier: L1
// verify: node src/render/resource/uniform-ring-demo.ts
//
// 把 UniformRing 接进一个模拟帧循环:每帧先按 fence 退役最老的帧,
// 再 acquire + write 本帧槽位。用来演示「容量必须 >= 在飞帧数」。
import { UniformRing } from './uniform-ring.ts';
export interface FrameStep {
readonly frame: number;
readonly slot: number;
readonly retired: number | null;
}
/**
* 模拟 frames 帧:GPU 比 CPU 慢 lag 帧。
* 每帧开始时,若帧 f-lag 已完成就退役它;然后 acquire 本帧槽位并写入。
*/
export function simulateRing(capacity: number, frames: number, lag: number): FrameStep[] {
const ring = new UniformRing(capacity, 4);
const steps: FrameStep[] = [];
for (let f = 0; f < frames; f++) {
const done = f - lag;
let retired: number | null = null;
if (done >= 0) {
ring.markRetired(done);
retired = done;
}
const slot = ring.acquire(f);
ring.write(f, [f, f + 1, f + 2, f + 3]);
ring.markSubmitted(f);
steps.push({ frame: f, slot, retired });
}
return steps;
}
/** 跑一个场景并打印结果;容量不足时打印抛出的异常信息。 */
function report(capacity: number, lag: number): void {
try {
const steps = simulateRing(capacity, 6, lag);
const slots = steps.map((s) => s.slot).join(',');
const retired = steps.map((s) => (s.retired === null ? '-' : s.retired)).join(',');
console.log(`capacity=${capacity} lag=${lag}: OK slots=[${slots}] retired=[${retired}]`);
} catch (e) {
console.log(`capacity=${capacity} lag=${lag}: THROW ${(e as Error).message}`);
}
}
report(3, 2); // 三份槽位,GPU 落后 2 帧 —— 够用
report(2, 3); // 两份槽位,GPU 落后 3 帧 —— 槽位不够,报错
本机实跑 node src/render/resource/uniform-ring-demo.ts 的输出:
capacity=3 lag=2: OK slots=[0,1,2,0,1,2] retired=[-,-,0,1,2,3]
capacity=2 lag=3: THROW 槽位 0 仍被在飞帧 0 占用,帧 2 若写入会覆盖它
第一行说明:三份槽位配 GPU 落后 2 帧时,槽位按 0,1,2 循环复用,退役帧号也整齐地落后 2 帧,全程不冲突。第二行是反面:两份槽位遇到 GPU 落后 3 帧,帧 2 想复用槽位 0 时帧 0 还没退役,池子直接抛错——这正是「容量必须 >= 在飞帧数」的硬边界。
退役时机
markRetired 什么时候调?最保守的答案是 queue.onSubmittedWorkDone() resolve 之后——但那会退化成上一节说的每帧串行。更好的做法是给每个槽位绑一个「已完成帧计数」:提交时记下 submit 的序号,fence 触发时把 <= 该序号 的帧一次性退役,一次 fence 回收多帧,CPU 只在真正要复用槽位时才等。
还要注意 writeBuffer 与命令缓冲的提交顺序:同一个 queue 上的 writeBuffer 会按调用顺序排在命令之前。所以只要在 acquire 之后、submit 之前写入,就不必担心「写晚了被上一帧的命令先读到」。
小结
- CPU 超前 GPU 后,「每帧写、GPU 读」的资源必须多份,否则帧 N 的 GPU 会读到帧 N+1 的半成品。
- 份数 = 最大在飞帧数 K,通常取 2~3;三缓冲比双缓冲多一个缓冲垫,复用槽位时上一任多半已退役。
- 环形池用
frame % capacity定槽位,acquire()在槽位仍在飞时抛错,把生命周期规则变成运行期强约束。
阅读导航:上一节:3.2 队列提交、帧同步与 fence · 下一节:4.1 bind group 与资源绑定 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。