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

作者:沈琰
前言
这是目前为止长度最长的一篇,难度也是直线上升。不仅此篇所用的三角剖分方法更为复杂,并且从这篇教程开始,会逐渐使用编辑着色器代码的方式添加一些简单的视觉效果。
尽管经过Unity的简化,但编辑着色器代码依然是Unity新手的一个难点。不仅是那与C#迥然相异的语法,如果要实现一个看的过去的效果,还需要相当扎实的数学功底。推荐基础较为薄弱的同学先跟着做一遍,不用太过纠结原理。当然底子强的同学要深入理解也可以另行查阅作者的shader系列教程。
本篇原文地址:http://link.zhihu.com/?target=https%3A//catlikecoding.com/unity/tutorials/hex-map/part-6/
本篇难度:★★★☆☆
这个教程是HexMap系列的第六部分,上一篇的内容是实现一个较大的地图,这部分现在已经完成,可以开始考虑更大范围的地形特性了,即此篇教程中的河流。

1 单元格与河流
在六边形地图中添加河流有三种方法。
第一种方法是让其从单元格中穿过,从一个单元格流向另一个单元格,这是《无尽传说》中的做法。
第二种方法是让其在单元格之间流过,沿着单元格个边缘到另一个单元格的边缘,《文明5》中是这么做的。
第三种方法是不使用额外的河流结构特性,而是直接用特殊的单元格表示水体,《奇迹时代3》中是这么做的。
而在我们的工程中,由于单元格的边缘连接已经用阶梯化或陡峭的方式特殊处理过,没有留给河流的空间,所以就采用第一种方法,让河流从一个单元格流向另一个单元格。这意味着每个单元格要么就是没有河流经过,要么河流穿过这个单元格,要么这个单元格是河流的起点或者终点。而在有河流穿过的单元格中,要么河流是笔直穿过,要么是一步锐角转弯,要么是两步钝角转弯。








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

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

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


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

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

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

什么是debug检视面板?
你可以在检视面板的标签菜单里切换为debug模式,在这个模式下检视面板会显示对象的原始数据。
3 单元格之间的河道
河流的三角化可以分为两个部分来考虑,即河道和水流。我们先创建河道,把水流放到后面。
河流的最简单部分是流经单元格之间的连接处的位置,这里目前用三个四边形组成的长条形状来三角化这个部分,可以通过降低中间四边形的高度和添加两道墙来创建河道。

但如果要这么做就需要添加两个额外的四边形来生成垂直的墙,另一个方法是使用四个四边形来组成连接单元格的部分,这样就能通过拉低中间的顶点形成河道的倾斜墙壁。
一直使用一样四边形数量会比较方便,所以我们选择后一个方法。
3.1 添加边界顶点
要把边界连接部分的三个四边形改为四个,就需要额外的边界顶点,因此重构EdgeVertices这个结构,首先重命名v4为v5,v3为v4。要确保所有代码始终能引用正确的顶点,要使用IDE的重命名或重构方法,这样改动就能应用到所有地方,不然你只能手动去检查代码并进行改动。




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


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


4 穿过单元格的河道
现在在两个单元格之间创建出了正确的河道,但是在河流穿过单元格时总是会在中心位置结束。要修正这个问题需要费些功夫。让我们先从河流笔直穿过单元格,从一边到其相反方向的另一边这种情况开始考虑。
如果没有河流,单元格每一个方向都是由扇形三角组成,但当河流穿过时就需要在中间插入一条河道。实际上就是需要把单元格的中心点延伸成一条线,从而把中间的两个三角形变成了四边形,这样三角扇部分就变为了梯形。

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

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


4.1河流笔直横穿情况下的三角剖分
要构建笔直穿过单元格的河道,需要把单元格的中心点延伸成一条线并与河道的宽度相同。
可以通过单元格的中心点到第一个角顶点的前一个方向的角顶点移动四分之一的位置到到左边的顶点。


