大世界原点移动后,迟到分块不能继续使用旧局部坐标

角色走到世界坐标 8192.5 米处时,系统把局部原点向前移动 8192 米。镜头里的角色只剩 0.5 米局部偏移,浮点精度恢复正常;但移动前启动的地形分块稍后完成,若它把构建时得到的局部 X=8200 米直接交给场景树,分块会出现在八公里之外。

这不是浮点误差,而是坐标缺少所属帧。大世界流送既需要跨原点移动仍稳定的世界坐标,也需要物理、渲染容易消费的局部浮点坐标。异步结果跨过一次原点移动后,局部值已经过期;提交者必须能从稳定世界锚点重新换算,而不能猜测旧值属于哪个原点。

大世界网格移动时角色保持在局部坐标泡内,迟到地形分块重新映射到当前位置的技术主视觉

原点移动改变坐标帧,不改变世界位置

探针以厘米整数保存持久世界坐标,以米制 Vector3 提供本帧局部位置。OriginSnapshot 的单调 Revision 标识换算帧;分块任务同时携带世界锚点、启动修订和当时的局部锚点。后两项可以诊断任务是否跨帧,但只有世界锚点能在任意新原点下重新得到正确位置。

核心实现位于 $PROBE_ROOT/LargeWorldOrigin.cs

using System.Numerics;

namespace LargeWorldOriginProbe;

public readonly record struct WorldPoint(long Xcm, long Ycm, long Zcm)
{
    public Vector3 RelativeTo(WorldPoint origin) => new(
        (Xcm - origin.Xcm) / 100f,
        (Ycm - origin.Ycm) / 100f,
        (Zcm - origin.Zcm) / 100f);
}

public readonly record struct OriginSnapshot(WorldPoint WorldOrigin, long Revision);

public readonly record struct ChunkBuildResult(
    string ChunkId,
    WorldPoint Anchor,
    long SourceRevision,
    Vector3 SourceLocalAnchor);

public sealed class LargeWorldFrame
{
    private OriginSnapshot _origin;
    private readonly Dictionary<string, Vector3> _activeChunks = new();

    public LargeWorldFrame(WorldPoint initialOrigin)
    {
        _origin = new OriginSnapshot(initialOrigin, 1);
    }

    public OriginSnapshot Origin => _origin;
    public IReadOnlyDictionary<string, Vector3> ActiveChunks => _activeChunks;
    public Vector3 ToLocal(WorldPoint point) => point.RelativeTo(_origin.WorldOrigin);

    public Vector3 Rebase(WorldPoint nextOrigin, Vector3 playerLocal)
    {
        var shift = nextOrigin.RelativeTo(_origin.WorldOrigin);
        _origin = new OriginSnapshot(nextOrigin, _origin.Revision + 1);

        foreach (var chunkId in _activeChunks.Keys.ToArray())
            _activeChunks[chunkId] -= shift;

        return playerLocal - shift;
    }

    public Vector3 Commit(ChunkBuildResult result)
    {
        var localAnchor = ToLocal(result.Anchor);
        _activeChunks[result.ChunkId] = localAnchor;
        return localAnchor;
    }
}

Rebase 先以旧原点计算位移,再推进修订,并把已激活对象统一减去同一位移。这个最小实现假设调用发生在一个明确的世界提交点;真实引擎还要把物理体、相机、导航查询入口和依赖局部空间的粒子系统纳入同一屏障。若这些消费者分帧移动,即使世界坐标正确,碰撞与画面仍会短暂分裂。

整数厘米不是唯一选择。分区坐标加单元内偏移、定点数或双精度世界坐标都能承担稳定锚点;关键是它不能随表现原点一起改写。本文选择 long 只是让固定输入和单位换算可直接复算,不代表所有大世界都应采用厘米粒度。

迟到结果要转换,不必一律丢弃

原点修订经常被用作“结果是否过期”的标识,但修订不同不等于内容无效。地形网格若按分块自身坐标构建,顶点、索引和材质数据可能仍然正确,过期的只是它附带的局部放置位置。此时丢弃并重建会增加流送延迟;保留世界锚点,提交时执行一次减法和单位换算即可复用结果。

固定场景中,分块世界锚点为 820000 厘米。任务在修订 1、原点 0 厘米时启动,所以记录的源局部值是 8200 米。完成前原点移动到 819200 厘米并进入修订 2,正确局部值为:

(820000 cm - 819200 cm) / 100 = 8.00 m

若结果只保存 SourceLocalAnchor,提交者无法区分它是当前局部坐标,还是相对某个历史原点的坐标。直接复用会得到 8200.00 - 8.00 = 8192.00 m 的确定性错位。修订号可以让系统拒绝这种无世界锚点的结果,却不能凭自身完成转换;转换仍需要原始世界位置或旧原点快照。

异步分块从修订 1 跨到修订 2 时,旧局部坐标与世界锚点重算结果的提交关系图

并非所有迟到结果都适合换算。若构建过程已经把顶点烘焙进旧局部空间,或者结果包含依赖旧物理原点的碰撞缓存,仅移动根节点可能不够。结果合同应明确哪些数据处于分块局部空间、哪些处于世界空间、哪些绑定原点修订。无法证明可转换的类型必须拒绝,不能因为它携带世界锚点就默认安全。

固定输出把八公里错位变成可回归条件

探针执行命令为:

dotnet build $PROBE_ROOT/LargeWorldOriginProbe.csproj -c Release --no-restore
dotnet run --project $PROBE_ROOT/LargeWorldOriginProbe.csproj -c Release --no-build

Release 构建退出码为 0,0 警告、0 错误,墙钟 4.86 秒;运行退出码为 0,8 项断言通过、失败 0、跳过 0,探针内部计时 9 毫秒,墙钟 1.81 秒。决定性输出如下:

origin: revision=2 world_cm=(819200,0,0)
player: world_cm=(819250,0,120) local_m=(0.50,0,1.20)
late-chunk: source_revision=1 current_revision=2 source_local_x=8200.00 committed_local_x=8.00 wrong_offset_m=8192.00
tests: pass=8 fail=0 skip=0 duration_ms=9

这组数据验证的是 CPU 侧坐标合同和原子提交语义,不是某款引擎在八公里处的渲染精度,也不构成性能比较。探针没有覆盖旋转原点、多人各自局部原点、跨分区寻路或 GPU 常量缓冲更新;一旦需要多个观察者拥有不同局部帧,单个全局 LargeWorldFrame 就应演进为稳定世界层加每视图转换层,而不是让场景对象在多个原点之间反复搬动。

开篇中的迟到分块可以继续使用它已经构建好的内容,但不能继续使用旧局部坐标。把稳定世界锚点留在异步结果中,让原点修订标明转换边界,并由唯一提交点按当前原点换算,原点移动才只是表现坐标帧的变化。凡是只携带局部浮点位置的跨帧结果,都应在接口层被视为信息不完整,而不是等八公里错位出现在画面后再补偏移。