动态图集整理:像素搬运完成前不能替换 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,而在唯一所有者能否证明所有目标像素完整、票据仍当前,并以一次引用交换向渲染器公开新布局。