不幸的是河道看起来好像被压缩了,中间边界的的顶点靠的太近,为什么会这样?
考虑六边形的外边长是1这个情况,那么中心点的延伸线的长度就是二分之一。因为中间边界线两端的顶点位于之间一半的位置,那么中间边界线的长度就是四分之三。
河道的宽度是不变的二分之一,由于中间边界的长度是四分之三,剩余的长度就是四分之一,每边的宽度就是八分之一。

由于现在的中间边界线的长度是四分之三,那它长度的八分之一实际值就是六分之一,这意味着中间边界线的第二个和第四个顶点应该使用六分之一进行插值而不是四分之一。
我们可以在EdgeVerices里添加一个构造函数实现这个特殊版本的插值,而不是强行修改v2和v4的值,使用一个参数来控制。

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

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



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




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





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

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


5.1 与河道吻合
当然,我们得确保我们使用的单元格中心点与河流部分的中心线吻合,这在锯齿急弯部分是对的,只需要在缓弯和笔直河流上做出些额外修改。所以这里得知道河流的类型以及相对方向。
先来看看当前方向在河流曲线内弯的情况,即前一个方向和下一个方向上都有河流穿过,在这种情况下中心点就得移动到边缘上去。

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


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


6 HexMesh广义化
河道的的三角化已经完成,现在可以填充水了。因为水与陆地有很大不用,所以我们会使用不同的mesh,不同的顶点数据与材质。如果能用HexMesh同时处理陆地与水的mesh信息将会比较方便,所以我们把HexMesh这个类广义化,用其专门处理mesh数据而不用关系它到底是用来干嘛的,HexGridChunk会去负责三角化它自己的单元格。
6.1移动顶点扰动方法
由于Petrurb方法比较通用,可能后面会用在其他地方,所以把它移动到HexMetrics中,重命名为HexMetrics.Perturb。(注:VS的重命名方法不能加上“.”,可以用文本替换功能,或者你也能人工一个个的修改)这是个无效的方法名,但是可以重命名所有的代码让其正确访问。如果编辑器具有特殊的功能修改方法名,你可以用这个功能代替。
当这个方法处于HexMetrics的内部,就设置其为公共和静态类型,然后修改名字。

下一步,重构所有HexMesh里调用Add..开头方法的位置为terrain.Add..,然后把所有Triangulate..开头的的方法移动到HexGridChunk中。这一步完成后就可以修改Add..类方法并设置为公共类型.其结果就是所有复杂的三角化方法现在都在HexGridChunk里了,并且简单的添加数据到mesh中的方法仍然保持在HexMesh里。
这一步还没做完,HexGridChunk.LateUpdate里现在调用它自己的Triangulate方法,再也不用传递单元格作为参数了,并且它应该委托清除和应用网格数据到HexMesh。

SetVertices,SetColors和SetTriangles是什么方法?
这些方法是unity最近添加到Mesh这个类中的,它能让你直接传递Mesh数据到列表中.这意味着我们在更新数据时不需要再创建临时存放数据的数组.
SetTriangles方法有第二个整数参数,即子网格的下标.我们不用子网格,所以它一直是零.
最后,手动关联地图块预制体里子对象的Mesh,这里不再自动赋值,因为马上就要添加第二个网格子对象,同样重命名为Terrain指出其用途。

无法重命名预制体的子对象?
工程预览中不会更新预制体名字的改动.你可以通过创建一个预制体的实例来更新它.修改实例,然后使用Apply按钮把这些改动保存到预制体上.这是当前最好的修改预制体在层级窗口内信息的方法。
6.3 列表池
尽管我们已经移动了很多代码的位置,但我们的地图还是与之前的工作方式一样。给每个地图块添加另一个mesh会改变它的工作方式,但是如果我们使用当前的HexMesh来做就会出错。
问题在于,我们之前一直假设在一个时间点上只会对一个mesh进行修改,这就允许我们使用静态列表存储临时mesh数据。但是当我们添加水面mesh的数据时,有可能就会在同一个时间点同时对两个mesh做出改动,所以现在不能继续用静态列表了。
然而我们也不需要改回到为每一个HexMesh设置一个列表笨办法,可以换成使用一个静态的列表池,默认数据结构是没有池这个类型的,所以我们自己创建一个泛型列表池的类。

