HexMap学习笔记(六)——河流

作者:沈琰 2019-04-24

前言

这是目前为止长度最长的一篇,难度也是直线上升。不仅此篇所用的三角剖分方法更为复杂,并且从这篇教程开始,会逐渐使用编辑着色器代码的方式添加一些简单的视觉效果。

尽管经过Unity的简化,但编辑着色器代码依然是Unity新手的一个难点。不仅是那与C#迥然相异的语法,如果要实现一个看的过去的效果,还需要相当扎实的数学功底。推荐基础较为薄弱的同学先跟着做一遍,不用太过纠结原理。当然底子强的同学要深入理解也可以另行查阅作者的shader系列教程。

本篇原文地址:Hex Map 6

本篇难度:★★★☆☆

这个教程是HexMap系列的第六部分,上一篇的内容是实现一个较大的地图,这部分现在已经完成,可以开始考虑更大范围的地形特性了,即此篇教程中的河流。

从山上流下的河流

1单元格与河流

在六边形地图中添加河流有三种方法。

第一种方法是让其从单元格中穿过,从一个单元格流向另一个单元格,这是《无尽传说》中的做法。

第二种方法是让其在单元格之间流过,沿着单元格个边缘到另一个单元格的边缘,《文明5》中是这么做的。

第三种方法是不使用额外的河流结构特性,而是直接用特殊的单元格表示水体,《奇迹时代3》中是这么做的。

而在我们的工程中,由于单元格的边缘连接已经用阶梯化或陡峭的方式特殊处理过,没有留给河流的空间,所以就采用第一种方法,让河流从一个单元格流向另一个单元格。这意味着每个单元格要么就是没有河流经过,要么河流穿过这个单元格,要么这个单元格是河流的起点或者终点。而在有河流穿过的单元格中,要么河流是笔直穿过,要么是一步锐角转弯,要么是两步钝角转弯。

五种可能的河流情况

1.1追踪河流方向

一个单元格内的河流流向可能是流入或流出,如果这里是河流的起点那么只可能是流出的河流。相对的如果是终点就只会是流入的河流。我们可以在HexCell里用两个bool类型变量存储这个信息。


但仅仅知道这个还不够,还需要知道河流的方向。在流出的情况下即河流的流向,而在流出的情况下则表明了河流是从哪个方向流入的。


我们需要在单元格三角化时获取这些信息,所以分别添加get属性但不需要set,之后会添加不同的方法去设置这些值。


还有一个比较有用的信息:单元格内是否存在河流而无论其具体情况。所以也为此添加一个属性。


另一个特殊问题是这个单元格是否是河流的起点或者终点,如果有河流流入和流出这两个布尔类型的值不同,那就是这种情况了。也为此新建一个属性。


1.2移除河流

在考虑如何为单元格添加河流之前,先考虑如何移除它们。第一步,创建一个方法移除流出部分的河流,如果没有流出方向的河流那就直接跳出,否则设置其布尔值为false并刷新。


这还没完,一条流出方向的河流肯定会流向其他单元格,所以一定会有相邻单元有流入的河流,我们同样也要处理这部分。


  • 河流不会延伸到地图之外么?
  • 虽然有能实现这个功能,但我们不会这么做。所以也不用检查相邻单元格是否存在。


从一个单元格移除河流只会改变它自己的外观,这一点不像编辑高度与颜色的时候还得考虑所有相邻单元格,所以我们只需要刷新这个单元格本身就行了。


这个RefershSelfOnly方法就是简单的刷新此单元格所在的地图块就行,当地图网格初始化时还没有对河流进行编辑,所以也不需要考虑此时地图块是否已赋值的问题。


移除流入方向的河流也是一样的。


然后移除全部的河流即意味着同时移除流入和流出的部分。


1.3添加河流

要实现添加河流的功能,只需要一个方法设置单元格的流出方向的河流。这个方法应该覆盖之前流出方向的河流,并设置相应的相邻单元格的流入方向的河流。

首先,当要设置的方向已经存在河流时直接跳出。


然后得确保在这个方向上存在一个相邻单元格。并且河流不能向着更高的位置流动,所以当检测到相邻单元格较高时跳出方法。


下一步,清除上一个流出方向的河流。并且当流入方向的河流与当前流出方向河流重叠时还需要清除流入方向的河流。


然后轮到设置流出方向的河流。


最后别忘了了,当相邻单元格上已经有流入方向的河流时,移除它并设置新的流入河流。



1.4防止逆流情况

虽然我们能保证只会添加有效流向的河流,但其他的操作依然会导致无效流向的情况发生。

