GPU 异步读回:行距解包必须先于像素快照提交

小地图遮罩从 GPU 异步读回到 CPU 后,若直接把 staging buffer 当作连续 RGBA8 像素,行尾对齐字节会被解释成下一行开头;若解包到一半才发现缓冲截断,旧快照还可能被半成品覆盖。证据标识 2026-08-02-1930 的最小 C# 探针把布局与已提交像素分开建模。

// .tmp/daily-labs/2026-08-02-1930/GpuReadbackSnapshot.cs
// ReadbackLayout / GpuReadbackSnapshot
public readonly record struct ReadbackLayout(
    int Width,
    int Height,
    int BytesPerPixel,
    int RowPitch
);

public sealed class GpuReadbackSnapshot
{
    private byte[] pixels = [];
    public int Version { get; private set; }
    public ReadOnlyMemory<byte> Pixels => pixels;
}

所有权边界

GPU 复制命令与 fence 位于读回层上游,只负责交付一段 staging 字节及其布局;GpuReadbackSnapshot 唯一拥有可供寻路、截图或编码线程读取的紧密像素。两个不变量是:已提交数组长度恒等于 width × height × bytesPerPixel;任何布局或长度错误都不得改变 pixelsVersion。调用者不能持有可写数组,避免提交后再被异步回调修改。

带行尾填充的 GPU 读回缓冲经过提交边界形成紧密 CPU 快照

staging.Length 等同于纹理字节数看似直接,却混淆了传输布局与资源布局。对齐策略属于图形 API 或驱动契约,消费者只应依赖显式 RowPitch;否则相同纹理在不同后端可能得到不同像素序列。

行距校验

紧密行宽先以受检乘法计算,再验证 RowPitch 不小于它,并验证 staging 至少覆盖 RowPitch × Height。所有条件通过后才分配候选数组;每行只复制有效的 12 字节,跳过 4 字节 padding。最后交换数组并递增版本,形成单一提交点。

// .tmp/daily-labs/2026-08-02-1930/GpuReadbackSnapshot.cs
// GpuReadbackSnapshot.TryCommit
public bool TryCommit(ReadbackLayout layout, ReadOnlySpan<byte> staging)
{
    if (layout.Width <= 0 || layout.Height <= 0 || layout.BytesPerPixel <= 0)
        return false;

    int tightRow;
    int required;
    try
    {
        tightRow = checked(layout.Width * layout.BytesPerPixel);
        required = checked(layout.RowPitch * layout.Height);
    }
    catch (OverflowException)
    {
        return false;
    }

    if (layout.RowPitch < tightRow || staging.Length < required)
        return false;

    var next = new byte[checked(tightRow * layout.Height)];
    for (var row = 0; row < layout.Height; row++)
        staging.Slice(row * layout.RowPitch, tightRow)
            .CopyTo(next.AsSpan(row * tightRow, tightRow));

    pixels = next;
    Version++;
    return true;
}

正常时序是 fence 完成、回调携带布局与 32 字节 staging、两行分别复制 12 字节、候选数组一次替换旧快照。失败时序在长度检查处终止:31 字节无法覆盖两条 16 字节传输行,既不开始逐行复制,也不发布新版本。

失败回归

回归先提交有效快照,再分别注入 8 字节短行距和 31 字节截断缓冲。两次失败后版本必须保持 1,像素仍为 1 至 24。若实现边读边写共享数组,测试会观察到旧版本内容被部分污染;若只检查总长度,短行距会导致相邻行重叠。

// .tmp/daily-labs/2026-08-02-1930/GpuReadbackSnapshot.cs
// short row pitch / truncated staging regression
Test("short row pitch preserves previous snapshot", () =>
{
    var snapshot = new GpuReadbackSnapshot();
    Equal(true, snapshot.TryCommit(layout, staging));
    Equal(false, snapshot.TryCommit(layout with { RowPitch = 8 }, staging));
    Equal(1, snapshot.Version);
    Sequence(expected, snapshot.Pixels.Span);
});

Test("truncated staging preserves previous snapshot", () =>
{
    var snapshot = new GpuReadbackSnapshot();
    Equal(true, snapshot.TryCommit(layout, staging));
    Equal(false, snapshot.TryCommit(layout, staging.AsSpan(0, 31)));
    Equal(1, snapshot.Version);
    Sequence(expected, snapshot.Pixels.Span);
});

三乘二 RGBA8 读回布局、逐行复制与原子提交关系

固定行为

探针使用 .NET 9 标准库,无外部包。Release 构建为 0 警告、0 错误;三个用例通过,失败 0、跳过 0,测试逻辑耗时 6.595 毫秒,完整运行进程耗时 0.69 秒,退出码为 0。时间只证明本次执行完成,不用于宣称渲染帧耗或吞吐改善。

$ cd "$WORK_DIR"
$ dotnet build .tmp/daily-labs/2026-08-02-1930/GpuReadbackProbe.csproj \
    -c Release --no-restore
Build succeeded.
    0 Warning(s)
    0 Error(s)
$ /usr/bin/time -p dotnet run \
    --project .tmp/daily-labs/2026-08-02-1930/GpuReadbackProbe.csproj \
    -c Release --no-build --no-restore
PASS row padding is removed
PASS short row pitch preserves previous snapshot
PASS truncated staging preserves previous snapshot
tests=3 passed=3 failed=0 skipped=0 duration_ms=6.595
real 0.69
user 0.41
sys 0.09
exit_code=0
{
  "evidenceId": "2026-08-02-1930",
  "layout": {
    "Width": 3,
    "Height": 2,
    "BytesPerPixel": 4,
    "RowPitch": 16
  },
  "stagingBytes": 32,
  "tightBytes": 24,
  "paddingBytesRemoved": 8,
  "committed": true,
  "rejected": false,
  "version": 1,
  "pixels": [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12,
             13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24]
}

容量边界

一次提交复制 height × tightRow 字节,时间复杂度为 O(width × height),峰值额外空间等于一份紧密快照。当前合同限定单平面、正向行序、每像素固定字节数;它不覆盖压缩纹理、平面式 YUV、负行距或 GPU 格式转换。固定输入中传输开销为 32 / 24 = 1.333,其中 8 字节仅为行对齐成本,不能据此外推真实分辨率的比例。

tightRow = 3 * 4 = 12 B
required  = 16 * 2 = 32 B
snapshot  = 12 * 2 = 24 B
padding   = 32 - 24 = 8 B

accept: rowPitch >= tightRow and stagingLength >= required
reject: keep pixels reference and keep version

每帧都读回并分配新数组能维持简单的所有权,却会把分配压力带入高频路径;复用同一可写数组则让读者可能看到撕裂快照。当前取舍优先不可变发布。若读回频率达到每帧、分辨率进入百万像素,或消费者跨线程长期持有快照,演进触发点是受引用计数保护的数组池与多槽发布,而不是删除校验或原地覆盖。

架构结论

GPU 异步完成只证明传输可访问,不证明 staging 布局等于纹理布局。读回边界必须显式接收行距,先完成尺寸、溢出与长度校验,再逐行构造候选像素,最后以一次引用替换发布新版本。这个顺序让 padding 成为可控的传输细节,也让截断、格式错误和未来多槽扩展共享同一个原则:失败可以丢弃本次读回,但不能污染上一份可读快照。