ListPool<T>是如何工作的?
我们已经使用了好久的泛型列表了.比如List<int>是一个存储整数的列表.通过在ListPool中声明类型后使用,表明它是一个泛型类。可以为泛型部分使用任何标识符,但通常只使用T作为类型标识符。
可以用栈来存储列表的集合,通常不使用栈是因为Unity没有为其序列化,不过在这个情况中没有关系。

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

这就保证了无论同时填充多少mesh信息,列表都可以复用。
6.4 碰撞可选
我们的地形mesh需要添加碰撞,但河流的mesh并不需要,射线会穿过河面击中河道底部。所以添加一个布尔类型的公共字段useCollider,并为地形mesh开启。


6.6 UV坐标可选
到目前为止还能为UV坐标添加可选功能,虽然地形不需要,但是水面会用到。


7 河流视觉效果
终于到了制作河流效果的时候了!这里会用四边形来代表河面,由于河水是会流动的,就用UV坐标表示流动方向。为了让其可视化,创建一个新的标准着色器命名为River,修改它的UV坐标放到红绿反射通道里。

创建一个河流的材质球,使用刚才新建的着色器,确保Rivers对象应用这个材质球。同时只勾选脚本的use UV coordinates。

7.1 水面的三角剖分
在三角化水面之前,首先要确定水面的高度。与河床的高度一样,在HexMetrics里去定义水面的高度偏移。因为Y方向的扰动设置的是高度等级的一半,所以我们也以此为水面高度的偏移,这确保了水面永远不会在地形之上。

TriangulateWithRiver是第一个添加河流四边形的方法,第一个四边形位于单元格的中心和中间边界线之间,第二个位于中间边界线和单元格边界之间。这里就简单的使用已经获取的顶点作为参数,因为这些顶点较地形会低一些,顶点的位置会在倾斜的河道墙面的里面,所以我们不用关心水面的边界顶点是否精确吻合河道。

为什么河面的宽度会变化?
这是因为单元格的高度被随机扰动了,但是河床和河面的高度并没有。单元格的高度越高,河道的墙面间距就越窄,这就使河面看起来变得狭窄了。
7.2 顺流而行
我们当前需要考虑的问题是UV坐标是不是与河流的方向一致,先定义当看向下游方向时U坐标值0位河流的左边,1为右边,并且V坐标从0到1表示河流流向。
根据当面定义的规则,我们UV坐标在三角化流向单元格外的河流时是正确的,对流向单元格内的则是错误并且刚好颠倒的。为了更方便的定义,添加一个布尔类型的参数reversed到TriangulateRiverQuad里,当需要颠倒UV坐标时使用。

7.3 河流的起点和终点
在TriangulateWithRiverBeginOrEnd里,只需要检测是否有流入的河流来确认河流的流向,这样就能在中间边界线和单元格边界之间插入其他的四边形。

结束的位置是否少了一些水面?
因为四边形时由两个三角形组成的,所以当四边形不是平的时候,它们的形状取决于方向。由于这个原因,河道两边的墙壁三角剖分是不对称的。当水面与河道墙壁相交时,这一点尤为明显。
可以通过镜像四边形来消除这种差异,但出现这种情况的原因明显是因为暂时未应用顶点扰动,一旦这么做对称性就被打破了。
7.4 单元格之间的河流部分
当要在单元格之间添加河流时,我们必须注意高度的差异。为了能让水流下斜坡和悬崖,TriangulateRiverQuad需要应用两个高度参数。


7.5 拉伸V坐标
目前V坐标从0到1贯穿河流的每一段,单元格内是四段,再算上单元格之间的连接部分就是5段,那么使用相同材质贴图赋值给河流,它就会重复很多次。
我们可以拉伸V坐标来减少重复性,让从0到1表示单元格加上连接部分的所有河流。这可以通过每段河流之间的V坐标递增0.2实现。如果单元格中心时0.4,中心线位置就是0.6,到达边界时就是0.8,连接部分就是1。
如果河流流向是相反方向,中心位置依然是0.4不变,但中间线位置就变成了0.2,边界就是0,继续算连接处的V坐标就是-0.2。这没有问题,因为这就等价于filter模式下的纹理设置重复时的0.8,就像0等价于1一样。

