动态图集整理:像素搬运完成前不能替换 UV 映射
战斗中持续装载头像、技能图标与伤害贴花时,动态图集会逐渐产生碎片。整理器可以把区域搬到连续空间,但若复制一个区域就立即更新它的 UV,渲染批次会同时采样新旧布局;复制失败还会留下指向未完成像素的映射。证据标识 2026-08-05-1930 的最小 C# 探针把一次整理定义为带代际的完整搬运事务。
// .tmp/daily-labs/2026-08-05-1930/AtlasRelocationGate.cs
// AtlasRect / Relocation / RelocationTicket
public readonly record struct AtlasRect(
int X, int Y, int Width, int Height
);
public readonly record struct Relocation(
string Sprite,
AtlasRect Source,
AtlasRect Destination
);
public readonly record struct RelocationTicket(
int Generation,
Relocation[] Copies
);
所有权边界
AtlasRelocationGate 唯一拥有渲染可见的区域映射、当前票据与已复制集合。规划器只提交搬运计划,复制队列只报告完成,渲染器只读取 Visible;纹理分配、命令编码和 GPU 执行均在门外。两个不变量是:未完成全部复制时可见映射不变;一次提交只发布完整旧表或完整新表,不发布混合表。像素可以先存在于目标区域,但在提交前不可寻址。

计划代际
每次 Begin 先校验空计划、重复精灵和非法矩形,再递增代际并清空上次复制进度。MarkCopied 同时核对票据代际和精灵成员资格。只核对精灵名会失败:连续两次整理可能搬运同一精灵,较慢的旧栅栏仍会把新事务误标为完成。
// .tmp/daily-labs/2026-08-05-1930/AtlasRelocationGate.cs
// Begin / MarkCopied / Owns
public RelocationTicket Begin(params Relocation[] copies)
{
ValidatePlan(copies);
copied.Clear();
pending = new RelocationTicket(++generation, copies);
return pending.Value;
}
public bool MarkCopied(RelocationTicket ticket, string sprite)
{
if (!Owns(ticket)) return false;
if (!ticket.Copies.Any(copy => copy.Sprite == sprite))
return false;
copied.Add(sprite);
return true;
}
private bool Owns(RelocationTicket ticket) =>
pending is { } current &&
current.Generation == ticket.Generation;
原子映射
正常时序先复制 hero,此时 TryCommit 返回 false,渲染仍读取 [0,0,64,64];effect 完成后,提交函数才在私有字典中构造新表并一次替换引用。复制命令的完成顺序不参与渲染语义,唯一观察点是映射引用交换。逐项改写原字典看似省一次分配,却允许同一帧中不同绘制命令观察到不同代际。
// .tmp/daily-labs/2026-08-05-1930/AtlasRelocationGate.cs
// TryCommit / Abort
public bool TryCommit(RelocationTicket ticket)
{
if (!Owns(ticket)) return false;
if (ticket.Copies.Any(copy =>
!copied.Contains(copy.Sprite))) return false;
var next = new Dictionary<string, AtlasRect>(visible);
foreach (var copy in ticket.Copies)
next[copy.Sprite] = copy.Destination;
visible = next;
pending = null;
copied.Clear();
return true;
}
public bool Abort(RelocationTicket ticket)
{
if (!Owns(ticket)) return false;
pending = null;
copied.Clear();
return true;
}

失败回归
失败时序固定让 hero 复制完成、effect 失败,再调用 Abort。正确恢复不是回滚已经写入的目标像素,而是清除票据并继续暴露旧映射;目标像素成为暂时不可达空间,可由后续分配覆盖。另一用例启动第二代计划后返回第一代栅栏,验证旧完成既不能增加当前计数,也不能触发提交。
// .tmp/daily-labs/2026-08-05-1930/Program.cs
// copy failure / stale fence regression
Run("copy failure preserves old mapping", () =>
{
var gate = NewGate();
var ticket = NewPlan(gate);
True(gate.MarkCopied(ticket, "hero"));
True(gate.Abort(ticket));
False(gate.TryCommit(ticket));
Equal(new AtlasRect(0, 0, 64, 64),
gate.Visible["hero"]);
Equal(new AtlasRect(64, 0, 32, 32),
gate.Visible["effect"]);
});
Run("stale fence cannot replace current mapping", () =>
{
var gate = NewGate();
var stale = NewPlan(gate);
var current = gate.Begin(
new("hero", new(0, 0, 64, 64),
new(0, 128, 64, 64)),
new("effect", new(64, 0, 32, 32),
new(64, 128, 32, 32))
);
False(gate.MarkCopied(stale, "hero"));
foreach (var copy in current.Copies)
True(gate.MarkCopied(current, copy.Sprite));
True(gate.TryCommit(current));
});
运行证据
探针使用 .NET 9 标准库,不含外部包。Release 构建为 0 警告、0 错误;三个用例全部通过,失败 0、跳过 0,测试逻辑耗时 131.481 毫秒,完整运行进程耗时 2.34 秒,退出码 0。时间仅证明本次执行完成,不构成帧耗或吞吐结论。
$ cd "$WORK_DIR"
$ dotnet restore \
.tmp/daily-labs/2026-08-05-1930/AtlasRelocationProbe.csproj
Restored AtlasRelocationProbe.csproj (in 170 ms).
$ dotnet build \
.tmp/daily-labs/2026-08-05-1930/AtlasRelocationProbe.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-05-1930/AtlasRelocationProbe.csproj \
-c Release --no-build --no-restore
PASS all copies commit mapping atomically
PASS copy failure preserves old mapping
PASS stale fence cannot replace current mapping
tests=3 passed=3 failed=0 skipped=0 duration_ms=131.481
real 2.34
user 1.26
sys 0.60
exit_code=0
{
"evidenceId": "2026-08-05-1930",
"generation": 1,
"pending": false,
"copied": [],
"visible": {
"effect": [192, 0, 32, 32],
"hero": [128, 0, 64, 64]
}
}
容量边界
当前实现用 HashSet 记录复制完成项,单次报告均摊 O(1);提交复制包含 S 个精灵的映射表并扫描 R 个搬运项,时间为 O(S+R)、额外空间为 O(S)。它适合单纹理、单待提交计划与主线程映射交换,不处理跨图集搬运、旋转装箱和 mip 级复制。当前取舍用一次字典分配换取读侧无锁且快照一致。
当前合同:单图集、单待提交票据、完整区域复制
失败恢复:废弃票据,旧映射持续可见,目标像素不可达
失效信号:映射复制产生帧尖峰,整理等待超过资源预算
演进触发:S 持续超过 4096,或多图集并行整理
下一结构:不可变映射页 + atlas epoch + GPU fence batch
当映射条目持续超过 4096 或多张图集并行整理时,应把整表复制替换为不可变映射页,并以图集代际和批量栅栏提交页根;但像素准备与 UV 发布仍不能合并成逐项更新。动态图集整理的安全边界不在复制 API,而在唯一所有者能否证明所有目标像素完整、票据仍当前,并以一次引用交换向渲染器公开新布局。