大地图负坐标分块:整数除法为何把原点两侧装进同一块

角色从世界原点向左移动一毫米,位置已经是 -1,资源请求却仍然指向第 0 块。若块宽是 1000 毫米,直接计算 C# 的 position / size 确实得到 0;同时计算的余数却是 -1。这个地址既没有进入左侧块,也不能直接作为非负的块内索引。给加载器增加重试不会改变它,因为错误发生在请求键生成之前。

这里用一维整数坐标把问题收紧:第 0 块拥有 [0,1000),第 -1 块拥有 [-1000,0),右端点属于下一块。毫米只是固定单位,1000 是方便复算的样本块宽,并非大地图应采用的一米分块建议。二维或三维可以逐轴分解,本文先验证一条轴的地址与范围合同。

原点左右的位置分别归属于相邻分块的空间示意

块号和块内偏移必须一起修正

C# 整数除法向零截断。对于 -1,它给出商 0、余数 -1;对于恰好落在左边界的 -1000,商 -1、余数 0 已经正确。因此不能对所有负坐标无条件把块号减一,否则整块边界会再次被分错。

所需分解满足 x = chunk × size + local,并要求 0 ≤ local < size。余数为负时,把商减一、余数加上块宽,等式仍成立,局部偏移也回到合法范围。正块宽下,这份分解唯一:若两份合法结果具有不同块号,它们相差至少一个完整块宽,但两份合法偏移之差的绝对值小于块宽,无法同时重建同一个 x。

这条唯一性决定了资源缓存和块内访问应共享一个入口。加载器若采用向下取整,碰撞网格却继续向零截断,同一实体就会拥有两份地址。这里只返回块号与偏移,不接管异步加载状态,也不把某个渲染原点的偏移写入持久资源键。

实现位于研究工程 ChunkAddress.cs,调用链是 Program.Main → ChunkAddress.Split;范围入口 Cover 复用同一个 Split。完整核心代码如下:

public readonly record struct ChunkPoint(long Chunk, long Local);
public readonly record struct ChunkRange(long First, long Last);

public static class ChunkAddress
{
    public static ChunkPoint Split(long position, long size)
    {
        if (size <= 0) throw new ArgumentOutOfRangeException(nameof(size));
        long chunk = position / size;
        long local = position % size;
        // 把向零截断修正为向下取整,块内偏移保持非负。
        if (local < 0)
        {
            chunk--;
            local += size;
        }
        return new ChunkPoint(chunk, local);
    }

    public static ChunkRange? Cover(long min, long maxExclusive, long size)
    {
        if (size <= 0) throw new ArgumentOutOfRangeException(nameof(size));
        if (min > maxExclusive) throw new ArgumentException("reversed range");
        // 只有非空整数区间才允许取最后一个坐标。
        if (min == maxExclusive) return null;
        return new ChunkRange(Split(min, size).Chunk,
            Split(maxExclusive - 1, size).Chunk);
    }
}

这里没有用 Math.Abs(position),因为最小有符号整数的绝对值不能用同一类型表示;也没有用 position - chunk * size 重新计算余数。即使最终坐标可表示,中间的块原点乘积仍可能越过 long 下界。直接保留除法余数,避免了这次不必要的乘法。

修正本身也能说明边界。local < 0 时,块宽必然大于 1;此时商不可能已经是 long.MinValue,减一不会下溢。负余数大于 -size,加上正块宽之后介于 1 与 size-1。宽度为 1 时余数恒为零,无须修正。工程启用了 checked 整数运算,并用极值样本验证这些分支,而非依赖默认回绕掩盖错误。

加载范围的右端点不属于请求集合

把角色所在块求对之后,预载区域还可能多出一块。请求范围 [-1000,0) 的最后一个整数坐标是 -1,所以只覆盖块 -1。若直接对右端点 0 求块号,就会把块 0 加进去;把所有边界都向外扩张,也无法解释空范围应该加载什么。

Cover 接收半开整数区间,先拒绝反转范围,再对空范围返回 null,最后用 maxExclusive - 1 求末块。顺序有实际作用:当两个端点同为 long.MinValue 时,先做减一会下溢,先识别空集则根本不需要算术。非空条件保证右端点大于最小整数,减一是安全的。

整数分块、负余数修正与半开请求范围的固定数据

返回的 ChunkRange 两端都是包含端点的块号;输入半开、输出闭区间是接口的一部分。范围 [-1,1) 返回 -1 到 0,而 [-1000,0) 返回 -1 到 -1。函数只交付边界,不枚举块,也不承诺该范围能够一次装入内存。消费端仍需要加载预算,尤其不能直接用 long 计算任意极值范围的 Last-First+1

还有一个表示限制:右端点类型也是 long,因而这个半开接口无法表达“包含 long 最大值”的范围;单点 Split 可以处理该坐标。测试中的最大范围到 long.MaxValue-1 为止。若产品确实要覆盖完整整数域,就要改用更宽端点或显式的无界标记,不能偷偷把右端点改成包含语义。

用重建等式检查原点以外的地址

Program.cstruncation-regression 保留了开篇错误,要求错误表达式仍产生 0,而正式入口必须返回 -1:

long negative = -1;
Check("truncation-regression", negative / 1000 == 0 &&
    ChunkAddress.Split(negative, 1000).Chunk == -1);

只检查六个边界点不足以覆盖极值问题。Reconstruct 使用 BigInteger 计算 chunk × size + local,并同时检查偏移范围,避免测试的乘法与被测代码一起溢出。固定网格遍历 -4096 到 4096,块宽取 1、3、16、1000;另取七个包含 long 两端的坐标与五种块宽组合,共 32,807 组重建样本。范围测试枚举 -24 到 24 内全部 1,225 个合法半开区间,用逐点得到的块集合核对首块、末块和连续性。

2026-09-10 使用 .NET SDK 9.0.202、net9.0 Release 执行。$PROBE_DIR 指向包含 ChunkLab.csprojChunkAddress.csProgram.cs 的研究工程目录:

cd "$PROBE_DIR"
dotnet build ChunkLab.csproj -c Release
dotnet run --project ChunkLab.csproj -c Release --no-build

构建退出码 0,警告 0、错误 0,耗时 9.77 秒;运行退出码 0,输出如下:

passed=18 failed=0 skipped=0 samples=32807 ranges=1225 duration_ms=88.5062
x=-1 size=1000 wrong_chunk=0 chunk=-1 local=999
range=[-1000,0) first=-1 last=-1; range=[-1,1) first=-1 last=0; empty=null

18 项检查还包括零宽度、负宽度、反转范围和最小整数处的空集。耗时只是本次 CPU 功能验证的墙钟记录,不能据此判断实际地图流送速度;当前也没有执行 Unity、Godot 的场景加载、碰撞查询或 GPU 渲染验收。

角色位于 -1 时,现在拿到的是块 -1 内的 999,跨过零点才进入块 0。接入大地图时,值得固定的是整数单位、块宽、边界归属以及唯一分解入口。浮点位置如何量化为毫米仍需另行约定;若存档曾使用错误块号,修改函数也不会自动搬迁旧键下的资源。先审计存量地址,再让加载、碰撞和保存链路共同采用这一合同,原点才不会继续成为各子系统分歧的特殊位置。