程序化地形生成(2)
噪声适合生成洞穴、山脉和矿物等自然地形。给定种子与坐标后,噪声算法就可以计算出一个稳定的值。
但类似未知伤亡的维生舱,十字木头,物资房之类具有明确特征的事先约定好的结构,几乎无法单靠噪声来描述。和未知伤亡不同,本文采用了目前比较成熟且轻量化的方案:字符蓝图。
《未知伤亡》的结构生成
未知伤亡的结构生成有两种生成方式。
算法生成
很难说这种类型应该归类为结构还是地形。我理解中的地形是那种覆盖整个地图(或者地图中的大量区域),由随机算法生成的tile群体,结构是小的离散的tile群体。
用算法生成的结构体,不同类型的结构体使用不同类型的算法。其优点在于灵活性高,各个参数很容易调整。
最典型的例子就是铜矿,在第一层,铜矿生成是在上一期讲的地形生成之后,调用GenerateOres,采用随机游走算法生成的。
还有就是第二层的混凝土障碍物。简单来说就是一个高度和宽度的双重循环,往里面塞混泥土方块,然后随机加一些缺省和其他类型方块的替换。比较朴素,就不贴源代码了。
预制体烘焙
比如维生舱。要人血才能打开的物资房。这类结构的特点通常是具有提前设计好的布局,内部还有其他可交互物品的生成。
在未知伤亡中,这类结构体的生成出现在地形生成之后,例如第二层,在噪声生成地形,然后算法生成本层独有的结构体(混凝土柱)后,调用WorldGenerateStructures来生成预制结构体。
翻阅源码可以注意到,这个方法中也是调用了一些算法生成结构的算法。不过是在预制结构体生成之前,时序上是没问题,但感觉有点乱(
在未知伤亡中,生成预制体结构,首先会调用GenerateObjectAtPos,读取预制体上挂载的tilemap,将其烘焙到世界方块中。例如
1 | GenerateObjectAtPos(pos4,Resources.Load<GameObject("Structures/CratePod").GetComponent<Tilemap>(), 0.82f, genMode: true); |
随后调用GenerateEntityAtPos,将其他部分(物品或建筑)实例化到对应的位置。
流程参考下图
1 | 选择结构 |
生成的确定性保证
首先,我们可以说:CU 的生成并不完全由地图种子决定
FastNoiseLite 内部会根据种子和坐标进行哈希。对于相同的输入会返回相同的噪声值。
但CU出现了大量类似下面的生成:
1 | if (worldBlocks[i, k] > 0 && |
UnityEngine.Random是一个全局随机数生成器,这意味着在其他地方的随机过程会影响随机数的产出。这可能也是未知伤亡本体无法进行地图保存的一个重要原因。
在程序化生成的过程中,如果想要保证生成过程的确定性,随机数的生产状态是需要隔离的。因此不能使用全局随机数生成器,而是要自己构建一个随机数生成器,并且保证用于程序生成的生成器只在当前系统中使用,并且当前系统中也不能使用其他系统的生成器。
使用字符蓝图来生成结构
字符蓝图实际上是一段规范化的字符串,比如:
1 | e=Clear |
这段文本描述了一种结构的方块和物品。
语法介绍
Tile语法
第一部分是支持自定义的符号规则
1 | e=Clear |
等号左边是蓝图使用的字符,右边是符号对应的操作。Clear代表清除,Set代表写入方块,括号里面是具体的方块类型
---是终止符,后面的字符区域代表方块图案。
1 | sssss |
这意味着最终会生成一个5×5的结构,外围一圈是钢板,内部是中空。
我一共实现了四种Tile操作:
1 | Keep |
Keep:不做任何处理
Clear:清空方块
Set(TileType):写入指定方块
SetIfEmpty(TileType):填充方块,如果已有方块则不做处理。
并且语法支持随机操作。比如:
1 | r=Set(Rock)@7|Set(Gravel)@2|Clear@1 |
表示在符号r对应的位置,生成岩石,生成砾石,和清空方块的权重分别为7,2和1。
Prefab语法
对于结构中还需要生成的物品,比如灯泡或者箱子,需要添加[Prefab]区段
1 | [Prefabs] |
目前开发了三种Prefab操作:
1 | None |
None表示不生成任何对象
Spawn表示在该位置生成指定对象
SpawnOnce表示会搜集所有相同的具有SpawnOnce符号,这些所有符号对应的位置都是候选者,最后在候选者中选择其中一个来生成指定的对象(也有可能不生成)。
也就是说,在这个例子中,C代表的所有位置必定生成一个物品,所有U代表的位置,有80%的概率在其中一个地方生成物品,有20%的概率不会在任何地方生成物品。
数据结构
约定一个这样的规范是比较简单的,难的在于解析。不过好在我们不需要像公开的json解析器一样考虑健壮性,只需要假定一切输入都是合法的,然后按照规则解析即可。
首先我们考虑这个过程的原子操作,那就是一个含权重的针对方块或者物品的特定操作。
因此对于这两种操作,实现对应的数据结构:
1 | public enum TilePlacementOperation |
1 | public enum PrefabPlacementOperation |
再从底层往上推,一个字符就对应着多个行为,最终是根据这些行为的权重随机抽取一个作为真正需要执行的行为。所以需要构造一个数据结构来存储字符和行为的映射关系。
1 | public class TileSymbolRule |
再往上推,我们需要一个解析器来解析字符串,然后返回一个内存对象,这个内存对象存储着可以直接用来生成具体结构的无需解包的数据。
1 | // 被解析完毕的结构数据 |
解析器实现
解析器首先按换行符切分文本,然后寻找第一处---,将---前面的内容解析为 Tile 规则,后面的解析成Tile布局。如果后续有[Prefabs],则寻找第二处 ---并解析 Prefab 规则和 Prefab 布局。可以参考下面流程:
1 | 规范化换行 |
值得注意的是,文本中的第一行对应图案顶部,但二维数组中的 y=0 位于底部。因此在解析图案时,还需要反转文本行对应的 Y 坐标。
结果
最终,我可以通过一段能够直接阅读和修改的字符蓝图,在噪声地形上生成由钢板、玻璃和 Prefab 组成的建筑。
相同的结构种子和结构原点会得到相同的 Tile 权重选择、Prefab 选择和 SpawnOnce 位置。由于每个位置和随机通道都有独立的随机种子,改变遍历顺序也不会影响生成结果。

白色是钢板,红色是测试物品。
还有我瞎吉儿搭建的丐版维生舱

蓝图是:
1 | k=Keep |
