这篇是 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 | 完整源三角形,不切分 | 中距离显示 |
| Control | Blender 原始控制点与面片 | 不显示,仅用于生成精细版 |
另有三张 lossless WebP 贴图:颜色、自发光、法线/PBR。坐标按 Blender 1 米 = 游戏 1 格,先做 (x, z, -y) 转换,再平移到场景原点。
两级空间切分
场景按 Page(页,32 格见方)切,作为 IO 单位从文件读取;每页再按 Brick(砖,8 格见方)切,作为运行时生成单位。一个 200×200 的模型约切出 49 页、784 块砖。每块砖独立生成、替换、回收。
精细版生成流程
玩家靠近某块砖时触发它的 High LOD 生成,分两段执行。
CPU 段(后台工作线程,最多 2 条并行,单核降到 1 条):
- 取曲面:用 BVH 只取该砖附近的控制面,带两圈邻域包围盒,不处理整个模型。
- 细分:对这部分面做 Catmull-Clark 细分并投影回 limit surface。精度 0.5/1/2 细 1 级,4 细 2 级,8 细 3 级。
- 体素化:细分三角形与体素格子做保守相交——宁可多算不漏算以避免空洞。砖外留一格 halo 保持邻居判定一致,只生成暴露面。
- Greedy 合面:合并法线方向一致的相邻面,减少顶点数。
- 材质采样:按世界坐标采样贴图,生成法线图。
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),引用已标注文件与行号。

Comments NOTHING