0%

浅析 1.13 世界生成

摘要

世界生成是我的世界的一个重要内容。Minecraft 在发展,世界生成的代码却在很长的一段时间里没有发生太大的变化,而 1.13 正是对这一切进行变革的一个版本。在之后各个版本的世界生成中,1.13 版本的核心价值一直在不断地体现。这就是为什么会有这篇文章:因为这是一次划时代的更新。本文从世界生成的各个方面,逐一探讨其中的奥秘,揭开新版世界生成神秘复杂的面纱。

文章链接

更多链接参见文章相关链接一栏。

引子:为什么?

世界生成World Generation, or WorldGen for short)是我的世界的一个重要内容。Minecraft 在发展,世界生成的代码却在很长的一段时间里没有发生太大的变化,而 1.13 正是对这一切进行变革的一个版本。那么为什么我们需要推翻一个使用这么长时间的、看似并没有太大问题的世界生成机制呢?我们为什么要这样变?这样变又有什么好处呢?

要解答这些问题,就要从旧版的世界生成说起,在这里,我不能把 1.12 及以前的地形生成解析一遍,在文末的相关链接我给出了土球的一个简要解析,供读者参考。也许在实际的游戏中,我们很难看出什么区别,但潜移默化间,Mojang 已经向前迈出了一大步。

这次 Mojang 代码的重构采用了全新的设计模式,增加了代码的可扩展性,主要体现在:

  • 将世界生成的功能被集中在了区块生成器和生物群系两部分上面,而不是离散在方方面面,更便于对代码之间的关系进行分析。
  • 细化了分类和世界生成的步骤,将不同的功能分离开,实现逻辑分层和模块化,代码更加清晰,复用性更高。
    • 比如把定位和生成分离,灵活组合,告别了过去一写特性就花半天写定位代码或复制定位代码的冗余。
    • 因为步骤被细化了,更加利于分配任务,所以世界生成可以异步了。
  • 采用大量函数式接口,助力函数式大潮,让传递代码成为一种时尚。
  • 采用了全新的设计模式,告别了过去Mojang一贯的代码风格,打破了我们的刻板印象。
    • (X-XType)模式,即用X表示对象本身,实现它的功能,而XType描述这类对象性质,提供对象的工厂方法。
    • (X-XConfig)模式,即用X实现功能,而用XConfig来传递实现此功能的参数,通过泛型避免不同行为的参数不同和大量无意义的类型转换。
    • 如有限状态机,用以表示区块生成的不同阶段和更加灵活地方式区分生物群系,删除了过去的硬编码。
  • 统一化的世界生成接口,让所有生成的装饰都归于一个概念——特性。避免了以往的混乱不堪、互相踩脚的弊端。
  • 开放了生物群系的接口,提供了生物群系的Builder,让你体验搭积木式的快感,让你不用Forge也能加特性。

在之后各个版本的世界生成中,1.13 版本的核心价值一直在不断地体现。这就是为什么会有这篇文章:因为这是一次划时代的更新。下面我们将从世界生成的各个方面,逐一探讨其中的奥秘,揭开新版世界生成神秘复杂的面纱。

区块生成概述

区块(Chunk)是Minecraft世界里一个大小为 16×256×16 的部分 ——Minecraft 中文维基

一个存档有多个维度,每个维度都有一个世界,世界是由一个个区块组成的,所以生成世界的实质就是生成区块。

在过去,区块生成分为 Generation 和 Population 两个阶段,而在 1.13 以后,不再这样笼统的区分,每个区块都拥有一个状态,每个状态都表示他已经完成的某一个任务,并提供下一个任务,以此实现异步的区块生成。

按顺序,它们分别是:

状态 翻译
empty
base 基础
carved 镂空
liquid_carved 流体镂空
decorated 装饰
lighted 光照
mobs_spawned 生物生成
finalized 收尾
fullchunk 完整区块
postprocessed 处理后

前面的阶段属于样板(proto)区块,而最后两个属于存档(level)区块,后者已经可以序列化为 NBT 了。这个两种类型定义在ChunkStatus.Type枚举中。

