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 == 0、PendingCount == 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 替换后由消费者在安全点读取。
架构判断是:渲染合并可以是优化策略,全部碰撞准备成功才允许发布。把可失败构建与不可失败引用提交分成两阶段,才能保证画面、导航与物理对同一次地图编辑形成一致事实。