GPU 粒子生成环形缓冲:栅栏完成前不能回卷覆盖

爆炸、命中特效与天气粒子可能在同一帧把生成参数写入 GPU 上传缓冲。环形缓冲能避免逐批分配,但“写指针回到零”不表示头部已经可写;只要上一批 Draw 或 Compute 尚未越过栅栏,覆盖该区间就会让 GPU 读取下一批数据。证据标识 2026-08-06-2000,以下探针把字节区间、提交顺序与完成栅栏放入同一所有权模型。

// .tmp/daily-labs/2026-08-06-2000/ParticleSpawnRing.cs
// SpawnSlice
public readonly record struct SpawnSlice(
    int Offset,
    int Length,
    ulong Fence)
{
    public int End => Offset + Length;
}

GPU 粒子生成环形缓冲主视觉

区间所有权

SpawnSlice 表示一次已经交给 GPU 的连续字节区间。状态由 ParticleSpawnRing 独占:调用方只能请求区间和提交栅栏,GPU 完成回调只能按栅栏释放队首。两个不变量是,在途区间不能重叠;失败请求不得改变写头或队列。这里假设命令按提交顺序完成,因此单调栅栏与 FIFO 队列同序。

// .tmp/daily-labs/2026-08-06-2000/ParticleSpawnRing.cs
// ParticleSpawnRing fields and constructor
public sealed class ParticleSpawnRing
{
    private readonly int _capacity;
    private readonly Queue<SpawnSlice> _inFlight = new();
    private int _writeHead;

    public ParticleSpawnRing(int capacity)
    {
        if (capacity <= 0)
            throw new ArgumentOutOfRangeException(nameof(capacity));
        _capacity = capacity;
    }

    public int Capacity => _capacity;
    public int WriteHead => _writeHead;
    public IReadOnlyCollection<SpawnSlice> InFlight => _inFlight;
}

连续预留

预留先比较尾部连续空间;尾部不足时,只有请求完整落在 [0, oldestOffset) 才能回卷。把剩余总字节相加后允许跨尾写入看似利用率更高,却会迫使一次 GPU 读取拆成两个区间,改变绑定合同。探针选择连续切片,容量不足直接返回 false,由调用方延后或降级粒子数量。

// .tmp/daily-labs/2026-08-06-2000/ParticleSpawnRing.cs
// ParticleSpawnRing.TryReserve
public bool TryReserve(int bytes, ulong fence, out SpawnSlice slice)
{
    slice = default;
    if (bytes <= 0 || bytes > _capacity)
        return false;

    if (_inFlight.Count == 0)
        return Commit(_writeHead + bytes <= _capacity ? _writeHead : 0,
            bytes, fence, out slice);

    var oldestOffset = _inFlight.Peek().Offset;
    if (_writeHead == oldestOffset)
        return false;
    if (_writeHead >= oldestOffset)
    {
        if (_writeHead + bytes <= _capacity)
            return Commit(_writeHead, bytes, fence, out slice);
        if (bytes <= oldestOffset)
            return Commit(0, bytes, fence, out slice);
        return false;
    }

    if (_writeHead + bytes <= oldestOffset)
        return Commit(_writeHead, bytes, fence, out slice);
    return false;
}

GPU 粒子生成区间的栅栏释放与安全回卷

正常回卷

固定容量为 256 B。fence 10 占 [0,96),fence 11 占 [96,176),尾部只剩 80 B,第三个 96 B 请求必须失败。CompleteThrough(10) 释放 96 B 后,请求才能提交到 offset 0;此时 fence 11 与 fence 12 仍各自拥有不相交区间。