例如当我们编辑单元格的高度时,我们必须再一次强制改变河流流向,所有错误流向的河流都需要移除。


2编辑河流

要实现编辑河流的功能,我们需要添加河流的选项卡(toggle)组件到UI上。事实上我们需要添加三种编辑模式:忽略,添加和移除,就简单的使用枚举来记录这个值。由于这个功能只能在编辑模式中使用,所以可以在HexMapEditor这个类中去定义这个枚举和编辑模式的字段。


并且还需要一个通过UI修改河流编辑模式的方法。


添加三个toggle组件到UI上并放到一个新的toggle group中,就像颜色编辑一样。这里修改了标签名的位置在其选项框下面。这样把三个选项框全放到一行时占用空间会足够薄。

河流编辑UI

  • 为什么不使用下拉菜单?
  • 你要喜欢你也可以用。不幸的是,Unity的下拉菜单在运行模式下不能处理重编译,选项列表会在重编译时丢失并无法使用。


2.1检测鼠标拖拽事件

要创建一条河流,同时需要单元格的位置和方向这两个信息,目前HexMapEditor中没有这两信息的获取方式,需要添加一个新方法实现从一个单元格到另一个单元格的拖拽。

在检测到有效拖拽事件时还需要记录其拖拽方向和上一个单元格。


最初的时候是没有拖拽事件的,也就没有上一个单元格的记录。所以当没有输入信息或者没有与地图交互时,需要设置其为null。


当前单元格是根据射线击中的点找到的,当在这一帧里结束编辑时,它就会变成下一次Update里的上一个单元格.


在确认了当前单元格之后,我们可以与前一个单元格(如果有的话)进行比较,当发现是两个不同的单元格时,就说明可能存在有效拖动并需要去检测,要不就是没有拖拽事件。


如何证实确实是拖拽事件?通过检测当前单元格是否是前一个单元格的相邻单元格,循环遍历前一个单元格所有的相邻单元格来进行检测,如果找到了与当前单元格相吻合的结果就能同时确认拖拽的方向。


  • 这不会产生拖拽抖动么?
  • 当你移动鼠标穿过单元格边界时,可能会在单元格之间快速来回摆动,这确实会导致拖拽抖动,但情况没那么糟。
  • 可以通过记录上一次拖拽事件来减缓抖动,然后防止下一次直接向相反方向拖拽。


2.2修改单元格

现在能检测到拖拽事件了,可以开始设置流出向的河流。同样也能移除河流,但移除功能不需要拖拽事件的支持。


这能在两个单元格之间创建出一条河流,但会忽略笔刷尺寸。这也许能说得通,但我们还是画出所有被笔刷覆盖的单元格之间的河流,这可以通过相对正在编辑的单元格来完成。这种情况下需要确保另一个单元格确实存在。


现在可以开始编辑单元格的河流了,尽管看不见,但可以通过检视面板(inspector)的debug模式下的字段来验证是否工作正常。

检视面板Debug模式下单元格的河流

什么是debug检视面板?

你可以在检视面板的标签菜单里切换为debug模式,在这个模式下检视面板会显示对象的原始数据。

3单元格之间的河道

河流的三角化可以分为两个部分来考虑,即河道和水流。我们先创建河道,把水流放到后面。

河流的最简单部分是流经单元格之间的连接处的位置,这里目前用三个四边形组成的长条形状来三角化这个部分,可以通过降低中间四边形的高度和添加两道墙来创建河道。

为河流添加边缘

但如果要这么做就需要添加两个额外的四边形来生成垂直的墙,另一个方法是使用四个四边形来组成连接单元格的部分,这样就能通过拉低中间的顶点形成河道的倾斜墙壁。

始终只有4个四边形

一直使用一样四边形数量会比较方便,所以我们选择后一个方法。

3.1添加边界顶点

要把边界连接部分的三个四边形改为四个,就需要额外的边界顶点,因此重构EdgeVertices这个结构,首先重命名v4为v5,v3为v4。要确保所有代码始终能引用正确的顶点,要使用IDE的重命名或重构方法,这样改动就能应用到所有地方,不然你只能手动去检查代码并进行改动。


在重命名完成后添加新的v3。


在构造函数中添加新的顶点,它应该是角顶点的一半,另外两个顶点的插值就变成了四分之一和四分之三的位置。


同样把v3添加到TerraceLerp方法中。


HexMesh中添加额外顶点到与边界连接的三角扇中。


还有四边形的条状连接中。



四个边界点和五个边缘顶点的区别

3.2河床高度

