GPU 粒子环形缓冲的写入预算:满载时必须显式丢弃
爆炸、命中和环境特效可能在同一帧集中产生粒子。若 CPU 无条件把全部实例写进 GPU 上传区,环形缓冲满载后要么覆盖尚未消费的数据,要么让本帧上传量失去上界。2026-07-27-1930 探针只研究一个入口:生成批次进入固定容量环,写入器同时受帧预算和剩余槽位约束,超出的粒子明确计入丢弃量。
// .tmp/daily-labs/2026-07-27-1930/particle-ring.js
export class ParticleRing {
constructor(capacity) {
if (!Number.isInteger(capacity) || capacity <= 0) {
throw new RangeError("capacity");
}
this.capacity = capacity;
this.buffer = new Array(capacity);
this.head = 0;
this.live = 0;
this.dropped = 0;
}
write(batch, budget) {
if (!Number.isInteger(budget) || budget < 0) {
throw new RangeError("budget");
}
const writable = Math.min(
batch.length,
budget,
this.capacity - this.live
);
for (let i = 0; i < writable; i++) {
const index = (this.head + this.live + i) % this.capacity;
this.buffer[index] = batch[i];
}
this.live += writable;
this.dropped += batch.length - writable;
return { written: writable, dropped: batch.length - writable };
}
}

缓冲所有权
生成系统拥有待写批次,ParticleRing 独占 head、live 与槽位内容,GPU 消费侧只能通过 consume 推进读头。两个不变量是 0 <= live <= capacity,以及生产者绝不覆盖尚未消费的槽位。丢弃计数也是状态合同的一部分:它把视觉降级从静默覆盖变为可观测容量信号。
真实渲染管线还需要把“CPU 已写入”与“GPU 已消费”分开。探针用同步 consume 表示后者,只验证索引协议;消费数量必须是非负整数,负数与小数都在计算读头前抛出错误,保证 head、live、buffer 与 dropped 全部不变化。接入图形 API 时,读头必须由围栏或上一帧完成标记推进,不能以提交命令等同消费完成。
// .tmp/daily-labs/2026-07-27-1930/particle-ring.js
consume(count) {
if (!Number.isInteger(count) || count < 0) {
throw new RangeError("count");
}
const consumed = Math.min(count, this.live);
const values = Array.from(
{ length: consumed },
(_, i) => this.buffer[(this.head + i) % this.capacity]
);
this.head = (this.head + consumed) % this.capacity;
this.live -= consumed;
return values;
}
// 可写数量的完整约束
const writable = Math.min(
batch.length,
budget,
this.capacity - this.live
);
写入上界
每次调用最多循环 min(batch.length, budget, free) 次,因此单帧预算可以直接限制 CPU 打包与后续上传的实例数。容量为 8 的固定输入先请求写 5 个,预算为 3,只提交 3 个并丢弃 2 个;随后请求 7 个,虽然预算允许 7 个,但剩余槽位只有 5 个,因此再次丢弃 2 个。这里的“丢弃”是明确策略,不声称改善 GPU 性能。
预算的单位必须是实例数或可换算字节数,不能混用“事件数”。一个事件可能展开为不同粒子数量;若调用者按事件计数,环仍可能接收无界实例。本文固定每个数组元素为一个等大小实例,因此 budget=3 可直接约束三次槽位写入。
正常时序
Spawner -> ParticleRing : batch=5, budget=3, free=8
ParticleRing -> Buffer : write 3 slots
ParticleRing -> Metrics : dropped += 2
GPU -> ParticleRing : consume committed slots
满载时序
Spawner -> ParticleRing : batch=7, budget=7, free=5
ParticleRing -> Buffer : write 5 slots
ParticleRing -> Metrics : dropped += 2
ParticleRing -> Spawner : { written: 5, dropped: 2 }
回绕验证
探针先消费五个槽位,使读头离开数组起点,再写入四个值。模运算把尾部之后的数据写回数组开头,最终消费顺序仍是 [22,23,24,30,31,32,33]。这项断言验证的是逻辑 FIFO,而不是 JavaScript 数组的物理连续性;真实 GPU 实现可把一次回绕上传拆成尾段和首段两次区间更新。
拆段上传不能改变逻辑提交数量:尾段和首段必须共享一次预先计算的 writable。若分别按各自剩余空间重新计算,两段合计可能突破帧预算。记录一次写入范围,再派生成最多两个物理区间,是保留同一不变量的实现方式。
// .tmp/daily-labs/2026-07-27-1930/probe.test.js
check("budget limits write", () => {
assert.deepEqual(
ring.write([10, 11, 12, 13, 14], 3),
{ written: 3, dropped: 2 }
);
assert.equal(ring.live, 3);
});
check("capacity rejects overflow", () => {
assert.deepEqual(
ring.write([20, 21, 22, 23, 24, 25, 26], 7),
{ written: 5, dropped: 2 }
);
assert.equal(ring.live, 8);
});
check("invalid consume preserves state", () => {
for (const count of [-1, 1.5]) {
const before = {
head: ring.head,
live: ring.live,
buffer: [...ring.buffer],
dropped: ring.dropped
};
assert.throws(
() => ring.consume(count),
{ name: "RangeError", message: "count" }
);
assert.deepEqual({
head: ring.head,
live: ring.live,
buffer: [...ring.buffer],
dropped: ring.dropped
}, before);
}
});
check("consume preserves fifo", () => {
assert.deepEqual(ring.consume(5), [10, 11, 12, 20, 21]);
assert.equal(ring.live, 3);
});
check("wrap around preserves fifo", () => {
assert.deepEqual(
ring.write([30, 31, 32, 33], 4),
{ written: 4, dropped: 0 }
);
assert.deepEqual(
ring.consume(7),
[22, 23, 24, 30, 31, 32, 33]
);
});

