浅析 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
模式的结构方块在存入结构时会被自动替换为结构虚空。
我们不妨以海底废墟为例:
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#createChunkGenerator
和 IForgeWorldType#createChunkGenerator
默认行为:前者返回当前维度的区块生成器,后者调用前者。其中前者被废弃要求调用后者。
因为返回了一个区块生成器(而里面实际上就包含了生物群系提供器),所以决定了这个世界的地形生成。
自定义维度或自定义世界类型时应当覆盖。
IForgeWorldType#getBiomeLayer
略,见新教程。
应用:自定义
唉唉唉,等一下,你确定这不是 Forge 干的事情?
是的,新版的矿物生成完全不需要 Forge 插手即可实现自定义于是Forge就真的懒到没有插手,其他相关内容 Forge 也少了很多的话语权,大量 Forge 钩子消失,甚至出现了存在但未被使用的 Forge 事件(来自旧版本),可见原版对 Forge 的冲击。下面的内容尽量少的提及 Forge 知识,但不会回避 Forge。