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;任何布局或长度错误都不得改变 pixels 与 Version。调用者不能持有可写数组,避免提交后再被异步回调修改。

把 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);
});

固定行为
探针使用 .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 成为可控的传输细节,也让截断、格式错误和未来多槽扩展共享同一个原则:失败可以丢弃本次读回,但不能污染上一份可读快照。