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 独占 headlive 与槽位内容,GPU 消费侧只能通过 consume 推进读头。两个不变量是 0 <= live <= capacity,以及生产者绝不覆盖尚未消费的槽位。丢弃计数也是状态合同的一部分:它把视觉降级从静默覆盖变为可观测容量信号。

真实渲染管线还需要把“CPU 已写入”与“GPU 已消费”分开。探针用同步 consume 表示后者,只验证索引协议;消费数量必须是非负整数,负数与小数都在计算读头前抛出错误,保证 headlivebufferdropped 全部不变化。接入图形 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,且各自失败前后的 headlivebufferdropped 完全相等。正常流程总计丢弃四个粒子,结束时 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 区。