我们通过拉低边界连接部分的中间顶点创建出了河道,这定义了河床竖直方向的坐标。尽管每个单元格的精确竖直坐标会受不规则化的影响,但还是应该在相同高度的单元格之间保持河床的恒定,这确保河流看起来不会是逆流而上。同样河床需要足够低,即使单元格的竖直方向的顶点扰动达到最大值也应该与单元格的底面保持一定的距离,为水流留下足够的空间。

让我们在HexMetrics里定义这个偏移量并把它作为高度等级的变量传递出来,一级高度等级的步长应该就足够了。


使用这个度量标准在HexCell里添加一个属性,获取当前单元格垂直坐标的河床高度。



3.3创建河道

当HexMesh三角化六个方向其中之一时就可以检测这个方向上是否有穿过的河流,如果有就修改中间顶点到河床的高度。


修改边界连接处的中间顶点后的样子

可以看到河流的痕迹初现并在地上留下了空隙,要填充空隙则需要在三角化连接部分时修正六个边界上的垂直坐标。


边界连接处的河道完成

4穿过单元格的河道

现在在两个单元格之间创建出了正确的河道,但是在河流穿过单元格时总是会在中心位置结束。要修正这个问题需要费些功夫。让我们先从河流笔直穿过单元格,从一边到其相反方向的另一边这种情况开始考虑。

如果没有河流,单元格每一个方向都是由扇形三角组成,但当河流穿过时就需要在中间插入一条河道。实际上就是需要把单元格的中心点延伸成一条线,从而把中间的两个三角形变成了四边形,这样三角扇部分就变为了梯形。

强制把河道变成三角形

穿过单元格之间的河道比穿过连接处的河道要长得多,当顶点被扰动时看起来会很明显。所以我们通过在中间和边界之间的一半的位置插入一组新的边界顶点来把梯形分为两段。

河道的三角剖分结构

由于对带有河流和没有河流的单元格进行三角剖分会大不相同,所以为此创建一个专用方法。如果单元格内有河流就使用这个方法,不然就用之前的。


本来应该是河流的位置,现在是空洞

为了更清楚的观察改动,暂时先禁用单元格的不规则坐标扰动。


禁用顶点扰动

4.1河流笔直横穿情况下的三角剖分

要构建笔直穿过单元格的河道,需要把单元格的中心点延伸成一条线并与河道的宽度相同。

可以通过单元格的中心点到第一个角顶点的前一个方向的角顶点移动四分之一的位置到到左边的顶点。


同样的方法找到右边的顶点,这里需要的是第二个角的下一个方向的叫顶点部分。


中心点到单元格边界之间的一组中间线顶点可以通过创建EdgeVertices数组获得。


下一步,修改中间线数组的中间顶点的坐标和单元格中心点坐标,使其与河道高度相同。


再用TrriangulateEdgeStrip方法填充中间线与单元格边界之间的空间。


压缩的河道

不幸的是河道看起来好像被压缩了,中间边界的的顶点靠的太近,为什么会这样?

考虑六边形的外边长是1这个情况,那么中心点的延伸线的长度就是二分之一。因为中间边界线两端的顶点位于之间一半的位置,那么中间边界线的长度就是四分之三。

河道的宽度是不变的二分之一,由于中间边界的长度是四分之三,剩余的长度就是四分之一,每边的宽度就是八分之一。

相对长度

由于现在的中间边界线的长度是四分之三,那它长度的八分之一实际值就是六分之一,这意味着中间边界线的第二个和第四个顶点应该使用六分之一进行插值而不是四分之一。

我们可以在EdgeVerices里添加一个构造函数实现这个特殊版本的插值,而不是强行修改v2和v4的值,使用一个参数来控制。


现在可以在HexMesh.TriangulateWithRiver使用这个新版本的构造函数。


笔直的河道

河道恢复笔直后就可以开始第二段梯形的三角化工作了,这里无法直接使用边界条的生成函数,只能手动添加。第一步先创建边上的三角形。


边上的三角形

看起来不错,继续用两个四边形填充剩余空间,完成河道的最后一部分。


实际上我们没有只用一个参数的AddQuadColor方法,在这之前我们都用不到,所以就直接创建一个。


笔直河道完成

4.2河流起点与终点的单元格的三角剖分

对起点或终点的单元格进行三角剖分与之前的方法有较大差异,这足以使我们为此创建一个专用方法.所以在Triangulate里检测,如果是起点或终点就调用这个专用f方法。


在这种情况下我们想要的是河道在单元格的中心位置终止,但这依然需要分为两个步骤。所以还是在边界和单元格中心之间插入一组中间线顶点。由于这一次我们确实需要河道在单元格中心终止,所以河道边界在中心压缩就是正确的。


