前注
本文是 Jasper Flick 的 Unity 教程中的六边形网格地图系列教程的第二篇。
原文地址:http://catlikecoding.com/unity/tutorials/hex-map/part-2/
译者获得作者授权翻译转载于 indienova,后续教程将会陆续翻译。
本人也是初学者,如有错译,望海涵并及时纠正。
前言
连接相邻单元格
在三角形中进行颜色插值
创建混色区域
简化多边形网格
本教程是六边形网格地图系列教程的第二部分。上一篇教程我们制作了最基本的网格结构与一个简易的单元格编辑器。现在每个单元格能够拥有自己独特的颜色了,但是单元格与单元格之间的颜色转换十分生硬。这次我们来介绍如何制作在相邻单元格之间混合颜色的过度区域。

相邻单元格:Cell Neighbor
在混合单元格之间的颜色之前,我们需要知道哪两个单元格是相连的。一个六边形单元格有六个相邻单元格,我们可以用六个罗盘方向来表示它们。这六个方向分别是 northeast, east, southeast, southwest, west, and northwest。我们创建一个枚举类型 enumeration 来表示它们。
public enum HexDirection {
NE, E, SE, SW, W, NW
}
枚举 enum 是什么?
你可以使用 enum 关键字来定义一个枚举类。枚举类拥有一个有序的名字列表,一个枚举类可以将列表中的一个名字作为它的值。在默认情况下,这些名字代表了由零开始的整数列。当你需要一个具有名字的有限个数的的选项列表时枚举类将会帮助到你。事实上,枚举类就是一个的整数。你可以对其做加、减与整型相互转换等运算。你也可以将其声明为其他类型,但正常来说应该是整形。

为了储存这六个邻居,我们为 HexCell 类添加一个数组。我们可以将其设置为公有的,也可以将其设置为私有的然后使用方法来访问他。同时要确保它能够被序列化以便相邻关系能够被重新编译。
[SerializeField] HexCell[] neighbors;
我们需要储存相邻关系吗?
这个邻居数组现在被显示在 inspector 上了。因为每个单元格有6个邻居,所以我们将其长度设置为6。

现在添加一个公有方法来检索某个方向上的邻居。因为方向总是从0到5的所以我们不需要检查是否越界。
public HexCell GetNeighbor (HexDirection direction) {
return neighbors[(int)direction];
}
同时添加一个方法来设置相邻单元格。
public void SetNeighbor (HexDirection direction, HexCell cell) {
neighbors[(int)direction] = cell;
}
相邻关系是双向的,所以在一个方向上设置之后我们需要对相反的方向进行设置。
public void SetNeighbor (HexDirection direction, HexCell cell) {
neighbors[(int)direction] = cell;
cell.neighbors[(int)direction.Opposite()] = this;
}

当然我们需要能够求出一个方向的相反方向才能实现它。我们可以通过为 HexDirection 创建一个扩展方 extension method 来实现这一切。在原有方向基础上加3以得到相反方向,这样只对头三个方向管用,后三个方向需要减3。
public static class HexDirectionExtensions {
public static HexDirection Opposite (this HexDirection direction) {
return (int)direction < 3 ? (direction + 3) : (direction - 3);
}
}
什么是拓展方法?
我们能够为任何东西添加拓展方法么?是的,就像是你能够将任何值作为静态方法的参数一样。使用拓展方法是一个好主意么?在适度使用的情况下,还不错。这是一个有着特定用途的工具,但是无节制的使用的话将会造成混乱。
连接相邻单元格:Connecting Neighbors
我们可以使用 HexGrid.CreateCell 方法初始化相邻关系。随着我们一行行一列列的创建单元格,我们知道哪些单元格已经被创建了。我们可以将已创建的单元格相连。
最简单的一种方式就是东西相连 E–W connection。每一行上的第一个单元格没有西向的邻居,其余的有。所有的这些邻居就是上一个被创建的多边形。因此,我们将其连接起来。

void CreateCell (int x, int z, int i) {
…
cell.color = defaultColor;
if (x > 0) {
cell.SetNeighbor(HexDirection.W, cells[i - 1]);
}
Text label = Instantiate(cellLabelPrefab);
…
}

剩余两组的双向连接都要跨越不同的行,而我们只能与上一行连接,这意味着我们必须将第一行舍弃。
if (x > 0) {
cell.SetNeighbor(HexDirection.W, cells[i - 1]);
}
if (z > 0) {
}
因为行与行之间是交错开的,所以奇偶行需要用不同的方式处理。首先来处理偶数行(译注:行号从零开始,零行舍弃),偶数行的所有单元格都有个 SE 方向邻居,将其连接。

