HexMap学习笔记(七)——道路

作者:沈琰编译 2019-05-14

前言

由于不涉及对已有地形的改变,这篇教程的难度是低于上一篇的,入过完全弄懂上一篇的教程读起来就会很容易。另外最后道路的着色器效果很赞,值得仔细研究一下思路

本期原文地址:rivers

此教程是HexMap系列的第七篇,在上篇中添加了河流的编辑功能,这一次添加道路。

文明的第一个标志

1单元格里的道路

与河流一样,道路是穿过单元格边缘的中间来连接不同单元格,最大的不同点是道路没有流动的河水规定方向,所以道路是双向的。一个看得过去的道路网络肯定会有十字路口,所以我们还定义单元格内穿过的道路可以超过两条。

在六个方向上都允许有道路穿过,这意味着单元格可以包含零到六条道路,这产生了十四种可能的道路结构,远超河流的五种。为使其更容易实现,我们需要一个可以表示所有结构类型的通用方法。

十四种可能的道路结构

1.1记录道路结构

最直接的方法是每个单元格都用一个布尔类型的数组保存其道路结构,添加一个私有的数组字段并将其序列化,这样就能在检视面板上看到值,在预制体中预设这个数组的大小为6.


每个单元格最多有六条道路

添加一个方法检测是否在给定的方向上有道路。


要知道单元格内是否至少有一条路,添加一个属性循环遍历数组,如果有就返回true,如果一条道路都没有就返回false。


1.2删除道路

与河流一样,添加一个删除单元格内所有道路的方法,循环遍历数组内的每个元素,全部设置为false就行。


当然我们还要确保相邻单元格相应方向上的道路也被删除。


完成之后还要刷新道路删除之后的单元格,由于道路只会影响单元格本身,所以只用刷新单元格自身而不用刷新所有相邻单元格。


1.3添加道路

添加道路的逻辑与删除道路一样,只不过是把记录数组的值改成true而不是false,可以添加一个私有方法同时完成这两种操作。


因为无法在单元格的一个方向上同时添加道路与河流,所以添加之前先检测一下。


道路同样也无法与较高的悬崖共存,也许能添加在缓一些的斜坡上但是陡峭的悬崖不行?为了实现这一功能,创建一个方法获取指定方向上的相邻单元格上的高度差异。


现在可以强制在高度差异足够小的时候添加道路,这里限制只能在阶梯连接的斜坡上添加,所以高度差距的最大值是1。



1.4删除无效道路

现在可以确保只在条件允许时添加道路,在来考虑稍后道路失效时候的删除问题,例如当添加河流时。可以禁止河流在道路上,但河流不会被道路阻挡,让其把路冲开。

只要将河流方向上的道路设置为false就行了,不用管是不是存在道路,修改道路状态始终会刷新受影响的单元格,所以现在不用特地在SetOutgoingRiver中去调用RefreshSelfOnly方法了。


另一个会使道路无效的操作是单元格高度的改变,这种情况下需要检测所有方向上的道路,如果高度差距过大,现存的道路就要删除。


2编辑道路

编辑道路的逻辑实在是很像编辑河流,所以HexMapEditor中需要另一个toggle组,加上随之一起的方法来设置道路编辑的状态。


现在EditCell方法同样支持添加和删除道路,这意味着检测到鼠标拖拽时有两种可能的操作,稍稍修改一下代码,这样在有效拖拽时两个toggle的状态都会被检测到。


你可以通过赋值河流的UI面板并修改其调用的方法来快速添加一个编辑道路的面板,不过这样UI看起来就太长了,所以修改了一下颜色编辑面板的样子使其更紧凑一些,好与河流面板想吻合。

添加道路编辑之后的UI

因为现在颜色编辑面板使两行三列,多出了一个颜色的位置,就添加一个橘黄色的地形颜色。


地形的五种颜色

现在就可以编辑道路了,尽管看不见,但可以在检视面板上验证其是否生效。

