Tilemap 脏分块提交:渲染合并与碰撞更新必须共享失败边界

角色破坏一格墙体时,Tilemap 的视觉网格与碰撞轮廓都要变化。逐格重建会把编辑密度直接放大成提交次数;逐区间边建边写,则会在后续区间失败时留下半批新碰撞。2026-07-29-1930 探针把单元坐标映射到固定分块,先准备全部不可变快照,再用一次引用替换发布整批结果。

// .tmp/daily-labs/2026-07-29-1930/Program.cs
readonly record struct Cell(int X, int Y);
readonly record struct Chunk(int X, int Y);
readonly record struct ChunkRange(
    int MinX,
    int MinY,
    int MaxX,
    int MaxY);
readonly record struct CollisionPreparation(
    bool Ready,
    ChunkRange Snapshot);

sealed class TilemapDirtyCommitter
{
    private readonly int chunkSize;
    private readonly HashSet<Chunk> dirty = [];
    public int PendingCount => dirty.Count;
    public int PublishedCount => published.Count;
}

分块更新主视觉

状态边界

状态所有者是 TilemapDirtyCommitter,调用者只提交被修改的格子,碰撞构建器只返回准备结果,消费者只读取已发布批次。三个不变量由此可检验:同一分块内重复格子只产生一个脏项;全部区间准备完成前发布批次不得变化;准备失败后脏集不得丢失。分块边长为 16 格,坐标映射必须使用向下取整,因此 (-1,-1) 属于 (-1,-1) 分块,而不是零分块。

这里的区间坐标始终使用分块单位,不混入格子或世界坐标。消费者需要世界包围盒时,应在边界外用统一分块尺寸换算;若在合并器内提前换算,负坐标取整与碰撞边界会出现两套规则。

// Program.cs: TilemapDirtyCommitter.Mark / FloorDiv
public void Mark(Cell cell) => dirty.Add(new Chunk(
    FloorDiv(cell.X, chunkSize),
    FloorDiv(cell.Y, chunkSize)));

private static int FloorDiv(int value, int divisor) =>
    value >= 0
        ? value / divisor
        : (value - divisor + 1) / divisor;

合并算法

探针采用横向连续区间,不尝试求最小矩形覆盖。它从 Y、X 最小的分块开始,只扩展同一行连续的 X,并从待处理集合删除已消费项。该规则的价值是确定性:相同脏集总得到相同区间顺序,技术图中的 3 个唯一分块可复算为 2 个区间。矩形合并可能进一步减少调用,但需要处理洞、跨行形状和面积膨胀;没有重建开销数据前不增加这层复杂度。

// Program.cs: TilemapDirtyCommitter.Merge
private static List<ChunkRange> Merge(HashSet<Chunk> source)
{
    var pending = new HashSet<Chunk>(source);
    var ranges = new List<ChunkRange>();
    while (pending.Count > 0)
    {
        var seed = pending
            .OrderBy(c => c.Y)
            .ThenBy(c => c.X)
            .First();
        var maxX = seed.X;
        while (pending.Contains(new Chunk(maxX + 1, seed.Y)))
            maxX++;
        for (var x = seed.X; x <= maxX; x++)
            pending.Remove(new Chunk(x, seed.Y));
        ranges.Add(new ChunkRange(seed.X, seed.Y, maxX, seed.Y));
    }
    return ranges;
}

脏分块提交证据图

失败语义

正常时序是格子变化、分块去重、区间合并、逐区间准备快照、一次引用替换发布、清空脏集。准备阶段允许失败但不得写外部状态;发布阶段只是内存引用替换,不再执行会失败的物理烘焙。第二个区间准备失败时,第一个快照仍只在局部列表中,published 和脏集都不变化。看似合理的逐区间重建并提交会在第二项失败时暴露第一项,无法声称整批原子。

// Program.cs: TilemapDirtyCommitter.Commit
public IReadOnlyList<ChunkRange> Commit(
    Func<ChunkRange, CollisionPreparation> prepareCollision)
{
    var ranges = Merge(dirty);
    var prepared = new List<ChunkRange>(ranges.Count);
    foreach (var range in ranges)
    {
        var result = prepareCollision(range);
        if (!result.Ready)
            throw new InvalidOperationException(
                $"collision rebuild rejected {range}");
        prepared.Add(result.Snapshot);
    }
    published = prepared;
    dirty.Clear();
    return ranges;
}

回归断言

四个固定格子落入三个分块,其中 (0,0)(1,0) 合并为一个区间,(0,1) 保持独立。关键回归另建两个纵向区间,让第一项准备成功、第二项明确失败;捕获异常后同时断言准备次数为 2、PublishedCount == 0PendingCount == 2。这比只测第一项立即失败更能证明没有部分提交。

// Program.cs: Probe.Main
Check("second_prepare_failure_publishes_nothing", () =>
{
    var c = new TilemapDirtyCommitter(16);
    c.Mark(new Cell(1, 1));
    c.Mark(new Cell(1, 17));
    var prepared = 0;
    try
    {
        c.Commit(range => new(++prepared != 2, range));
    }
    catch (InvalidOperationException) { }
    if (prepared != 2)
        throw new Exception("second range was not prepared");
    if (c.PublishedCount != 0)
        throw new Exception("partial batch was published");
    if (c.PendingCount != 2)
        throw new Exception("dirty batch was lost");
});

执行结果

本次运行使用 .NET 9 Release。探针只报告控制流与集合状态,不据此声称真实物理引擎的耗时改善;7.934 ms 包含四个断言与 JSON 写入,只用于证明当次执行完成。

cd $WORK_DIR/.tmp/daily-labs/2026-07-29-1930
dotnet run -c Release
# exit code: 0
PASS duplicate_cells_collapse_to_chunks
PASS negative_cells_use_floor_division
PASS collision_failure_keeps_dirty_set
PASS second_prepare_failure_publishes_nothing
FIXED chunks=3 ranges=2 published=2 pending=0
RESULT passed=4 failed=0 skipped=0 elapsed_ms=7.934 exit=0

容量边界

当前合并每轮排序一次,最坏复杂度高于线性,适用于每帧几十至数百个脏分块的纵向切片。失效信号不是格子总量,而是 PendingCount 持续增长、单次提交跨越多帧或碰撞重建超过帧预算。达到该条件时,应把脏集改成按行有序集合并设置每帧提交预算;在此之前,保留一个集合与一个提交点更容易审计。

分帧准备时仍需保留批次代际:旧批次未发布前,新编辑只能进入下一代脏集,不能修改正在准备的区间,否则快照对应的就不再是渲染所见版本。真实引擎接入时,Snapshot 应是已烘焙且不可变的碰撞数据,published 替换后由消费者在安全点读取。

架构判断是:渲染合并可以是优化策略,全部碰撞准备成功才允许发布。把可失败构建与不可失败引用提交分成两阶段,才能保证画面、导航与物理对同一次地图编辑形成一致事实。