if (z > 0) {
if ((z & 1) == 0) {
cell.SetNeighbor(HexDirection.SE, cells[i - width]);
}
}
为什么需要做 z&1 运算?
在计算机内部,数值是使用只有0和1的二进制数来表示的。数列1,2,3,4用二进制表示就是1,10,11,100。正如你所见,偶数的末尾总是0.
我们将某数与1做“按位与”运算可以留下第一位(译注:从右侧开始数)而舍去其他所有位。如果结果是0的话就说明原数是偶数。
我们也可以连接 SW 方向上的邻居,除了每行的第一个单元格。

if (z > 0) {
if ((z & 1) == 0) {
cell.SetNeighbor(HexDirection.SE, cells[i - width]);
if (x > 0) {
cell.SetNeighbor(HexDirection.SW, cells[i - width - 1]);
}
}
}
奇数行遵循着同样的逻辑,但是相反。一旦完成,网格中所有的单元格的邻居都连接好了。
if (z > 0) {
if ((z & 1) == 0) {
cell.SetNeighbor(HexDirection.SE, cells[i - width]);
if (x > 0) {
cell.SetNeighbor(HexDirection.SW, cells[i - width - 1]);
}
}
else {
cell.SetNeighbor(HexDirection.SW, cells[i - width]);
if (x < width - 1) {
cell.SetNeighbor(HexDirection.SE, cells[i - width + 1]);
}
}
}

当然不是所有的单元格都恰好有6个邻居。网格边界上的单元格有2个到5个不等的邻居,我们应当注意这一点。

颜色混合:Blending Colors
颜色混合将会让每个单元格的三角剖分 triangulation 更加复杂,所以我们先将三角剖分这部分的代码独立出来。因为我们现在已经有方向概念了,所以我们用它来重写这部分的代码,以取代顶点索引。
void Triangulate (HexCell cell) {
for (HexDirection d = HexDirection.NE; d <= HexDirection.NW; d++) {
Triangulate(d, cell);
}
}
void Triangulate (HexDirection direction, HexCell cell) {
Vector3 center = cell.transform.localPosition;
AddTriangle(
center,
center + HexMetrics.corners[(int)direction],
center + HexMetrics.corners[(int)direction + 1]
);
AddTriangleColor(cell.color);
}
既然我们已经使用方向了,那就应该将顶角与方向做一个对应,而不是将方向转换为索引。
AddTriangle(
center,
center + HexMetrics.GetFirstCorner(direction),
center + HexMetrics.GetSecondCorner(direction)
);
这就需要为 HexMetrics 类添加2个静态方法。而且这也可以使 corners 数组变为私有的。
static Vector3[] corners = {
new Vector3(0f, 0f, outerRadius),
new Vector3(innerRadius, 0f, 0.5f * outerRadius),
new Vector3(innerRadius, 0f, -0.5f * outerRadius),
new Vector3(0f, 0f, -outerRadius),
new Vector3(-innerRadius, 0f, -0.5f * outerRadius),
new Vector3(-innerRadius, 0f, 0.5f * outerRadius),
new Vector3(0f, 0f, outerRadius)
};
public static Vector3 GetFirstCorner (HexDirection direction) {
return corners[(int)direction];
}
public static Vector3 GetSecondCorner (HexDirection direction) {
return corners[(int)direction + 1];
}
逐三角形混色:Multiple Colors Per Triangle
到目前为止 HexMesh.AddTriangleColor 方法只有一个颜色参数。这样只能为三角形添加一个颜色。现在我们要使三角形的每一个顶角都拥有一个颜色。
void AddTriangleColor (Color c1, Color c2, Color c3) {
colors.Add(c1);
colors.Add(c2);
colors.Add(c3);
}
现在我们可以开始混合颜色了!从给边界上的两个顶点使用相邻三角形的颜色开始。
void Triangulate (HexDirection direction, HexCell cell) {
Vector3 center = cell.transform.localPosition;
AddTriangle(
center,
center + HexMetrics.GetFirstCorner(direction),
center + HexMetrics.GetSecondCorner(direction)
);
HexCell neighbor = cell.GetNeighbor(direction);
AddTriangleColor(cell.color, neighbor.color, neighbor.color);
}
不幸的是,这将引发一个 NullReferenceException 异常,因为网格边界上的单元格并没有足够的6个邻居。当单元格缺少邻居时应该如何做呢?一个使用的方法是使用三角形自身的颜色作为代替。
HexCell neighbor = cell.GetNeighbor(direction) ?? cell;
"??"操作符是用来干啥的?

