GPU 还在读骨骼矩阵时,上传环形缓冲区不能提前绕回

三帧骨骼矩阵已经依次写进三个上传槽位,CPU 准备第四帧时,环形游标自然回到槽位 0。这个位置在 CPU 看来“上一轮已经用过”,GPU 却可能仍在读取它对应的第 100 号提交。若此时直接覆盖,随后一次 draw 读到的就不再是一套完整的旧姿态:同一角色的部分骨骼来自新帧,部分读取仍落在旧命令所引用的地址,画面会表现为难以稳定复现的关节拉扯。

蒙皮上传环形缓冲区主视觉

问题不在环形数组本身,而在“空闲”的定义。CPU 游标只能说明下一个候选槽位,不能证明 GPU 已经放弃该内存。可写资格必须由 GPU 完成进度决定。

游标选择候选,fence 决定资格

探针用 SkinningUploadRing 同时持有游标、每槽代次、在途 fence 和全局已完成 fence。调用链只有 TryAcquireCompleteFence:前者尝试为一次骨骼上传取得租约,后者接收渲染后端报告的单调完成值。

public readonly record struct UploadLease(
    int Slot,
    int Generation,
    long FenceValue);

public enum AcquireResult
{
    Acquired,
    GpuStillReading
}

public sealed record AcquireResponse(
    AcquireResult Result,
    UploadLease? Lease);

public sealed class SkinningUploadRing(int slotCount)
{
    private readonly Slot[] slots = Enumerable.Range(0, slotCount)
        .Select(index => new Slot(index))
        .ToArray();
    private int cursor;
    private long completedFence;

    public AcquireResponse TryAcquire(long submitFence)
    {
        Slot slot = slots[cursor];
        if (slot.InFlightFence > completedFence)
        {
            return new AcquireResponse(
                AcquireResult.GpuStillReading,
                null);
        }

        slot.Generation++;
        slot.InFlightFence = submitFence;
        cursor = (cursor + 1) % slots.Length;
        return new AcquireResponse(
            AcquireResult.Acquired,
            new UploadLease(
                slot.Index,
                slot.Generation,
                submitFence));
    }

    public void CompleteFence(long fenceValue)
    {
        if (fenceValue < completedFence)
        {
            throw new InvalidOperationException(
                "Fence completion must be monotonic");
        }

        completedFence = fenceValue;
    }

    private sealed class Slot(int index)
    {
        public int Index { get; } = index;
        public int Generation { get; set; }
        public long InFlightFence { get; set; }
    }
}

关键比较是 slot.InFlightFence > completedFence。槽位记录“最后一次引用我的提交”,完成值记录“GPU 已确认执行到哪里”。只有前者不大于后者,CPU 才能获得写权限。拒绝分支不推进游标、不递增代次,也不返回半有效租约;调用者可以等待、降级为上一帧姿态,或改走容量更大的缓冲区,但不能假装这次分配已经成功。

这里把代次放进租约,是为了阻止另一个常见错误:槽位 0 在 fence 100 完成后仍叫槽位 0,但它已从 0:1@100 变成 0:2@103。任何缓存旧租约的录制任务、蒙皮批次或取消回调,都必须同时匹配槽位与代次,不能只凭数组索引确认所有权。

fence 也不能被“CPU 已经写完”替代。持久映射缓冲区允许 CPU 很早完成矩阵拷贝,但旧 draw 是否已经消费这些字节,仍由 GPU 队列进度回答。反过来,GPU fence 完成只释放该槽位的读取所有权;新矩阵写入后是否需要 flush、资源屏障或命令队列间同步,取决于具体图形 API 的内存模型。这两层同步若被压成一个布尔值,代码评审时就无法区分“旧读者已离开”和“新数据已对后续读者可见”。探针刻意只证明前一层,并把后一层留给渲染后端实现。

提前绕回必须是可观察的失败

固定流程先提交 fence 100、101、102,三个槽位全部在途。第四次以 fence 103 请求槽位 0 时,完成值仍为 0,因此返回 GpuStillReading。随后只推进 CompleteFence(100),同一次请求才成功取得 0:2@103

三个上传槽位的 fence 回收与拒绝路径

对应回归样本还验证了两个容易被忽略的边界:完成值倒退会直接抛错;槽位 0 获准复用并不意味着槽位 1 也空闲,游标走到 fence 101 时仍应拒绝。换言之,完成 fence 是一个单调水位,但每个候选槽位仍要用自己的在途值单独判断。

源码位于 $LAB_DIR/Program.cs,入口符号是 SkinningUploadRing.TryAcquire,断言由 Program.Check 汇总。当天执行命令如下:

cd "$LAB_DIR"
dotnet restore GpuSkinningRingLab.csproj -p:NuGetAudit=false
dotnet build GpuSkinningRingLab.csproj -c Release --no-restore
dotnet run --project GpuSkinningRingLab.csproj -c Release --no-build
Build succeeded.
    0 Warning(s)
    0 Error(s)
early_submit=103 result=GpuStillReading active_slot0=0:1@100
completed_fence=100 reused_slot0=0:2@103
pass=8 fail=0 skip=0 elapsed_ms=29.520

八个断言覆盖首次填满、提前复用拒绝、完成后复用、下一槽仍繁忙、连续完成范围、倒退 fence 拒绝、代次变化与失败无租约。数据只证明状态与时序合同成立;它没有测量真实 GPU 等待时间、上传带宽或帧率,因此不能据此宣称三槽容量足够,也不能声称该结构提高了性能。

调用方还需要把拒绝结果变成明确策略。角色表现通常可以继续引用上一份已提交调色板一帧;过场动画录制可能要求阻塞以保证每个采样点都落盘;离线烘焙则可以扩展临时容量。三种选择的业务代价不同,却都必须建立在“没有获得租约就没有写权限”之上。若 TryAcquire 为了方便而返回槽位 0,再附带一个 busy=true 提示,调用者迟早会有一条分支忽略提示并写入;让失败结果根本不携带租约,类型形状才能把错误路径截断在写入之前。

扩容不能替代回收协议

把三个槽位改成四个或八个,只会推迟游标追上 GPU 的时刻。若 GPU 长时间积压,任何有限环最终都会绕回;没有 fence 门禁,容量越大只是让污染更晚出现。反过来,门禁存在后,容量才成为可测量的调优参数:持续出现 GpuStillReading 说明 CPU 提交深度超过当前环容量,此时可以依据真实等待分布决定扩容、批量合并或允许姿态复用。

生产渲染器还需要把租约绑定到实际偏移、骨骼数量、对齐粒度与命令列表生命周期,并在设备丢失时整体失效;多队列渲染则不能用一个未经定义的全局 fence 混合比较。本次纵向切片只处理单队列、单调 fence、固定槽位的所有权边界。

因此,第四帧到来时最安全的结果可能正是“暂时没有可写槽位”。环形游标负责寻找候选,GPU fence 负责释放资格,代次负责区分同一物理地址的不同生命周期。三者由同一个分配器原子推进,CPU 才不会把“数组绕回”误判成“GPU 已经读完”。