为了确保河道在变浅之前有个过渡,还是将中间线的中间顶点设置为河床的高度。但是单元格中心点的高度就不必修改了。


这部分可以直接作为单个的边界条状带和三角扇进行三角化。


起点和终点

4.3一折弯道

下一步,来考虑锯齿形急弯拐入相邻单元格的河道的三角剖分情况,这部分也归TriangulateWithRiver方法负责,所以首先要搞清楚正在为哪种类型的河流三角化。

锯齿弯道形河流

如果一个单元格内,流入河流的方向与流出河流的反方向相同,那毫无疑问就是笔直的河流,这种情况下就使用之前算好的中心线,否则就把中心线重新压缩回一个点。


压缩的锯齿河道

我们可以通过河道是否穿过下一个方向或者上一个方向的相邻单元格部分来检测这个单元格内是否有锯齿急弯。如果是这样,我们就必须将中心线与这部分和相邻部分之间的边对齐。我们可以通过在中心线和共享角之间放置适当的边来实现这一点,那么这条线的另一端就变成了中心点。


在确认了左边和右边点的位置后,我们可以通过计算这两个点的平均值来确定最后的中心点。


扭曲的中间边缘

尽管河道在两边有相同的宽度,但看起来还是有些挤压的感觉,这是因为中心线被旋转了60度,可以将中心线的长度适当延长一点来缓解挤压感,用三分之一而不是二分之一作为插值参数。


不再有挤压感的锯齿弯道

4.4两折弯道

剩下的就是既不是急弯又不是笔直河道的情况,即分两步旋转产生相对平缓的曲线河流。

缓慢弯曲的河流

为了区分这两种可能的方位,我们需要用到direction.Next().Next()这种繁琐的写法,为了让其更简化,在HexDirection里添加Next2()和Previous2()这两个扩展方法。


回到HexMesh.TrigulateWithRiver这个方法里,现在可以用direction.Next2()来检测是否是弯曲的河流。


在最后两个情况中,我们把中心线推移到单元格部分的曲线上。如果我们有一个指向中间固定六边形边界中心的向量,我们就能用此定位结束点,先假设我们有这么一个方法。


当然,我们要在HexMetrics里添加这个方法,就是简单的平均计算两个角向量然后乘上固定内六边形比例的一半。


有些微扭曲的曲线

中心线现在是正确的旋转了30度,但它不够长以至于河道有轻微挤压的感觉。这是因为边界线的中间点靠单元格的中心点比边界角更近,它的距离等于中间固定部分六边形的内径而不是外径,所以我们这里应用了一个错误的大小。

我们已经在HexMetrics中定义了内径到外径的转换,这里要做的就是颠倒过来,所以在HexMetrics中添加两个转换比率变量。


现在HexMesh.TriangulatWithRiver里转换成了正确的比例,但由于中间线旋转的原因,河道还是会有轻微挤压的感觉,但这已经比锯齿急弯要缓和多了,所以我们不必再为此额外费神了。


平滑的曲线

5单元格邻近河流部分的三角剖分

现在河道完成了,但没有三角化包含河流的单元格的其他部分,现在就去填充这部分空间。

河道边上的空洞

在三角化时,当单元格内有河流但又不留经当前方向时,调用一个新方法。


在这个方法里,用条状连接带和三角扇来填充单元格内的空隙。仅用三角扇不够,因为还要确保能和中间边界线吻合。


弯曲和笔直的河流上有重叠部分

5.1与河道吻合

当然,我们得确保我们使用的单元格中心点与河流部分的中心线吻合,这在锯齿急弯部分是对的,只需要在缓弯和笔直河流上做出些额外修改。所以这里得知道河流的类型以及相对方向。

先来看看当前方向在河流曲线内弯的情况,即前一个方向和下一个方向上都有河流穿过,在这种情况下中心点就得移动到边缘上去。


当河流在两边移动时修正中心点

如果在下一个方向的边界有河流穿过而不是前一个方向,就检查一下是不是笔直的河流。如果是就需要把中心点向固定内六边形的第一个角上移动。


修正了半边的笔直河流

这能修正一半的问题,最后一种情况是当前方向的前一个方向上有河流并且是笔直河流,这就需要把中心点移向固定内六边形的下一个角。


不再有重叠部分了

6 HexMesh广义化

河道的的三角化已经完成,现在可以填充水了。因为水与陆地有很大不用,所以我们会使用不同的mesh,不同的顶点数据与材质。如果能用HexMesh同时处理陆地与水的mesh信息将会比较方便,所以我们把HexMesh这个类广义化,用其专门处理mesh数据而不用关系它到底是用来干嘛的,HexGridChunk会去负责三角化它自己的单元格。