[提示] 为了更加直观的观看区块生成时所处的不同状态,可以观看 1.14 世界加载时的动画,其中不同的颜色代表不同的状态,可以说没有 1.13 的变革也不会有 1.14 的进步。

区块生成任务

接下来详细讲解每一个各个任务的内容。

[注意] 再说一遍是对应的任务的内容,其他内容不再此列。

基础

选定生物群系

生物群系选定是区块生成的第一步,所以也是世界生成的第一步。因此重要程度不言而喻。随着版本更迭,这一部分变化也很大。有关这一部分的内容,请参考我的新作。

高度图

高度图记录每个(x, z)下最高的方块,用于描述地形的起伏升降,根据“最高方块”的标准不同,高度图分为以下几种,注意的优先级是高于的。其中有_WG后缀的都是为世界生成准备的。

枚举名 含义:最高的 (...) 的方块
LIGHT_BLOCKING 空气 透明
MOTION_BLOCKING 空气 允许移动 不包含流体
MOTION_BLOCKING_NO_LEAVES 空气 树叶 允许移动 不包含流体
OCEAN_FLOOR 空气 允许移动
OCEAN_FLOOR_WG 空气 流体
WORLD_SURFACE 空气
WORLD_SURFACE_WG 空气

[说明]

  • 透明:即不透明度为0的方块,不透明度会影响光的传递
  • 树叶:即拥有minecraft:leaves标签(tag)的方块
  • 允许移动:允许实体进入方块并在其中移动,比如树苗、按钮、花盆、地毯
  • 不包含流体:即该方块里面没有流体浸入,比如正常的梯子,就属于 允许移动 且 不包含流体,但是如果梯子里面倒桶水,它就“包含流体”了

这些类型都可以在Heightmap.Type找到,这两种用途对应枚举Heightmap.Usage的值。

为了构造地表,我们需要一些高度图,高度图怎么得来,生成随机数?那样生成的地形七上八下支离破碎,为了确保地形看上去更加平滑真实,就需要用到噪声算法,可以最大程度保证不出现极端的高度跳跃。

上面说到的高度图,指的是世界生成专用的高度图,而其他的高度图,则是在每个区块生成的任务执行前进行更新。区块生成的前面四个阶段都不会更新高度图,而剩下的都会更新高度图。

地表构造器

有了高度图,我们就可以据此生成地表了。新版本生成地表的工具便是地表构造器。

[注意] 地表构造器(ISurfaceBuilder)的Builder跟通常的Builder不同,它本身就是功能性的一个类,这个Build指的是地形的构造,而不是对象的构造

地表构造器就是原来的Biome#genTerrainBlocks,独立出来之后,它变得更加灵活、复用性更强。

而地表构造器配置提供至少两个信息,即顶层(top)方块和中层(middle)方块,比如在普通的平原生物群系,顶层(top)方块就是草方块,而中层方块则是泥土方块,此外还会有水底方块的信息(比如默认为砂砾)。

基岩的生成代码独立出来,而不是像过去一样包含在Biome#genTerrainBlocks的代码里,这也就意味着你不会因为不谨慎的覆盖而导致基岩的生成被“取消”了。

镂空、流体镂空

所谓镂空就是钻洞注水,采用IWorldCarver

GenerationStage.Carving枚举的AIR表示镂空,LIQUID表示流体镂空。

镂空是生物群系相关的,因为镂空器是从生物群系那里取得的。

[注意] GenerationStage 中的那个与旧版的 Generation 无关。

装饰

装饰可以说是整个地形生成中最为重要的一部分了,你不想看到光秃秃的世界吧,在这个阶段,所有可掉落掉落的方块都会立即掉落(但是镂空阶段却不会,这也就是为什么原版生成的地图有这么多浮沙)。

装饰的载体是生物群系,它实际上就是从原来的Biome#decorate独立而成,独立出来之后,它变得更加灵活、复用性更强,不过在过去,有一个叫装饰器(decorator)的东西,被彻底移除了,现在用特性来统一管理所有生物群系特性,分为三类。

类别 说明
特性
可以通过用骨粉催熟后天生成的特性
特殊之处:有一个专门的列表以便骨粉迭代
结构 它们的一些数据会被单独保存在世界里面,我们会在下面单独谈到
特殊之处:有一个专门的映射以便保存相关配置
必须要调用两个方法来注册,分别加入特性列表和添加配置的映射

