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 映射不属于该所有者。帧结束时整页统一失效,切片不得缓存到下一帧,也不能被材质或角色对象长期持有。

状态所有者:SkinningPalettePage
输入:boneCount
输出:PaletteSlice(offsetBytes, boneCount, sizeBytes)
正常:计算对齐起点 -> 校验末端 -> 提交游标 -> 返回切片
失败:计算对齐起点 -> 末端越界 -> 返回 false -> 游标不变
页内预留
实现先在局部变量中计算 aligned 与 size。只有 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 字节。容量判断必须包含对齐空洞,不能只累加矩阵净字节。

| 批次 | 骨骼数 | 起点 | 大小 | 结果 |
|---|---|---|---|---|
| 英雄 | 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 路径,并为多线程批处理交付了明确的原子范围预留边界。