6.1移动顶点扰动方法

由于Petrurb方法比较通用,可能后面会用在其他地方,所以把它移动到HexMetrics中,重命名为HexMetrics.Perturb。(注:VS的重命名方法不能加上“.”,可以用文本替换功能,或者你也能人工一个个的修改)这是个无效的方法名,但是可以重命名所有的代码让其正确访问。如果编辑器具有特殊的功能修改方法名,你可以用这个功能代替。

当这个方法处于HexMetrics的内部,就设置其为公共和静态类型,然后修改名字。


6.2移动三角化方法

在HexGridChunk中,修改hexMesh变量名为公共类型的terrain。


下一步,重构所有HexMesh里调用Add..开头方法的位置为terrain.Add..,然后把所有Triangulate..开头的的方法移动到HexGridChunk中。这一步完成后就可以修改Add..类方法并设置为公共类型.其结果就是所有复杂的三角化方法现在都在HexGridChunk里了,并且简单的添加数据到mesh中的方法仍然保持在HexMesh里。

这一步还没做完,HexGridChunk.LateUpdate里现在调用它自己的Triangulate方法,再也不用传递单元格作为参数了,并且它应该委托清除和应用网格数据到HexMesh。


添加必须的clear()和apply()方法到HexMesh中。


SetVertices,SetColors和SetTriangles是什么方法?

这些方法是unity最近添加到Mesh这个类中的,它能让你直接传递Mesh数据到列表中.这意味着我们在更新数据时不需要再创建临时存放数据的数组.

SetTriangles方法有第二个整数参数,即子网格的下标.我们不用子网格,所以它一直是零.

最后,手动关联地图块预制体里子对象的Mesh,这里不再自动赋值,因为马上就要添加第二个网格子对象,同样重命名为Terrain指出其用途。

Terrain赋值

无法重命名预制体的子对象?

工程预览中不会更新预制体名字的改动.你可以通过创建一个预制体的实例来更新它.修改实例,然后使用Apply按钮把这些改动保存到预制体上.这是当前最好的修改预制体在层级窗口内信息的方法。

6.3列表池

尽管我们已经移动了很多代码的位置,但我们的地图还是与之前的工作方式一样。给每个地图块添加另一个mesh会改变它的工作方式,但是如果我们使用当前的HexMesh来做就会出错。

问题在于,我们之前一直假设在一个时间点上只会对一个mesh进行修改,这就允许我们使用静态列表存储临时mesh数据。但是当我们添加水面mesh的数据时,有可能就会在同一个时间点同时对两个mesh做出改动,所以现在不能继续用静态列表了。

然而我们也不需要改回到为每一个HexMesh设置一个列表笨办法,可以换成使用一个静态的列表池,默认数据结构是没有池这个类型的,所以我们自己创建一个泛型列表池的类。


ListPool<T>是如何工作的?

我们已经使用了好久的泛型列表了.比如List<int>是一个存储整数的列表.通过在ListPool中声明类型后使用,表明它是一个泛型类。可以为泛型部分使用任何标识符,但通常只使用T作为类型标识符。

可以用栈来存储列表的集合,通常不使用栈是因为Unity没有为其序列化,不过在这个情况中没有关系。


  • Stack<List<T>>是什么意思?
  • 这是嵌套泛型类型,这意味着我们需要一个存放列表的栈,列表中的内容则取决于池的类型。


添加一个静态公共方法去获取池内的列表,如果栈不是空的,就弹出最上面的列表并返回这个列表,否则就创建一个新的列表。


为了能在实际情况中重复使用列表,需要在用完后再添加回池中,ListPool会负责清理列表,然后压入栈中。


现在可以在HexMesh中使用列表池了,把静态列表换为非静态的私有引用,标记为NonSerialized,这样Unity就不会在重编译时保存它们。写作System.NonSerialized或者在脚本的头部添加using System都行。


当mesh在清除旧数据,添加新数据之前,就是从池中获取列表的地方。


并且在数据应用之后就不再需要它们了,所以可以在这里加回到列表池中。


这就保证了无论同时填充多少mesh信息,列表都可以复用。

6.4碰撞可选

我们的地形mesh需要添加碰撞,但河流的mesh并不需要,射线会穿过河面击中河道底部。所以添加一个布尔类型的公共字段useCollider,并为地形mesh开启。


使用网格碰撞器

在脚本里要做的就是确保只在碰撞功能开启时创建碰撞器和赋值。


6.5顶点颜色可选

顶点颜色也同样是可选的,我们
最新评论
暂无评论
参与评论

商务合作 查看更多

编辑推荐 查看更多