GenerationStage.Decoration枚举里面的值列出了装饰的所有阶段,依次是:

状态 翻译 例子
raw_generation 原生生成 目前只有末地岛
local_modifications 本地修饰 湖、岩浆湖、冰山、苔石堆
underground_structures 地下结构 略,特例:地牢(它只是特性而不是结构)
surface_structures 地表结构 略,特例:冰刺地区的冰刺和冰道
underground_ores 地下矿石
underground_decoration 地下装饰 化石、萤石、下界火、岩浆、蠹虫刷怪蛋
下界蘑菇(主世界蘑菇在下一个阶段生成)
vegetal_decoration 植物装饰 略,特例
主世界蘑菇(因为蘑菇不是植物下界蘑菇在上一个阶段生成)
单格的水和岩浆(常在矿洞里面形成瀑布,
鬼知道Mojang为什么要把它放到这里面)
top_layer_modification 顶层修饰 在温度足够低的位置生成冰雪

生成装饰时,特性往往要借助定位器的协助,定位器可以根据周围的地形调整生成的位置,而复合特性就包含了定位器和特性,在生成时就自带定位了。

[提示] 定位器常常会用到前面说的高度图来确保特性在地表生成。

光照

此时区块的方块已经生成完成,但是光照没有更新,所以根据目前区块中方块的光照,当然还有天空的光照来更新区块的光照。

生物生成

为了让玩家在进入游戏后里面看到生物,这个阶段将会预先生成一些生物。

所以下界和末地在这个阶段什么都不做。因为就算是空的也没关系,过会就有了嘛,反正都是怪物。

收尾

确保更新高度图。

完整区块、处理后

什么都不做,直接由相关代码设置为此状态。

结束了?结束了。

当然了,最后两个阶段并不是真的什么都不做,而是由别人做,下面介绍一下相关代码:

当到完整区块的阶段时,表示一个Chunk对象已经完成了构造。

而在玩家正式进入世界之前,还需要一些处理。

当一个区块周围的 8 个区块都生成完毕,那么它会:

  • 对区块内的一些方块状态进行更新。
  • 更新需要 tick 的方块和流体。
  • 检验区块里的TileEntity

在收尾完成之后,Chunk#isPopulated将会返回true

[注意] 这个 Populate 和旧版也没多大联系,硬要说,就是都表示这个区块的生成已经完成。

生物群系

有关这一部分的内容,请参考我的新作。

结构生成

Structure -> StructureStart -> StructurePiece

如图,在生成结构(你已经知道结构也是特性的一种,对吧?)时,先判断是否为正确的生物群系,接着以中心区块(Feature本应生成于的区块)为正方形的中心,结构尺度(size,单位是区块,这也就是为什么不译作大小)的两倍加一为正方形的边长,在这些区块中寻找一个合适的区块作为整个结构的起点,这个起点本身的大小不能越过正方形的边界。

结构起点储存这个这个结构的所有可能有的部分。而在确认此处要生成结构之后,会把任务交给结构起点,结构生成就正式开始了。

在结构起点的构造器里面,第一个阶段将会被调用。

buildComponent

在这个阶段,从起点开始延伸,结构的各个组成环节产生,但是此时此刻,方块不会被放置,而是形成一个个 Component,为了便于讲解,我译作(结构生成)任务,把所有 Component 成为任务列表。

为了更好地说明,我们用村庄做例子。

[说明] 村庄是一个易于理解的,常见的例子,但是 Mojang 的源代码确实太难看。我不原封不动的解析,而是把真正有意义的内容讲出。

  • 在最开始的时候,只有一个水井任务被加入任务列表。
  • 水井的buildComponet里面加入了朝向四个方面路的任务,我们有四条“开路”。
  • 现在我们对每一条路都buildComponet,毫无疑问路会延伸,我们把已经build之后的路从我们的开路列表里面移除,同时刚才有新增了几个开路。
  • 路在延伸的时候,有一定的几率添加一个屋子到任务列表,如果是在路边,路还能继续延伸,否则路到这就变成死路了。
  • 也有可能会生成岔路,岔路也会被放进开路的列表。
  • 如果生成屋子和岔路都生成失败了,那么还有一定几率拐弯。
  • 如果上面的尝试都失败了,那么这条路就成了死路。