单元格坐标标签哪里去了?
色彩均值:Color Averaging
颜色混合工作了,但是很明显,现在的结果并不是我们想要的效果。六边形网格边界上的颜色的值应该是两个相邻六边形颜色的混合值。
HexCell neighbor = cell.GetNeighbor(direction) ?? cell; Color edgeColor = (cell.color + neighbor.color) * 0.5f; AddTriangleColor(cell.color, edgeColor, edgeColor);

现在我们已经在边界混色了,但是得到的颜色边界依然是十分锋利的。这是因为六边形的每一个顶点是有三个六边形共享的。

这就意味着我们必须考虑前后两个方向的颜色。所以我们将使用四种颜色,每个方向上三种。
我们为 HexDirectionExtensions 类添加两个额外的方法来实现得到前后两个方向。
public static HexDirection Previous (this HexDirection direction) {
return direction == HexDirection.NE ? HexDirection.NW : (direction - 1);
}
public static HexDirection Next (this HexDirection direction) {
return direction == HexDirection.NW ? HexDirection.NE : (direction + 1);
}
现在我们可以检索到与一条边有关的全部三个邻居,并且进行两个三色混色。
HexCell prevNeighbor = cell.GetNeighbor(direction.Previous()) ?? cell;
HexCell neighbor = cell.GetNeighbor(direction) ?? cell;
HexCell nextNeighbor = cell.GetNeighbor(direction.Next()) ?? cell;
AddTriangleColor(
cell.color,
(cell.color + prevNeighbor.color + neighbor.color) / 3f,
(cell.color + neighbor.color + nextNeighbor.color) / 3f
);

这样就产生了正确的混色效果,除了在网格边界的地方。因为在网格边界,相邻的网格对共同缺失的相邻网格没有进行一致的处理,所以你仍然可以在边界处看到锋利的边缘线。总体来说,我们现在的手段并没有取得一个令人足够满意的效果。我们需要一个更好的方法。
混色区域:Blend Regions
在六边形的整个表面进行混色导致了一些混乱,混色之后你再也无法分辨那些是独立的单元格了。你可以通过只在六边形临近边界的区域混色来解决这一问题。在单元格的内部预留出一个六边形使用其固有色渲染。

相对于混色区域,固有色核心应该有多大呢?不同的数值会导致不同的效果。我们可以用相对于外接圆半径的百分数来定义核心的大小。先将其设定为75%。这产生了两个新的参数,相加等于100%。
public const float solidFactor = 0.75f; public const float blendFactor = 1f - solidFactor;
我们可以使用这两个参数创建索引核心六边形顶点的方法。
public static Vector3 GetFirstSolidCorner (HexDirection direction) {
return corners[(int)direction] * solidFactor;
}
public static Vector3 GetSecondSolidCorner (HexDirection direction) {
return corners[(int)direction + 1] * solidFactor;
}
现在更改 Hexh.Triangulate 方法,使用核心的顶点而不是原来单元格的顶点。暂时使用它们现在的颜色。
AddTriangle(
center,
center + HexMetrics.GetFirstSolidCorner(direction),
center + HexMetrics.GetSecondSolidCorner(direction)
);

混色区域的三角剖分:Triangulating Blend Regions
我们需要填充缩小三角形留下的空白区域。六边形的每个方向上的空白区域是一个梯形。我们可以使用一个平面来覆盖它。创建一个方法来添加平面与其颜色。

void AddQuad (Vector3 v1, Vector3 v2, Vector3 v3, Vector3 v4) {
int vertexIndex = vertices.Count;
vertices.Add(v1);
vertices.Add(v2);
vertices.Add(v3);
vertices.Add(v4);
triangles.Add(vertexIndex);
triangles.Add(vertexIndex + 2);
triangles.Add(vertexIndex + 1);
triangles.Add(vertexIndex + 1);
triangles.Add(vertexIndex + 2);
triangles.Add(vertexIndex + 3);
}
void AddQuadColor (Color c1, Color c2, Color c3, Color c4) {
colors.Add(c1);
colors.Add(c2);
colors.Add(c3);
colors.Add(c4);
}
修改 HexMesh.Triangulate 方法以便让固有色核心的三角形使用单一的固有色,同时混色区域使用固有色与顶角的颜色进行混色。
void Triangulate (HexDirection direction, HexCell cell) {
Vector3 center = cell.transform.localPosition;
Vector3 v1 = center + HexMetrics.GetFirstSolidCorner(direction);
Vector3 v2 = center + HexMetrics.GetSecondSolidCorner(direction);
AddTriangle(center, v1, v2);
AddTriangleColor(cell.color);
Vector3 v3 = center + HexMetrics.GetFirstCorner(direction);
Vector3 v4 = center + HexMetrics.GetSecondCorner(direction);
AddQuad(v1, v2, v3, v4);
HexCell prevNeighbor = cell.GetNeighbor(direction.Previous()) ?? cell;
HexCell neighbor = cell.GetNeighbor(direction) ?? cell;
HexCell nextNeighbor = cell.GetNeighbor(direction.Next()) ?? cell;
AddQuadColor(
cell.color,
cell.color,
(cell.color + prevNeighbor.color + neighbor.color) / 3f,
(cell.color + neighbor.color + nextNeighbor.color) / 3f
);
}

