六边形网格的双轴坐标系映射
瓦片式,或者说网格式地图里最符合直觉和最容易进行各种操作以及运算的是基于正方形的网格,因为它刚好可以和XY平面上的格点贴合,顶多会有少量偏移。
而其它一些非正方形网格的地图就没有那么容易和坐标轴组合使用了,这就导致在进行各种运算乃至寻路时会遇到各种各样的问题。
以六边形网格为例,当地图单元以六边形网格的形式平铺在整个平面上时,它的每个单元并不会和正常的XY坐标格点完全对应,根据六边形单元的形态不同会有不同的偏移。
通常而言,六边形网格地图存在两种形态,一种是单元格的尖角朝上,另一种则是横边朝上,两种形态在单元格排列方面有较大不同,因此在进行坐标映射的时候有不同的方法。
以Unity中的HexagonTileMap为例,它采用的单元格形态是尖角朝上,而横边朝上的模式实际上是将整个Tilemap旋转90度得到结果,因此X和Y坐标会反过来。
如果说仅仅是为了拼出一张地图,那么使用怎样的坐标映射方式都一样,只要能准确定位到某个单元格的位置即可。
但实际使用中,对网格式地图的操作有时需要涉及一些数学计算,比如哈密尔顿距离,或者计算格点是否落在指定区域之类的。
没有一个确定的坐标轴映射系统,那这些数学计算将无从谈起,六边形网格地图的功能也会受到极大的限制。
而最简单而且直接的映射方式毫无疑问就是套用正方形网格的XY轴映射,只不过由于六边形网格中无论以何种单元形态进行平铺,必然存在一条轴线无法被正常映射,需要考虑偏移。
还是以Unity中的Tilemap为例,采用尖角朝上的单元格形态,奇数行偏移的方式构造的地图,映射到XY轴坐标系之后的效果如图所示
可以看到每当Y轴坐标为奇数时,格点会发生小幅度的偏移,换言之在实际计算坐标的过程中需要将这个偏移量纳入考虑。
这样的坐标表示方法在进行某些坐标运算时会遇到麻烦,比如说计算任意两个格点之间的哈密尔顿距离,由于该方案是将六边形网格映射到XY轴平面上的,因此计算距离时不能直接将两点的坐标相减来得到答案。
比如说点(-1,1)和原点(0,0)之间的距离,从图上来看应该是1个单位,但套用平面坐标的距离计算方法后却发现得不到这个结果。
原因也很简单,起点或者终点位于奇数行的距离计算要进行相应的调整,将偏移值从距离中减掉。
还有邻居的判定和连接也存在一定问题,直接套用正方形网格的坐标系虽然可以表达整个六边形网格平铺,但这种方式默认了每个格点只有四个直接邻居,因此会导致有两个邻居被分配到了另外一个格点上。
换言之,从原点(0,0)没法直接通过邻居连接抵达(-1,1)格点,因为在XY坐标轴方案下它们两个格点并不是邻居,这个连接只能手动添加上去。
而为了添加这条连接,还必须提前判断行数的奇偶性以确定是哪两个邻居需要手动连接,这在寻路算法的过程中会产生一些麻烦。
为了能克服XY轴平面坐标系直接套用所带来的问题,可以考虑一种新的坐标映射方案,也是针对六边形网格最直观的一种,即三轴坐标系映射。
六边形网格的三轴坐标系映射
从单元格的形态出发考虑,正方形有四条边,可以被双轴坐标系完美映射,那么六边形有六条边,理论上应该可以被三轴坐标系完美映射。
但实际上如果直接人为规定三条坐标轴来映射六边形网格的话,会发现这个映射很难建立,因为六边形网格虽然看起来可以适配两两夹角为60度的三条坐标轴,但实际上这依然只是个平面坐标系,只存在两个自由度,根本无法人为规定三个不同的参数来约束某个点的位置。
因此为了达到三轴坐标表示平面格点的目的,我们需要引入一个额外的约束,也就是让三个坐标值的和始终为零。
关于这条约束的产生和作用可以参考文章https://www.redblobgames.com/grids/hexagons/
简单来说就是将六边形网格的每个单元看成是斜45度角观察的一个正方体,然后以正方体的三条边为三条坐标轴来构造坐标系。
而坐标值的和始终为零的这条约束来自于将三维立体坐标转换回平面的时候,选择了X+Y+Z=0的平面作为参照。
根据三维坐标系的点位外加那条规定的约束,我们便可以构建出一套用三轴坐标表示平面上六边形网格的方法,具体的坐标值如图所示
这套坐标表示方法除了能和XY轴平面映射相互转化之外,它最大的优点在于可以适应一些向量算法,距离公式,还能正确识别每个格点的六个邻居,也能正确计算出和邻居之间的距离为一个单位。
基于前文提到的那篇文章给出的方法,针对Unity中六边形Tilemap的构造方式,可以编写出如下的一些工具代码以实现这套坐标体系。
/// <summary> /// 用立方体坐标系储存的六边形网格坐标,针对角朝上 /// </summary> public class CubicVector3 { public int x; public int y; public int z; public int d; public CubicVector3() { x = 0; y = 0; z = 0; d = 0; } public CubicVector3(int x, int y, int z, int depth = 0) { this.x = x; this.y = y; this.z = z; d = depth; } /// <summary> /// 获取三轴坐标的字符串表示 /// </summary> /// <returns></returns> public string GetCubicID() { return HexagonUtils.GetUnitID(x, y, z); } /// <summary> /// 重载三轴坐标的加法操作 /// </summary> /// <param name="left">左操作数</param> /// <param name="right">右操作数</param> /// <returns>结果</returns> public static CubicVector3 operator +(CubicVector3 left, CubicVector3 right) { return new CubicVector3() { x = left.x + right.x, y = left.y + right.y, z = left.z + right.z }; } /// <summary> /// 重载三轴坐标的减法操作 /// </summary> /// <param name="left">左操作数</param> /// <param name="right">右操作数</param> /// <returns>结果</returns> public static CubicVector3 operator -(CubicVector3 left, CubicVector3 right) { return new CubicVector3() { x = left.x - right.x, y = left.y - right.y, z = left.z - right.z }; } /// <summary> /// 从平面映射坐标转化为三轴坐标 /// </summary> /// <param name="coord">平面坐标</param> public void ConvertFrom(Vector3Int coord) { d = coord.z; x = coord.x + ((coord.y & 1) + coord.y) / 2; z = -coord.y; y = -x - z; } /// <summary> /// 将当前三轴坐标转化为平面映射坐标 /// </summary> /// <returns>平面坐标</returns> public Vector3Int ConvertTo() { Vector3Int result = Vector3Int.zero; result.x = x + (z - (z & 1)) / 2; result.y = -z; result.z = d; return result; } } /// <summary> /// 六边形坐标系相关辅助方法 /// </summary> public static class HexagonUtils { public const int HEX_DIR_LEFT = 0; public const int HEX_DIR_LEFT_TOP = 1; public const int HEX_DIR_RIGHT_TOP = 2; public const int HEX_DIR_RIGHT = 3; public const int HEX_DIR_RIGHT_BOT = 4; public const int HEX_DIR_LEFT_BOT = 5; public static string GetUnitID(int x, int y) { return $"({x},{y})"; } public static string GetCubicID(int x, int y, int z) { return $"({x},{y},{z})"; } /// <summary> /// 获取反向索引 /// </summary> /// <param name="dir">参照方向</param> /// <returns>反向索引值</returns> public static int GetReverseDir(int dir) { dir += 3; if (dir >= 6) { dir -= 6; } return dir; } /// <summary> /// 平面坐标转化为三轴坐标 /// </summary> /// <param name="x">X坐标值</param> /// <param name="y">Y坐标值</param> /// <returns>三轴坐标值</returns> public static CubicVector3 CoordinateToCubic(int x, int y) { var result = new CubicVector3 {x = x - ((y & 1) - y) / 2, z = -y}; result.y = -result.x - result.z; return result; } /// <summary> /// 平面坐标转化为三轴坐标 /// </summary> /// <param name="coord">平面坐标值</param> /// <returns>三轴坐标值</returns> public static CubicVector3 CoordinateToCubic(Vector3Int coord) { var result = new CubicVector3 {x = coord.x - ((coord.y & 1) - coord.y) / 2, z = -coord.y}; result.y = -result.x - result.z; return result; } /// <summary> /// 三轴坐标转化为平面坐标 /// </summary> /// <param name="x">X坐标值</param> /// <param name="y">Y坐标值</param> /// <param name="z">Z坐标值</param> /// <returns>平面坐标值</returns> public static Vector3Int CubicToCoordinate(int x, int y, int z) { var result = new Vector3Int {x = x + (z - (z & 1)) / 2, y = -z, z = 0}; return result; } /// <summary> /// 三轴坐标转化为平面坐标 /// </summary> /// <param name="cubic"></param> /// <returns></returns> public static Vector3Int CubicToCoordinate(CubicVector3 cubic) { var result = new Vector3Int {x = cubic.x + (cubic.z - (cubic.z & 1)) / 2, y = -cubic.z, z = 0}; return result; } /// <summary> /// 获取两个三轴坐标点之间的距离 /// </summary> /// <param name="src">起始点</param> /// <param name="dst">终点</param> /// <returns>距离值</returns> public static int GetCubicDistance(CubicVector3 src, CubicVector3 dst) { CubicVector3 delta = dst - src; return (Mathf.Abs(delta.x) + Mathf.Abs(delta.y) + Mathf.Abs(delta.z)) / 2; } /// <summary> /// 检查两个单元的邻接关系 /// </summary> /// <param name="a">A单元</param> /// <param name="b">B单元</param> /// <returns>表示B单元为A单元的何种邻居,不是邻居的返回-1</returns> public static int CheckUnitAdjcent(CubicVector3 a, CubicVector3 b) { var xDelta = b.x - a.x; var yDelta = b.y - a.y; var zDelta = b.z - a.z; if (Mathf.Abs(xDelta) > 1 || Mathf.Abs(yDelta) > 1 || Mathf.Abs(zDelta) > 1) { // 表示间隔至少一个格子,非邻居 return -1; } else { switch (xDelta) { case -1 when yDelta == 1 && zDelta == 0: return HEX_DIR_LEFT; case -1 when yDelta == 0 && zDelta == 1: return HEX_DIR_LEFT_BOT; case 0 when yDelta == 1 && zDelta == -1: return HEX_DIR_LEFT_TOP; case 0 when yDelta == -1 && zDelta == 1: return HEX_DIR_RIGHT_BOT; case 1 when yDelta == 0 && zDelta == -1: return HEX_DIR_RIGHT_TOP; case 1 when yDelta == -1 && zDelta == 0: return HEX_DIR_RIGHT; } } return -1; } public static CubicVector3 MoveNextCubic(CubicVector3 origin, int dir) { CubicVector3 result = new CubicVector3(origin.x, origin.y, origin.z, origin.d); switch (dir) { case HEX_DIR_LEFT: result.x += -1; result.y += 1; result.z += 0; break; case HEX_DIR_LEFT_TOP: result.x += 0; result.y += 1; result.z += -1; break; case HEX_DIR_RIGHT_TOP: result.x += 1; result.y += 0; result.z += -1; break; case HEX_DIR_RIGHT: result.x += 1; result.y += -1; result.z += 0; break; case HEX_DIR_RIGHT_BOT: result.x += 0; result.y += -1; result.z += 1; break; case HEX_DIR_LEFT_BOT: result.x += -1; result.y += 0; result.z += 1; break; } return result; } }
使用以上这些工具便可以简单实现一个六边形网格的三轴坐标系表达,并且建立起一个平面映射到三轴坐标的系统,方便此后的寻路和向量运算等操作。
感谢分享,学到了