[说明] 上面的原理揭示了为什么把村庄的size设置得非常大(比如正常=0,超平坦=1,我设置为65535)并不会让村庄真的变得非常大,因为村庄的生存实际上很大程度受到路的限制,一旦没路了,村庄就不会生成了。

同样的原理对于下界堡垒也适用,但是下界堡垒相对来说更加复杂多变。

为了防止发生两个部分撞在一起的悲剧,所有的过程都是被监管的,只要这个部分太大,那么任务就无法被添加,但是这只对同一个结构有效,如果不巧有两个结构,那就无法避免地其中一个会被另外一个替换方块,这可能是我的世界极少数资源与区块加载顺序联系在一起的实例,而造成的影响也几乎是可以忽略不计的,除非他把你的末地门弄坏了2333

addComponentParts

真正破坏末地门是在这里(雾。

在这个阶段,所有结构的方块被放置,始终确保方块不超出结构的边界,同时也要保证方向的正确,返回的boolean表示它的生成是否成功,此后,还会对这个结构的大小进行重算。

模板

模板是已经被证明C++最强大的功能之一,但却常常被人们忽视、误解或误用。
——Nicolai M. Josuttis《C++Templates》

咳咳,串味了,此模板非彼模板。

在生成结构的时候,你是否对不断地调用方法放置方块地硬编码感到厌倦?模板可是说是你的大救星,“但却常常被人们忽视、误解或误用”。早在 1.9,Mojang 就引入了结构方块,随之而来的就是 template,可以说,结构从硬编码转为 template 可以说是大势所趋。那么,模板究竟是怎样存储结构数据,又是怎样呈现在世界上的呢?

模板最重要的三个内容便是方块、实体、大小,其中方块和实体的位置都是相对于这个结构的原点—— xyz 都最小的那一个角表示的。这也就意味着模板易于在任意区域建造。同时,模板同样储存了方块和实体的朝向,这也就意味着模板本身具有方向性——好在模板还有一个特点,那就是易于旋转或者镜像。在模板建造在世界上的同时,当中的方块也会更新,来保证结构的功能性。

调色盘

往往一个模板里面会有很多完全相同的方块,如果这些方块都一一存入NBT,实在是有些浪费空间。因此Mojang引入了调色盘这个概念,说白了就是一个字典——给Blockstate编号,按照读入的先后,从0开始依次编号,与以前的数字id不同,这只是一个临时编号,仅仅对于这个结构有效,此外,这个id是NBT无关的,即它不表示IBlockstate所在的TileEntity所携带的NBT。

[提示] 如果你对原版的区块格式有所了解,你就会知道,实际上区块也是有调色盘的。

TemplateStructurePiece

为了更方便的使用模板生成结构,Mojang 准备了这个类。

其他的不用说,我们把眼光投向TemplateStructurePiece#handleDataMarker

嘿,DataMaker是什么?

不知道大家注意到没有,结构方块的四种模式里面,有一种叫做“数据”,这就是所谓的DataMaker,它填补了结构中无法携带特殊信息的不足,通过这个,我们能够吧代码和模板联系起来,让模板“调用”代码。

Data模式的结构方块在存入结构时会被自动替换为结构虚空。

我们不妨以海底废墟为例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
protected void handleDataMarker(String function, BlockPos pos, IWorld worldIn, Random rand, MutableBoundingBox sbb) {
if ("chest".equals(function)) {
worldIn.setBlockState(pos, Blocks.CHEST.getDefaultState().with(BlockChest.WATERLOGGED, Boolean.valueOf(worldIn.getFluidState(pos).isTagged(FluidTags.WATER))), 2);
TileEntity tileentity = worldIn.getTileEntity(pos);
if (tileentity instanceof TileEntityChest) {
((TileEntityChest)tileentity).setLootTable(this.field_204040_h ? LootTableList.CHESTS_UNDERWATER_RUIN_BIG : LootTableList.CHESTS_UNDERWATER_RUIN_SMALL, rand.nextLong());
}
} else if ("drowned".equals(function)) {
EntityDrowned entitydrowned = new EntityDrowned(worldIn.getWorld());
entitydrowned.enablePersistence();
entitydrowned.moveToBlockPosAndAngles(pos, 0.0F, 0.0F);
entitydrowned.onInitialSpawn(worldIn.getDifficultyForLocation(pos), (IEntityLivingData)null, (NBTTagCompound)null);
worldIn.spawnEntity(entitydrowned);
if (pos.getY() > worldIn.getSeaLevel()) {
worldIn.setBlockState(pos, Blocks.AIR.getDefaultState(), 2);
} else {
worldIn.setBlockState(pos, Blocks.WATER.getDefaultState(), 2);
}
}
}

在这里,根据不同的函数(function),调用了不同的代码,比如生成箱子、生成溺尸。无一例外的,他们往往对附近环境进行的判断和适应,来达到更好的结构生成效果,这是单纯的 NBT 数据无法做到的。

维度与世界类型

这一部分看似已经超出了狭义的“世界生成”,但其实它们有与世界生成千丝万缕的联系。

这里指简单介绍了几个方法,在新的教程里,有关于这一部分更详细的介绍。


Dimension#createChunkGeneratorIForgeWorldType#createChunkGenerator

默认行为:前者返回当前维度的区块生成器,后者调用前者。其中前者被废弃要求调用后者。

因为返回了一个区块生成器(而里面实际上就包含了生物群系提供器),所以决定了这个世界的地形生成。

自定义维度或自定义世界类型时应当覆盖。


IForgeWorldType#getBiomeLayer

略,见新教程。

应用:自定义

唉唉唉,等一下,你确定这不是 Forge 干的事情?

是的,新版的矿物生成完全不需要 Forge 插手即可实现自定义于是Forge就真的懒到没有插手,其他相关内容 Forge 也少了很多的话语权,大量 Forge 钩子消失,甚至出现了存在但未被使用的 Forge 事件(来自旧版本),可见原版对 Forge 的冲击。下面的内容尽量少的提及 Forge 知识,但不会回避 Forge。

自定义矿物生成

我们在mod主类的构造器里这么写:

1
2
3
4
Biomes.PLAINS.addFeature(GenerationStage.Decoration.UNDERGROUND_ORES, 
Biome.createCompositeFeature(Feature.MINABLE,
new MinableConfig(MinableConfig.IS_ROCK, Blocks.GOLD_BLOCK.getDefaultState(), 9),
Biome.COUNT_RANGE, new CountRangeConfig(20, 0, 0, 64)));

我就完成了矿物生成的注册,上面的代码可以让金块像铁矿一样地在平原的地下生成,我们不妨来解析一下。

Biomes.PLAINS,即平原,在实际应用中,我们往往会用ForgeRegistries.BIOMES.forEach(biome -> biome.add...());来向所有生物群系种添加,但是你就得找一个所有模组Biome都已经全部注册完成的地方调用了。

addFeature可以用于添加特性或是花,addCarver则是添加镂空器,addStructure添加结构。我们直接调用了Biome的工厂方法createCompositeFeature创建复合特性,使用了Feature预定义的minable特性及其相应配置,配合Biome里面的预定义的定位器及其相应配置,对应这个方法的四个参数。另外,也可以在Biome里找到createWorldCarverWrappercreateCompositeFlowerFeature

[提示] 不难看出,BiomeFeature里面都预定义了大量的实用特性、定位器,甚至还有镂空器和一些protectedIBlockState,应该尽可能多的使用它们。

自定义结构

不不不,我不会真的写一个结构给你看,我只会提出几个自定义结构时所需要的注意事项:

  • StructurePiece里面提供了很多摆放方块的方法,请务必使用这些方法而不是直接放置,这不仅可以防止你放置到结构的外面,还会根据结构的方向对你的方块转向。

  • 任何结构及其部分都需要在StructureIO进行注册,否则会导致结构无法生成并造成游戏崩溃。原版采用大写下划线的形式(如:Desert_Pyramid),但是我更加建议是modid + ':' + 小写下划线(即ResourceLocation)的形式,这样有助于防止模组之间发生命名冲突,并更加规范、统一。

自定义生物群系

不不不,我不会真的写一个生物群系给你看,我只会提出几个自定义生物群系时所需要的注意事项:

  • 你是不是发现许多Biome的构造器都有共同的代码?是的,极大的自由同时也带来了极大的工作量。好在在新的版本里面,Mojang 也意识到也改善了这一点:他们为此设置了一个类,里面装满了许多生物群系共用的特性添加的代码。这毫无疑问可以直接被我们调用,或者作为我们设置组合的参考。
  • 出生点生物群系会被优先选中为出生点,若你的生物群系想被选中,可以直接向BiomeProvider#BIOMES_TO_SPAWN_IN中添加。
  • 更多内容参见我的新教程。

附录

下面介绍 1.13 Forge 提供的事件和钩子。

事件

值得注意的是,原有的MinecraftForge#TERRAIN_GEN_BUSMinecraftForge#ORE_GEN_BUS已经被移除。

~表示上面的主事件。

值得注意的是,弃用指的不是@Deprecated而是简单的“存在,但是从未被使用”。

事件 描述
BiomeEvent 生物群系相关事件
参见前文生物群系栏
~.GetVillageBlockID 获取村庄特色方块
~.BiomeColor 生物群系颜色相关事件
新版已经弃用
参见前文生物群系栏
~.GetGrassColor 获取草的颜色
新版已经弃用
参见前文生物群系栏
~.GetFoliageColor 获取植物的颜色
新版已经弃用
参见前文生物群系栏
~.GetWaterColor 获取水的颜色
新版已经弃用
参见前文生物群系栏
ChunkGeneratorEvent 区块生成器相关的事件
参见前文区块生成部分
~.ReplaceBiomeBlocks 替换生物群系方块
参见前文地表构造器栏
~.InitNoiseField 初始化噪声字段事件
参见前文高度图部分
InitNoiseGensEvent 噪声生成器事件
参见前文高度图栏
WorldTypeEvent 世界类型相关事件
参见前文世界类型栏
~.BiomeSize 生物群系大小时间
参见前文GenLayer的产生栏
~.InitBiomeGens 初始化生物群系生成(即GenLayer
新版已经弃用
参见前文GenLayer的产生栏

钩子

钩子 描述
TerrainGen#getModdedNoiseGenerators 获取模组修改后的噪声生成器
LayerUtil#getModdedBiomeSize 获取模组修改后的生物群系大小
ForgeEventFactory#onReplaceBiomeBlocks 构造模组修改后的地表

后记

本来这个介绍是附在我的《浅析 1.13 Minecraft - MCP | Forge - FML》里面的,不过我后来直接放弃了那个项目,专心投身于这个部分。

1.13 Mojang 做出的努力使我眼前一亮,世界生成可以说是最重要的一部分,Mojang 迈出了第一步,希望这不是最后一步。

我这篇文章写的我很艰辛,大量未反混淆的的代码和 Mojang 奇怪的脑回路使我感到痛苦,但是我还是在最短的时间内肝出这篇文章,不出意外,是世界上第一篇(中文)新版地形生成解析。

鸣谢

  • MCP 对 Minecraft 源代码的反混淆,如果不是他们,我也不能写出这篇文章。
  • 森林蝙蝠 审读并提出来让我加入的结构的部分。
  • 33 让我加入浅析新版本世界生成与旧版本的本质区别和好处分析一栏。
  • 素学姐 和 3T 对教程提出的宝贵建议。
  • 感谢 chyx 指出我文章中一系列的谬误。
  • 土球(ustc_zzzz)制作的 BBCode 转换器,为当初发布到 MCBBS 提供了便利。

参考

按参考内容质量、数量降序排列:

  • Minecraft 1.13.2 源代码 - 反编译、反混淆 by MCP 快照 20180921-1.13
  • MinecraftForge 1.13.2-25.0.10 源代码
  • Minecraft 旧版(1.12.2)源码 - 反编译、反混淆 by MCP
  • MinecraftForge 旧版(1.12.2-14.23.5.2768)源代码
  • Minecraft 维基百科 - 英文、中文相关页面
  • 土球的旧版本地形生成解析

相关链接