检查单元格的道路状态

3道路的三角剖分

要让道路可视化就需要将其三角化,这与河流mesh类似,除了地形上不会生成一条通道之外。

第一步,在新建一个标准着色器,使用UV坐标为道路表面着色。


创建道路的材质球并应用这个着色器。

道路材质球

然后为地图块的预制体添加另一个挂载有HexMesh的子物体,只在脚本上勾选uses UV coordinates并关闭阴影投射,比较快的办法是复制河流的预制体修改名字和材质球。

道路子物体

在这之后添加一个HexMesh类型的公共字段roads到HexGridChunk里,并包含到Triangulate方法里,在脚本的检视面板上创建其关联。


道路对象的关联

3.1单元格之间的道路

第一步先考虑单元格之间的路段。就像河流一样,道路覆盖的范围是连接部分中间的两个四边形,这里将使用道路的四边形将其完全覆盖,所以可以使用相同的六个顶点。在HexGridChunk中添加一个TriangulateRoadSegment方法。


由于不用管水流的效果,所以不需要V坐标,所有位置的V坐标都设置成零就行。用U坐标来表示是道路的中间还是边界,这里定义U坐标1为道路的中间,0为两边。


路段在单元格之间的区域

TrangulateEdgeStrip即是调用此方法的位置,并且只有在确实存在道路时才需要这么去做,添加一个布尔类型的参数去传递这个信息。


这样当然会出现编译错误,因为调用这个方法的位置缺少参数。现在可以在没个调用方法的位置都补上一个false,或者也能声明这个参数的默认值是false,这样一来这个参数就变成了可选的并解决了编译错误的问题。


可选参数是如何工作的?

你可以认为是函数重载的缩写,填入了缺失的参数。例如以下方法:

  1. <p>int MyMethod(int x=1,int y=2){return x+y;}</p><p>
  2. </p><p>它等价于下面三个方法:</p><p>
  3. </p><p>int MyMethod(int x,int y){return x+y;}</p><p>
  4. </p><p>int MyMethod(int x){return MyMethod(x,2);}</p><p>
  5. </p><p>int MyMethod(){return MyMethod(1,2};}</p><p></p>
复制代码

这里的参数顺序很重要,可选参数可以从右到左省略,最后的一个参数第一个被隐藏,而且可选参数总是放在正常参数之后。

如果这里需要就简单的通过六个中间顶点调用TriangulateRoadSegment来三角化路段。


这里只负责平坦连接处部分,要在阶梯连接处三角化道路,还需要在TriangulateEdgeTerraces里添加传递信息的参数,并传递到TriangulateEdgeStrip中。


TriangulateEdgeTerraces方法是在TriangulateConnection里调用,这里是能够确认当前方向上是否有道路穿过的地方,无论是对于三角化边界和阶梯连接处都一样。


单元格之间的路段


3.2渲染到顶部

当编辑道路时,你会看到路段在单元格之间突然出现,靠近路段中间的位置是紫色,到边上变化为蓝色。目前看起来好像符合预期,然而四处移动相机时就会发现,路段有可能出现闪烁,甚至有些时候还会完全消失。这是因为道路的三角形精确的覆盖在地形三角上,渲染至顶部的三角形是随机的,要修复这个问题需要分为两步。

首先,我们想让道路三角形总在地形三角形之后绘制,通过绘制常规集合图形之后再渲染来实现,方法是把道路放在靠后的渲染队列中。


第二步,我们希望即使是坐标相同,道路三角形也绘制在地形的顶部。这要通过添加一个深度测试偏移量实现,使得GPU将道路三角形视为比实际距离更接近相机。


3.3穿过单元格的道路

当三角化河流时,每个单元格最多只需要处理两个河流的方向,于是我们能为五种可能的方案创建符合其规则的三角化方法。

然而道路有十四种可能的方案,因此我们不会用不同的方法来应对每个方案,相反将会以完全相同的方式处理每个方向,而不去考虑特定的道路结构。