运行证据
Node.js 探针完成五组测试,无失败与跳过;同一持久环依次执行 consume(-1) 与 consume(1.5),两次都抛出 RangeError,且各自失败前后的 head、live、buffer 与 dropped 完全相等。正常流程总计丢弃四个粒子,结束时 live=0。技术图固定数据未变化。单次耗时样本仅证明探针执行,不支持性能结论。
cd "$WORK_DIR"
/usr/bin/time -p npm test \
--prefix .tmp/daily-labs/2026-07-27-1930
# exit code: 0
# tests: passed=5 failed=0 skipped=0
{"slot":"2026-07-27-1930","tests":{"passed":5,"failed":0,"skipped":0},
"duration_ms":0.54,"capacity":8,"live":0,"dropped":4,
"first_write":{"requested":5,"budget":3,"written":3,"dropped":2}}
real 0.09
user 0.07
sys 0.02
策略取舍
扩容缓冲可以推迟满载,却不能建立每帧写入上界;覆盖最旧粒子会破坏 GPU 尚未消费数据的所有权。当前策略丢弃批次尾部,简单且可预测,代价是不同特效没有优先级。当丢弃率持续越过视觉预算,或关键技能特效与环境粒子竞争时,应演进为固定总预算下的优先级配额,而不是无限增大环。
监控也应区分“因帧预算丢弃”和“因容量不足丢弃”。探针将两者合并为总量以保持纵向切片最小;生产接入时若需要调参,应拆成两个计数器,否则无法判断应调整单帧预算、GPU 消费节奏还是缓冲容量。
当前复杂度: 写入 O(w),消费 O(c),其中 w/c 均受调用预算限制
容量边界: 0 <= live <= 8;非法消费不改变全部环状态
失效信号: dropped 持续增加或消费参数不是非负整数
演进触发: 关键特效饥饿、持续满载、多上传队列
后续合同: write(batch, budget, priorityClass)
架构结论
粒子环形缓冲首先是所有权和容量协议,其次才是内存布局。把批次长度、帧预算和剩余空间共同纳入写入数量,能在过载时保持数据不覆盖、工作有上界、降级可观测。若视觉质量要求更高,应在这份合同之上分配优先级,而不是让生产者绕过预算直接写 GPU 区。