边界桥梁:Edge Bridges
效果看起来好多了,但是还没有达到我们的目标。两个相邻单元格之间的混色被附近其他的邻居所干扰。为了消除干扰,我们需要砍掉梯形的顶角让它变成一个矩形。这将在两个相邻单元格之间形成一座桥梁而在夹角出留出空隙。

我们可以通过 v1、v2 的坐标求出 v3、v4 的新坐标,将其沿着垂直于边界的方向向外偏移就好了。那么偏移量是多少呢?我们可以求出三角形的中线,然后乘以我们之前求出的 blendFactor。这是 HexMetrics 类的工作。
public static Vector3 GetBridge (HexDirection direction) {
return (corners[(int)direction] + corners[(int)direction + 1]) *
0.5f * blendFactor;
}
回到 HexMesh 类,修改 AddQuadColor 方法为只接受两个参数。
void AddQuadColor (Color c1, Color c2) {
colors.Add(c1);
colors.Add(c1);
colors.Add(c2);
colors.Add(c2);
}
调整 Triangulate 方法,使其能对边界桥梁进行正确混色。
Vector3 bridge = HexMetrics.GetBridge(direction); Vector3 v3 = v1 + bridge; Vector3 v4 = v2 + bridge; AddQuad(v1, v2, v3, v4); HexCell prevNeighbor = cell.GetNeighbor(direction.Previous()) ?? cell; HexCell neighbor = cell.GetNeighbor(direction) ?? cell; HexCell nextNeighbor = cell.GetNeighbor(direction.Next()) ?? cell; AddQuadColor(cell.color, (cell.color + neighbor.color) * 0.5f);

填充空隙:Filling the Gaps
我们现在在三个三角形交汇的顶点留下了一个三角形空隙。是我们将六边形边界截取为矩形桥梁时得到的。我们现在要将三角形补回来。
先考虑连接着当前方向前一个方向邻居的那个小三角形。其第一个顶点(译注:v1,看上面的图3-5)是单元格的固有色,第二个顶点(左侧无名点,译注)是三个单元格的混合色,最后一个顶点(译注:v3)和边界桥梁的中点是相同的颜色。
Color bridgeColor = (cell.color + neighbor.color) * 0.5f;
AddQuadColor(cell.color, bridgeColor);
AddTriangle(v1, center + HexMetrics.GetFirstCorner(direction), v3);
AddTriangleColor(
cell.color,
(cell.color + prevNeighbor.color + neighbor.color) / 3f,
bridgeColor
);

最后,用同样的方式为最后一个小三角形着色。注意,第2、3顶点顺序相反。
AddTriangle(v2, v4, center + HexMetrics.GetSecondCorner(direction));
AddTriangleColor(
cell.color,
bridgeColor,
(cell.color + neighbor.color + nextNeighbor.color) / 3f
);

现在我们已经拥有了一个可以任意设置大小的混色区域。模糊的还是清晰的六边形边界现在任你选择。但你可能注意到了接近网格边的地方混色效果依旧存在问题。我们先不要管它,关心一下另外一个问题。
融合边界网格:Fusing Edges
看一看我们网格的解剖结构。有哪些不同的形状呢?如果我们忽略网格边界的话,我们会发现3种不同的形状。单一颜色的六边形核心,双色混色的矩形桥梁,三色混色的三角形连接处。你可以在所有的三个单元格的交汇处找到这三种形状。