当有道路穿过单元格时,直接让其笔直达到单元格的中心并不超过这个方向的三角形区域。将从边缘到中心画一条路,然后用两个三角形来覆盖到中心的剩余部分。

道路的三角化部分

要三角化这部分就需要获取单元格中心顶点,左右位置的顶点和边缘上的顶点。添加一个TriangulateRoad方法和相应的参数。


需要一个额外的顶点去构建道路,它位于左右顶点的中间。


现在就能添加剩余的两个三角形了。


三角形也要添加它的UV坐标,它们的两个顶点都在道路的中间,另一个在边上。


先只考虑没有河流在内的单元格,这种情况下Triangulate就简单的构建三角扇。把这些代码移动到它自己的方法中,然后在道路确实存在时在TriangualteRoad中添加调用。道路左右的中间位置顶点可以通过插值计算单元格的中心点和两个角顶点得到。


道路穿过的样子

3.4道路边缘

现在可以看到道路了,但是其向着单元格中心的方向是逐渐变细的。由于没有检测现在是十四种道路结构中的哪一种,所以没办法移动单元格的中心让它更好看一些,现在能做的就是添加额外的道路边缘部分到单元格的其他地方。

当单元格内有道路穿过,但又不是当前三角化的方向时,添加道路边缘的三角形。这个三角形由单元格的中心点和左右边缘的中间点构成。在这种情况中只有中心点位于道路中间,其他两个顶点都在道路的边缘上。



不管是要三角化整个道路还是仅仅只是道路的边缘,都应该留给Triangulate方法负责,所以这里得知道道路是否经过当前单元格边界的方向,为此添加一个参数。


TriangulateWithoutRiver方法会在单元格内有道路穿过时调用TriangulateRoad方法,并同时传递道路是否经过当前方向的信息。


道路的边缘完成

3.5道路轮廓的平滑处理

现在道路完工,但是单元格的中心会有凸起的感觉。当有道路与左右边的顶点相邻时,把左右顶点放置在单元格中心和角顶点的中间时,看起来还行,但如果没有,就会产生凸起。为解决这个问题,可以在没有相邻道路的情况下,把边缘顶点放到更靠近单元格中心点的位置,具体来说就是插值的因子由二分之一改为四分之一。

创建一个额外方法去计算出需要使用哪个插值,可以把这两个值放在一个Vector2中,它的X分量是左边顶点的插值,Y分量是右边顶点的插值。


如果道路在当前方向上,就把边缘顶点都放到二分之一的位置。


否则就根据相邻道路来决定,对于左边的顶点,当上一个方向有道路穿过时用二分之一作为插值因子,如果没有就用四分之一。这个逻辑对右边的顶点也是一样,只不过其参照的是下一个方向上有没道路经过。


现在就能用这个新方法确定使用哪种插值,这会让道路的轮廓显得更平滑一些。



平滑的道路

4道路与河流的结合

在没有河流的情况下道路的功能已经完成了,但一旦有节流穿过,道路就不会被三角化。

河流边没有道路

创建一个新方法TriangulateRoadAdjacentToRiver负责这种情况下道路的三角化,并添加三角化惯有的参数,在TriangulateAdjacentToRiver方法一开始调用。


一开始做的与没有河流时道路的三角化一样,检测道路是否穿过这个方向的单元格边缘,提供插值系数,计算中间顶点接着调用TriangulateRoad方法。但由于河流会挡住道路,所以要把道路移开,结果就是道路在单元格内的中心点会在不同的位置,使用roadCenter变量来记住这个新位置,它一开始的时候等于单元格的中心点。


这会在有河流穿过的单元格中生成部分道路,河流穿过的方向会在道路上形成缺口。

道路上的缺口


4.1河流起点与终点的道路处理

首先考虑包含河流起点或终点的道路处理,若要确保道路不会覆盖在河流之上,我们需要把道路在单元格内的中线点推移到河流的范围之外。未测需要获得流入或流出的河流方向,在HexCell中添加一个方便获取的属性。


