把两个世界缝在一起:拆解 MINETALE-FAULT 的无缝换维与体素 LOD

A I 发布于 2026-10-04 17 次阅读


这篇是 MINETALE-FAULT 的源码阅读笔记,只讲两处工程实现:换维度的无缝交接,和高精度 3D 模型的运行时 LOD。文中代码片段均标注了文件与行号。

项目速览

Minecraft 1.21.10 + NeoForge 21.10.64 + GeckoLib 5.3-alpha-3,Java 21,Gradle userdev 7.1.38。包名 cn.jehorstudio.minetale,入口 MineTale.java:49。649 个 Java 文件,约 95400 行,外加 158 个数据 JSON、87 个着色器文件。

模块划分:battle(战斗)、dimension/ebott(山体与换维)、dimension/worldgen(地下世界生成)、narrative(对话)、magic(法术)、voxel/scene(体素场景)。每个模块目录带一个 CONTEXT.md,写明职责边界与不变量。

一、无缝换维

原版换维度的流程是:黑屏 → 丢旧区块缓存 → 服务端发 Respawn → 客户端从零加载新区块、重算光照、重编译网格。这个模组要做到玩家穿过山脚那层膜时,脚下直接是地底,没有黑屏和 Loading。做法是把工作全部提前。

状态机

服务端为每个玩家维护一个会话,状态只能向前推进:

PREWARMING -> PASSABLE -> CROSSING -> COMPLETE
     \------------------------------> FAILED

主循环是 TransitionManager.tickPlayer()(TransitionManager.java:191)。TransitionSession.advance() 校验状态跳转,非法边直接抛异常。

预热:512 格外启动

玩家进入山体 512 格以内(PREWARM_MARGIN)时启动预热。服务端给目标区域加 Chunk Ticket 强制生成区块,但不是一次加完,而是每 tick 限量加预算:

private static final int MAX_TICKETS_PER_TICK = 2;
private static final long TICKET_BUDGET_NANOS = 1_250_000L;

(CacheManager.java:22)数量上限控住平均负载,时间上限防止某一批任务特别慢把单帧拖垮。

客户端是整套机制的核心。收到预热通知后,它先拦下即将到来的 Respawn:

public boolean shouldInterceptPreparationRespawn(CommonPlayerSpawnInfo spawnInfo) {
    return this.sessionId != null
            && this.stagingState.accepting(this.sessionId)
            && this.preparedTargetLevel == null
            && ModWorldgenKeys.UNDERGROUND_LEVEL.equals(spawnInfo.dimension());
}

然后手动构造一套并行的 ClientLevel + LevelRenderer:

LevelRenderer renderer = new LevelRenderer(
        minecraft, minecraft.getEntityRenderDispatcher(),
        minecraft.getBlockEntityRenderDispatcher(), minecraft.renderBuffers(),
        minecraft.gameRenderer.getLevelRenderState(),
        minecraft.gameRenderer.getFeatureRenderDispatcher());
ClientLevel level = new ClientLevel(connection, targetLevelData, spawnInfo.dimension(), ...);

服务端陆续发来的目标世界区块包,被逐个装进这个影子世界(同样是每 tick 2 个 + 1.25ms 预算,MAX_TARGET_CHUNK_INSTALLS_PER_TICK)。装完后客户端主动驱动光照引擎,再用 PreparedTargetSectionPrecompiler 提前编译地形网格,最后回执服务端"就绪"。

建好影子世界后、安装区块前,必须先把渲染器的 ViewArea 移到目标接缝位置,否则超范围的 dirty 通知会被丢弃:

// 安装目标 Chunk 前先把 ViewArea 移到目标接缝,否则超出范围的 dirty 通知会被丢弃。
((PreparedLevelRendererAccessor) renderer).minetale$getViewArea()
        .repositionCamera(SectionPos.of(...));

膜前可见地底

回执之后服务端发 BarrierStatePayload 开放结界,客户端由 TargetDimensionRender 在当前主世界画面上直接绘制影子世界已编译的 Section。此时玩家站在膜前能看见地底洞穴,脚下仍是主世界方块。