每两个相邻六边形都由一个矩形桥梁连接。每三个六边形都由一个三角形连接。但是我们现在却用了一个更复杂的方式去将其三角形网格化。我们现在在两个六边形交接处使用了两个平面而不是一个,每三个六边形交界处使用了六个三角形而不是一个,十分消耗资源。另外,如果我们使用更简单的网格连接的话就不用做数量如此多的颜色差值来混色了。所以现在我们要降低网格的复杂度,使用更少的资源,更少的三角形。

为什么我们不一开始就这样做呢?
使用一个桥直接相连:Direct Bridges
我们的边界桥现在包含两个平面。我们需要加倍边界的长度好让他们穿越到下一个六边形。这意味着我们不再需要在 HexMetrics.GetBridge 方法中乘0.5了,只需要将其相加并乘上混合系数。
public static Vector3 GetBridge (HexDirection direction) {
return (corners[(int)direction] + corners[(int)direction + 1]) *
blendFactor;
}

现在桥直接将两个相邻六边形相连了。但是每个连接依旧有两个平面,只不过是相互重合而已。所以,接下来我们让相邻的连个六边形只有一个生成桥。
我们开始简化我们的三角形剖分代码。移出所有处理边界三角形与混合颜色的部分。将创建边界桥的代码移到一个新的方法中。将前面的两个顶点作为参数传入这个方法,这样我们就不必重复推导了。
void Triangulate (HexDirection direction, HexCell cell) {
Vector3 center = cell.transform.localPosition;
Vector3 v1 = center + HexMetrics.GetFirstSolidCorner(direction);
Vector3 v2 = center + HexMetrics.GetSecondSolidCorner(direction);
AddTriangle(center, v1, v2);
AddTriangleColor(cell.color);
TriangulateConnection(direction, cell, v1, v2);
}
void TriangulateConnection (
HexDirection direction, HexCell cell, Vector3 v1, Vector3 v2
) {
HexCell neighbor = cell.GetNeighbor(direction) ?? cell;
Vector3 bridge = HexMetrics.GetBridge(direction);
Vector3 v3 = v1 + bridge;
Vector3 v4 = v2 + bridge;
AddQuad(v1, v2, v3, v4);
AddQuadColor(cell.color, neighbor.color);
}
现在我们可以方便地限制连接处的三角剖分了。从 NE 方向上的连接只添加一个桥开始。
if (direction == HexDirection.NE) {
TriangulateConnection(direction, cell, v1, v2);
}

似乎我么只需要为头三个方向创建桥就可以覆盖所有的连接。所有接下来是 NE、E 和 SE。
if (direction <= HexDirection.SE) {
TriangulateConnection(direction, cell, v1, v2);
}

所有的连个相邻单元格的连接都被覆盖了。但是我们在网格边界的外部也得到了一些。我们通过修改 TriangulateConnection 方法中没有邻居的情况来去掉他们。我们不再用单元格自己代替它并不存在的邻居。
void TriangulateConnection (
HexDirection direction, HexCell cell, Vector3 v1, Vector3 v2
) {
HexCell neighbor = cell.GetNeighbor(direction);
if (neighbor == null) {
return;
}
…
}

三角形连接部分:Triangular Connections
我们需要重新填补上三角形的洞。首先我们创建链接当前方向下一个方向邻居的三角形。再一次地,我们只在六边形的确存在邻居时才创建它。
void TriangulateConnection (
HexDirection direction, HexCell cell, Vector3 v1, Vector3 v2
) {
…
HexCell nextNeighbor = cell.GetNeighbor(direction.Next());
if (nextNeighbor != null) {
AddTriangle(v2, v4, v2);
AddTriangleColor(cell.color, neighbor.color, nextNeighbor.color);
}
}
第三个顶点的坐标是多少呢?我先使用 v2作为占位符,但这显然是不正确的。因为三角形的每一条边都与一个桥相连,我们可以在沿着桥方向的邻居上找到它。
AddTriangle(v2, v4, v2 + HexMetrics.GetBridge(direction.Next()));

我们已经完成了么?还没有,我们现在产生了重复的三角形。因为每三个六边形共享一个三角形连接,我们只需要为每个六边形添加两个连接。只有 NE 和 E 方向上的。
if (direction <= HexDirection.E && nextNeighbor != null) {
AddTriangle(v2, v4, v2 + HexMetrics.GetBridge(direction.Next()));
AddTriangleColor(cell.color, neighbor.color, nextNeighbor.color);
}
下一片教程是阶梯状地形实现。


很不错的教程,实用性很强
。-。呃 这些教程很好用,可以扩展成地形编辑器的样子
这让我想到了一个叫末日拾荒者的游戏,也是用这种六边形构成的地图