现在能在HexGridChunk中使用这个属性,在TriangulateRoadAdjacentToRiver中把道路的中心点推移向相反的方向,沿着这个方向移动三分之一的距离就可以了。


修正中心点

下一步就是填充缺口。当与河流相邻时我们添加额外的道路边缘三角形来实现。如果在当前方向的上一个方向上有河流穿过,那么就在道路中心,单元格的中心点和道路左边中间位置的顶点之间添加一个三角形。如果下一个方向上有河流穿过,就在道路中心,道路右边的中间顶点和单元格中心点之间添加一个三角形。

不管在处理何种河流结构都要进行这样的处理,所以把这段代码放在方法的末尾。


不使用else么?

这样就不适用所有情况了,河流有可能同时流经这两个方向。

道路完成

4.2笔直河流的道路处理

单元格内有笔直河流穿过情况下道路的处理带来了额外的挑战,因为单元格的中心点事实上被分为了两个。我们已经添加了额外的三角形来填补沿河的空隙,但我们还得断开河流两边的道路。

道路覆盖在笔直河流之上

如果单元格内不是河流的起点或终点,那么再检测一下河流流入和流出方向是否相反,如果是,那毫无疑问就是笔直的河流。


为了确定河流相对于当前方向的位置,我们必须检查相邻的方向,河流方向不是在左边就是在右边。如同我们在方法末尾做的那样,把这些检测缓存到布尔值中,这也使代码更容易阅读。


我们需要把道路的中心点往远离河流方向的角上推移,如果这条河流经过前一个方向,那这个角就是固定里六边形的第二方向上的角,否则就是第一个角。


移动道路使其在于河流方向相邻的位置结束,需要移动道路的中心点到朝向这个角的方向的一半的位置,然后还有把单元格的中心向这个方向移动四分之一的距离。


分开的道路

现在单元格内的道路网已经分开,当河流两边都有路的时候看起来没问题,但当一边没有时,会在这边留下一点孤零零的小尾巴,这部分没什么用也不美观,所以把这部分去掉。

检查一下当前方向是否有道路穿过,如果没有,检测一下河流同边的其他方向,如果两者都没有道路穿过,就在三角化之前跳出方法。


修剪后的道路

为什么不搭一座桥?

现在先把注意力集中在道路上,桥和其他的建筑结构放在未来的教程中。

4.3锯齿急弯河流的道路处理

下一步是包含锯齿急弯河流单元格的道路处理,这种形状的河流不会分割路网,所以我们只需要移动道路的中心点就行了。


最简单的检测是否是急弯的方法是比较单元格内河流流出和流出的方向,如果他们相邻,那这就是急弯了。这有两种可能的情况,取决于河流的流向。


我们可以使用河流流入方向的角作为移动道路中心点的方向,具体是哪个取决于河流的流向,把道路中心点往这个方向上移动0.2的距离。


道路从锯齿急弯上移开


4.4弯曲河流内侧的道路处理

最后的河流形状是平滑的曲线,与笔直河流相同,这是可以分离道路的类型。但在这种情况下曲线内的一边处理起来会有一些不同,先处理这部分。


如果当前处理方向的两个相邻方向都有河流穿过,那就是在弯曲河流的内侧。


我们需要把道路中心点向当前方向的边缘位置拉,使道路缩短一截,0.7的距离就差不多了。单元格的中心点位置也要移动,它的值是0.5。


被缩短的道路

与笔直河流那做的处理一样,把单独剩下的一点尾巴去掉。这里只需要检测当前的方向。



4.5弯曲河流外侧的道路处理

在检测了之前所有情况之后,唯一剩下的可能就是在弯曲河流的外测。外侧的位置有单个单元格的部分,需要找到其中间的方向。一但找到就以此方向为参照把道路中心点移动0.25的距离。


在外侧修正道路位置

