地图画原理
这篇文章将会仔细介绍地图画工作原理,包括立体地图画、平板地图画和纯文件地图画。SlopeCraft 正是基于这些机制开发出来的。通过研究并利用这些机制,人们可以让地图显示出自定义的图像。
为什么用地图?
Minecraft 地图画可以分为两类:视觉型和地图型。视觉型地图画顾名思义,是建造出来直接用眼睛看的,它们可以是水平平板,也可以是垂直墙面。视觉型地图画简单直接,但往往体积巨大,不是很方便观看。然而地图可以放进物品展示框里,显示在地板或者墙上。多张地图甚至可以拼成一张更大的画面。这就是地图的优势。
在视觉型地图画中,方块直接显示它们材质贴图的颜色,每个方块都是不一样的。但地图型地图画并非如此。地图显示的颜色与方块的材质贴图可能有细微差别,很多方块拥有完全一样的颜色,比如雪块和白色混凝土。此外,对于绝大多数方块,地图显示的颜色和眼睛看到的颜色都会有细微但可察觉的差异,这导致很多视觉型的地图画在地图上表现的不太好。所以第一件事情就是弄明白 Minecraft 地图的机制。
地图如何显示方块?
每个地图的分辨率都是 128 × 128 像素,与缩放尺度无关。 地图对应的区域由缩放尺度 scale
决定,scale
是一个整数,取值范围在 0 到 4 之间(包含 0 和 4)。地图对应的区域是正方形,长宽均为 \(128\times 2^{Scale}\)。当 scale
为 0 时,地图对应着 128 × 128 范围的方块,其中每个像素都代表一个方块;否则每个像素代表 \(2^{Scale}\) 个方块。scale
大于 0 的地图对地图画而言没有意义,我不会再讨论它们,不过下文会告诉你为什么它们没有意义。
为了让地图更容易拼接,每个地图都会与一个网格自动对齐。地图起始坐标(西北角)为 \((128\times 2^{Scale}-64,y,128\times 2^{Scale}-64)\)。
坐标轴和东西南北的关系表:
方向 | 地图方向 | 坐标轴方向 |
---|---|---|
北 | 上 | z- |
南 | 下 | z+ |
西 | 左 | x- |
东 | 右 | x+ |
-
Minecraft 怎样存储地图数据?
在 Java 版中,地图的内容并没有存储在每个地图物品里,而是存储在 data 文件夹下。它们的文件名形如 map_i.dat,
i
为不超过 2,147,483,647(1.12 中是 32767)的自然数。i
称为地图序号,游戏中每个地图物品都只是存储了对应的地图序号。比如,如果玩家拿着一个序号为 114 的地图,存档加载时 Minecraft 会寻找名为
map_114.dat
的地图数据文件并加载它。然后它的内容就会显示在地图上。因此,地图的本质不是物品,而是地图数据文件。地图几乎所有信息都存储在地图数据文件里,包括但不限于每个像素。地图画最重要的就是地图数据的存储方式。
-
地图像素与地图色(MapColor)
地图的每个像素都是一个 8 位无符号整数,取值范围为 0 到 255。由于地图数据文件是压缩 NBT 格式,地图像素都存储在一个包含了 16384 个元素的字节数组(byte array)里(虽然 NBT 格式中的 byte 是有符号整数,但不妨当做是无符号)。这个 128 × 128 的像素矩阵以行优先方式存储。
这决定了每个像素最多可能有 256 种颜色,每个值对应着不同的颜色。这个 8 位无符号整数称为地图色。 我们可以说地图是一个颜色表固定的 8 位索引图像。
-
基色(BaseColor)与阴影(Shadow)
如上文所述,地图色是 8 位无符号整数。它的高 6 位形成了 6 位无符号整数,称之为基色 (BaseColor),余下 2 位形成了 2 位无符号整数,称为阴影。地图色、基色和阴影的关系如下:
\[ 地图色=4 \times 基色 + 阴影 \]基色取决于方块类型。游戏中最多可能有 64 种基色。截止最新版(在本文撰写时为 1.17),游戏中有 62 个基色已经被使用;在 1.16,有 59 个基色被使用,每个基色都有对应的基色颜色(RGB);从 1.12 到 1.15,有 52 个基色被使用。未使用的基色没有相应的颜色,它们对地图画没有意义。
wiki 上有基色和方块的对照表:地图物品格式.
但基色颜色不等于地图色的颜色。基色颜色的 R、G、B 三个分量要分别乘一个因子,再除以 255(向下取整)才能得到地图色的颜色。这个因子由阴影值决定。
阴影值 因子 0 180 1 220 2 255 3 135 可见,阴影值为
2
的地图色颜色等于基色颜色,阴影值1
稍暗,阴影值0
更暗,阴影值3
最暗。阴影有什么意义?阴影表示一个方块与周边方块的相对高度。如果方块 A 的坐标为 \((x,y_A,z)\),它北侧方块 B 的坐标 \((x,y_B,z-1)\),则 A 的阴影值
\[ Shadow(A)=\left\{ \begin{aligned} 0\quad,&\quad \text{如果 } y_A<y_B \\ 1\quad,&\quad \text{如果 } y_A=y_B \\ 2\quad,&\quad \text{如果 } y_A>y_B \end{aligned} \right. \]在所有基色中,水(
12
)是最特殊的。水的阴影值与相对高度无关,只由水深决定。 水深 1 格时阴影值为2
;水深 6 格时阴影值为1
;水深等于或超过 11 格时阴影值为0
。若一个方块比它北侧的方块高,那么它会表现的更亮;(水越浅越亮),这让地图可以展示出地势的高低起伏。
需要注意,阴影是 2 位无符号整数,可以有 4 种取值,却只使用了前 3 个。剩下的那个阴影值 3 可以在地图上正常工作,但不可能在原版生存里获得。 我很纳闷 Mojang 在想什么。
-
0
也是个特殊的基色基色
0
代表空气或者未探索,与其他所有基色都截然不同。基色0
对应的颜色是全透明,看到的只有地图物品的背景色(或物品展示框的材质)。很多透明方块都属于基色0
,如玻璃、下界门、火把等。很奇怪的是,红石灯也属于基色0
。 -
为什么说缩放地图是没有意义的?
根据地图机制,缩放地图既不能提高地图分辨率,也不能带来更多颜色。所以按缩放后的尺寸建造更大的地图画,纯粹是浪费方块、浪费空间。
地图画工作原理
-
立体地图画机制
立体地图画中,方块按照特定位置放置,形成立体地图画。每个方块都有两个作用:
- 显示本身的基色
- 决定南侧方块的阴影
可见,每个方块的高度不是胡来的,而是地图色决定的。
-
平板地图画机制
如果你限定地图画只能由阴影为
1
的基色组成(或阴影为2
的水),你就得到了平板地图画。每个方块都有相同的高度。 -
纯文件地图画机制
原版地图画(立体和平板)都是先建造,再用地图记录,这也的确是原版生存中唯一的办法。但如果你不要求这么香草,直接替换地图数据文件也是不错的选择。纯文件地图画直接把地图数据文件放进存档里,或者用生成的替换现有的地图数据文件,然后再用命令获得对应的地图物品。这是使用第三种阴影的唯一方法。
什么是高度压缩
高度压缩是一种新技术,可以降低立体地图画的最大高度。鉴于大尺寸地图画很容易超过限高,压缩是很有意义的。目前压缩有两种方式:无损压缩和有损压缩。
-
无损压缩
无损压缩保持地图显示效果完全不变。它尝试将地图的某些片段下沉。
压缩时,SlopeCraft 依次独立处理地图画的每一列。
\[ \displaylines{ \Delta H_i=\left\{ \begin{aligned} Shadow_i-1\quad &,\quad \text{如果 }BaseColor_i\neq 0,12 \\ 0 \quad &,\quad \text{其他情况} \end{aligned} \right. \newline H_i=\sum_{j=0}^i \Delta H_j \newline maxHeight=\max H } \]上式中,\(\Delta H\) 是每个方块与自己北侧方块的高度差,\(H\) 是每个方块的高度。这个公式与实际源代码略有差别,但基本原理一致。
上面的公式限定了高度差必须为
-1
,0
或者1
,但这种限制不是必须的。例如,阴影2
的方块只要求自己与北侧方块的高度差大于0
,但不一定是1
。这就提供了无损压缩的机会。无损压缩还会特殊处理水和空气方块,因为它们的阴影值与高度差无关。虽然这让代码实现难度高了不少,但压缩效果会更好。
请注意,无损压缩不能绝对保证把高度压缩到 256 以内。有时候地图画的一列甚至可能是单调递增/递减,根本就不可压缩。
-
有损压缩
显然,无损压缩没有彻底解决问题。为了把地图画总高度压缩到任何值以内,微调一些像素的颜色也是可以接受的。有损压缩算法会细微调整一些像素的地图色来压缩总高度,并保证色差尽量小。
有损压缩可以把地图画压缩到任何高度以内。 介于是用遗传算法实现,有损压缩的表现有些随机,且相对缓慢。
尽管理论上可以把总高度压缩到任意值,但我不建议你把最大允许高度设为小于
14
的值,否则遗传算法需要相当长长长长长的时间来压缩————甚至可能压缩失败。遗传算法,永远滴神!
值得一提的是,上述两种方法是平行的,你可以同时使用两种压缩算法,也可以只使用一种。如果你已经启用了有损压缩,建议顺手勾上无损压缩,这可以降低有损压缩对画质的损伤。
立体地图画中的玻璃桥
取立体地图画任一水平切面,不难发现,切面上有很多孤立的方块。这种结构真的很难在原版生存建造,即便有投影 mod 辅助也是如此。但如果我们将这些孤立的方块连接起来,就比较容易建造了。
玻璃桥就是这个“连接”。使用 Prim 算法可以用最少的玻璃把一层内所有方块都连接起来,这个过程就叫搭桥。通常来说不是每个层都要搭桥,否则会浪费很多玻璃。默认间隔 4 层,即每 5 层搭一次桥。
考虑到 Prim 算法的时间复杂度为 \(O(n^3)\),这会耗费些时间。不过比起建造的肝度,实际上还是节省时间的。