GPU 蒙皮矩阵页:容量校验必须先于游标提交

同一帧要绘制英雄、敌人与杂兵时,蒙皮批处理器会把各角色的骨骼矩阵写入一页 GPU 可见缓冲。若分配器先推进游标,再发现某个角色超出页容量,后续本可容纳的小角色也会被错误拒绝。证据标识 2026-08-03-2000 的最小 C# 探针把每次预留定义为不可拆分的字节区间。

// .tmp/daily-labs/2026-08-03-2000/SkinningPalettePage.cs
// PaletteSlice / SkinningPalettePage fields
public readonly record struct PaletteSlice(
    int OffsetBytes,
    int BoneCount,
    int SizeBytes
);

public sealed class SkinningPalettePage
{
    public const int MatrixBytes = 48;
    private readonly int capacityBytes;
    private readonly int alignmentBytes;
    private int cursorBytes;
}

所有权边界

SkinningPalettePage 唯一拥有页容量、对齐量与已提交游标;批处理器只提交骨骼数量,GPU 上传阶段只读取成功返回的 PaletteSlice。这里采用 3×4 仿射矩阵,每骨骼 12 个 float,即 48 字节。两个不变量是:成功区间互不重叠且完全位于页内;失败预留不能改变游标。骨骼姿态计算、可见性裁剪和实际图形 API 映射不属于该所有者。帧结束时整页统一失效,切片不得缓存到下一帧,也不能被材质或角色对象长期持有。

多角色骨骼矩阵进入有界 GPU 缓冲页

状态所有者:SkinningPalettePage
输入:boneCount
输出:PaletteSlice(offsetBytes, boneCount, sizeBytes)
正常:计算对齐起点 -> 校验末端 -> 提交游标 -> 返回切片
失败:计算对齐起点 -> 末端越界 -> 返回 false -> 游标不变

页内预留

实现先在局部变量中计算 alignedsize。只有 aligned + size 不超过容量时,才构造切片并更新 cursorBytes。这使检查与提交之间没有半成品状态。checked 还把异常大的骨骼数转换为显式算术失败,避免整数回绕伪造页内区间。

// .tmp/daily-labs/2026-08-03-2000/SkinningPalettePage.cs
// SkinningPalettePage.TryReserve
public bool TryReserve(int boneCount, out PaletteSlice slice)
{
    if (boneCount <= 0)
    {
        slice = default;
        return false;
    }

    int aligned = AlignUp(cursorBytes, alignmentBytes);
    int size = checked(boneCount * MatrixBytes);
    if (aligned + size > capacityBytes)
    {
        slice = default;
        return false;
    }

    slice = new PaletteSlice(aligned, boneCount, size);
    cursorBytes = aligned + size;
    return true;
}

private static int AlignUp(int value, int alignment) =>
    checked((value + alignment - 1) / alignment * alignment);

对齐容量

固定页为 4096 字节,切片起点按 256 字节对齐。英雄 32 骨骼占 1536 字节;敌人 20 骨骼占 960 字节,结束于 2496;下一个起点必须跳到 2560,因此产生 64 字节显式空洞。杂兵 8 骨骼再占 384 字节,最终游标为 2944,剩余 1152 字节。容量判断必须包含对齐空洞,不能只累加矩阵净字节。

4096 字节骨骼矩阵页的对齐区间与溢出请求

批次 骨骼数 起点 大小 结果
英雄 32 0 1536 B 提交
敌人 20 1536 960 B 提交
大型请求 40 2560 1920 B 拒绝
杂兵 8 2560 384 B 提交

溢出回归

最危险的替代方案是先执行 cursor += size,越界后再返回失败;它看似减少分支,却把拒绝动作变成了状态写入。回归用 2048 字节小页先放入 32 骨骼,再制造 16 骨骼溢出,最后验证 10 骨骼仍能从 1536 开始。若失败路径推进过游标,最后一步就无法成功。

// .tmp/daily-labs/2026-08-03-2000/SkinningPalettePage.cs
// overflow keeps cursor unchanged
Test("overflow keeps cursor unchanged", () =>
{
    var page = new SkinningPalettePage(2048, 256);
    page.TryReserve(32, out _);
    Equal(false, page.TryReserve(16, out _));
    Equal(true, page.TryReserve(10, out var fallback));
    Equal(1536, fallback.OffsetBytes);
});

Test("alignment gap is explicit", () =>
{
    var page = new SkinningPalettePage(4096, 256);
    page.TryReserve(25, out _);
    page.TryReserve(10, out var second);
    Equal(1280, second.OffsetBytes);
});

骨骼矩阵页正常预留、溢出拒绝与后续提交时序

运行证据

探针使用 .NET 9 标准库且没有外部包。Release 构建为 0 警告、0 错误;三个用例通过,失败 0、跳过 0,测试逻辑耗时 5.592 毫秒,完整运行进程耗时 1.18 秒,退出码为 0。这些时间只证明本次执行完成,不构成 CPU 或 GPU 性能结论。

$ cd "$WORK_DIR"
$ dotnet build .tmp/daily-labs/2026-08-03-2000/SkinningPaletteProbe.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-03-2000/SkinningPaletteProbe.csproj \
    -c Release --no-build --no-restore
PASS aligned reservations do not overlap
PASS alignment gap is explicit
PASS overflow keeps cursor unchanged
tests=3 passed=3 failed=0 skipped=0 duration_ms=5.592
real 1.18
user 0.28
sys 0.10
exit_code=0
{
  "evidenceId": "2026-08-03-2000",
  "heroSlice": { "OffsetBytes": 0, "BoneCount": 32, "SizeBytes": 1536 },
  "enemySlice": { "OffsetBytes": 1536, "BoneCount": 20, "SizeBytes": 960 },
  "overflowAccepted": false,
  "minionSlice": { "OffsetBytes": 2560, "BoneCount": 8, "SizeBytes": 384 },
  "page": {
    "capacityBytes": 4096,
    "alignmentBytes": 256,
    "cursorBytes": 2944,
    "remainingBytes": 1152
  }
}

演进边界

当前算法每次预留为 O(1),页对象只保存一个游标,适用于单线程构建一帧渲染批次。取舍是接受对齐空洞,以换取图形后端可直接绑定的稳定偏移;按角色紧密拼接虽节省几十字节,却会把后端对齐约束推迟到上传阶段,造成第二套偏移真相。溢出角色当前应进入 CPU 蒙皮、低骨骼 LOD 或下一页,而不是部分上传骨骼。

当前合同:单帧、单写者、固定矩阵布局、固定页容量
失效信号:rejected bones 持续增加或对齐空洞占比超过预算
演进触发:多线程批次构建、跨页角色、每骨骼附加法线矩阵
下一结构:线程本地预估 -> 页级原子范围预留 -> 已提交切片上传队列
禁止退化:把一个角色拆成部分成功的骨骼区间

架构结论

GPU 蒙皮矩阵页的关键不是把字节尽量塞满,而是让容量、对齐和提交共享一个状态所有者。预留必须先完整计算候选区间,验证页内边界后才能推进游标;失败只产生可观测拒绝,不留下状态痕迹。该合同允许上层安全选择 LOD、备用页或 CPU 路径,并为多线程批处理交付了明确的原子范围预留边界。