最后一步是修剪这边道路的多余部分,最简单的方法是相对中间方向检测其包括相邻方向在内的三个方向,如果这三个方向上均没有道路,就跳出。


道路在修剪前后的对比

5道路的外表

到目前为止都使用UV坐标为道路着色,由于只是改变了U坐标,我们实际看到的是道路中间到边缘之间的过渡。

道路显示的UV坐标

现在已经能确保道路的三角剖分的正确性,就可以开始改变道路的颜色,使其表现得更像道路,如同对河流所做的一样。这将是一个简单的可视化改动,没什么特变的。

首先从使用纯色开始,使用材质球的颜色将其涂为红色。


红色的道路

现在已经看起来好多了,接着使用U坐标作为混合因子混合道路和地形之间的颜色。


似乎没有效果,这是因为着色器是不透明的。现在需要把alpha值进行混合,具体来说就是需要一个混合贴花的表面着色器。可以通过在#pragma suface指令中添加decal:blend来获得预想的着色器。


透明度混合的道路

结果产生了从中间到边缘的线性混合,效果似乎不是太好。为了让其看起来更像一条路,需要在路中间保留一个固定的颜色区域,然后再快速过渡到一个不透明的区域。这里可以使用smoothstep函数,把从0到1之间的线性变化变为s形的曲线。

线性变化与平滑曲线

smoothstep函数有一个最大和最小参数,可以在任意范围内拟合曲线,超出这个范围的输入被固定住,因此曲线会变得平滑。这里使用0.4作为曲线的起点,0.7作为曲线的终点,这表示U坐标从0到0.4之间是完全透明的,从0.1到1之间是完全不透明的,转换发生在0.4到0.7之间。


从透明到不透明之间的快速变化

5.1道路外表噪声化

由于道路的顶点也会受到噪声扰动,所以道路的宽度会发生变化。因此道路边缘过渡部分的宽度也会发生变化,有时模糊有时清晰,这样很像是土路或者沙路带来的感觉。

让我们更进一步,在道路的边缘添加一些噪声效果,这将是道路的形状开起来更粗糙一些,不会显得那么棱角分明,这可以通过对噪声纹理采样做到。使用世界坐标的XZ对噪声纹理进行采样,就像在扰动单元格顶点时候一样。

要访问表面着色器中的世界坐标,需要在输入结构中添加float3 worldPos。


现在可以在surf中使用这个坐标来对主纹理图进行采样,一定要缩小坐标的比例,不然纹理会在小范围平铺显示。


通过将U坐标与噪声纹理的X值相乘来对过渡值进行扰动,但由于噪声纹理的平均值是0.5,这将会覆盖大部分的道路。为防止这种情况发生,在相乘之前先加上0.5。


道路边缘的扰动

最后也对道路的颜色进行扰动,这配和凌乱的道路边缘给其一些脏乱的效果。

将颜色乘上纹理图的另一个通道,比如noise.y。所以颜色的平均值只有最大值的一半,这扰动的幅度有些大,所以缩小一点噪声采样的取值并加上一个常量,所以总值还是可以达到1。



本期工程地址Hex-Map-Learning-Roads

有想系统学习游戏开发的童鞋,欢迎访问http://levelpp.com/。

下一篇教程是Water。

系列文章
HexMap学习笔记(一)——创建六边形网格
HexMap学习笔记(二)——单元格颜色混合
HexMap学习笔记(三)——海拔高度与阶梯连接

HexMap学习笔记(四)——不规则化
HexMap学习笔记(五)——更大的地图
HexMap学习笔记(六)——河流
HexMap学习笔记(七)——道路

HexMap学习笔记(八)——水体
HexMap学习笔记(九)——地形特征
HexMap学习笔记(十)——城墙
HexMap学习笔记(十一)——更多种地形特征物

作者:沈琰编译
专栏地址:https://zhuanlan.zhihu.com/p/59214888




最新评论
暂无评论
参与评论

商务合作 查看更多

编辑推荐 查看更多