交接:替换渲染器引用

玩家继续前进,服务端每 tick 用脚底点(new Vec3(x, boundingBox.minY, z))判断是否越过膜面。检测方式有两处值得说明。

用运动线段求交,而非直接比较坐标。只比较 Y 坐标的话,玩家贴面抖动会反复触发。SeamCrossingDetector.observe()(SeamCrossingDetector.java:22)取上一 tick 到这一 tick 的线段与平面求交点:

if (d1 < d0 && d0 > 0.0 && d1 <= 0.0) {
    double denominator = d0 - d1;
    double t = d0 / denominator;
    Vec3 hit = previousProbe.lerp(currentProbe, t);
    ...
    pendingIntersection = aperture.signedBoundaryDistance(localX, localZ)
            >= requiredClearance ? hit : null;
}

交点须落在圆形开口内,并保留 玩家碰撞半径 + 0.15 的边距。SeamSide.BAND 表示距离膜面 0.03 格以内的模糊带,不更新确定侧,用于防止贴面抖动。

结界靠真实碰撞,不靠脚本。Mixin 只把结界的 VoxelShape 并入普通碰撞查询,不改写玩家位置、速度、重力。撞击手感、摩擦、击退均由原版物理处理。

确认穿过时,DimensionCommitter 调原版 player.teleport(),位置经 SeamTransform 换算:XZ 平移对齐、Y 高度换算、速度原样传递、不做旋转。客户端把 Minecraft.levelRenderer 字段整体替换为影子世界的 renderer。所以交接时没有任何字段需要从零加载,地图、光照、网格都已就绪,换的只是一个引用。TransitionClient.beginTargetLoad() 还会顺手关掉原版的 LevelLoadingScreen。

边界处理

  • 防摔死:接触膜面时由 onIncomingDamage 取消坠落伤害;虚拟墙碰撞盒比膜面高 0.001 格,保证"刚好贴到"也能判为接触。
  • 多人共享:CacheLease 引用计数。多人同时站在山脚,地底预热只做一份,最后一人离开才撤销区块票。
  • 客户端缓存半径:取 max(服务器给的半径, 覆盖全部目标列所需半径),避免装到一半被外层裁掉。
  • 网格裁剪:shouldClipPreparedTargetMesh() 在 mesh 阶段裁掉目标接缝以上的方块,避免依赖可能被光影替换的 shader 裁剪。
  • 清理:离开范围、退出、死亡、服务器关闭或任一步失败时,session、区块票、影子世界、GPU 资源全部释放;shutdown() 会 forceRelease() 在途任务。
  • 计时光照:代码保留 [DEBUG-transition-flicker]、[TransitionPerf] 日志打印各阶段耗时。

二、体素场景 LOD

voxel/scene 要在 Minecraft 里渲染高精度 3D 模型且不掉帧。原版世界是一格一方块的体素逻辑,塞高精度模型意味着自建一套渲染管线。

资产格式 .mtscene

从 Blender 导出为一个 ZIP,内含三种几何数据:

层内容用途
Low模型切成矩形片,每片 4 顶点常驻显示,保底
Raw完整源三角形,不切分中距离显示
ControlBlender 原始控制点与面片不显示,仅用于生成精细版

另有三张 lossless WebP 贴图:颜色、自发光、法线/PBR。坐标按 Blender 1 米 = 游戏 1 格,先做 (x, z, -y) 转换,再平移到场景原点。

两级空间切分

场景按 Page(页,32 格见方)切,作为 IO 单位从文件读取;每页再按 Brick(砖,8 格见方)切,作为运行时生成单位。一个 200×200 的模型约切出 49 页、784 块砖。每块砖独立生成、替换、回收。

精细版生成流程

玩家靠近某块砖时触发它的 High LOD 生成,分两段执行。

