连接相邻单元格
在三角形中进行颜色插值
创建混色区域
简化多边形网格
本教程是六边形网格地图系列教程的第二部分。上一篇教程我们制作了最基本的网格结构与一个简易的单元格编辑器。现在每个单元格能够拥有自己独特的颜色了,但是单元格与单元格之间的颜色转换十分生硬。这次我们来介绍如何制作在相邻单元格之间混合颜色的过度区域。
Figure 1‑1具有过渡效果的单元格
相邻单元格 | Cell Neighbor
在混合单元格之间的颜色之前,我们需要知道哪两个单元格是相连的。一个六边形单元格有六个相邻单元格,我们可以用六个罗盘方向来表示它们。这六个方向分别是 northeast, east, southeast, southwest, west, and northwest。我们创建一个枚举类型 enumeration 来表示它们。
public enum HexDirection {
NE, E, SE, SW, W, NW
}
枚举 enum 是什么?
你可以使用 enum 关键字来定义一个枚举类。枚举类拥有一个有序的名字列表,一个枚举类可以将列表中的一个名字作为它的值。在默认情况下,这些名字代表了由零开始的整数列。当你需要一个具有名字的有限个数的的选项列表时枚举类将会帮助到你。
事实上,枚举类就是一个的整数。你可以对其做加、减、与整型相互转换等运算。你也可以将其声明为其他类型,但正常来说应该是整形。
Figure 1‑1六个方向上的六个邻居
为了储存这六个邻居,我们为HexCell类添加一个数组。我们可以将其设置为公有的,也可以将其设置为私有的然后使用方法来访问他。同时要确保它能够被序列化以便相邻关系能够被重新编译。
[SerializeField]
HexCell[] neighbors;
我们需要储存相邻关系么?
我么也可以通过坐标来确定相邻关系,并在网格中检索出所需的单元格。但是将它储存起来比较简单并直截了当。
这个邻居数组现在被显示在 inspector 上了。因为每个单元格有6个邻居,所以我们将其长度设置为6.
Figure 1‑2六个邻居的预制体空位
现在添加一个公有方法来检索某个方向上的邻居。因为方向总是从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;
}
Figure 1‑3双向的相邻关系
当然我们需要能够求出一个方向的相反方向才能实现它。我们可以通过为 HexDirection 创建一个扩展方 extension method 来实现这一切。在原有方向基础上加3以得到相反方向,这样只对头三个方向管用,后三个方向需要减3。
public static class HexDirectionExtensions {
public static HexDirection Opposite (this HexDirection direction) {
return (int)direction < 3 ? (direction + 3) : (direction - 3);
}
}
什么是拓展方法?
拓展方法就是一个静态类型中的静态方法,但是可以像调用某个类型的实例方法一样来调用它。这个类型可以是任何东西,比如说,类、接口、结构体、值、乃至于枚举类型。拓展方法的第一个参数需要this关键字,它定义了拓展方法对哪些实例类型起作用。
我们能够为任何东西添加拓展方法么?是的,就像是你能够将任何值作为静态方法的参数一样。使用拓展方法是一个好主意么?在适度使用的情况下,还不错。这是一个有着特定用途的工具,但是无节制的使用的话将会造成混乱。
连接相邻单元格 | Connecting Neighbors
我们可以使用 HexGrid.CreateCell 方法初始化相邻关系。随着我们一行行一列列的创建单元格,我们知道哪些单元格已经被创建了。我们可以将已创建的单元格相连。
最简单的一种方式就是东西相连E–W connection。每一行上的第一个单元格没有西向的邻居,其余的有。所有的这些邻居就是上一个被创建的多边形。因此,我们将其连接起来。
Figure 1‑4从东到西连接已创建的多边形
void CreateCell (int x, int z, int i) {
…
cell.color = defaultColor;
if (x > 0) {
cell.SetNeighbor(HexDirection.W, cells[i - 1]);
}
Text label = Instantiate<Text>(cellLabelPrefab);
…
}
Figure 1‑5东西向的邻居已经被连接起来
剩余两组的双向连接都要跨越不同的行,而我们只能与上一行连接,这意味着我们必须将第一行舍弃。
if (x > 0) {
cell.SetNeighbor(HexDirection.W, cells[i - 1]);
}
if (z > 0) {
}
因为行与行之间是交错开的,所以奇偶行需要用不同的方式处理。首先来处理偶数行(译注:行号从零开始,零行舍弃),偶数行的所有单元格都有个SE方向邻居,将其连接。
Figure 1‑6在偶数行从NW向SE方向连接
if (z > 0) {
if ((z & 1) == 0) {
cell.SetNeighbor(HexDirection.SE, cells[i - width]);
}
}
为什么需要做z&1运算?
&&是“逻辑与“运算符,而&是“按位与“运算符。逻辑上是一样的操作,“按位与”对其操作数的每一个独立的位进行运算。两个数位需要都是1结果才是1。例如,10101010 & 00001111 = 00001010。
在计算机内部,数值是使用只有0和1的二进制数来表示的。数列1,2,3,4用二进制表示就是1,10,11,100。正如你所见,偶数的末尾总是0.
我们将某数与1做“按位与”运算可以留下第一位(译注:从右侧开始数)而舍去其他所有位。如果结果是0的话就说明原数是偶数。
我们也可以连接SW方向上的邻居,除了每行的第一个单元格。
Figure 1‑7在偶数行从NE向SE方向连接
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]);
}
}
}
Figure 1‑8所有的相邻单元格都被连接
当然不是所有的单元格都恰好有6个邻居。网格边界上的单元格有2个到5个不等的邻居,我们应当注意这一点。
Figure 1‑9每个单元格的邻居
颜色混合 | 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;
“??“操作符是用来干啥的?
这是一个空合并运算符 null-coalescing operator。简言之,a ?? b是a != null ? a : b的缩写。
这里依旧有些问题,因为 Unity 在将对象拿来与 components 进行比较时需要做一些额外的工作,而这个操作通过与null进行比较越过了这些操作。目前不用担心,只有你摧毁某一对象时这才是一个问题。
Figure 2‑1错误的颜色混合
[spoilerblock text=单元格坐标标签哪里去了?"]
他们还在那,但是我在截屏中隐藏了UI层。
[/spoilerblock]
色彩均值 | Color Averaging
颜色混合工作了,但是很明显,现在的结果并不是我们想要的效果。六边形网格边界上的颜色的值应该是两个相邻六边形颜色的混合值。
HexCell neighbor = cell.GetNeighbor(direction) ?? cell; Color edgeColor = (cell.color + neighbor.color) * 0.5f; AddTriangleColor(cell.color, edgeColor, edgeColor);
Figure 2‑2对边线混色
现在我们已经在边界混色了,但是得到的颜色边界依然是十分锋利的。这是因为六边形的每一个顶点是有三个六边形共享的。
Figure 2‑3三个邻居,四种颜色
这就意味着我们必须考虑前后两个方向的颜色。所以我们将使用四种颜色,每个方向上三种。
我们为 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 );
Figure 2‑4对顶点混色
这样就产生了正确的混色效果,除了在网格边界的地方。因为在网格边界,相邻的网格对共同缺失的相邻网格没有进行一致的处理,所以你仍然可以在边界处看到锋利的边缘线。总体来说,我们现在的手段并没有取得一个令人足够满意的效果。我们需要一个更好的方法。
混色区域 | Blend Regions
在六边形的整个表面进行混色导致了一些混乱,混色之后你再也无法分辨那些是独立的单元格了。你可以通过只在六边形临近边界的区域混色来解决这一问题。在单元格的内部预留出一个六边形使用其固有色渲染。
Figure 3‑1带有混色区域的固有色核心
相对于混色区域,固有色核心应该有多大呢?不同的数值会导致不同的效果。我们可以用相对于外接圆半径的百分数来定义核心的大小。先将其设定为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) );
Figure 3‑2不含边界区域的六边形核心
混色区域的三角剖分 | Triangulating Blend Regions
我们需要填充缩小三角形留下的空白区域。六边形的每个方向上的空白区域是一个梯形。我们可以使用一个平面来覆盖它。创建一个方法来添加平面与其颜色。
Figure 3‑3梯形的边界
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 ); }
Figure 3‑4使用梯形边界进行混色
边界桥梁 | Edge Bridges
效果看起来好多了,但是还没有达到我们的目标。两个相邻单元格之间的混色被附近其他的邻居所干扰。为了消除干扰,我们需要砍掉梯形的顶角让它变成一个矩形。这将在两个相邻单元格之间形成一座桥梁而在夹角出留出空隙。
Figure 3‑5边界桥梁
我们可以通过 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);
Figure 3‑6被正确着色的带有顶点空隙的边界桥梁
填充空隙 | 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 );
Figure 3‑7差不多了
最后,用同样的方式为最后一个小三角形着色。注意,第2、3顶点顺序相反。
AddTriangle(v2, v4, center + HexMetrics.GetSecondCorner(direction));
AddTriangleColor(
cell.color,
bridgeColor,
(cell.color + neighbor.color + nextNeighbor.color) / 3f
);
Figure 3‑8完成填充
现在我们已经拥有了一个可以任意设置大小的混色区域。模糊的还是清晰的六边形边界现在任你选择。但你可能注意到了接近网格边的地方混色效果依旧存在问题。我们先不要管它,关心一下另外一个问题。
但是颜色转换依然很丑?
这是线性颜色混合的局限性。在只有单一的颜色的时候确实不是很好看。我们将会在未来的教程中加入地形材质并做出更绚丽的混色。
融合边界网格 | Fusing Edges
看一看我们网格的解剖结构。有哪些不同的形状呢?如果我们忽略网格边界的话,我们会发现3种不同的形状。单一颜色的六边形核心,双色混色的矩形桥梁,三色混色的三角形连接处。你可以在所有的三个单元格的交汇处找到这三种形状。
Figure 4‑1三种可见结构
每两个相邻六边形都由一个矩形桥梁连接。每三个六边形都由一个三角形连接。但是我们现在却用了一个更复杂的方式去将其三角形网格化。我们现在在两个六边形交接处使用了两个平面而不是一个,每三个六边形交界处使用了六个三角形而不是一个。十分消耗资源。另外,如果我们使用更简单的网格连接的话就不用做数量如此多的颜色差值来混色了。所以现在我们要降低网格的复杂度,使用更少的资源,更少的三角形。
Figure 4‑2过于复杂的网格
为什么我们一开始的时候不这样做呢?
你将会在你的生命中多次遇到这样的问题。这就是后见之明。这是一个代码以合理的方式进行演变的例子,新的见解将给我们以新的方法。思考刚刚做过的事之后总会给你新的智慧。
使用一个桥直接相连 | Direct Bridges
我们的边界桥现在包含两个平面。我们需要加倍边界的长度好让他们穿越到下一个六边形。这意味着我们不再需要在HexMetrics.GetBridge方法中乘0.5了,只需要将其相加并乘上混合系数。
public static Vector3 GetBridge (HexDirection direction) {
return (corners[(int)direction] + corners[(int)direction + 1]) *
blendFactor;
}
Figure 4‑3横穿两个六边形并且相互覆盖的边界桥
现在桥直接将两个相邻六边形相连了。但是每个连接依旧有两个平面,只不过是相互重合而已。所以,接下来我们让相邻的连个六边形只有一个生成桥。
我们开始简化我们的三角形剖分代码。移出所有处理边界三角形与混合颜色的部分。将创建边界桥的代码移到一个新的方法中。将前面的两个顶点作为参数传入这个方法,这样我们就不必重复推导了。
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); }
Figure 4‑4 NE方向上的单桥
似乎我么只需要为头三个方向创建桥就可以覆盖所有的连接。所有接下来是 NE、E和SE。
if (direction <= HexDirection.SE) {
TriangulateConnection(direction, cell, v1, v2);
}
Figure 4‑5网格内部与边界上的桥
所有的连个相邻单元格的连接都被覆盖了。但是我们在网格边界的外部也得到了一些。我们通过修改 TriangulateConnection 方法中没有邻居的情况来去掉他们。我们不再用单元格自己代替它并不存在的邻居。
void TriangulateConnection (
HexDirection direction, HexCell cell, Vector3 v1, Vector3 v2
) {
HexCell neighbor = cell.GetNeighbor(direction);
if (neighbor == null) {
return;
}
…
}
Figure 4‑6只有网格内部的桥
三角形连接部分 | 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()));
Figure 4‑7重新划分三角形的网格
我们已经完成了么?还没有,我们现在产生了重复的三角形。因为每三个六边形共享一个三角形连接,我们只需要为每个六边形添加两个连接。只有NE和E方向上的。
if (direction <= HexDirection.E && nextNeighbor != null) {
AddTriangle(v2, v4, v2 + HexMetrics.GetBridge(direction.Next()));
AddTriangleColor(cell.color, neighbor.color, nextNeighbor.color);
}
下一片教程是阶梯状地形实现。
暂无关于此日志的评论。