现在为处理流出河流提供正确的UV坐标,首先在TriangulateWithRiver里。

然后是TriangulateConnection里。

为了正确看到V坐标的效果,确保其在着色器中保持正值。

8 河流动画
处理好UV坐标后,开始处理河流动画。这部分会由着色器负责,所以不用连续更新mesh信息表现动画。
这篇教程不会教你如何创建一条有精细效果的河流,那些内容会放到后面。现在用一个简单的视觉效果让你了解动画是如何工作的。
河流动画会通过滑动基于运行时间的V坐标实现,Unity中通过_Time在着色器中获取这个变量,它的y分量中包含未修改的原始时间。我们就使用这个,其他的分量包含不同的时间缩放。
去掉V坐标的修正,现在不再需要它了,相应地从V坐标中减去当前时间,这将使坐标向下滑动,从而产生河流向前流动的错觉。

一秒钟后,所有地方的V坐标都会小于零,所以我们不会再看到差异。同样,这是由于重复纹理过滤模式。但是为了看看到底发生了什么,我们可以取V坐标的小数部分。


8.1 使用噪声纹理
现在河流能动起来了,但是方向和速度的过渡都很粗糙。我们的UV模式使这一点看起来非常明显,但是当使用更像水的纹理时,就不会那么容易发现了。因此我们去采样一个纹理,而不是显示原始的UV。可以就使用我们有的那张噪声图并对其采样,把纹理的颜色乘上噪声图的第一个通道。

因为V坐标拉伸的太明显,噪声图也沿着河流的方向被拉伸了,这个效果并不好看。通过缩小U坐标的比例来从另一个方向拉伸,十六分之一应该是个合适的值。这意味着只在噪声图的一条窄带上进行采样。


8.2 混合噪声
已经看起来好多了,但是河流的样子看起来一直都是一个样,真正的水流看起来可不像这样。
由于只使用了纹理图上的一条窄带,可以滑动这个窄带的位置来改变样式。可以通过时间滑动U坐标实现,但是要确保其变化缓慢,否则河流看起来就像是往边上在流动。先设置这个缩放因子为0.005,这意味着200秒纹理才能完成一个循环。


不幸的是看起来不怎么样,即使滑动很慢,侧向移动也很明显,并且水面的样式看起来仍然是静止的。可以通过组合两个噪声采样来隐藏滑动,这两个样本都往相反的方向滑动,如果使用稍微不同的值来移动第二个样本,它将产生一个微妙的变形动画。
为了确保不会重叠一样的噪声,为第二个样本使用噪声图的另一个通道。


8.3 半透明水面效果
水面的纹理效果已经足够了,下一步是让其变得半透明。
首先,确保水面不会投射阴影,设置河流mesh预制上渲染器的阴影投射为关闭状态。


9 调整优化
现在所有的东西看起来都工作正常,是时候重新启用顶点扰动了,单元格的边界变形也会使河流的形状不规则。


检查一下地形,看看顶点扰动是不是会引起问题。事实证明确实会有问题,注意这些较高的瀑布。

从高处落下的水会消失在悬崖的后面,当这种情况发生时是很明显的,所以我们得做点什么。
不太明显的是瀑布是倾斜而不是直线下降的,虽然水不是这么运动的,但也不会明显感觉违和,所以忽略这个。
防止水消失的最简单方法是使河道更深,在水面和河床之间创造更多空间,但这也使河道墙壁更偏向垂直,所以我们不修改太多。设置MexMetrics里的streamBedElevationOffset到-1.75,这就能解决大部分问题,并且河道不会显得太深。一些水面仍然会被截断,但不会出现整段都隐藏在悬崖后面的情况了。


本期工程地址: https://github.com/tank1018702/Hex-Map-Learning/tree/Rivers
下一篇教程是:https://catlikecoding.com/unity/tutorials/hex-map/part-7/
有想系统学习游戏开发的童鞋,欢迎访问http://levelpp.com/
有专业开发交(gao)流(ji)群等待大家强势插入:869551769