// .tmp/daily-labs/2026-08-06-2000/Program.cs
// completed-fence-allows-safe-wrap
Run("completed-fence-allows-safe-wrap", () =>
{
    var ring = new ParticleSpawnRing(256);
    True(ring.TryReserve(96, 10, out var first));
    True(ring.TryReserve(80, 11, out var second));
    False(ring.TryReserve(96, 12, out _));
    Equal(2, ring.InFlight.Count);

    Equal(96, ring.CompleteThrough(10));
    True(ring.TryReserve(96, 12, out var wrapped));
    Equal(new SpawnSlice(0, 96, 12), wrapped);
    Equal(new SpawnSlice(0, 96, 10), first);
    Equal(new SpawnSlice(96, 80, 11), second);
});

正常时序是 reserve → copy → submit → fence complete → release。环形分配器不拥有粒子模拟状态,也不判断特效优先级;它只保证 CPU 写入范围在 GPU 消费结束前保持稳定。粒子系统可在预留失败时减少低优先级发射量,但不能绕过区间门禁。

失败退化

失败时序覆盖两类错误。陈旧的 fence 9 完成事件不能释放 fence 10 或 11;96 B 预留失败后,写头仍为 176,队列仍有两个成员。恢复路径是等待最旧栅栏或降低本批字节数,不是强制推进头指针。

// .tmp/daily-labs/2026-08-06-2000/Program.cs
// stale-fence-cannot-release-in-flight-bytes
Run("stale-fence-cannot-release-in-flight-bytes", () =>
{
    var ring = new ParticleSpawnRing(256);
    True(ring.TryReserve(96, 10, out _));
    True(ring.TryReserve(80, 11, out _));

    Equal(0, ring.CompleteThrough(9));
    False(ring.TryReserve(96, 12, out _));
    Equal(2, ring.InFlight.Count);
});

Run("failed-reservation-keeps-head-and-queue", () =>
{
    var ring = new ParticleSpawnRing(256);
    True(ring.TryReserve(176, 20, out _));
    var head = ring.WriteHead;
    var count = ring.InFlight.Count;

    False(ring.TryReserve(96, 21, out _));
    Equal(head, ring.WriteHead);
    Equal(count, ring.InFlight.Count);
});

运行证据

Release 构建和运行退出码均为 0,构建 0 警告、0 错误,三项测试全部通过。固定状态显示释放前 176 B 在途、写头为 176,96 B 请求未提交;fence 10 完成后释放 96 B,新切片以 offset 0、length 96、fence 12 提交。

$ cd $WORK_DIR/.tmp/daily-labs/2026-08-06-2000
$ dotnet build ParticleSpawnRingProbe.csproj -c Release --nologo
Build succeeded.
    0 Warning(s)
    0 Error(s)
Time Elapsed 00:00:07.36
exit=0

$ dotnet run --project ParticleSpawnRingProbe.csproj \
    -c Release --no-build
PASS completed-fence-allows-safe-wrap
PASS stale-fence-cannot-release-in-flight-bytes
PASS failed-reservation-keeps-head-and-queue
tests: passed=3 failed=0 skipped=0 elapsedMs=34.0019
beforeFence: capacity=256 inFlight=176 head=176 request=96 committed=false
afterFence10: released=96 offset=0 length=96 fence=12 committed=true
exit=0

34.0019 毫秒包含进程启动、断言与输出,不代表 GPU 上传耗时。当前探针验证分配正确性,不支持吞吐结论。容量可按 峰值每帧生成数 × 单粒子参数字节 × 最大在途帧数 估算,但必须由真实内容峰值和命令队列延迟重新取样。

演进边界

单环适用于一个提交队列、单调栅栏和连续读取合同。出现多队列异步计算时,单个完成值无法证明所有消费者结束,应改为每切片记录队列栅栏集合;当大批请求持续因碎片失败但总空闲充足时,可引入固定大小页或允许双区间绑定。架构判断是:回卷是地址计算,不是所有权释放。只有最旧在途区间对应的 GPU 栅栏完成,头部字节才重新属于 CPU 写入端;否则宁可丢弃低优先级粒子,也不能覆盖仍被 GPU 读取的数据。