CPU 段(后台工作线程,最多 2 条并行,单核降到 1 条):

  1. 取曲面:用 BVH 只取该砖附近的控制面,带两圈邻域包围盒,不处理整个模型。
  2. 细分:对这部分面做 Catmull-Clark 细分并投影回 limit surface。精度 0.5/1/2 细 1 级,4 细 2 级,8 细 3 级。
  3. 体素化:细分三角形与体素格子做保守相交——宁可多算不漏算以避免空洞。砖外留一格 halo 保持邻居判定一致,只生成暴露面。
  4. Greedy 合面:合并法线方向一致的相邻面,减少顶点数。
  5. 材质采样:按世界坐标采样贴图,生成法线图。

GPU 段:CPU 结果传入 compute shader,依次采样、写图集、生成 mipmap,全程用 fence 异步查询完成状态,不阻塞渲染线程。

帧预算

生成不会一次性全做完,而是按预算分帧:

  • 同时最多 2 个生成任务;在途 + 待上传成果合计不超过通道数的 2 倍。
  • 渲染线程每帧轮转推进已完成任务,在任务之间检查软限额:时间 1.5ms、内存 2MiB,超限则停止,下一帧继续。
  • 单次上传按 512 KiB 分批;compute 每批最多 131072 项。
  • 回收同样分帧:每帧最多检查 64 个驻留成果、回收 2 块砖。

生成速度跟不上移动时,模型只降精度,不掉帧。

切换条件与距离滞回

High 进入距离 = min(256, 96 × sqrt(8 / geometry))
退出距离 = 进入距离 × 1.25

geometry 为精度档位,默认 2 时进入 192 格、退出 240 格。1.25 倍的滞回用于防止玩家在边界附近来回走动导致砖反复生成与丢弃。接管条件为几何、材质、mip 全部完成且最后一批 GPU fence 完成;"零面 High"同样可以接管,用于表示该砖表面为空,否则会一直显示错误的 Low。

与光影模组共存

顶点布局有两套:无 Iris 时 48 字节/顶点,装 Iris 时 80 字节(多出 Iris 扩展属性)。切线数据必须追加在 Iris 扩展属性之后,否则光影共享程序的属性序号对不上,场景不显示。

接线方式上,项目选择只走 LabPBR 标准出口,不按光影包名或文件名做针对性适配。验证环境固定为 Minecraft 1.21.10 / NeoForge 21.10.64 / Iris 1.9.6 / Sodium 0.7.3。

该场景系统不参与阴影投射,不写方块、不做碰撞、不同步服务器,属于纯客户端视觉层。

两套机制对比

维度穿越体素场景
计算时机事前,玩家未到就算好事后,玩家靠近才算
状态一次性状态机,只能向前连续的距离/LOD 状态机
切换方式整体替换,零延迟逐砖替换,滞回防抖
预算每 tick 固定预算每帧软预算,超限延后
权威方服务端纯客户端

源码里发现的问题

  • 编码 bug:gradle.properties 第 22 行的 mod_description 是 GBK 字节(A7 6C ...),Gradle 按 ISO-8859-1 读取,游戏内模组简介显示为乱码。修法是改为 \uXXXX 转义。已核对字节。
  • 双查询路径:voxel/scene/geometry/docs/known-issues.md 记录了 SceneVisibility 的问题——build 用全零 links 创建对象再逐页填连通位,unrestricted 得到 false;同一数据序列化后回读,构造函数得到 true,导致构建结果与回读结果走两条查询路径。复现步骤为 32 方块页、blocker 数量 0。当前客户端观测正常。
  • 完成度不均:地下世界七层区域只有 Snowdin 完整实现。Ruins、Waterfall、Hot Land 仍为 PlaceholderRegionGenerationPipeline,Transition Tunnel 只做空气切割。
  • 缺少单元测试:无 src/test 目录,验证依赖 GameTest(SnowtownLocateGameTests)与数据驱动的 test_instance。
  • 文档检索性:voxel/scene/CONTEXT.md 单文件 66 行,每行均为长约束,读代码时较难定位某条约束对应的生效文件。

代码片段摘自 MINETALE-FAULT 项目源码(Apache-2.0),引用已标注文件与行号。