Unity六边形图:粗糙度,河流和道路

图片


第1-3部分:网格,颜色和像元高度

第4-7部分:颠簸,河流和道路

第8-11部分:水,地貌和城墙

第12-15部分:保存和加载,纹理,距离

第16-19部分:找到道路,队员,动画

第20-23部分:战争迷雾,地图研究,程序生成

第24-27部分:水循环,侵蚀,生物群落,圆柱图

第4部分:粗糙度


目录


  • 采样噪声纹理。
  • 移动顶点。
  • 我们保持单元的平坦度。
  • 细分单元格的边缘。

虽然我们的网格是蜂窝的严格模式。 在这一部分中,我们将添加凹凸以使地图看起来更自然。


甚至没有六角形。

噪音


要增加凹凸,我们需要随机化,而不是真正的随机性。 更改地图时,我们希望一切保持一致。 否则,当您进行任何更改时,对象将跳跃。 也就是说,我们需要某种形式的可复制伪随机噪声。

佩林的噪音是一个很好的候选人。 在任何地方都可以重现。 当组合多个频率时,还会产生噪声,该噪声在远距离时可能会发生很大变化,但在近距离时几乎保持不变。 因此,可以创建相对平滑的失真。 彼此相邻的点通常保持在附近,并且不会在相反的方向上分散。

我们可以通过编程方式生成Perlin噪声。 在“ 噪声”教程中,我解释了如何执行此操作。 但是我们也可以从预先生成的噪声纹理中采样。 使用纹理的优点是,它比计算Perlin的多频噪声更简单,更快。 它的缺点是纹理占用更多内存,并且仅覆盖一小部分噪声。 因此,它应该无缝连接并且足够大,以免重复出现。

噪音纹理


我们将使用纹理,因此“ 噪波”教程是可选的。 因此,我们需要一个纹理。 这是:


无缝连接perlin噪声纹理。

上面显示的纹理包含Perlin的无缝耦合多频噪声。 这是灰度图像。 其平均值为0.5,极值趋向于0和1。

但是,等等,每一点只有一个值。 如果需要3D失真,则至少需要三个伪随机样本! 因此,我们需要另外两个具有不同噪声的纹理。

我们可以创建它们,或在每个颜色通道中存储不同的噪声值。 这将使我们可以在一个纹理中存储多达四个噪声模式。 这是这种纹理。


四合一。

如何创建这样的纹理?
我使用了NumberFlow 。 这是我为Unity创建的过程纹理编辑器。

下载此纹理并将其导入到您的Unity项目中。 由于我们将通过代码对纹理进行采样,因此它应该是可读的。 将“ 纹理类型”切换为“ 高级”并启用“启用读/写” 。 这会将纹理数据保存在内存中,并且可以从C#代码进行访问。 将格式设置为自动Truecolor ,否则将无效 。 我们不希望纹理压缩破坏我们的噪声模式。

您可以禁用Generate Mip Maps ,因为我们不需要它们。 同时启用“ 旁路sRGB采样” 。 我们将不需要它,但是确实如此。 此参数指示纹理在伽玛空间中不包含颜色数据。



导入的噪波纹理。

sRGB采样什么时候重要?
如果我们想在着色器中使用纹理,那将会有所作为。 使用线性渲染模式时,纹理采样会自动将颜色数据从伽玛转换为线性颜色空间。 就我们的噪声纹理而言,这将导致错误的结果,因此我们不需要这样做。

为什么我的纹理导入设置看起来不同?
在编写本教程后,对它们进行了更改。 您需要使用默认的2D纹理设置,应该禁用sRGB(彩色纹理) ,并且应将Compression设置为None

噪音采样


让我们向HexMetrics添加噪声采样功能,以便您可以在任何地方使用它。 这意味着HexMetrics必须包含对噪声纹理的引用。

  public static Texture2D noiseSource; 

由于这不是组件,因此我们无法通过编辑器为其分配纹理。 因此,作为中介,我们使用HexGrid 。 由于HexGrid将首先执行操作,因此如果我们在Awake方法的开头传递纹理,就可以了。

  public Texture2D noiseSource; void Awake () { HexMetrics.noiseSource = noiseSource; … } 

但是,这种方法将无法在“播放”模式下重新编译。 静态变量不会由Unity引擎序列化。 要解决此问题,也可以在OnEnable事件方法中重新分配纹理。 重新编译后将调用此方法。

  void OnEnable () { HexMetrics.noiseSource = noiseSource; } 


分配噪波纹理。

现在HexMetrics可以访问纹理了,让我们为其添加一个方便的噪声采样方法。 该方法在世界上占据一席之地,并创建一个包含四个噪声样本的4D向量。

  public static Vector4 SampleNoise (Vector3 position) { } 

通过使用双线性过滤对纹理进行采样来创建样本,其中将世界X和Z的坐标用作UV坐标,由于我们的噪声源是二维的,因此我们忽略了世界的第三个坐标。 如果噪声源是三维的,我们也将使用Y坐标。

结果,我们得到一种可以转换为4D矢量的颜色。 这种减少可以是间接的,也就是说,我们可以直接返回颜色,而无需显式包含(Vector4)

  public static Vector4 SampleNoise (Vector3 position) { return noiseSource.GetPixelBilinear(position.x, position.z); } 

双线性滤波如何工作?
有关UV坐标和纹理过滤的说明,请参见“ 渲染2”教程“ Shader基础知识”

统一包装

顶点运动


我们将扭曲我们的蜂窝状平滑网格,分别移动每个顶点。 为此,我们将HexMesh方法添加到Perturb 。 它需要一个固定的点并返回移动的点。 为此,他在采样噪声时使用了一个不变的点。

  Vector3 Perturb (Vector3 position) { Vector4 sample = HexMetrics.SampleNoise(position); } 

让我们直接将噪声样本X,Y和Z添加到相应的点坐标,并将其用作结果。

  Vector3 Perturb (Vector3 position) { Vector4 sample = HexMetrics.SampleNoise(position); position.x += sample.x; position.y += sample.y; position.z += sample.z; return position; } 

我们如何快速更改HexMesh以移动所有顶点? AddTriangleAddTriangleAddQuad AddTriangle顶点添加到列表时更改每个顶点。 来吧

  void AddTriangle (Vector3 v1, Vector3 v2, Vector3 v3) { int vertexIndex = vertices.Count; vertices.Add(Perturb(v1)); vertices.Add(Perturb(v2)); vertices.Add(Perturb(v3)); … } void AddQuad (Vector3 v1, Vector3 v2, Vector3 v3, Vector3 v4) { int vertexIndex = vertices.Count; vertices.Add(Perturb(v1)); vertices.Add(Perturb(v2)); vertices.Add(Perturb(v3)); vertices.Add(Perturb(v4)); … } 

四边形移动顶点后会保持平坦吗?
很有可能不会。 它们由两个不再位于同一平面上的三角形组成。 但是,由于这些三角形具有两个公共顶点,因此这些顶点的法线将被平滑。 这意味着我们在两个三角形之间不会出现尖锐的过渡。 如果失真不是太大,那么我们仍然会认为四边形是平坦的。


顶点是否移动。

虽然变化不是很明显,但是只有单元格标签消失了。 发生这种情况是因为我们向这些点添加了噪声样本,并且它们始终为正。 因此,所有三角形均上升到其标记之上,从而将其闭合。 我们必须居中更改,以便它们在两个方向上都可以发生。 将噪声采样的间隔从0–1更改为-1–1。

  Vector3 Perturb (Vector3 position) { Vector4 sample = HexMetrics.SampleNoise(position); position.x += sample.x * 2f - 1f; position.y += sample.y * 2f - 1f; position.z += sample.z * 2f - 1f; return position; } 


中心位移。

位移量(力)


现在很明显,我们扭曲了网格,但是效果几乎不明显。 每个尺寸的变化不超过1个单位。 也就是说,理论上的最大位移为√3≈1.73单位,如果有的话,这种情况极少发生。 由于单元的外半径为10个单位,因此位移较小。

解决方案是在HexMetrics添加一个HexMetrics参数,以便可以缩放运动。 让我们尝试使用力5。在这种情况下,理论上的最大位移为√75≈8.66单位,这一点要明显得多。

  public const float cellPerturbStrength = 5f; 

我们通过将力乘以HexMesh.Perturb的样本来施加力。

  Vector3 Perturb (Vector3 position) { Vector4 sample = HexMetrics.SampleNoise(position); position.x += (sample.x * 2f - 1f) * HexMetrics.cellPerturbStrength; position.y += (sample.y * 2f - 1f) * HexMetrics.cellPerturbStrength; position.z += (sample.z * 2f - 1f) * HexMetrics.cellPerturbStrength; return position; } 



增加力量。

噪音等级


尽管更改前网格看起来不错,但在壁架出现后,一切都可能出错。 它们的峰值可能会在不同的方向上扭曲,从而造成混乱。 使用Perlin杂讯时,这不会发生。

出现问题是因为我们直接使用世界坐标来采样噪声。 因此,纹理在每个单元中都是隐藏的,并且单元格远大于此值。 实际上,纹理是在任意点采样的,这破坏了它的现有完整性。


10 x 10网格的行与单元格重叠。

我们将不得不缩放噪声采样,以使纹理覆盖更大的区域。 让我们将此比例添加到HexMetrics并为其指定0.003的值,然后通过该因子缩放样本的坐标。

  public const float noiseScale = 0.003f; public static Vector4 SampleNoise (Vector3 position) { return noiseSource.GetPixelBilinear( position.x * noiseScale, position.z * noiseScale ); } 

突然发现我们的纹理覆盖了333⅓ 平方单位,其本地完整性变得明显。



缩放噪声。

另外,新的标度增加了噪声之间的距离。 实际上,由于单元的内径为10√3单位,因此它永远不会在X维度上精确平铺,但是,由于噪声的局部完整性,在更大的范围内,我们仍然能够识别出大约每20个单元重复的模式,即使细节不匹配。 但是它们只有在没有其他特征的情况下才在地图上显而易见。

统一包装

对齐细胞中心


移动所有顶点可使地图看起来更自然,但是存在一些问题。 由于单元现在呈锯齿状,因此它们的标签与网格相交。 在与悬崖的壁架连接处,会出现裂缝。 我们将留待以后再解决,但现在我们将集中讨论单元格的表面。


地图变得不太严格,但出现了更多问题。

解决交叉问题的最简单方法是使像元的中心平坦。 我们只是不更改HexMesh.Perturb的Y坐标。

  Vector3 Perturb (Vector3 position) { Vector4 sample = HexMetrics.SampleNoise(position); position.x += (sample.x * 2f - 1f) * HexMetrics.cellPerturbStrength; // position.y += (sample.y * 2f - 1f) * HexMetrics.cellPerturbStrength; position.z += (sample.z * 2f - 1f) * HexMetrics.cellPerturbStrength; return position; } 


对齐的单元格。

进行此更改后,无论是在单元格的中心还是在壁架的台阶处,所有垂直位置都将保持不变。 应该注意的是,这仅在XZ平面上将最大位移减小到√50≈7.07。

这是一个很好的更改,因为它简化了单个细胞的识别,并且不允许壁架变得过于混乱。 但是增加一点垂直运动还是不错的。

移动单元格高度


除了将垂直移动应用于每个顶点之外,我们还可以将其应用于单元。 在这种情况下,每个像元将保持平坦,但像元之间仍将保持变异性。 使用不同的比例来移动高度也是合乎逻辑的,因此请将其添加到HexMetrics 。 1.5个单位的力会产生轻微的变化,大约等于壁架一级的高度。

  public const float elevationPerturbStrength = 1.5f; 

更改HexCell.Elevation属性,以便将此移动HexCell.Elevation单元格的垂直位置。

  public int Elevation { get { return elevation; } set { elevation = value; Vector3 position = transform.localPosition; position.y = value * HexMetrics.elevationStep; position.y += (HexMetrics.SampleNoise(position).y * 2f - 1f) * HexMetrics.elevationPerturbStrength; transform.localPosition = position; Vector3 uiPosition = uiRect.localPosition; uiPosition.z = -position.y; uiRect.localPosition = uiPosition; } } 

为了立即应用移动,我们需要在HexGrid.CreateCell显式设置每个单元的高度。 否则,网格最初将是平坦的。 创建UI后,最后做一下。

  void CreateCell (int x, int z, int i) { … cell.Elevation = 0; } 



移位的高处有裂缝。

使用相同的高度


网格中出现了许多裂缝,因为在对网格进行三角剖分时,我们不会使用相同的像元高度。 让我们向HexCell添加一个属性以获取其位置,以便您可以在任何地方使用它。

  public Vector3 Position { get { return transform.localPosition; } } 

现在我们可以在HexMesh.Triangulate使用此属性来确定单元格的中心。

  void Triangulate (HexDirection direction, HexCell cell) { Vector3 center = cell.Position; … } 

当定义相邻单元的垂直位置时,我们可以在TriangulateConnection使用它。

  void TriangulateConnection ( HexDirection direction, HexCell cell, Vector3 v1, Vector3 v2 ) { … Vector3 bridge = HexMetrics.GetBridge(direction); Vector3 v3 = v1 + bridge; Vector3 v4 = v2 + bridge; v3.y = v4.y = neighbor.Position.y; … HexCell nextNeighbor = cell.GetNeighbor(direction.Next()); if (direction <= HexDirection.E && nextNeighbor != null) { Vector3 v5 = v2 + HexMetrics.GetBridge(direction.Next()); v5.y = nextNeighbor.Position.y; … } } 


一致使用像元高度。

统一包装

单元边缘单元


尽管这些单元格有漂亮的变化,但它们看起来仍然像明显的六边形。 这本身不是问题,但是我们可以改善它们的外观。


清晰可见的六角形细胞。

如果我们有更多的顶点,那么局部变异性就会更大。 因此,通过在每对角之间的中间添加边的顶部,将单元的每个边分成两部分。 这意味着HexMesh.Triangulate不应该添加一个,而是两个三角形。

  void Triangulate (HexDirection direction, HexCell cell) { Vector3 center = cell.Position; Vector3 v1 = center + HexMetrics.GetFirstSolidCorner(direction); Vector3 v2 = center + HexMetrics.GetSecondSolidCorner(direction); Vector3 e1 = Vector3.Lerp(v1, v2, 0.5f); AddTriangle(center, v1, e1); AddTriangleColor(cell.color); AddTriangle(center, e1, v2); AddTriangleColor(cell.color); if (direction <= HexDirection.SE) { TriangulateConnection(direction, cell, v1, v2); } } 


十二面而不是六面。

顶点和三角形加倍会为单元的边缘增加更多的可变性。 让我们将顶点数量增加三倍,使它们更加不均匀。

  Vector3 e1 = Vector3.Lerp(v1, v2, 1f / 3f); Vector3 e2 = Vector3.Lerp(v1, v2, 2f / 3f); AddTriangle(center, v1, e1); AddTriangleColor(cell.color); AddTriangle(center, e1, e2); AddTriangleColor(cell.color); AddTriangle(center, e2, v2); AddTriangleColor(cell.color); 


18面。

肋骨联合司


当然,我们还需要细分边缘连接。 因此,我们会将新的顶点边传递给TriangulateConnection

  if (direction <= HexDirection.SE) { TriangulateConnection(direction, cell, v1, e1, e2, v2); } 

将适当的参数添加到TriangulateConnection以便它可以与其他顶点一起使用。

  void TriangulateConnection ( HexDirection direction, HexCell cell, Vector3 v1, Vector3 e1, Vector3 e2, Vector3 v2 ) { … } 

我们还需要计算相邻单元的边缘的附加边缘。 将桥连接到另一侧后,我们可以计算它们。

  Vector3 bridge = HexMetrics.GetBridge(direction); Vector3 v3 = v1 + bridge; Vector3 v4 = v2 + bridge; v3.y = v4.y = neighbor.Position.y; Vector3 e3 = Vector3.Lerp(v3, v4, 1f / 3f); Vector3 e4 = Vector3.Lerp(v3, v4, 2f / 3f); 

接下来,我们需要更改肋骨的三角剖分。 在我们忽略带有壁架的斜坡之前,只需添加三个而不是一个四边形即可。

  if (cell.GetEdgeType(direction) == HexEdgeType.Slope) { TriangulateEdgeTerraces(v1, v2, cell, v3, v4, neighbor); } else { AddQuad(v1, e1, v3, e3); AddQuadColor(cell.color, neighbor.color); AddQuad(e1, e2, e3, e4); AddQuadColor(cell.color, neighbor.color); AddQuad(e2, v2, e4, v4); AddQuadColor(cell.color, neighbor.color); } 


细分的连接。

边缘的并集


由于要描述边缘,我们现在需要四个顶点,因此将它们组合成一个集合是合乎逻辑的。 这比使用四个独立的顶点更方便。 EdgeVertices创建一个简单的EdgeVertices结构。 它应包含沿单元格边缘顺时针方向旋转的四个顶点。

 using UnityEngine; public struct EdgeVertices { public Vector3 v1, v2, v3, v4; } 

它们不应该可序列化吗?
我们仅将这种结构用于三角剖分。 在此阶段,我们不需要存储边缘的顶点,因此不必进行序列化。

向其添加一个方便的构造方法,该方法将处理边缘的中间点。

  public EdgeVertices (Vector3 corner1, Vector3 corner2) { v1 = corner1; v2 = Vector3.Lerp(corner1, corner2, 1f / 3f); v3 = Vector3.Lerp(corner1, corner2, 2f / 3f); v4 = corner2; } 

现在,我们可以向HexMesh添加一个单独的三角剖分方法,以在单元的中心与其边缘之一之间创建三角形的扇形。

  void TriangulateEdgeFan (Vector3 center, EdgeVertices edge, Color color) { AddTriangle(center, edge.v1, edge.v2); AddTriangleColor(color); AddTriangle(center, edge.v2, edge.v3); AddTriangleColor(color); AddTriangle(center, edge.v3, edge.v4); AddTriangleColor(color); } 

还有一种在两个边缘之间对四边形条进行三角测量的方法。

  void TriangulateEdgeStrip ( EdgeVertices e1, Color c1, EdgeVertices e2, Color c2 ) { AddQuad(e1.v1, e1.v2, e2.v1, e2.v2); AddQuadColor(c1, c2); AddQuad(e1.v2, e1.v3, e2.v2, e2.v3); AddQuadColor(c1, c2); AddQuad(e1.v3, e1.v4, e2.v3, e2.v4); AddQuadColor(c1, c2); } 

这将使我们简化Triangulate方法。

  void Triangulate (HexDirection direction, HexCell cell) { Vector3 center = cell.Position; EdgeVertices e = new EdgeVertices( center + HexMetrics.GetFirstSolidCorner(direction), center + HexMetrics.GetSecondSolidCorner(direction) ); TriangulateEdgeFan(center, e, cell.color); if (direction <= HexDirection.SE) { TriangulateConnection(direction, cell, e); } } 

让我们继续TriangulateConnection 。 现在我们可以使用TriangulateEdgeStrip ,但是还需要其他替换。 在以前使用v1 ,我们需要使用e1.v1 。 类似地, v2变成e1.v4v3变成e2.v1v4变成e2.v4

  void TriangulateConnection ( HexDirection direction, HexCell cell, EdgeVertices e1 ) { HexCell neighbor = cell.GetNeighbor(direction); if (neighbor == null) { return; } Vector3 bridge = HexMetrics.GetBridge(direction); bridge.y = neighbor.Position.y - cell.Position.y; EdgeVertices e2 = new EdgeVertices( e1.v1 + bridge, e1.v4 + bridge ); if (cell.GetEdgeType(direction) == HexEdgeType.Slope) { TriangulateEdgeTerraces(e1.v1, e1.v4, cell, e2.v1, e2.v4, neighbor); } else { TriangulateEdgeStrip(e1, cell.color, e2, neighbor.color); } HexCell nextNeighbor = cell.GetNeighbor(direction.Next()); if (direction <= HexDirection.E && nextNeighbor != null) { Vector3 v5 = e1.v4 + HexMetrics.GetBridge(direction.Next()); v5.y = nextNeighbor.Position.y; if (cell.Elevation <= neighbor.Elevation) { if (cell.Elevation <= nextNeighbor.Elevation) { TriangulateCorner( e1.v4, cell, e2.v4, neighbor, v5, nextNeighbor ); } else { TriangulateCorner( v5, nextNeighbor, e1.v4, cell, e2.v4, neighbor ); } } else if (neighbor.Elevation <= nextNeighbor.Elevation) { TriangulateCorner( e2.v4, neighbor, v5, nextNeighbor, e1.v4, cell ); } else { TriangulateCorner( v5, nextNeighbor, e1.v4, cell, e2.v4, neighbor ); } } 

壁架部


我们需要划分壁架。 因此,我们将边缘传递给TriangulateEdgeTerraces

  if (cell.GetEdgeType(direction) == HexEdgeType.Slope) { TriangulateEdgeTerraces(e1, cell, e2, neighbor); } 

现在我们需要修改TriangulateEdgeTerraces以便它在边之间而不是在成对的顶点之间进行插值。 假设EdgeVertices具有方便的静态方法来执行此操作。 这将使我们简化TriangulateEdgeTerraces而不是使其复杂化。

  void TriangulateEdgeTerraces ( EdgeVertices begin, HexCell beginCell, EdgeVertices end, HexCell endCell ) { EdgeVertices e2 = EdgeVertices.TerraceLerp(begin, end, 1); Color c2 = HexMetrics.TerraceLerp(beginCell.color, endCell.color, 1); TriangulateEdgeStrip(begin, beginCell.color, e2, c2); for (int i = 2; i < HexMetrics.terraceSteps; i++) { EdgeVertices e1 = e2; Color c1 = c2; e2 = EdgeVertices.TerraceLerp(begin, end, i); c2 = HexMetrics.TerraceLerp(beginCell.color, endCell.color, i); TriangulateEdgeStrip(e1, c1, e2, c2); } TriangulateEdgeStrip(e2, c2, end, endCell.color); } 

EdgeVertices.TerraceLerp方法只是在两个边缘的所有四对顶点之间插入壁架。

  public static EdgeVertices TerraceLerp ( EdgeVertices a, EdgeVertices b, int step) { EdgeVertices result; result.v1 = HexMetrics.TerraceLerp(a.v1, b.v1, step); result.v2 = HexMetrics.TerraceLerp(a.v2, b.v2, step); result.v3 = HexMetrics.TerraceLerp(a.v3, b.v3, step); result.v4 = HexMetrics.TerraceLerp(a.v4, b.v4, step); return result; } 


细分的壁架。

统一包装

重新连接悬崖和壁架


到目前为止,我们忽略了悬崖和壁架交界处的裂缝。 现在是解决这个问题的时候了。 首先,让我们看一下陡坡(OSS)和陡坡(SOS)的情况。


网格孔。

出现问题的原因是边界的顶部已移动。 这意味着它们现在不完全位于悬崖的侧面,这会导致裂缝。 有时这些孔是看不见的,有时是惊人的。

解决方法是不要移动边框的顶部。 这意味着我们需要控制是否移动该点。 最简单的方法是创建一个完全不移动顶点的AddTriangle替代品。

  void AddTriangleUnperturbed (Vector3 v1, Vector3 v2, Vector3 v3) { int vertexIndex = vertices.Count; vertices.Add(v1); vertices.Add(v2); vertices.Add(v3); triangles.Add(vertexIndex); triangles.Add(vertexIndex + 1); triangles.Add(vertexIndex + 2); } 

更改TriangulateBoundaryTriangle以便它使用此方法。 这意味着他将必须明确移动除边界以外的所有顶点。

  void TriangulateBoundaryTriangle ( Vector3 begin, HexCell beginCell, Vector3 left, HexCell leftCell, Vector3 boundary, Color boundaryColor ) { Vector3 v2 = HexMetrics.TerraceLerp(begin, left, 1); Color c2 = HexMetrics.TerraceLerp(beginCell.color, leftCell.color, 1); AddTriangleUnperturbed(Perturb(begin), Perturb(v2), boundary); AddTriangleColor(beginCell.color, c2, boundaryColor); for (int i = 2; i < HexMetrics.terraceSteps; i++) { Vector3 v1 = v2; Color c1 = c2; v2 = HexMetrics.TerraceLerp(begin, left, i); c2 = HexMetrics.TerraceLerp(beginCell.color, leftCell.color, i); AddTriangleUnperturbed(Perturb(v1), Perturb(v2), boundary); AddTriangleColor(c1, c2, boundaryColor); } AddTriangleUnperturbed(Perturb(v2), Perturb(left), boundary); AddTriangleColor(c2, leftCell.color, boundaryColor); } 

值得注意的是:由于我们不使用v2来取得其他一些信息,因此我们可以立即将其移动。 这是一个简单的优化,它减少了代码量,因此让我们对其进行介绍。

  void TriangulateBoundaryTriangle ( Vector3 begin, HexCell beginCell, Vector3 left, HexCell leftCell, Vector3 boundary, Color boundaryColor ) { Vector3 v2 = Perturb(HexMetrics.TerraceLerp(begin, left, 1)); Color c2 = HexMetrics.TerraceLerp(beginCell.color, leftCell.color, 1); AddTriangleUnperturbed(Perturb(begin), v2, boundary); AddTriangleColor(beginCell.color, c2, boundaryColor); for (int i = 2; i < HexMetrics.terraceSteps; i++) { Vector3 v1 = v2; Color c1 = c2; v2 = Perturb(HexMetrics.TerraceLerp(begin, left, i)); c2 = HexMetrics.TerraceLerp(beginCell.color, leftCell.color, i); AddTriangleUnperturbed(v1, v2, boundary); AddTriangleColor(c1, c2, boundaryColor); } AddTriangleUnperturbed(v2, Perturb(left), boundary); AddTriangleColor(c2, leftCell.color, boundaryColor); } 


不可移动的边界。

看起来更好,但我们还没有完成。 在TriangulateCornerTerracesCliff方法内部,边界点被插在左右点之间。 但是,这些要点尚未移动。 为了使边界点与生成的悬崖相对应,我们需要在移动的点之间进行插值。

  Vector3 boundary = Vector3.Lerp(Perturb(begin), Perturb(right), b); 

TriangulateCornerCliffTerraces方法也是如此。

  Vector3 boundary = Vector3.Lerp(Perturb(begin), Perturb(left), b); 


孔不见了。

双悬崖和斜坡


在所有其余有问题的情况下,都存在两个悬崖和一个斜坡。


因为有单个三角形,所以孔很大。

通过在TriangulateCornerTerracesCliff末尾的else块中手动移动单个三角形,可以解决此问题。

  else { AddTriangleUnperturbed(Perturb(left), Perturb(right), boundary); AddTriangleColor(leftCell.color, rightCell.color, boundaryColor); } 

TriangulateCornerCliffTerraces

  else { AddTriangleUnperturbed(Perturb(left), Perturb(right), boundary); AddTriangleColor(leftCell.color, rightCell.color, boundaryColor); } 


摆脱最新的裂缝。

统一包装

完成时间


现在我们有了一个完全正确的变形网格。它的外观取决于特定的噪声,其大小和失真力。在我们的情况下,失真似乎太强了。尽管这种不平整看起来很漂亮,但我们不希望这些单元格偏离均匀网格。最后,我们仍然使用它来定义要调整大小的单元格。而且,如果单元格的大小变化太大,那么我们将很难将内容放入其中。



网格不失真。

似乎使单元变形的力5太大。


像元的失真是从0到5。

让我们将其减小到4,以增加网格的便利性,而不必使其过于正确。这样可确保最大XZ偏移为√32≈5.66单位。

  public const float cellPerturbStrength = 4f; 


细胞变形强度4.
另一个可以更改的值是完整性系数。如果我们增加它,则单元的平坦中心将变大,也就是说,将来的内容将有更多的空间。当然,这样做会使它们变得更加六角形。


完整性系数从0.75到0.95。

完整性系数略微增加到0.8会稍微简化我们的生活。

  public const float solidFactor = 0.8f; 


完整性系数0.8。

最后,您可能会注意到海拔高度之间的差异过大。当您需要确保正确生成网格时,这很方便,但是我们已经完成了此操作。让我们将其减少到每步步长1个单位,即3个。

  public const float elevationStep = 3f; 


音调减小到3。

我们还可以更改音调失真的强度。但是现在它的值为1.5,等于高度的一半,适合我们。

高度的小步幅允许对所有七个高度水平进行更合理的使用。这增加了地图的可变性。


我们使用七个高度级别。

统一包装

第5部分:较大的卡片


  • 我们将网格划分为多个片段。
  • 我们控制相机。
  • 分别着色颜色和高度。
  • 使用扩大的细胞刷。

到目前为止,我们一直在处理非常小的地图。现在是增加它的时候了。


是时候放大了。

网格碎片


我们不能使网格太大,因为我们遇到了可以放入一个网格的限制。如何解决这个问题?使用多个网格。为此,我们需要将网格划分为几个片段。我们使用大小不变的矩形片段。


将网格划分为3 x 3的段,

让我们使用5 x 5的块,即每个片段25个像元。在中定义它们HexMetrics

  public const int chunkSizeX = 5, chunkSizeZ = 5; 

什么片段大小可以认为合适?
. , . . , (frustum culling), . .

现在我们不能对网格使用任何大小;它必须是片段大小的倍数。因此,让我们对其进行更改,HexGrid以使其不在单独的单元中而是在片段中设置其大小。将默认大小设置为4 x 3片段,即只有12个片段或300个单元。这样我们就获得了方便的测试卡。

  public int chunkCountX = 4, chunkCountZ = 3; 

我们仍然使用widthheight,但是现在它们应该变为私有。并将它们重命名为cellCountXcellCountZ使用编辑器一次重命名所有出现的这些变量。现在很清楚,何时我们要处理碎片或单元格的数量。

 // public int width = 6; // public int height = 6; int cellCountX, cellCountZ; 



指定片段大小。

进行更改,Awake以便在必要时根据碎片数计算出细胞数。我们用另一种方法突出显示单元格的创建,以免造成阻塞Awake

  void Awake () { HexMetrics.noiseSource = noiseSource; gridCanvas = GetComponentInChildren<Canvas>(); hexMesh = GetComponentInChildren<HexMesh>(); cellCountX = chunkCountX * HexMetrics.chunkSizeX; cellCountZ = chunkCountZ * HexMetrics.chunkSizeZ; CreateCells(); } void CreateCells () { cells = new HexCell[cellCountZ * cellCountX]; for (int z = 0, i = 0; z < cellCountZ; z++) { for (int x = 0; x < cellCountX; x++) { CreateCell(x, z, i++); } } } 

片段预制


为了描述网格碎片,我们需要一种新型的组件。

 using UnityEngine; using UnityEngine.UI; public class HexGridChunk : MonoBehaviour { } 

接下来,我们将创建一个预制片段。我们将通过复制十六进制网格对象并将其重命名为十六进制网格块来实现此目的删除其组件HexGrid并添加一个组件HexGridChunk然后将其变成预制件,并从场景中移除对象。



具有自己的画布和网格的片段预制件。

由于他将创建这些片段的实例,因此HexGrid我们将为他提供该片段的预制件的链接。

  public HexGridChunk chunkPrefab; 


现在有碎片。

创建片段实例与创建单元实例非常相似。我们将在数组的帮助下跟踪它们,并使用双循环填充它。

  HexGridChunk[] chunks; void Awake () { … CreateChunks(); CreateCells(); } void CreateChunks () { chunks = new HexGridChunk[chunkCountX * chunkCountZ]; for (int z = 0, i = 0; z < chunkCountZ; z++) { for (int x = 0; x < chunkCountX; x++) { HexGridChunk chunk = chunks[i++] = Instantiate(chunkPrefab); chunk.transform.SetParent(transform); } } } 

初始化片段类似于我们初始化六边形网格的方式。她将所有内容都放入其中,Awake并在中执行三角剖分Start它需要引用其画布和网格以及单元格的数组。但是,该片段不会创建这些单元格。网格将继续这样做。

 public class HexGridChunk : MonoBehaviour { HexCell[] cells; HexMesh hexMesh; Canvas gridCanvas; void Awake () { gridCanvas = GetComponentInChildren<Canvas>(); hexMesh = GetComponentInChildren<HexMesh>(); cells = new HexCell[HexMetrics.chunkSizeX * HexMetrics.chunkSizeZ]; } void Start () { hexMesh.Triangulate(cells); } } 

将细胞分配给片段


HexGrid仍然创建所有单元。这是正常现象,但是现在我们需要将每个像元添加到合适的片段中,而不是使用我们自己的网格和画布对其进行设置。

  void CreateCell (int x, int z, int i) { … HexCell cell = cells[i] = Instantiate<HexCell>(cellPrefab); // cell.transform.SetParent(transform, false); cell.transform.localPosition = position; cell.coordinates = HexCoordinates.FromOffsetCoordinates(x, z); cell.color = defaultColor; … Text label = Instantiate<Text>(cellLabelPrefab); // label.rectTransform.SetParent(gridCanvas.transform, false); label.rectTransform.anchoredPosition = new Vector2(position.x, position.z); label.text = cell.coordinates.ToStringOnSeparateLines(); cell.uiRect = label.rectTransform; cell.Elevation = 0; AddCellToChunk(x, z, cell); } void AddCellToChunk (int x, int z, HexCell cell) { } 

我们可以使用整数除法xz按片段大小找到正确的片段

  void AddCellToChunk (int x, int z, HexCell cell) { int chunkX = x / HexMetrics.chunkSizeX; int chunkZ = z / HexMetrics.chunkSizeZ; HexGridChunk chunk = chunks[chunkX + chunkZ * chunkCountX]; } 

使用中间结果,我们还可以确定该片段中细胞的局部索引。之后,您可以将单元格添加到片段中。

  void AddCellToChunk (int x, int z, HexCell cell) { int chunkX = x / HexMetrics.chunkSizeX; int chunkZ = z / HexMetrics.chunkSizeZ; HexGridChunk chunk = chunks[chunkX + chunkZ * chunkCountX]; int localX = x - chunkX * HexMetrics.chunkSizeX; int localZ = z - chunkZ * HexMetrics.chunkSizeZ; chunk.AddCell(localX + localZ * HexMetrics.chunkSizeX, cell); } 

然后,它将HexGridChunk.AddCell单元格放置在其自己的数组中,然后为该单元格及其UI设置父元素。

  public void AddCell (int index, HexCell cell) { cells[index] = cell; cell.transform.SetParent(transform, false); cell.uiRect.SetParent(gridCanvas.transform, false); } 

扫一扫


在这一点上,他HexGrid可以摆脱他的孩子的画布和六角形网格以及代码。

 // Canvas gridCanvas; // HexMesh hexMesh; void Awake () { HexMetrics.noiseSource = noiseSource; // gridCanvas = GetComponentInChildren<Canvas>(); // hexMesh = GetComponentInChildren<HexMesh>(); … } // void Start () { // hexMesh.Triangulate(cells); // } // public void Refresh () { // hexMesh.Triangulate(cells); // } 

既然我们摆脱了Refresh,我们HexMapEditor就不再使用它。

  void EditCell (HexCell cell) { cell.color = activeColor; cell.Elevation = activeElevation; // hexGrid.Refresh(); } 


六边形的清洁网格。

启动播放模式后,该卡仍然看起来相同。但是对象的层次结构会有所不同。现在,Hex Grid会创建包含单元以及它们的网格和画布的片段子对象。


子片段在“播放”模式下播放。

也许我们在单元格标签方面存在一些问题。最初,我们将标签宽度设置为5。这足以在一张小地图上显示两个足以满足我们要求的字符。但是现在我们可以拥有类似-10的坐标,其中包含三个字符。它们将不适合且将被修剪。要解决此问题,请将单元格标签的宽度增加到10甚至更​​大。



扩展单元格标签。

现在我们可以创建更大的地图!由于我们在启动时会生成整个网格,因此创建大型地图可能会花费很长时间。但是完成之后,我们将有巨大的实验空间。

修复单元格编辑


在当前阶段,似乎无法进行编辑,因为我们不再更新网格。我们片段,所以添加一个方法需要更新RefreshHexGridChunk

  public void Refresh () { hexMesh.Triangulate(cells); } 

什么时候应该调用此方法?每次都更新整个网格,因为只有一个网格。但是现在我们有很多碎片。与其每次都更新它们,不如更新更改后的片段。否则,更换大卡将变得非常缓慢。

但是我们如何知道要更新哪个片段?最简单的方法是让每个单元都知道它属于哪个片段。然后,当更改此单元格时,该单元格将能够更新其片段。因此,让我们给出HexCell其片段链接。

  public HexGridChunk chunk; 

HexGridChunk 可以在添加时将自身添加到单元格中。

  public void AddCell (int index, HexCell cell) { cells[index] = cell; cell.chunk = this; cell.transform.SetParent(transform, false); cell.uiRect.SetParent(gridCanvas.transform, false); } 

通过连接它们,我们将添加到HexCell方法中Refresh每次更新单元时,它只会更新其片段。

  void Refresh () { chunk.Refresh(); } 

我们不需要使其HexCell.Refresh通用,因为单元本身在更改时知道得更多。例如,更改其高度后。

  public int Elevation { get { return elevation; } set { … Refresh(); } } 

实际上,仅当它的高度更改为其他值时,我们才需要更新它。如果我们为她分配与以前相同的身高,她甚至不需要重新计算任何东西。因此,我们可以退出设置器的开头。

  public int Elevation { get { return elevation; } set { if (elevation == value) { return; } … } } 

但是,当高度设置为0时,我们也会第一次跳过计算,因为这是默认的网格高度值。为了避免这种情况,我们将使用从未使用过的初始值。

  int elevation = int.MinValue; 

什么是int.MinValue?
, integer. C# integer —
32- , 2 32 integer, , . .

— −2 31 = −2 147 483 648. !

2 31 − 1 = 2 147 483 647. 2 31 - .

为了识别单元格的颜色变化,我们还需要将其变成一个属性。将其重命名为Color大写,然后将其转换为带有私有变量的属性color默认颜色值将是透明的黑色,这适合我们。

  public Color Color { get { return color; } set { if (color == value) { return; } color = value; Refresh(); } } Color color; 

现在,当我们开始播放模式时,我们会得到空引用异常。发生这种情况是因为我们在将单元格分配给其片段之前将颜色和高度设置为其默认值。通常不要在此阶段更新片段,因为在所有初始化完成后我们会对它们进行三角测量。换句话说,只有分配了片段,我们才会更新片段。

  void Refresh () { if (chunk) { chunk.Refresh(); } } 

我们终于可以再次更改单元格了!但是,出现问题。沿碎片的边界绘制时,会出现接缝。


片段边界处的错误。

这是合乎逻辑的,因为当单个单元更改时,与其相邻单元的所有连接也会更改。这些邻居可能处于其他碎片中。最简单的解决方案是更新所有相邻小区(如果它们不同)。

  void Refresh () { if (chunk) { chunk.Refresh(); for (int i = 0; i < neighbors.Length; i++) { HexCell neighbor = neighbors[i]; if (neighbor != null && neighbor.chunk != chunk) { neighbor.chunk.Refresh(); } } } } 

尽管这行得通,但事实证明我们多次更新了一个片段。当我们开始一次为多个单元着色时,一切都会变得更糟。

但是,我们不需要在更新片段后立即进行三角剖分。取而代之的是,我们只写需要更新,并在更改完成后进行三角剖分。

由于它HexGridChunk什么都不做,因此我们可以使用其启用状态来表示需要更新。更新时,我们将包括该组件。多次打开它不会改变任何东西。该组件随后被更新。我们将在此处进行三角剖分,然后再次禁用该组件。

我们LateUpdate改用Update 以确保在完成当前帧的更改后发生三角剖分。

  public void Refresh () { // hexMesh.Triangulate(cells); enabled = true; } void LateUpdate () { hexMesh.Triangulate(cells); enabled = false; } 

Update和LateUpdate有什么区别?
Update - . LateUpdate . , .

由于默认情况下启用了我们的组件,因此我们不再需要在中显式进行三角剖分Start因此,可以删除此方法。

 // void Start () { // hexMesh.Triangulate(cells); // } 


20 x 20的片段包含10,000个细胞。

广义列表


尽管我们已经大大改变了三角剖分网格的方式,HexMesh但是仍然保持不变。他需要做的只是一排牢房。他不在乎是否有一个或多个六边形网格。但是我们尚未考虑使用多个网格。也许可以在这里有所改善?

所使用的HexMesh列表本质上是临时缓冲区。它们仅用于三角剖分。碎片一次被三角剖分。因此,实际上,我们只需要一组列表,而对于每个六角形网格对象就不需要一组列表。这可以通过使列表为静态来实现。

  static List<Vector3> vertices = new List<Vector3>(); static List<Color> colors = new List<Color>(); static List<int> triangles = new List<int>(); void Awake () { GetComponent<MeshFilter>().mesh = hexMesh = new Mesh(); meshCollider = gameObject.AddComponent<MeshCollider>(); hexMesh.name = "Hex Mesh"; // vertices = new List<Vector3>(); // colors = new List<Color>(); // triangles = new List<int>(); } 

静态列表真的那么重要吗?
. , , .

, . 20 20 100.

统一包装

相机控制


大型相机很棒,但是如果我们看不到它,那就没用了。要检查整个地图,我们需要移动相机。缩放也很有用。因此,让我们创建一个相机来执行这些操作。

创建一个虚拟对象,并将其称为Hex Map Camera。放下其变换分量,以使其移至原点,而无需更改其旋转和比例。向其添加一个名为Swivel的子项,并向其添加Stick子项。将主相机设为Stick的子代,然后重置其变换组件。


摄像机的层次结构。

相机铰链(旋转)的目的是控制相机看地图的角度。让我们转一下(45,0,0)。手柄(Stick)控制摄像机的放置距离。让我们为她设置一个位置(0,0,-45)。

现在我们需要一个组件来控制该系统。将此组件分配给摄像机层次结构的根。给他一个铰链和手柄的链接,将其插入Awake

 using UnityEngine; public class HexMapCamera : MonoBehaviour { Transform swivel, stick; void Awake () { swivel = transform.GetChild(0); stick = swivel.GetChild(0); } } 


六角地图相机。

变倍


我们将创建的第一个功能是缩放(zoom)。我们可以使用float变量控制当前的缩放级别。值0表示我们完全相距遥远,而值1表示我们完全相距遥远。让我们从最大缩放开始。

  float zoom = 1f; 

通常使用鼠标滚轮或模拟控件进行缩放。我们可以使用默认的Mouse ScrollWheel输入轴来实现它添加一种Update检查输入增量是否存在的方法,如果存在,则调用缩放更改方法。

  void Update () { float zoomDelta = Input.GetAxis("Mouse ScrollWheel"); if (zoomDelta != 0f) { AdjustZoom(zoomDelta); } } void AdjustZoom (float delta) { } 

要更改缩放级别,我们只需向其添加一个增量,然后将值(钳位)限制在0–1范围内即可。

  void AdjustZoom (float delta) { zoom = Mathf.Clamp01(zoom + delta); } 

放大和缩小时,到相机的距离应相应改变。这可以通过在Z中更改手柄的位置来完成。添加两个常见的float变量以最小和最大缩放来调整手柄的位置。由于我们正在开发一个相对较小的地图,因此请将值设置为-250和-45。

  public float stickMinZoom, stickMaxZoom; 

更改缩放后,我们将根据新的缩放值在这两个值之间执行线性插值。然后更新手柄的位置。

  void AdjustZoom (float delta) { zoom = Mathf.Clamp01(zoom + delta); float distance = Mathf.Lerp(stickMinZoom, stickMaxZoom, zoom); stick.localPosition = new Vector3(0f, 0f, distance); } 



最小和最大摇杆值。

现在可以进行缩放,但是到目前为止,它并不是很有用。通常,当变焦距离更远时,相机进入顶视图。我们可以通过旋转铰链来实现。因此,我们为铰链添加变量min和max。让我们为它们设置值90和45。

  public float swivelMinZoom, swivelMaxZoom; 

与手柄位置一样,我们进行插值以找到合适的缩放角度。然后我们设置铰链的旋转。

  void AdjustZoom (float delta) { zoom = Mathf.Clamp01(zoom + delta); float distance = Mathf.Lerp(stickMinZoom, stickMaxZoom, zoom); stick.localPosition = new Vector3(0f, 0f, distance); float angle = Mathf.Lerp(swivelMinZoom, swivelMaxZoom, zoom); swivel.localRotation = Quaternion.Euler(angle, 0f, 0f); } 



旋转的最小值和最大值。

可以通过更改鼠标滚轮输入参数的灵敏度来调整缩放比例的变化率。它们可以在“ 编辑” /“项目设置” /“输入”中找到例如,将其从0.1更改为0.025,我们得到的缩放速度会变得更慢,更平滑。


鼠标滚轮输入选项。

搬家


现在让我们继续移动相机。Update与缩放一样,我们必须在中执行X和Z方向的移动我们可以为此使用水平垂直输入轴这将使我们能够使用箭头和WASD键移动相机。

  void Update () { float zoomDelta = Input.GetAxis("Mouse ScrollWheel"); if (zoomDelta != 0f) { AdjustZoom(zoomDelta); } float xDelta = Input.GetAxis("Horizontal"); float zDelta = Input.GetAxis("Vertical"); if (xDelta != 0f || zDelta != 0f) { AdjustPosition(xDelta, zDelta); } } void AdjustPosition (float xDelta, float zDelta) { } 

最简单的方法是获取摄像头系统的当前位置,将增量X和Z添加到其中,然后将结果分配给系统位置。

  void AdjustPosition (float xDelta, float zDelta) { Vector3 position = transform.localPosition; position += new Vector3(xDelta, 0f, zDelta); transform.localPosition = position; } 

因此,相机将在按住箭头或WASD的同时移动,但速度不会保持恒定。这将取决于帧速率。为了确定您需要移动的距离,我们使用时间增量以及所需的速度。因此,我们添加一个公共变量moveSpeed并将其设置为100,然后将其乘以时间增量以获得位置增量。

  public float moveSpeed; void AdjustPosition (float xDelta, float zDelta) { float distance = moveSpeed * Time.deltaTime; Vector3 position = transform.localPosition; position += new Vector3(xDelta, 0f, zDelta) * distance; transform.localPosition = position; } 


移动速度。

现在我们可以沿X轴或Z轴以恒定的速度移动,但是当同时(对角)沿两个轴移动时,移动速度会更快。为了解决这个问题,我们需要对增量向量进行归一化。这将使您可以将其用作目的地。

  void AdjustPosition (float xDelta, float zDelta) { Vector3 direction = new Vector3(xDelta, 0f, zDelta).normalized; float distance = moveSpeed * Time.deltaTime; Vector3 position = transform.localPosition; position += direction * distance; transform.localPosition = position; } 

现在可以正确实现对角线移动,但是突然发现,即使松开所有键,相机仍会继续移动相当长的时间。发生这种情况是因为在按下键后输入轴不会立即跳到极限值。他们需要一些时间。释放密钥也是如此。返回零轴值需要花费时间。但是,由于我们对输入值进行了归一化,因此始终保持最大速度。

我们可以调整输入参数来消除延迟,但是它们给人以平滑感,值得节省。我们可以将轴的最极端值用作运动的阻尼系数。

  Vector3 direction = new Vector3(xDelta, 0f, zDelta).normalized; float damping = Mathf.Max(Mathf.Abs(xDelta), Mathf.Abs(zDelta)); float distance = moveSpeed * damping * Time.deltaTime; 


运动衰减。

现在,该机芯运行良好,至少在放大时有效。但相距甚远,事实证明它太慢了。缩小变焦后,我们需要加快速度。这可以通过以下方式完成:将一个变量替换moveSpeed为两个变量以实现最小和最大缩放,然后进行插值。为它们分配值400和100。

 // public float moveSpeed; public float moveSpeedMinZoom, moveSpeedMaxZoom; void AdjustPosition (float xDelta, float zDelta) { Vector3 direction = new Vector3(xDelta, 0f, zDelta).normalized; float damping = Mathf.Max(Mathf.Abs(xDelta), Mathf.Abs(zDelta)); float distance = Mathf.Lerp(moveSpeedMinZoom, moveSpeedMaxZoom, zoom) * damping * Time.deltaTime; Vector3 position = transform.localPosition; position += direction * distance; transform.localPosition = position; } 



移动速度随变焦级别而变化。

现在我们可以快速在地图上移动!实际上,我们可以远远超出地图,但这是不可取的。相机应留在地图内。为了确保这一点,我们需要知道地图的边界,因此需要一个到网格的链接。添加并连接。

  public HexGrid grid; 


需要要求网格大小。

移到新位置后,我们将使用新方法对其进行限制。

  void AdjustPosition (float xDelta, float zDelta) { … transform.localPosition = ClampPosition(position); } Vector3 ClampPosition (Vector3 position) { return position; } 

位置X的最小值为0,最大值取决于地图的大小。

  Vector3 ClampPosition (Vector3 position) { float xMax = grid.chunkCountX * HexMetrics.chunkSizeX * (2f * HexMetrics.innerRadius); position.x = Mathf.Clamp(position.x, 0f, xMax); return position; } 

位置Z同样如此。

  Vector3 ClampPosition (Vector3 position) { float xMax = grid.chunkCountX * HexMetrics.chunkSizeX * (2f * HexMetrics.innerRadius); position.x = Mathf.Clamp(position.x, 0f, xMax); float zMax = grid.chunkCountZ * HexMetrics.chunkSizeZ * (1.5f * HexMetrics.outerRadius); position.z = Mathf.Clamp(position.z, 0f, zMax); return position; } 

实际上,这有点不准确。起点在单元格的中心,而不是在左侧。因此,我们希望相机停在最右边单元格的中心。为此,请从X的最大值中减去一半的单元格。

  float xMax = (grid.chunkCountX * HexMetrics.chunkSizeX - 0.5f) * (2f * HexMetrics.innerRadius); position.x = Mathf.Clamp(position.x, 0f, xMax); 

出于同样的原因,我们需要减小最大Z。由于指标略有不同,因此我们需要减去整个单元格。

  float zMax = (grid.chunkCountZ * HexMetrics.chunkSizeZ - 1) * (1.5f * HexMetrics.outerRadius); position.z = Mathf.Clamp(position.z, 0f, zMax); 

随着运动的完成,仅剩一个很小的细节。有时,UI对箭头键做出反应,这导致以下事实:当您移动相机时,滑块也会移动。当UI认为自己处于活动状态时,在您单击它并且光标继续位于其上方时,会发生这种情况。

您可以防止UI监听键盘输入。这可以通过指示EventSystem对象不执行“ 发送导航事件”来完成


没有更多的导航事件。

转弯


想看看悬崖后面是什么吗?能够旋转相机会很方便!让我们添加此功能。

缩放级别对于旋转并不重要,仅速度就足够了。添加一个公共变量rotationSpeed并将其设置为180度。Update通过对“ 旋转”轴进行采样并在必要时更改旋转来检查旋转增量

  public float rotationSpeed; void Update () { float zoomDelta = Input.GetAxis("Mouse ScrollWheel"); if (zoomDelta != 0f) { AdjustZoom(zoomDelta); } float rotationDelta = Input.GetAxis("Rotation"); if (rotationDelta != 0f) { AdjustRotation(rotationDelta); } float xDelta = Input.GetAxis("Horizontal"); float zDelta = Input.GetAxis("Vertical"); if (xDelta != 0f || zDelta != 0f) { AdjustPosition(xDelta, zDelta); } } void AdjustRotation (float delta) { } 



转弯速度。

实际上,“ 旋转”默认情况下不是。我们将不得不自己创建它。转到输入参数,并复制最上面的条目Vertical将重复项的名称更改为Rotation,并将键更改为QE和带句点(。)的逗号(,)。


旋转输入轴。

我下载了unitypackage,为什么我没有此输入?
. Unity. , . , , .

我们将跟踪和改变的旋转角度AdjustRotation之后,我们将旋转整个摄像机系统。

  float rotationAngle; void AdjustRotation (float delta) { rotationAngle += delta * rotationSpeed * Time.deltaTime; transform.localRotation = Quaternion.Euler(0f, rotationAngle, 0f); } 

由于整个圆为360度,因此我们旋转旋转角度,使其在0到360之间。

  void AdjustRotation (float delta) { rotationAngle += delta * rotationSpeed * Time.deltaTime; if (rotationAngle < 0f) { rotationAngle += 360f; } else if (rotationAngle >= 360f) { rotationAngle -= 360f; } transform.localRotation = Quaternion.Euler(0f, rotationAngle, 0f); } 


行动起来。

现在旋转工作了。如果选中它,则可以看到运动是绝对的。因此,在旋转180度之后,运动将与预期相反。对于用户而言,相对于摄像机的视角进行移动将更加方便。我们可以通过将当前旋转乘以运动方向来实现。

  void AdjustPosition (float xDelta, float zDelta) { Vector3 direction = transform.localRotation * new Vector3(xDelta, 0f, zDelta).normalized; … } 


相对位移。

统一包装

进阶编辑


现在我们有了更大的地图,您可以改进地图编辑工具。一次更改一个单元格太长,因此最好创建更大的笔刷。如果您可以选择绘画或更改高度,而其他一切都保持不变,这也将很方便。

可选的颜色和高度


我们可以通过在切换组中添加一个空的选择选项来使颜色可选。复制一个颜色切换器,并将其标签替换为---或类似的标记,以表明它不是颜色。然后将其On Value Changed事件的参数更改为-1。


无效的颜色索引。

当然,该索引对于颜色数组无效。我们可以使用它来确定是否应将颜色应用于单元格。

  bool applyColor; public void SelectColor (int index) { applyColor = index >= 0; if (applyColor) { activeColor = colors[index]; } } void EditCell (HexCell cell) { if (applyColor) { cell.Color = activeColor; } cell.Elevation = activeElevation; } 

高度由滑块控制,因此我们无法为其添加开关。相反,我们可以使用单独的开关来启用或禁用高度编辑。默认情况下,它将被启用。

  bool applyElevation = true; void EditCell (HexCell cell) { if (applyColor) { cell.Color = activeColor; } if (applyElevation) { cell.Elevation = activeElevation; } } 

在用户界面中添加一个新的高度开关。我还将所有内容放置在新面板上,并使高度滑块水平,以便UI更加美观。


可选的颜色和高度。

要启用高度,我们需要一个新方法,该方法将与UI连接。

  public void SetApplyElevation (bool toggle) { applyElevation = toggle; } 

通过将其连接到高度开关,请确保在方法列表的顶部使用了动态布尔方法。正确的版本不会在检查器中显示选中标记。


我们传送高度开关的状态。

现在,我们只能选择花朵着色或高度着色。或两者都照常。我们甚至可以选择不更改任何一个,但是到目前为止,这对我们并不是特别有用。


在颜色和高度之间切换。

为什么选择颜色时高度会关闭?
, toggle group. , , toggle group.

刷子大小


要支持可调整大小的笔刷大小,请添加一个整数变量brushSize和一种通过UI进行设置的方法。我们将使用滑块,因此再次必须将值从float转换为int。

  int brushSize; public void SetBrushSize (float size) { brushSize = (int)size; } 


笔刷大小滑块。

您可以通过复制高度滑块来创建新的滑块。将其最大值更改为4并将其附加到相应的方法。我还为他添加了标签。


笔刷大小滑块设置。

现在我们可以同时编辑多个单元格,我们需要使用方法EditCells此方法将调用EditCell所有涉及的单元格。最初选择的单元格将被视为画笔的中心。

  void HandleInput () { Ray inputRay = Camera.main.ScreenPointToRay(Input.mousePosition); RaycastHit hit; if (Physics.Raycast(inputRay, out hit)) { EditCells(hexGrid.GetCell(hit.point)); } } void EditCells (HexCell center) { } void EditCell (HexCell cell) { … } 

笔刷的大小决定了编辑的半径。半径为0时,这将只是一个中央单元。半径为1时,它将是中心及其邻居。半径为2时,中心的邻居及其直接邻居打开。依此类推。


半径不超过3。

要编辑单元格,您需要循环移动它们。首先,我们需要中心的X和Z坐标。

  void EditCells (HexCell center) { int centerX = center.coordinates.X; int centerZ = center.coordinates.Z; } 

我们通过减去半径找到最小的Z坐标。因此,我们定义了零线。从这条线开始,我们一直循环直到覆盖中心的线。

  void EditCells (HexCell center) { int centerX = center.coordinates.X; int centerZ = center.coordinates.Z; for (int r = 0, z = centerZ - brushSize; z <= centerZ; z++, r++) { } } 

底部行中的第一个单元格具有与中心单元格相同的X坐标。该坐标随着行数的增加而减小。

最后一个像元的X坐标始终等于中心坐标加半径。

现在我们可以在每一行中循环并通过其坐标获取单元格。

  for (int r = 0, z = centerZ - brushSize; z <= centerZ; z++, r++) { for (int x = centerX - r; x <= centerX + brushSize; x++) { EditCell(hexGrid.GetCell(new HexCoordinates(x, z))); } } 

我们还没有一个HexGrid.GetCell带有坐标参数的方法,因此创建它。转换为位移的坐标并获取像元。

  public HexCell GetCell (HexCoordinates coordinates) { int z = coordinates.Z; int x = coordinates.X + z / 2; return cells[x + z * cellCountX]; } 


画笔的下部,大小为2。

我们覆盖了画笔的其余部分,从顶部到底部到中心执行一个循环。在这种情况下,逻辑将被镜像,并且中央行需要排除。

  void EditCells (HexCell center) { int centerX = center.coordinates.X; int centerZ = center.coordinates.Z; for (int r = 0, z = centerZ - brushSize; z <= centerZ; z++, r++) { for (int x = centerX - r; x <= centerX + brushSize; x++) { EditCell(hexGrid.GetCell(new HexCoordinates(x, z))); } } for (int r = 0, z = centerZ + brushSize; z > centerZ; z--, r++) { for (int x = centerX - brushSize; x <= centerX + r; x++) { EditCell(hexGrid.GetCell(new HexCoordinates(x, z))); } } } 


整个笔刷,大小为2。

除非我们的笔刷超出网格的边界,否则它将起作用。发生这种情况时,我们得到索引超出范围的异常。为避免这种情况,请检查边界HexGrid.GetCellnull在请求不存在的单元格时返回

  public HexCell GetCell (HexCoordinates coordinates) { int z = coordinates.Z; if (z < 0 || z >= cellCountZ) { return null; } int x = coordinates.X + z / 2; if (x < 0 || x >= cellCountX) { return null; } return cells[x + z * cellCountX]; } 

为了避免null-reference-exception,它HexMapEditor必须在编辑单元格是否确实存在之前进行检查。

  void EditCell (HexCell cell) { if (cell) { if (applyColor) { cell.Color = activeColor; } if (applyElevation) { cell.Elevation = activeElevation; } } } 


使用多种画笔大小。

切换单元格标签可见性


通常,我们不需要查看单元格标签。因此,让它们成为可选的。由于每件管理自己的画布,添加一个方法ShowUIHexGridChunk当用户界面应该可见时,我们激活画布。否则,将其禁用。

  public void ShowUI (bool visible) { gridCanvas.gameObject.SetActive(visible); } 

让我们默认隐藏用户界面。

  void Awake () { gridCanvas = GetComponentInChildren<Canvas>(); hexMesh = GetComponentInChildren<HexMesh>(); cells = new HexCell[HexMetrics.chunkSizeX * HexMetrics.chunkSizeZ]; ShowUI(false); } 

由于用户界面的可见性已在整个地图上切换,因此我们将方法添加ShowUIHexGrid它只是将请求传递给它的片段。

  public void ShowUI (bool visible) { for (int i = 0; i < chunks.Length; i++) { chunks[i].ShowUI(visible); } } 

HexMapEditor 获取相同的方法,将请求传递到网格。

  public void ShowUI (bool visible) { hexGrid.ShowUI(visible); } 

最后,我们可以将开关添加到UI并将其连接。


标签可见性开关。

统一包装

第六部分:河流


  • 在单元格中增加河流。
  • 拖放支持绘制河流。
  • 创建河床。
  • 每个片段使用多个网格。
  • 创建一个共享列表池。
  • 流水的三角剖分和动画处理。

在上一部分中,我们讨论了支持大型地图。现在,我们可以继续讨论较大的救济要素。这次我们将谈论河流。


河流从山上流出。

河流细胞


有三种方法可以将河流添加到六边形网格中。第一种方法是让它们从一个单元流到另一个单元。这就是《无尽传说》中的实现方式。第二种方法是允许它们在细胞之间从边缘到边缘流动。因此,它是在《文明5》中实施的。因此,河流是在奇幻时代3中实现的。

在我们的案例中,单元的边缘已经被斜坡和悬崖所占据。这给河流留下了很小的空间。因此,我们将使它们从一个单元流向另一个单元。这意味着在每个单元格中要么没有河流,要么沿着河流流动,或者在其中有河流的起点或终点。在河流所沿的那些小室中,河流可以笔直地流,转一两步。


五种可能的河流配置。

我们将不支持分支或合并河流。这将使事情变得更加复杂,尤其是水流。此外,我们也不会因大量的水而感到困惑。我们将在另一个教程中考虑它们。

河流追踪


河流沿其流动的单元可以同时视为有流入和流出的河流。如果它包含一条河流的起点,那么它只有一条流出的河流。如果它包含河流的尽头,那么它只有一条流入的河流。我们可以HexCell使用两个布尔值存储此信息

  bool hasIncomingRiver, hasOutgoingRiver; 

但这还不够。我们还需要知道这些河流的方向。如果是流出的河流,则指示河流在向何处移动。如果是入河,则指示出河源。

  bool hasIncomingRiver, hasOutgoingRiver; HexDirection incomingRiver, outgoingRiver; 

在对单元格进行三角剖分时,我们将需要此信息,因此我们将添加属性以对其进行访问。我们不支持直接分配它们。为此,我们将进一步添加一个单独的方法。

  public bool HasIncomingRiver { get { return hasIncomingRiver; } } public bool HasOutgoingRiver { get { return hasOutgoingRiver; } } public HexDirection IncomingRiver { get { return incomingRiver; } } public HexDirection OutgoingRiver { get { return outgoingRiver; } } 

一个重要的问题是,无论细节如何,牢房中是否都有河流。因此,我们还要为此添加一个属性。

  public bool HasRiver { get { return hasIncomingRiver || hasOutgoingRiver; } } 

另一个逻辑问题:是单元格中河流的起点或终点。如果流入河和流出河的状态不同,那么情况就是这样。因此,我们将使它成为另一个属性。

  public bool HasRiverBeginOrEnd { get { return hasIncomingRiver != hasOutgoingRiver; } } 

最后,了解河流是否流过某个山脊(是流入还是流出)将很有用。

  public bool HasRiverThroughEdge (HexDirection direction) { return hasIncomingRiver && incomingRiver == direction || hasOutgoingRiver && outgoingRiver == direction; } 

除河


在开始向单元格添加河流之前,让我们首先实现对河流清除的支持。首先,我们将编写一种仅删除河流出水部分的方法。

如果该单元中没有流出的河流,则无需执行任何操作。否则,将其关闭并执行更新。

  public void RemoveOutgoingRiver () { if (!hasOutgoingRiver) { return; } hasOutgoingRiver = false; Refresh(); } 

但这还不是全部。流出的河流必须继续前进。因此,流入的河流一定要有邻居。我们也需要摆脱她。

  public void RemoveOutgoingRiver () { if (!hasOutgoingRiver) { return; } hasOutgoingRiver = false; Refresh(); HexCell neighbor = GetNeighbor(outgoingRiver); neighbor.hasIncomingRiver = false; neighbor.Refresh(); } 

河流不能从地图上流出吗?
, . , .

从单元格中删除河流只会更改该单元格的外观。与编辑高度或颜色不同,它不会影响邻居。因此,我们只需要更新单元本身,而不更新其邻居。

  public void RemoveOutgoingRiver () { if (!hasOutgoingRiver) { return; } hasOutgoingRiver = false; RefreshSelfOnly(); HexCell neighbor = GetNeighbor(outgoingRiver); neighbor.hasIncomingRiver = false; neighbor.RefreshSelfOnly(); } 

此方法RefreshSelfOnly只是更新单元格所属的片段。由于我们在网格初始化期间不更改河流,因此我们不必担心是否已经分配了片段。

  void RefreshSelfOnly () { chunk.Refresh(); } 

删除流入的河流的方法相同。

  public void RemoveIncomingRiver () { if (!hasIncomingRiver) { return; } hasIncomingRiver = false; RefreshSelfOnly(); HexCell neighbor = GetNeighbor(incomingRiver); neighbor.hasOutgoingRiver = false; neighbor.RefreshSelfOnly(); } 

整条河流的清除仅意味着清除河流的入水和出水部分。

  public void RemoveRiver () { RemoveOutgoingRiver(); RemoveIncomingRiver(); } 

增加河流


为了支持河流的创建,我们需要一种方法来指定单元格的流出河流。他必须重新定义所有先前的流出河流并设置相应的流入河流。

首先,如果河流已经存在,我们不需要做任何事情。

  public void SetOutgoingRiver (HexDirection direction) { if (hasOutgoingRiver && outgoingRiver == direction) { return; } } 

接下来,我们需要确保在正确的方向上有一个邻居。另外,河流不能向上流动。因此,如果邻居更高,我们必须完成操作。

  HexCell neighbor = GetNeighbor(direction); if (!neighbor || elevation < neighbor.elevation) { return; } 

接下来,我们需要清除先前的流出河。而且,如果流入的河流叠加在新流出的河流上,我们还需要删除流入的河流。

  RemoveOutgoingRiver(); if (hasIncomingRiver && incomingRiver == direction) { RemoveIncomingRiver(); } 

现在我们可以继续设置流出的河流了。

  hasOutgoingRiver = true; outgoingRiver = direction; RefreshSelfOnly(); 

并且,如果存在,请不要忘记在删除其他单元格后为其设置输入河流。

  neighbor.RemoveIncomingRiver(); neighbor.hasIncomingRiver = true; neighbor.incomingRiver = direction.Opposite(); neighbor.RefreshSelfOnly(); 

摆脱河流泛滥


现在,我们已经可以仅添加正确的河流,其他操作仍然可以创建错误的河流。当我们改变细胞的高度时,我们必须再次确保河流只能顺流而下。必须清除所有不规则的河流。

  public int Elevation { get { return elevation; } set { … if ( hasOutgoingRiver && elevation < GetNeighbor(outgoingRiver).elevation ) { RemoveOutgoingRiver(); } if ( hasIncomingRiver && elevation > GetNeighbor(incomingRiver).elevation ) { RemoveIncomingRiver(); } Refresh(); } } 

统一包装

换河


为了支持河流编辑,我们需要在用户界面中添加河流开关。其实 我们需要支持三种编辑模式。我们需要忽略河流,或者添加河流,或者删除河流。我们可以使用开关的简单帮助程序枚举来跟踪状态。由于我们将仅在编辑器内部使用它,因此我们可以在class内HexMapEditor以及river模式字段中定义它

  enum OptionalToggle { Ignore, Yes, No } OptionalToggle riverMode; 

我们需要一种通过UI更改河流政权的方法。

  public void SetRiverMode (int mode) { riverMode = (OptionalToggle)mode; } 

要控制河流状态,请像在颜色上一样,将三个开关添加到UI并将它们连接到新的切换组。我配置了开关,使它们的标签位于复选框下方。因此,它们将保持足够薄以适合所有三个选项。


UI河

为什么不使用下拉列表?
, . dropdown list Unity Play. , .

拖放识别


要创建一条河流,我们需要一个单元和一个方向。目前,HexMapEditor尚未向我们提供此信息。因此,我们需要将拖放支持从一个单元格添加到另一个单元格。

我们需要知道这种拖动是否正确,并确定其方向。为了识别拖放,我们需要记住上一个单元格。

  bool isDrag; HexDirection dragDirection; HexCell previousCell; 

最初,当不执行拖动时,不执行上一个单元格。也就是说,当没有输入或我们不与卡互动时,您需要为其分配一个值null

  void Update () { if ( Input.GetMouseButton(0) && !EventSystem.current.IsPointerOverGameObject() ) { HandleInput(); } else { previousCell = null; } } void HandleInput () { Ray inputRay = Camera.main.ScreenPointToRay(Input.mousePosition); RaycastHit hit; if (Physics.Raycast(inputRay, out hit)) { EditCells(hexGrid.GetCell(hit.point)); } else { previousCell = null; } } 

当前单元格是通过将光束与网格相交而找到的单元格。编辑单元格后,它会被更新并成为新更新的前一个单元格。

  void HandleInput () { Ray inputRay = Camera.main.ScreenPointToRay(Input.mousePosition); RaycastHit hit; if (Physics.Raycast(inputRay, out hit)) { HexCell currentCell = hexGrid.GetCell(hit.point); EditCells(currentCell); previousCell = currentCell; } else { previousCell = null; } } 

确定当前单元格后,我们可以将其与上一个单元格(如果有)进行比较。如果我们得到两个不同的单元格,则可能是正确的拖放操作,需要进行检查。否则,这绝对不是拖放。

  if (Physics.Raycast(inputRay, out hit)) { HexCell currentCell = hexGrid.GetCell(hit.point); if (previousCell && previousCell != currentCell) { ValidateDrag(currentCell); } else { isDrag = false; } EditCells(currentCell); previousCell = currentCell; isDrag = true; } 

我们如何检查拖放?检查当前单元格是否是前一个的邻居。我们通过周期性地绕过其邻居来进行检查。如果找到匹配项,我们也会立即识别出拖动的方向。

  void ValidateDrag (HexCell currentCell) { for ( dragDirection = HexDirection.NE; dragDirection <= HexDirection.NW; dragDirection++ ) { if (previousCell.GetNeighbor(dragDirection) == currentCell) { isDrag = true; return; } } isDrag = false; } 

我们会产生生涩的阻力吗?
, . «» , .

, . .

更换细胞


现在我们可以识别拖放了,我们可以定义流出的河流了。我们还可以删除河流;为此,不需要拖放支持。

  void EditCell (HexCell cell) { if (cell) { if (applyColor) { cell.Color = activeColor; } if (applyElevation) { cell.Elevation = activeElevation; } if (riverMode == OptionalToggle.No) { cell.RemoveRiver(); } else if (isDrag && riverMode == OptionalToggle.Yes) { previousCell.SetOutgoingRiver(dragDirection); } } } 

此代码将从前一个单元格绘制到当前单元格。但是他忽略了刷子的大小。这是很合逻辑的,但是让我们为所有被画笔关闭的单元格绘制河流。这可以通过对编辑的单元格执行操作来完成。在我们的情况下,我们需要确保确实存在另一个单元格。

  else if (isDrag && riverMode == OptionalToggle.Yes) { HexCell otherCell = cell.GetNeighbor(dragDirection.Opposite()); if (otherCell) { otherCell.SetOutgoingRiver(dragDirection); } } 

现在,我们可以编辑河流,但看不到它们。我们可以通过在调试检查器中检查修改后的单元来验证此方法是否有效。


在调试检查器中带有河流的单元格。

什么是调试检查器?
. . , .

统一包装

细胞间的河床


在对河流进行三角剖分时,我们需要考虑两个部分:河床的位置和流经河床的水。首先,我们将创建一个通道,并将水留待以后使用。

河流最简单的部分是河流在小室之间的交汇处。当我们用三个四边形条对该区域进行三角测量时。我们可以通过降低中间四边形并添加两个通道墙来为其添加河床。


在肋条上添加一条河流。

为此,在河流的情况下,将需要两个附加的四边形,并创建一个具有两个垂直壁的河道。另一种方法是使用四个四边形。然后,我们降低中间峰以创建具有倾斜墙的床。


始终为四个四边形。

恒定使用相同数量的四边形很方便,因此让我们选择此选项。

添加边缘顶部


每个边缘从三到四的过渡要求创建边缘的附加顶点。我们EdgeVertices首先重命名v4v5,然后重命名v3为进行重写v4按此顺序执行的操作可确保所有代码继续引用正确的顶点。为此,请使用编辑器的重命名或重构选项,以使更改适用于所有地方。否则,您将必须手动检查整个代码并进行更改。

  public Vector3 v1, v2, v4, v5; 

重命名所有内容后,添加一个新的v3

  public Vector3 v1, v2, v3, v4, v5; 

向构造函数添加一个新顶点。它位于拐角峰之间的中间。此外,其他顶点现在应位于½和¾中,而不应位于&frac13;中。和&frac23;。

  public EdgeVertices (Vector3 corner1, Vector3 corner2) { v1 = corner1; v2 = Vector3.Lerp(corner1, corner2, 0.25f); v3 = Vector3.Lerp(corner1, corner2, 0.5f); v4 = Vector3.Lerp(corner1, corner2, 0.75f); v5 = corner2; } 

在中添加v3TerraceLerp

  public static EdgeVertices TerraceLerp ( EdgeVertices a, EdgeVertices b, int step) { EdgeVertices result; result.v1 = HexMetrics.TerraceLerp(a.v1, b.v1, step); result.v2 = HexMetrics.TerraceLerp(a.v2, b.v2, step); result.v3 = HexMetrics.TerraceLerp(a.v3, b.v3, step); result.v4 = HexMetrics.TerraceLerp(a.v4, b.v4, step); result.v5 = HexMetrics.TerraceLerp(a.v5, b.v5, step); return result; } 

现在,我HexMesh必须在肋的扇形三角形中包含一个附加顶点。

  void TriangulateEdgeFan (Vector3 center, EdgeVertices edge, Color color) { AddTriangle(center, edge.v1, edge.v2); AddTriangleColor(color); AddTriangle(center, edge.v2, edge.v3); AddTriangleColor(color); AddTriangle(center, edge.v3, edge.v4); AddTriangleColor(color); AddTriangle(center, edge.v4, edge.v5); AddTriangleColor(color); } 

并且在其四边形的条纹中。

  void TriangulateEdgeStrip ( EdgeVertices e1, Color c1, EdgeVertices e2, Color c2 ) { AddQuad(e1.v1, e1.v2, e2.v1, e2.v2); AddQuadColor(c1, c2); AddQuad(e1.v2, e1.v3, e2.v2, e2.v3); AddQuadColor(c1, c2); AddQuad(e1.v3, e1.v4, e2.v3, e2.v4); AddQuadColor(c1, c2); AddQuad(e1.v4, e1.v5, e2.v4, e2.v5); AddQuadColor(c1, c2); } 



每个边缘比较四个和五个顶点。

河床的高度


我们通过降低肋骨的底部顶部来创建通道。它确定河床的垂直位置。尽管每个像元的精确垂直位置会变形,但是在具有相同高度的像元中,我们必须保持河床的高度不变。由于有了这种水,它不必向上游流动。此外,即使在垂直方向上最不规则的单元格中,床也应足够低以保持在下方,同时还要留出足够的水位。

让我们将此偏移量设置为HexMetrics并将其表示为高度。一级偏移就足够了。

  public const float streamBedElevationOffset = -1f; 

我们可以使用该指标添加属性HexCell以获取单元格河床的垂直位置。

  public float StreamBedY { get { return (elevation + HexMetrics.streamBedElevationOffset) * HexMetrics.elevationStep; } } 

建立频道


HexMesh小区的六个三角形部分的三角测量一个,就可以判断是否在其边缘的河流。如果是这样,那么我们可以将肋骨的中间峰降低到河床的高度。

  void Triangulate (HexDirection direction, HexCell cell) { Vector3 center = cell.Position; EdgeVertices e = new EdgeVertices( center + HexMetrics.GetFirstSolidCorner(direction), center + HexMetrics.GetSecondSolidCorner(direction) ); if (cell.HasRiverThroughEdge(direction)) { e.v3.y = cell.StreamBedY; } TriangulateEdgeFan(center, e, cell.Color); if (direction <= HexDirection.SE) { TriangulateConnection(direction, cell, e); } } 


更改肋骨的中间顶点。

我们可以看到这条河的最初迹象是如何出现的,但在浮雕上却出现了洞。要关闭它们,我们需要更改另一个边,然后对连接进行三角测量。

  void TriangulateConnection ( HexDirection direction, HexCell cell, EdgeVertices e1 ) { HexCell neighbor = cell.GetNeighbor(direction); if (neighbor == null) { return; } Vector3 bridge = HexMetrics.GetBridge(direction); bridge.y = neighbor.Position.y - cell.Position.y; EdgeVertices e2 = new EdgeVertices( e1.v1 + bridge, e1.v5 + bridge ); if (cell.HasRiverThroughEdge(direction)) { e2.v3.y = neighbor.StreamBedY; } … } 


完整的肋骨关节通道。

统一包装

河床穿过牢房


现在,我们在单元之间具有正确的河床。但是,当河流流过该单元时,渠道始终在其中心终止。解决这个问题将必须工作。让我们从河流从一条边直接流到另一边的情况开始。

如果没有河流,则单元的每个部分都可以是三角形的简单扇形。但是当河流直接流入时,有必要插入一条河道。实际上,我们需要将中心顶点拉伸为一条线,从而将中间的两个三角形变成四边形。然后三角形的扇形变成梯形。


我们将通道插入三角形。

这样的通道将比通过单元连接的通道长得多。当顶点位置变形时,这变得显而易见。因此,我们通过在中心和边缘之间的中间插入另一组顶点边缘,将梯形分为两段。


通道三角剖分。

由于用河流进行三角剖分与不使用河流进行三角剖分非常不同,因此让我们为它创建一个单独的方法。如果我们有一条河,那么我们将使用这种方法,否则我们将留下一个三角形的扇形。

  void Triangulate (HexDirection direction, HexCell cell) { Vector3 center = cell.Position; EdgeVertices e = new EdgeVertices( center + HexMetrics.GetFirstSolidCorner(direction), center + HexMetrics.GetSecondSolidCorner(direction) ); if (cell.HasRiver) { if (cell.HasRiverThroughEdge(direction)) { e.v3.y = cell.StreamBedY; TriangulateWithRiver(direction, cell, center, e); } } else { TriangulateEdgeFan(center, e, cell.Color); } if (direction <= HexDirection.SE) { TriangulateConnection(direction, cell, e); } } void TriangulateWithRiver ( HexDirection direction, HexCell cell, Vector3 center, EdgeVertices e ) { } 


应该有河流的洞。

为了更好地了解会发生什么,请暂时禁用单元失真。

  public const float cellPerturbStrength = 0f; // 4f; 


峰未失真。

直接通过单元进行三角剖分


要直接通过单元的一部分创建通道,我们需要将中心拉伸成一条线。此线应与通道具有相同的宽度。我们可以通过从中心到上一个部分的第一个角的距离移动1/4来找到左顶点。

  void TriangulateWithRiver ( HexDirection direction, HexCell cell, Vector3 center, EdgeVertices e ) { Vector3 centerL = center + HexMetrics.GetFirstSolidCorner(direction.Previous()) * 0.25f; } 

正确的顶点也是如此。在这种情况下,我们需要下一部分的第二个角。

  Vector3 centerL = center + HexMetrics.GetFirstSolidCorner(direction.Previous()) * 0.25f; Vector3 centerR = center + HexMetrics.GetSecondSolidCorner(direction.Next()) * 0.25f; 

可以通过在中心和边缘之间创建顶点边缘来找到中间线。

  EdgeVertices m = new EdgeVertices( Vector3.Lerp(centerL, e.v1, 0.5f), Vector3.Lerp(centerR, e.v5, 0.5f) ); 

接下来,更改中间肋骨的中间顶点以及中心,因为它们将成为通道的较低点。

  m.v3.y = center.y = e.v3.y; 

现在我们可以TriangulateEdgeStrip用来填充中间线和边缘线之间的空间。

  TriangulateEdgeStrip(m, cell.Color, e, cell.Color); 


压缩通道。

不幸的是,通道看起来很压缩。发生这种情况是因为肋骨的中间顶点彼此太靠近。为什么会这样呢?

如果我们假设外边缘的长度为1,则中心线的长度将为1/2。由于中间边缘位于它们之间的中间,因此其长度应等于¾。

通道宽度为½,应保持恒定。根据&frac18;,由于中间边缘的长度为¾,因此仅剩下¼。在通道的两侧。


相对长度。

由于中间边缘的长度是¾,因此&frac18; 相对于中间肋骨的长度等于&frac16;。这意味着应在第二和第四顶点上插入六分之一而不是四分之一。

我们可以通过添加到EdgeVertices另一个构造函数来为此类替代插值提供支持相反,固定插值v2v4我们使用的选项。

  public EdgeVertices (Vector3 corner1, Vector3 corner2, float outerStep) { v1 = corner1; v2 = Vector3.Lerp(corner1, corner2, outerStep); v3 = Vector3.Lerp(corner1, corner2, 0.5f); v4 = Vector3.Lerp(corner1, corner2, 1f - outerStep); v5 = corner2; } 

现在,我们可以将其与&frac16;一起使用。c HexMesh.TriangulateWithRiver

  EdgeVertices m = new EdgeVertices( Vector3.Lerp(centerL, e.v1, 0.5f), Vector3.Lerp(centerR, e.v5, 0.5f), 1f / 6f ); 


直接渠道。

将通道弄平后,我们可以转到梯形的第二部分。在这种情况下,我们无法使用肋条,因此我们必须手动进行操作。首先让我们在侧面创建三角形。

  AddTriangle(centerL, m.v1, m.v2); AddTriangleColor(cell.Color); AddTriangle(centerR, m.v4, m.v5); AddTriangleColor(cell.Color); 


边三角形。

看起来不错,所以让我们用两个四边形填充剩余空间,创建通道的最后一部分。

  AddTriangle(centerL, m.v1, m.v2); AddTriangleColor(cell.Color); AddQuad(centerL, center, m.v2, m.v3); AddQuadColor(cell.Color); AddQuad(center, centerR, m.v3, m.v4); AddQuadColor(cell.Color); AddTriangle(centerR, m.v4, m.v5); AddTriangleColor(cell.Color); 

实际上,我们没有AddQuadColor只需要一个参数的替代方案虽然我们不需要它。因此,让我们创建它。

  void AddQuadColor (Color color) { colors.Add(color); colors.Add(color); colors.Add(color); colors.Add(color); } 


完成了直接渠道。

开始和结束三角剖分


仅具有河流起点或终点的部分的三角剖分完全不同,因此需要使用自己的方法。因此,我们将签入Triangulate并调用适当的方法。

  if (cell.HasRiver) { if (cell.HasRiverThroughEdge(direction)) { e.v3.y = cell.StreamBedY; if (cell.HasRiverBeginOrEnd) { TriangulateWithRiverBeginOrEnd(direction, cell, center, e); } else { TriangulateWithRiver(direction, cell, center, e); } } } 

在这种情况下,我们想完成中间的通道,但是我们仍然使用两个阶段。因此,我们将再次在中心或边缘之间创建中间边缘。由于我们想完成频道,因此我们很高兴将其压缩。

  void TriangulateWithRiverBeginOrEnd ( HexDirection direction, HexCell cell, Vector3 center, EdgeVertices e ) { EdgeVertices m = new EdgeVertices( Vector3.Lerp(center, e.v1, 0.5f), Vector3.Lerp(center, e.v5, 0.5f) ); } 

为了使河道不会很快变浅,我们将河床的高度分配给中峰。但是中心不需要更改。

  m.v3.y = e.v3.y; 

我们可以用一个肋条和一个风扇进行三角测量。

  TriangulateEdgeStrip(m, cell.Color, e, cell.Color); TriangulateEdgeFan(center, m, cell.Color); 


起点和终点。

一站式转弯


接下来,考虑相邻单元格之间曲折的急剧转弯。我们也会处理它们TriangulateWithRiver因此,我们需要确定正在使用的河流类型。


之字形河。

如果单元格中有一条河沿相反的方向流动,也朝我们的工作方向流动,那么这条河应该是一条直河。在这种情况下,我们可以保存已经计算的中心线。否则,它将返回到一点,并折叠中心线。

  Vector3 centerL, centerR; if (cell.HasRiverThroughEdge(direction.Opposite())) { centerL = center + HexMetrics.GetFirstSolidCorner(direction.Previous()) * 0.25f; centerR = center + HexMetrics.GetSecondSolidCorner(direction.Next()) * 0.25f; } else { centerL = centerR = center; } 


曲折的曲折。

我们可以通过检查单元格是否有一条河流经过单元格的下一部分或上一部分来识别急转弯。如果存在,则需要将中心线与该零件和相邻零件之间的边缘对齐。我们可以通过将线的相应边放在中心和公共角度之间的中间位置来实现。然后,线的另一侧成为中心。

  if (cell.HasRiverThroughEdge(direction.Opposite())) { centerL = center + HexMetrics.GetFirstSolidCorner(direction.Previous()) * 0.25f; centerR = center + HexMetrics.GetSecondSolidCorner(direction.Next()) * 0.25f; } else if (cell.HasRiverThroughEdge(direction.Next())) { centerL = center; centerR = Vector3.Lerp(center, e.v5, 0.5f); } else if (cell.HasRiverThroughEdge(direction.Previous())) { centerL = Vector3.Lerp(center, e.v1, 0.5f); centerR = center; } else { centerL = centerR = center; } 

确定了左点和右点的位置后,我们可以通过对它们进行平均来确定中心。

  if (cell.HasRiverThroughEdge(direction.Opposite())) { … } center = Vector3.Lerp(centerL, centerR, 0.5f); 


中央肋骨偏移。

尽管通道的两侧宽度相同,但看起来很压缩。这是由于将中心线旋转60°引起的。您可以通过稍微增加中心线的宽度来平滑此效果。而不是用½进行插值,我们使用&frac23;。

  else if (cell.HasRiverThroughEdge(direction.Next())) { centerL = center; centerR = Vector3.Lerp(center, e.v5, 2f / 3f); } else if (cell.HasRiverThroughEdge(direction.Previous())) { centerL = Vector3.Lerp(center, e.v1, 2f / 3f); centerR = center; } 


之字形无需压缩。

两阶段转弯


其余的情况介于之字形和直河之间。这是两个阶段的转弯,形成柔和弯曲的河流。


蜿蜒的河。

为了区分两个可能的方向,我们需要使用direction.Next().Next()但是,让我们把它加更方便HexDirection扩展方法Next2Previous2

  public static HexDirection Previous2 (this HexDirection direction) { direction -= 2; return direction >= HexDirection.NE ? direction : (direction + 6); } public static HexDirection Next2 (this HexDirection direction) { direction += 2; return direction <= HexDirection.NW ? direction : (direction - 6); } 

回到HexMesh.TriangulateWithRiver现在我们可以通过识别出蜿蜒的河流的方向direction.Next2()

  if (cell.HasRiverThroughEdge(direction.Opposite())) { centerL = center + HexMetrics.GetFirstSolidCorner(direction.Previous()) * 0.25f; centerR = center + HexMetrics.GetSecondSolidCorner(direction.Next()) * 0.25f; } else if (cell.HasRiverThroughEdge(direction.Next())) { centerL = center; centerR = Vector3.Lerp(center, e.v5, 2f / 3f); } else if (cell.HasRiverThroughEdge(direction.Previous())) { centerL = Vector3.Lerp(center, e.v1, 2f / 3f); centerR = center; } else if (cell.HasRiverThroughEdge(direction.Next2())) { centerL = centerR = center; } else { centerL = centerR = center; } 

在后两种情况下,我们需要将中心线移动到位于曲线内侧的单元格部分。如果我们在实心边缘的中间有一个向量,则可以使用它来定位端点。假设我们有一个方法。

  else if (cell.HasRiverThroughEdge(direction.Next2())) { centerL = center; centerR = center + HexMetrics.GetSolidEdgeMiddle(direction.Next()) * 0.5f; } else { centerL = center + HexMetrics.GetSolidEdgeMiddle(direction.Previous()) * 0.5f; centerR = center; } 

当然,现在我们需要向中添加这样的方法HexMetrics他只需要对相邻角的两个向量求平均值并应用完整性系数。

  public static Vector3 GetSolidEdgeMiddle (HexDirection direction) { return (corners[(int)direction] + corners[(int)direction + 1]) * (0.5f * solidFactor); } 


曲线略微压缩。

现在,我们的中心线已正确旋转30°。但是它们不够长,这就是为什么通道有些压缩的原因。这是因为肋骨的中点比肋骨的角度更靠近中心。它的距离等于内半径,而不是外半径。也就是说,我们的工作规模不正确。

我们已经从处的外部半径转换为内部半径HexMetrics我们需要执行相反的操作。因此,我们可以通过来提供两个转换因子HexMetrics

  public const float outerToInner = 0.866025404f; public const float innerToOuter = 1f / outerToInner; public const float outerRadius = 10f; public const float innerRadius = outerRadius * outerToInner; 

现在我们可以继续进行正确的缩放了HexMesh.TriangulateWithRiver通道仍会因转向而受到挤压,但是比之字形的情况要少得多。因此,我们不需要对此进行补偿。

  else if (cell.HasRiverThroughEdge(direction.Next2())) { centerL = center; centerR = center + HexMetrics.GetSolidEdgeMiddle(direction.Next()) * (0.5f * HexMetrics.innerToOuter); } else { centerL = center + HexMetrics.GetSolidEdgeMiddle(direction.Previous()) * (0.5f * HexMetrics.innerToOuter); centerR = center; } 


平滑曲线。

统一包装

河流附近的三角剖分


我们的河已经准备好了。但是我们尚未对包含河流的单元格的其他部分进行三角剖分。现在我们将关闭这些漏洞。


通道附近的孔。

如果单元格中有一条河流,但没有沿当前方向流动,则Triangulate我们将调用一个新方法。

  if (cell.HasRiver) { if (cell.HasRiverThroughEdge(direction)) { e.v3.y = cell.StreamBedY; if (cell.HasRiverBeginOrEnd) { TriangulateWithRiverBeginOrEnd(direction, cell, center, e); } else { TriangulateWithRiver(direction, cell, center, e); } } else { TriangulateAdjacentToRiver(direction, cell, center, e); } } else { TriangulateEdgeFan(center, e, cell.Color); } 

在这种方法中,我们用条带和扇形填充单元三角形。仅仅一个风扇对我们来说是不够的,因为山峰应该对应于包含河流的部分的中间边缘。

  void TriangulateAdjacentToRiver ( HexDirection direction, HexCell cell, Vector3 center, EdgeVertices e ) { EdgeVertices m = new EdgeVertices( Vector3.Lerp(center, e.v1, 0.5f), Vector3.Lerp(center, e.v5, 0.5f) ); TriangulateEdgeStrip(m, cell.Color, e, cell.Color); TriangulateEdgeFan(center, m, cell.Color); } 


覆盖曲线和直河。

匹配频道


当然,我们需要使使用的中心与河流部分使用的中心部分匹配。使用锯齿形,一切都井井有条,曲线和笔直的河流需要引起注意。因此,我们需要确定河流的类型及其相对方位。

让我们开始检查我们是否在曲线内。在这种情况下,上一个和下一个方向都包含河流。如果是这样,那么我们需要将中心移到边缘。

  if (cell.HasRiverThroughEdge(direction.Next())) { if (cell.HasRiverThroughEdge(direction.Previous())) { center += HexMetrics.GetSolidEdgeMiddle(direction) * (HexMetrics.innerToOuter * 0.5f); } } EdgeVertices m = new EdgeVertices( Vector3.Lerp(center, e.v1, 0.5f), Vector3.Lerp(center, e.v5, 0.5f) ); 


修复了河流从两侧流出的情况。

如果我们有一条河的方向不同,但前一条河没有,那么我们检查一下它是否是直的。如果是这样,则将中心移到第一个角。

  if (cell.HasRiverThroughEdge(direction.Next())) { if (cell.HasRiverThroughEdge(direction.Previous())) { center += HexMetrics.GetSolidEdgeMiddle(direction) * (HexMetrics.innerToOuter * 0.5f); } else if ( cell.HasRiverThroughEdge(direction.Previous2()) ) { center += HexMetrics.GetFirstSolidCorner(direction) * 0.25f; } } 


固定了一条直河的一半覆盖物。

因此,我们通过将部分河流与直河相邻解决了该问题。最后一种情况-我们在前面的方向上有一条河,它是直的。在这种情况下,您需要将中心移到下一个角。

  if (cell.HasRiverThroughEdge(direction.Next())) { if (cell.HasRiverThroughEdge(direction.Previous())) { center += HexMetrics.GetSolidEdgeMiddle(direction) * (HexMetrics.innerToOuter * 0.5f); } else if ( cell.HasRiverThroughEdge(direction.Previous2()) ) { center += HexMetrics.GetFirstSolidCorner(direction) * 0.25f; } } else if ( cell.HasRiverThroughEdge(direction.Previous()) && cell.HasRiverThroughEdge(direction.Next2()) ) { center += HexMetrics.GetSecondSolidCorner(direction) * 0.25f; } 


没有更多的叠加层。

统一包装

HexMesh概括


我们已经完成了通道的三角测量。现在我们可以装满水了。由于水与土地不同,因此我们将需要使用具有不同顶点数据和不同材质的不同网格。如果我们可以同时使用HexMesh寿司和水,那将是非常方便的因此,让我们HexMesh将其转化为一个处理这些网格物体的类来进行概括,而不论其用途是什么。我们将继续进行三角剖分其细胞的任务HexGridChunk

移动微扰方法


由于该方法已被Perturb广泛推广并且将在不同的地方使用,因此将其移至HexMetrics首先,将其重命名为HexMetrics.Perturb这是一个不正确的方法名称,但是会重构所有代码以使其正确使用。如果您的代码编辑器具有用于移动方法的特殊功能,请使用它。

通过向内移动该方法HexMetrics,使其通用且静态,然后更正其名称。

  public static Vector3 Perturb (Vector3 position) { Vector4 sample = SampleNoise(position); position.x += (sample.x * 2f - 1f) * cellPerturbStrength; position.z += (sample.z * 2f - 1f) * cellPerturbStrength; return position; } 

移动三角剖分方法


HexGridChunk变量的变化hexMesh在该共享变量terrain

  public HexMesh terrain; // HexMesh hexMesh; void Awake () { gridCanvas = GetComponentInChildren<Canvas>(); // hexMesh = GetComponentInChildren<HexMesh>(); cells = new HexCell[HexMetrics.chunkSizeX * HexMetrics.chunkSizeZ]; ShowUI(false); } 

接下来,重构的所有方法Add…HexMeshterrain.Add…然后将所有方法Triangulate…移至HexGridChunk然后,您可以纠正的方法的名称Add…HexMesh,使他们普遍。结果,将找到所有复杂的三角剖分方法HexGridChunk,而将数据添加到网格的简单方法将保留在中HexMesh

我们还没有完成。现在它HexGridChunk.LateUpdate应该调用自己的方法Triangulate此外,它不应再将单元格作为参数传递。因此,它Triangulate可能会丢失其参数。并且他必须委托网格数据的清洁和应用HexMesh

  void LateUpdate () { Triangulate(); // hexMesh.Triangulate(cells); enabled = false; } public void Triangulate () { terrain.Clear(); // hexMesh.Clear(); // vertices.Clear(); // colors.Clear(); // triangles.Clear(); for (int i = 0; i < cells.Length; i++) { Triangulate(cells[i]); } terrain.Apply(); // hexMesh.vertices = vertices.ToArray(); // hexMesh.colors = colors.ToArray(); // hexMesh.triangles = triangles.ToArray(); // hexMesh.RecalculateNormals(); // meshCollider.sharedMesh = hexMesh; } 

添加所需的方法ClearApplyHexMesh

  public void Clear () { hexMesh.Clear(); vertices.Clear(); colors.Clear(); triangles.Clear(); } public void Apply () { hexMesh.SetVertices(vertices); hexMesh.SetColors(colors); hexMesh.SetTriangles(triangles, 0); hexMesh.RecalculateNormals(); meshCollider.sharedMesh = hexMesh; } 

SetVertices,SetColors和SetTriangles呢?
Mesh . . , .

SetTriangles integer, . , .

最后,手动将网格的子代附加到片段预制件上。我们无法再自动执行此操作,因为我们很快将向网格添加第二个子级。将其重命名为Terrain以表明其用途。


分配救济。

重命名预制孩子不起作用?
. , . , Apply , . .

创建列表池


尽管我们已经移动了很多代码,但我们的地图仍应与以前一样工作。向片段添加另一个网格不会更改此设置。但是,如果我们现在就这样做HexMesh,则可能会出现错误。

问题是我们假设一次只能使用一个网格。这使我们可以使用静态列表存储临时网格数据。但是添加水后,我们将同时使用两个网格,因此我们将无法再使用静态列表。

但是,我们不会返回每个实例的列表集HexMesh相反,我们使用静态列表池。默认情况下,此池不存在,所以让我们从自己创建一个公共列表池类开始。

 public static class ListPool<T> { } 

ListPool <T>如何工作?
, List<int> . <T> ListPool , , . , T ( template).

要将列表集合存储在池中,我们可以使用堆栈。我通常不使用列表,因为Unity不会序列化它们,但是在这种情况下,这并不重要。

 using System.Collections.Generic; public static class ListPool<T> { static Stack<List<T>> stack = new Stack<List<T>>(); } 

堆栈<列表<t >>是什么意思?
. , . .

添加一个常用的静态方法以从池中获取列表。如果堆栈不为空,我们将提取顶部列表并返回此列表。否则,我们将在适当位置创建一个新列表。

  public static List<T> Get () { if (stack.Count > 0) { return stack.Pop(); } return new List<T>(); } 

要重用列表,您需要在使用完列表后将它们添加到池中。ListPool将清除列表并将其推入堆栈。

  public static void Add (List<T> list) { list.Clear(); stack.Push(list); } 

现在我们可以使用中的池了HexMesh用非静态专用链接替换静态列表。让我们对其进行标记,NonSerialized以使Unity在重新编译期间不会保留它们。或编写System.NonSerialized,或using System;在脚本的开头添加

  [NonSerialized] List<Vector3> vertices; [NonSerialized] List<Color> colors; [NonSerialized] List<int> triangles; // static List<Vector3> vertices = new List<Vector3>(); // static List<Color> colors = new List<Color>(); // static List<int> triangles = new List<int>(); 

由于网格是在添加新数据之前被清理的,因此您需要从池中获取列表。

  public void Clear () { hexMesh.Clear(); vertices = ListPool<Vector3>.Get(); colors = ListPool<Color>.Get(); triangles = ListPool<int>.Get(); } 

应用这些网格之后,我们不再需要它们,因此在这里我们可以将它们添加到池中。

  public void Apply () { hexMesh.SetVertices(vertices); ListPool<Vector3>.Add(vertices); hexMesh.SetColors(colors); ListPool<Color>.Add(colors); hexMesh.SetTriangles(triangles, 0); ListPool<int>.Add(triangles); hexMesh.RecalculateNormals(); meshCollider.sharedMesh = hexMesh; } 

因此,无论我们同时填充多少个网格,我们都实现了列表的多次使用。

可选对撞机


尽管我们的地形需要撞机,但河流实际上并不需要。射线将仅穿过水并与下面的通道相交。让我们进行配置,以便可以为设置对撞机HexMesh我们通过添加一个公共字段来实现这一点bool useCollider对于地形,我们将其打开。

  public bool useCollider; 


使用网格对撞机。

我们需要仅在打开碰撞器时对其进行创建和分配。

  void Awake () { GetComponent<MeshFilter>().mesh = hexMesh = new Mesh(); if (useCollider) { meshCollider = gameObject.AddComponent<MeshCollider>(); } hexMesh.name = "Hex Mesh"; } public void Apply () { … if (useCollider) { meshCollider.sharedMesh = hexMesh; } … } 

可选颜色


顶点颜色也可以是可选的。我们需要他们展示各种浮雕,但水不会改变颜色。我们可以将它们设置为可选,就像我们将对撞机设置为可选。

  public bool useCollider, useColors; public void Clear () { hexMesh.Clear(); vertices = ListPool<Vector3>.Get(); if (useColors) { colors = ListPool<Color>.Get(); } triangles = ListPool<int>.Get(); } public void Apply () { hexMesh.SetVertices(vertices); ListPool<Vector3>.Add(vertices); if (useColors) { hexMesh.SetColors(colors); ListPool<Color>.Add(colors); } … } 

当然,地形应使用顶点的颜色,因此将其打开。


使用颜色。

可选的紫外线


在执行此操作时,我们还可以添加对可选UV坐标的支持。尽管浮雕没有使用它们,但我们将需要它们来浇水。

  public bool useCollider, useColors, useUVCoordinates; [NonSerialized] List<Vector2> uvs; public void Clear () { hexMesh.Clear(); vertices = ListPool<Vector3>.Get(); if (useColors) { colors = ListPool<Color>.Get(); } if (useUVCoordinates) { uvs = ListPool<Vector2>.Get(); } triangles = ListPool<int>.Get(); } public void Apply () { hexMesh.SetVertices(vertices); ListPool<Vector3>.Add(vertices); if (useColors) { hexMesh.SetColors(colors); ListPool<Color>.Add(colors); } if (useUVCoordinates) { hexMesh.SetUVs(0, uvs); ListPool<Vector2>.Add(uvs); } … } 


我们不使用UV坐标。

要使用此功能,请创建将UV坐标添加到三角形和四边形的方法。

  public void AddTriangleUV (Vector2 uv1, Vector2 uv2, Vector3 uv3) { uvs.Add(uv1); uvs.Add(uv2); uvs.Add(uv3); } public void AddQuadUV (Vector2 uv1, Vector2 uv2, Vector3 uv3, Vector3 uv4) { uvs.Add(uv1); uvs.Add(uv2); uvs.Add(uv3); uvs.Add(uv4); } 

让我们添加另一种方法AddQuadUV来方便地添加矩形UV区域。这是四边形及其纹理相同时的标准情况,我们将其用于河水​​。

  public void AddQuadUV (float uMin, float uMax, float vMin, float vMax) { uvs.Add(new Vector2(uMin, vMin)); uvs.Add(new Vector2(uMax, vMin)); uvs.Add(new Vector2(uMin, vMax)); uvs.Add(new Vector2(uMax, vMax)); } 

统一包装

当前河流


最后是时候创造水了!我们将使用四边形进行此操作,该四边形将指示水的表面。而且由于我们与河流合作,因此必须流水。为此,我们使用指示河流方向的UV坐标。为了可视化,我们需要一个新的着色器。因此,创建一个新的标准着色器并将其命名为River对其进行更改,以便将紫外线坐标记录在绿色和红色反照率通道中。

 Shader "Custom/River" { … void surf (Input IN, inout SurfaceOutputStandard o) { fixed4 c = tex2D(_MainTex, IN.uv_MainTex) * _Color; o.Albedo = c.rgb * IN.color; o.Metallic = _Metallic; o.Smoothness = _Glossiness; o.Alpha = ca; o.Albedo.rg = IN.uv_MainTex; } ENDCG } FallBack "Diffuse" } 

添加到HexGridChunk常规字段HexMesh rivers我们将其清理并以与救济时相同的方式应用。

  public HexMesh terrain, rivers; public void Triangulate () { terrain.Clear(); rivers.Clear(); for (int i = 0; i < cells.Length; i++) { Triangulate(cells[i]); } terrain.Apply(); rivers.Apply(); } 

即使我们没有河流,我们还会打更多电话吗?
Unity , . , - .

更改预制件(通过实例),复制其地形对象,将其重命名为Rivers并将其连接。



预制片段与河流。 使用我们的新着色器

创建River材料,并使Rivers对象使用它我们还设置了对象的六边形网格组件,以便它使用UV坐标,但不使用顶点颜色或对撞机。


子对象河流。

三角水


在对水进行三角剖分之前,我们需要确定其表面的高度。HexMetrics我们像在河床一样改变高度由于单元格的垂直变形等于高度偏移的一半,因此我们将其用于移动河流表面。因此,我们保证水永远不会超出细胞的地形。

  public const float riverSurfaceElevationOffset = -0.5f; 

为什么不降低它呢?
, . , .

添加一个HexCell属性以获取其河流表面的垂直位置。

  public float RiverSurfaceY { get { return (elevation + HexMetrics.riverSurfaceElevationOffset) * HexMetrics.elevationStep; } } 

现在我们可以开始工作了HexGridChunk由于我们将创建许多河流四边形,因此我们为此添加一个单独的方法。让我们给它四个顶点和一个高度作为参数。这将使我们能够在添加四边形之前方便地同时设置所有四个顶点的垂直位置。

  void TriangulateRiverQuad ( Vector3 v1, Vector3 v2, Vector3 v3, Vector3 v4, float y ) { v1.y = v2.y = v3.y = v4.y = y; rivers.AddQuad(v1, v2, v3, v4); } 

我们将在此处添加四边形的UV坐标。只需从左到右,从下到上。

  rivers.AddQuad(v1, v2, v3, v4); rivers.AddQuadUV(0f, 1f, 0f, 1f); 

TriangulateWithRiver-这是添加河流四边形的第一种方法。第一个四边形在中心和中间之间。第二个在中间和肋骨之间。我们只使用已经拥有的顶点。由于这些峰值将被低估,因此,水将部分位于通道的倾斜壁下。因此,我们不必担心水边缘的确切位置。

  void TriangulateWithRiver ( HexDirection direction, HexCell cell, Vector3 center, EdgeVertices e ) { … TriangulateRiverQuad(centerL, centerR, m.v2, m.v4, cell.RiverSurfaceY); TriangulateRiverQuad(m.v2, m.v4, e.v2, e.v4, cell.RiverSurfaceY); } 


水的最初迹象。

为什么水的宽度会变化?
, , — . . .

随风而动


目前,UV坐标与河流方向不一致。我们需要在这里保持一致性。假设向下游看时,U坐标在河的左侧为0,在河流的右侧为1。并且V坐标在河流方向上应在0到1之间变化。

使用此规范,将流出的河流进行三角剖分时,UV将是正确的,但事实证明它们是不正确的,并且当将流入的河流进行三角剖分时,将需要将其翻转。为了简化工作,请添加到TriangulateRiverQuad参数bool reversed如有必要,使用它翻转紫外线。

  void TriangulateRiverQuad ( Vector3 v1, Vector3 v2, Vector3 v3, Vector3 v4, float y, bool reversed ) { v1.y = v2.y = v3.y = v4.y = y; rivers.AddQuad(v1, v2, v3, v4); if (reversed) { rivers.AddQuadUV(1f, 0f, 1f, 0f); } else { rivers.AddQuadUV(0f, 1f, 0f, 1f); } } 

正如TriangulateWithRiver我们知道,我们需要转弯的方向,与传入江的时候。

  bool reversed = cell.IncomingRiver == direction; TriangulateRiverQuad( centerL, centerR, m.v2, m.v4, cell.RiverSurfaceY, reversed ); TriangulateRiverQuad( m.v2, m.v4, e.v2, e.v4, cell.RiverSurfaceY, reversed ); 


河流的商定方向。

河的起点和终点


在内部,TriangulateWithRiverBeginOrEnd我们只需要检查是否有流入的河流来确定水流的方向即可。然后我们可以在中间和肋骨之间插入另一个四边形河。

  void TriangulateWithRiverBeginOrEnd ( HexDirection direction, HexCell cell, Vector3 center, EdgeVertices e ) { … bool reversed = cell.HasIncomingRiver; TriangulateRiverQuad( m.v2, m.v4, e.v2, e.v4, cell.RiverSurfaceY, reversed ); } 

中心与中间之间的部分是三角形,所以我们不能使用它TriangulateRiverQuad唯一的区别是中央峰在河中。因此,其坐标U始终等于1/2。

  center.y = m.v2.y = m.v4.y = cell.RiverSurfaceY; rivers.AddTriangle(center, m.v2, m.v4); if (reversed) { rivers.AddTriangleUV( new Vector2(0.5f, 1f), new Vector2(1f, 0f), new Vector2(0f, 0f) ); } else { rivers.AddTriangleUV( new Vector2(0.5f, 0f), new Vector2(0f, 1f), new Vector2(1f, 1f) ); } 


在开始和结束时注水。

末端是否有缺水部分?
, quad , . . .

, . , . .

细胞间流动


在电池之间加水时,必须注意高度的差异。为了使水可以顺着斜坡和悬崖流下来,它TriangulateRiverQuad必须支持两个高度参数。因此,让我们添加第二个。

  void TriangulateRiverQuad ( Vector3 v1, Vector3 v2, Vector3 v3, Vector3 v4, float y1, float y2, bool reversed ) { v1.y = v2.y = y1; v3.y = v4.y = y2; rivers.AddQuad(v1, v2, v3, v4); if (reversed) { rivers.AddQuadUV(1f, 0f, 1f, 0f); } else { rivers.AddQuadUV(0f, 1f, 0f, 1f); } } 

另外,为方便起见,让我们添加一个将接收一个高度的选项。它只会调用另一个方法。

  void TriangulateRiverQuad ( Vector3 v1, Vector3 v2, Vector3 v3, Vector3 v4, float y, bool reversed ) { TriangulateRiverQuad(v1, v2, v3, v4, y, y, reversed); } 

现在我们可以在中添加quad river TriangulateConnection在单元之间,我们无法立即找出正在处理的河流类型。为了确定是否有必要转弯,我们需要检查是否有流入的河流,以及它是否正在朝我们的方向移动。

  if (cell.HasRiverThroughEdge(direction)) { e2.v3.y = neighbor.StreamBedY; TriangulateRiverQuad( e1.v2, e1.v4, e2.v2, e2.v4, cell.RiverSurfaceY, neighbor.RiverSurfaceY, cell.HasIncomingRiver && cell.IncomingRiver == direction ); } 


完成的河。

V坐标拉伸


到目前为止,在河流的每个部分中,我们的V坐标从0到1。也就是说,该单元格上只有四个。如果我们还添加单元之间的连接,则为5。无论我们用什么来构造河流,都必须重复多次。

我们可以通过拉伸V坐标来减少重复次数,以使它们在整个单元格中加上一个连接而从0变为1。这可以通过将每个段中的V坐标增加0.2来完成。如果我们将0.4放在中心,那么在中间它将变成0.6,在边缘将达到0.8。然后在单元格连接中,该值为1。

如果河流沿相反方向流动,我们仍然可以将0.4放置在中心,但是在中间将变为0.2,在边缘处变为0。如果继续进行直到该单元连接,则结果为-0.2。这是正常现象,因为对于具有重复过滤模式的纹理,它类似于0.8,就像0等于1一样。


更改坐标V。

要为此提供支持,我们需要再添加TriangulateRiverQuad一个参数。

  void TriangulateRiverQuad ( Vector3 v1, Vector3 v2, Vector3 v3, Vector3 v4, float y, float v, bool reversed ) { TriangulateRiverQuad(v1, v2, v3, v4, y, y, v, reversed); } void TriangulateRiverQuad ( Vector3 v1, Vector3 v2, Vector3 v3, Vector3 v4, float y1, float y2, float v, bool reversed ) { … } 

当方向不反向时,我们仅使用四边形底部的透射坐标,并在顶部添加0.2。

  else { rivers.AddQuadUV(0f, 1f, v, v + 0.2f); } 

我们可以通过从0.8和0.6中减去坐标来反向处理。

  if (reversed) { rivers.AddQuadUV(1f, 0f, 0.8f - v, 0.6f - v); } 

现在我们必须传输正确的坐标,就好像我们正在处理一条流出的河流一样。让我们从开始TriangulateWithRiver

  TriangulateRiverQuad( centerL, centerR, m.v2, m.v4, cell.RiverSurfaceY, 0.4f, reversed ); TriangulateRiverQuad( m.v2, m.v4, e.v2, e.v4, cell.RiverSurfaceY, 0.6f, reversed ); 

然后TriangulateConnection更改如下。

  TriangulateRiverQuad( e1.v2, e1.v4, e2.v2, e2.v4, cell.RiverSurfaceY, neighbor.RiverSurfaceY, 0.8f, cell.HasIncomingRiver && cell.IncomingRiver == direction ); 

最后TriangulateWithRiverBeginOrEnd

  TriangulateRiverQuad( m.v2, m.v4, e.v2, e.v4, cell.RiverSurfaceY, 0.6f, reversed ); center.y = m.v2.y = m.v4.y = cell.RiverSurfaceY; rivers.AddTriangle(center, m.v2, m.v4); if (reversed) { rivers.AddTriangleUV( new Vector2(0.5f, 0.4f), new Vector2(1f, 0.2f), new Vector2(0f, 0.2f) ); } else { rivers.AddTriangleUV( new Vector2(0.5f, 0.4f), new Vector2(0f, 0.6f), new Vector2(1f, 0.6f) ); } 


拉伸的V坐标:

要正确显示V坐标的折叠,请确保它们在河流着色器中保持正值。

  if (IN.uv_MainTex.y < 0) { IN.uv_MainTex.y += 1; } o.Albedo.rg = IN.uv_MainTex; 


折叠后的坐标V.

unitypackage

河动画


完成UV坐标后,我们可以继续对河流进行动画处理。 River着色器将执行此操作,这样我们就不必不断更新网格。

在本教程中,我们不会创建复杂的河着色器,但稍后会做。现在,我们将创建一个简单的效果,以了解动画的工作原理。

通过根据游戏时间移动V坐标来创建动画。 Unity允许您使用变量获取其值_Time。它的分量Y包含我们使用的不变时间。其他组件包含不同的时标。

我们将摆脱沿V的折叠,因为我们不再需要它。相反,我们从V坐标中减去当前时间,这会使坐标向下移动,从而产生了向河流下游流动的电流的错觉。

 // if (IN.uv_MainTex.y < 0) { // IN.uv_MainTex.y += 1; // } IN.uv_MainTex.y -= _Time.y; o.Albedo.rg = IN.uv_MainTex; 

一秒钟后,所有点的V坐标将小于零,因此我们将不再看到差异。同样,在纹理重复模式下使用过滤时,这是正常现象。但是要了解发生了什么,我们可以取V坐标的小数部分。

  IN.uv_MainTex.y -= _Time.y; IN.uv_MainTex.y = frac(IN.uv_MainTex.y); o.Albedo.rg = IN.uv_MainTex; 


动画的V坐标。

噪音的使用


现在,我们的河流充满生气,但在方向和速度上却有急剧的转变。我们的紫外线图案使它们非常明显,但是如果您使用更像水的图案,将很难识别。因此,让我们对纹理进行采样,而不是显示原始UV。我们可以使用现有的噪声纹理。我们对其进行采样,然后将材料的颜色乘以第一个噪声通道。

  void surf (Input IN, inout SurfaceOutputStandard o) { float2 uv = IN.uv_MainTex; uv.y -= _Time.y; float4 noise = tex2D(_MainTex, uv); fixed4 c = _Color * noise.r; o.Albedo = c.rgb; o.Metallic = _Metallic; o.Smoothness = _Glossiness; o.Alpha = ca; } 

将噪声纹理分配给河流材质,并确保其为白色。



使用噪声纹理。

由于V坐标非常拉伸,因此噪声纹理也会沿河拉伸。不幸的是,课程不是很漂亮。让我们尝试以另一种方式拉伸它-大大减小U坐标的比例。十六分之一就足够了。这意味着我们将仅对窄带的噪声纹理进行采样。

  float2 uv = IN.uv_MainTex; uv.x *= 0.0625; uv.y -= _Time.y; 


拉伸U坐标,

让我们也放慢到每秒四分之一,以便完成纹理循环需要四秒钟。

  uv.y -= _Time.y * 0.25; 


当前的噪音。

噪音混合


一切看起来已经好多了,但是模式始终保持不变。水不会那样表现。

由于我们仅使用一小段噪声,因此可以通过沿着纹理移动该频带来改变图案。这是通过将时间添加到U坐标来完成的。我们必须慢慢地做,否则这条河似乎会侧流。让我们尝试0.005的系数。这意味着完成图案需要200秒。

  uv.x = uv.x * 0.0625 + _Time.y * 0.005; 


移动噪音。

不幸的是,这看起来并不漂亮。尽管水很慢,但水似乎仍然是静止的,并且变化明显可见。我们可以通过组合两个噪声样本并将它们沿相反的方向移动来隐藏偏移。如果我们使用稍有不同的值移动第二个样本,我们将创建更改的简短动画。

因此,我们永远不会重叠相同的噪声模式,因此第二个样本将使用不同的通道。

  float2 uv = IN.uv_MainTex; uv.x = uv.x * 0.0625 + _Time.y * 0.005; uv.y -= _Time.y * 0.25; float4 noise = tex2D(_MainTex, uv); float2 uv2 = IN.uv_MainTex; uv2.x = uv2.x * 0.0625 - _Time.y * 0.0052; uv2.y -= _Time.y * 0.23; float4 noise2 = tex2D(_MainTex, uv2); fixed4 c = _Color * (noise.r * noise2.a); 


两个移动噪声模式的组合。

半透明水


我们的模式看起来很动态。下一步是使其透明。

首先,确保水不会蒙上阴影。您可以通过预制中Rivers对象的渲染器组件禁用它们


阴影投射已禁用。

现在将着色器切换到透明模式。为了说明这一点,请使用着色器标签。然后将#pragma surface关键字添加到该行alpha当我们在这里时,您可以删除关键字fullforwardshadows,因为我们仍然不会投射阴影。

  Tags { "RenderType"="Transparent" "Queue"="Transparent" } LOD 200 CGPROGRAM #pragma surface surf Standard alpha // fullforwardshadows #pragma target 3.0 

现在,我们将更改设置河流颜色的方式。除了将噪声乘以颜色外,我们还将为其添加噪声。然后我们使用函数saturate来限制结果,使其不超过1。

  fixed4 c = saturate(_Color + noise.r * noise2.a); 

这将使我们能够使用材质颜色作为基础颜色。噪音会增加其亮度和不透明度。让我们尝试使用不透明度非常低的蓝色。结果,我们得到带有白色飞溅的蓝色半透明水。



有色半透明的水。

统一包装

完成时间


现在一切似乎都正常了,是时候再次扭曲峰了。除了使细胞边缘变形之外,这还将使我们的河流不平坦。

  public const float cellPerturbStrength = 4f; 



峰变形和扭曲。

让我们检查一下地形是否因变形而引起的问题。看起来像是!让我们看看高大的瀑布。


水被悬崖截断了。

从高高的瀑布掉下来的水在悬崖后面消失了。发生这种情况时,它非常明显,因此我们需要对此做些事情。

不那么明显的是,瀑布可能是倾斜的,而不是向下直线下降。尽管实际上水不会那​​样流动,但并不是特别引人注目。我们的大脑将以对我们来说似乎正常的方式来解释它。所以就忽略它。

避免水流失的最简单方法是加深河床。因此,我们将在水面和河床之间创造更多空间。这也将使通道的壁更垂直,因此不要太深。问一下HexMetrics.streamBedElevationOffset值-1.75。这将解决大部分问题,并且床不会变得太深。部分水仍会被切断,但整个瀑布不会被切断。

  public const float streamBedElevationOffset = -1.75f; 


深入渠道。

统一包装

第七部分:道路


  • 添加道路支持。
  • 划分道路。
  • 我们结合了道路和河流。
  • 改善道路外观。


文明的最初迹象。

道路单元格


像河流一样,道路从一个单元到另一个单元,穿过单元边缘的中间。最大的区别是道路上没有水流,因此它们是双向的。此外,功能性道路网络需要交叉路口,因此我们需要为每个单元支持两条以上的道路。

如果允许道路沿所有六个方向行驶,则该单元格可以包含从零到六个道路。总共有十四种可能的道路配置。这远远超过五个可能的河流配置。为了解决这个问题,我们需要使用一种更通用的方法来处理所有配置。


14种可能的道路配置。

道路追踪


跟踪单元格中道路的最简单方法是使用布尔值数组。将数组的私有字段添加到其中HexCell并使其可序列化,以便您可以在检查器中看到它。通过单元预制来设置阵列的大小,以使其支持六条道路。

  [SerializeField] bool[] roads; 


有六个道路的预制单元。

添加一种方法来检查单元格是否具有沿特定方向的路径。

  public bool HasRoadThroughEdge (HexDirection direction) { return roads[(int)direction]; } 

知道单元格中是否至少有一条道路也很方便,因此我们将为此添加一个属性。只要在循环中遍历数组,并在true找到方法后立即返回即可如果没有道路,则返回false

  public bool HasRoads { get { for (int i = 0; i < roads.Length; i++) { if (roads[i]) { return true; } } return false; } } 

清除道路


与河流一样,我们将添加一种方法来删除单元格中的所有道路。可以通过循环断开先前启用的每条道路的连接来完成此操作。

  public void RemoveRoads () { for (int i = 0; i < neighbors.Length; i++) { if (roads[i]) { roads[i] = false; } } } 

当然,我们还需要禁用邻居中相应的昂贵小区。

  if (roads[i]) { roads[i] = false; neighbors[i].roads[(int)((HexDirection)i).Opposite()] = false; } 

之后,我们需要更新每个单元。由于道路对于小区来说是本地的,因此我们只需要更新小区本身而不更新它们的邻居即可。

  if (roads[i]) { roads[i] = false; neighbors[i].roads[(int)((HexDirection)i).Opposite()] = false; neighbors[i].RefreshSelfOnly(); RefreshSelfOnly(); } 

新增道路


添加道路类似于删除道路。唯一的区别是我们为Boolean分配了一个值true,而不是false我们可以创建一个可以执行这两种操作的私有方法。然后可以使用它来添加和删除道路。

  public void AddRoad (HexDirection direction) { if (!roads[(int)direction]) { SetRoad((int)direction, true); } } public void RemoveRoads () { for (int i = 0; i < neighbors.Length; i++) { if (roads[i]) { SetRoad(i, false); } } } void SetRoad (int index, bool state) { roads[index] = state; neighbors[index].roads[(int)((HexDirection)index).Opposite()] = state; neighbors[index].RefreshSelfOnly(); RefreshSelfOnly(); } 

我们不能同时有一条河和一条道路朝同一方向行驶。因此,在添加道路之前,我们将检查该道路是否存在。

  public void AddRoad (HexDirection direction) { if (!roads[(int)direction] && !HasRiverThroughEdge(direction)) { SetRoad((int)direction, true); } } 

此外,道路太陡,不能与悬崖合并。还是值得穿过低矮的悬崖,而不是穿过高高的悬崖?为了确定这一点,我们需要创建一种方法来告诉我们特定方向的高度差。

  public int GetElevationDifference (HexDirection direction) { int difference = elevation - GetNeighbor(direction).elevation; return difference >= 0 ? difference : -difference; } 

现在我们可以使道路以足够小的高度差添加。我将仅限于斜坡,即最多1个单位。

  public void AddRoad (HexDirection direction) { if ( !roads[(int)direction] && !HasRiverThroughEdge(direction) && GetElevationDifference(direction) <= 1 ) { SetRoad((int)direction, true); } } 

拆除错误的道路


我们只允许在允许的情况下增加道路。现在,我们需要确保在以后变得不正确时(例如在添加河流时)将其删除。我们可以禁止在道路上放置河流,但是河流不会被道路打断。让他们将路洗净。无论道路是否经过

,我们只要问路就足够了false在这种情况下,两个单元都将始终被更新,因此我们不再需要显式调用RefreshSelfOnlyin SetOutgoingRiver

  public void SetOutgoingRiver (HexDirection direction) { if (hasOutgoingRiver && outgoingRiver == direction) { return; } HexCell neighbor = GetNeighbor(direction); if (!neighbor || elevation < neighbor.elevation) { return; } RemoveOutgoingRiver(); if (hasIncomingRiver && incomingRiver == direction) { RemoveIncomingRiver(); } hasOutgoingRiver = true; outgoingRiver = direction; // RefreshSelfOnly(); neighbor.RemoveIncomingRiver(); neighbor.hasIncomingRiver = true; neighbor.incomingRiver = direction.Opposite(); // neighbor.RefreshSelfOnly(); SetRoad((int)direction, false); } 

可能使道路错误的另一种操作是改变高度。在这种情况下,我们将必须检查所有方向的道路。如果高度差太大,则需要删除现有道路。

  public int Elevation { get { return elevation; } set { … for (int i = 0; i < roads.Length; i++) { if (roads[i] && GetElevationDifference((HexDirection)i) > 1) { SetRoad(i, false); } } Refresh(); } } 

统一包装

道路编辑


编辑道路就像编辑河流一样。因此HexMapEditor,还需要一个开关,以及用于设置其状态的方法。

  OptionalToggle riverMode, roadMode; public void SetRiverMode (int mode) { riverMode = (OptionalToggle)mode; } public void SetRoadMode (int mode) { roadMode = (OptionalToggle)mode; } 

该方法EditCell现在应支持通过添加道路来进行拆除。这意味着在拖放时,他可以执行两种可能的动作之一。我们对代码进行了一些重组,以便在执行正确的拖放操作时检查两个开关的状态。

  void EditCell (HexCell cell) { if (cell) { if (applyColor) { cell.Color = activeColor; } if (applyElevation) { cell.Elevation = activeElevation; } if (riverMode == OptionalToggle.No) { cell.RemoveRiver(); } if (roadMode == OptionalToggle.No) { cell.RemoveRoads(); } if (isDrag) { HexCell otherCell = cell.GetNeighbor(dragDirection.Opposite()); if (otherCell) { if (riverMode == OptionalToggle.Yes) { otherCell.SetOutgoingRiver(dragDirection); } if (roadMode == OptionalToggle.Yes) { otherCell.AddRoad(dragDirection); } } } } } 

我们可以通过复制河流条并更改开关调用的方法来快速向用户界面添加路标。

结果,我们获得了相当高的用户界面。为了解决这个问题,我更改了颜色面板的布局以适合更紧凑的道路和河流面板。


UI与道路。

从现在开始,我使用两行三种颜色的选项,因此还有余地可以容纳另一种颜色。所以我添加了一个橙色的物品。



五种颜色:黄色,绿色,蓝色,橙色和白色。

现在,我们可以编辑道路,但是到目前为止它们是不可见的。您可以使用检查器来确保一切正常。


在检查器中带有道路的单元格。

统一包装

道路三角剖分


要显示道路,您需要对它们进行三角测量。这类似于为河流创建网格,只有河床不会出现在浮雕中。

首先,创建一个新的标准着色器,该着色器将再次使用UV坐标来绘制路面。

 Shader "Custom/Road" { Properties { _Color ("Color", Color) = (1,1,1,1) _MainTex ("Albedo (RGB)", 2D) = "white" {} _Glossiness ("Smoothness", Range(0,1)) = 0.5 _Metallic ("Metallic", Range(0,1)) = 0.0 } SubShader { Tags { "RenderType"="Opaque" } LOD 200 CGPROGRAM #pragma surface surf Standard fullforwardshadows #pragma target 3.0 sampler2D _MainTex; struct Input { float2 uv_MainTex; }; half _Glossiness; half _Metallic; fixed4 _Color; void surf (Input IN, inout SurfaceOutputStandard o) { fixed4 c = fixed4(IN.uv_MainTex, 1, 1); o.Albedo = c.rgb; o.Metallic = _Metallic; o.Smoothness = _Glossiness; o.Alpha = ca; } ENDCG } FallBack "Diffuse" } 

使用此着色器创建道路材质。


物质之路。

设置碎片的预制件,使其接收道路的另一个六边形子网格。此网格不应投射阴影,而只能使用UV坐标。最快的方法是通过预制实例-复制Rivers对象并替换其材料。



子对象道路。

之后,添加到HexGridChunk常规字段HexMesh roads并将其包含在中Triangulate将其在检查器中与Roads对象连接

  public HexMesh terrain, rivers, roads; public void Triangulate () { terrain.Clear(); rivers.Clear(); roads.Clear(); for (int i = 0; i < cells.Length; i++) { Triangulate(cells[i]); } terrain.Apply(); rivers.Apply(); roads.Apply(); } 


Roads对象已连接。

细胞之间的道路


让我们首先看一下单元格之间的路段。像河流一样,道路由两个中等四边形封闭。我们用道路四边形完全覆盖了这些连接四边形,以便可以使用相同的六个峰的位置。为此添加到HexGridChunk方法中TriangulateRoadSegment

  void TriangulateRoadSegment ( Vector3 v1, Vector3 v2, Vector3 v3, Vector3 v4, Vector3 v5, Vector3 v6 ) { roads.AddQuad(v1, v2, v4, v5); roads.AddQuad(v2, v3, v5, v6); } 

由于我们不再需要担心水的流动,​​因此不需要V坐标,因此我们在每个地方都将其赋值为0,我们可以使用U坐标来指示我们是在道路中间还是在路边。使其在中间等于1,在两侧等于0。

  void TriangulateRoadSegment ( Vector3 v1, Vector3 v2, Vector3 v3, Vector3 v4, Vector3 v5, Vector3 v6 ) { roads.AddQuad(v1, v2, v4, v5); roads.AddQuad(v2, v3, v5, v6); roads.AddQuadUV(0f, 1f, 0f, 0f); roads.AddQuadUV(1f, 0f, 0f, 0f); } 


单元之间的路段。

在中调用此方法是合乎逻辑的TriangulateEdgeStrip,但前提是确实有路。向该方法添加一个布尔参数以传递此信息。

  void TriangulateEdgeStrip ( EdgeVertices e1, Color c1, EdgeVertices e2, Color c2, bool hasRoad ) { … } 

当然,现在我们将收到编译器错误,因为到目前为止该信息尚未传输。作为所有情况下的最后一个参数,TriangulateEdgeStrip可以添加该调用false但是,我们也可以声明此参数的默认值为equal false因此,该参数将变为可选参数,并且编译错误将消失。

  void TriangulateEdgeStrip ( EdgeVertices e1, Color c1, EdgeVertices e2, Color c2, bool hasRoad = false ) { … } 

可选参数如何工作?
, . ,

 int MyMethod (int x = 1, int y = 2) { return x + y; } 



 int MyMethod (int x, int y) { return x + y; } int MyMethod (int x) { return MyMethod(x, 2); } int MyMethod () { return MyMethod(1, 2}; } 

. . . .

要对道路进行三角测量TriangulateRoadSegment,请在必要时调用中间的六个峰。

  void TriangulateEdgeStrip ( EdgeVertices e1, Color c1, EdgeVertices e2, Color c2, bool hasRoad = false ) { terrain.AddQuad(e1.v1, e1.v2, e2.v1, e2.v2); terrain.AddQuadColor(c1, c2); terrain.AddQuad(e1.v2, e1.v3, e2.v2, e2.v3); terrain.AddQuadColor(c1, c2); terrain.AddQuad(e1.v3, e1.v4, e2.v3, e2.v4); terrain.AddQuadColor(c1, c2); terrain.AddQuad(e1.v4, e1.v5, e2.v4, e2.v5); terrain.AddQuadColor(c1, c2); if (hasRoad) { TriangulateRoadSegment(e1.v2, e1.v3, e1.v4, e2.v2, e2.v3, e2.v4); } } 

这就是我们处理扁平电池连接的方式。为了在壁架上支撑道路,我们还需要告知TriangulateEdgeTerraces应在何处添加道路。他可以简单地传达这些信息TriangulateEdgeStrip

  void TriangulateEdgeTerraces ( EdgeVertices begin, HexCell beginCell, EdgeVertices end, HexCell endCell, bool hasRoad ) { EdgeVertices e2 = EdgeVertices.TerraceLerp(begin, end, 1); Color c2 = HexMetrics.TerraceLerp(beginCell.Color, endCell.Color, 1); TriangulateEdgeStrip(begin, beginCell.Color, e2, c2, hasRoad); for (int i = 2; i < HexMetrics.terraceSteps; i++) { EdgeVertices e1 = e2; Color c1 = c2; e2 = EdgeVertices.TerraceLerp(begin, end, i); c2 = HexMetrics.TerraceLerp(beginCell.Color, endCell.Color, i); TriangulateEdgeStrip(e1, c1, e2, c2, hasRoad); } TriangulateEdgeStrip(e2, c2, end, endCell.Color, hasRoad); } 

TriangulateEdgeTerraces叫里面TriangulateConnection在这里,我们可以确定在肋骨的三角剖分和壁架的三角剖分期间,实际上是否存在沿当前方向行驶的道路。

 if (cell.GetEdgeType(direction) == HexEdgeType.Slope) { TriangulateEdgeTerraces( e1, cell, e2, neighbor, cell.HasRoadThroughEdge(direction) ); } else { TriangulateEdgeStrip( e1, cell.Color, e2, neighbor.Color, cell.HasRoadThroughEdge(direction) ); } 


单元格之间的路段。

单元格过度渲染


绘制道路时,您会看到路段出现在单元之间。这些段的中间将是紫色,边缘过渡到蓝色。

但是,当您移动相机时,这些片段可能会闪烁,有时会完全消失。这是因为道路的三角形与地形三角形完全重叠。随机选择用于渲染的三角形。此问题可以分两个阶段解决。

首先,我们要在清道夫之后再修路。这可以通过在渲染常规几何图形之后对其进行渲染来实现,也就是将它们放置在以后的渲染队列中。

  Tags { "RenderType"="Opaque" "Queue" = "Geometry+1" } 

其次,我们需要确保在同一位置的地形三角形上绘制道路。这可以通过添加深度测试偏移量来完成。它将允许GPU假定三角形比实际三角形更接近相机。

  Tags { "RenderType"="Opaque" "Queue" = "Geometry+1" } LOD 200 Offset -1, -1 

穿越细胞的道路


在对河流进行三角剖分时,每个单元只能处理不超过两个河流方向。我们可以识别出五个可能的选项,并对它们进行不同的三角剖分,以创建看起来合适的河流。但是,在道路上,有十四种可能的选择。我们不会为每个选项使用单独的方法。取而代之的是,无论具体的道路配置如何,我们都将以相同的方式处理六个单元方向中的每个方向。

当道路经过单元格的一部分时,我们将其直接绘制到单元格的中心,而不会离开三角形区域。我们将沿着中心的方向从边缘到一半绘制一段路段。然后,我们使用两个三角形将其余部分封闭到中心。


道路的一部分的三角剖分。

要对这种方案进行三角剖分,我们需要知道像元的中心,左右中间的顶点以及边的顶点。添加TriangulateRoad具有适当参数的方法

  void TriangulateRoad ( Vector3 center, Vector3 mL, Vector3 mR, EdgeVertices e ) { } 

要修建路段,我们需要再增加一个高峰。它位于左右中间峰之间。

  void TriangulateRoad ( Vector3 center, Vector3 mL, Vector3 mR, EdgeVertices e ) { Vector3 mC = Vector3.Lerp(mL, mR, 0.5f); TriangulateRoadSegment(mL, mC, mR, e.v2, e.v3, e.v4); } 

现在我们还可以添加其余两个三角形。

  TriangulateRoadSegment(mL, mC, mR, e.v2, e.v3, e.v4); roads.AddTriangle(center, mL, mC); roads.AddTriangle(center, mC, mR); 

我们还需要添加三角形的UV坐标。他们的两个高峰在道路中间,其余的在边缘。

  roads.AddTriangle(center, mL, mC); roads.AddTriangle(center, mC, mR); roads.AddTriangleUV( new Vector2(1f, 0f), new Vector2(0f, 0f), new Vector2(1f, 0f) ); roads.AddTriangleUV( new Vector2(1f, 0f), new Vector2(1f, 0f), new Vector2(0f, 0f) ); 

现在,让我们限制在没有河流的单元中。在这些情况下,它Triangulate仅创建三角形的扇形。将此代码移到单独的方法。然后,我们TriangulateRoad在道路实际存在时添加通话可以通过在中心和两个角顶点之间进行插值来找到左右中间顶点。

  void Triangulate (HexDirection direction, HexCell cell) { … if (cell.HasRiver) { … } else { TriangulateWithoutRiver(direction, cell, center, e); } … } void TriangulateWithoutRiver ( HexDirection direction, HexCell cell, Vector3 center, EdgeVertices e ) { TriangulateEdgeFan(center, e, cell.Color); if (cell.HasRoadThroughEdge(direction)) { TriangulateRoad( center, Vector3.Lerp(center, e.v1, 0.5f), Vector3.Lerp(center, e.v5, 0.5f), e ); } } 


穿过牢房的道路。

路肋


现在我们可以看到道路,但更靠近它们变窄的单元格的中心。由于我们不检查要处理的十四个选项中的哪个,因此无法移动道路的中心来创建更漂亮的形状。相反,我们可以在单元的其他部分添加其他道路边缘。

当道路通过单元时,但不在当前方向上通过时,我们将在道路边缘添加一个三角形。该三角形由中心,左侧和右侧中间顶点定义。在这种情况下,只有中央山峰位于道路中间。另外两个躺在她的肋骨上。

  void TriangulateRoadEdge (Vector3 center, Vector3 mL, Vector3 mR) { roads.AddTriangle(center, mL, mR); roads.AddTriangleUV( new Vector2(1f, 0f), new Vector2(0f, 0f), new Vector2(0f, 0f) ); } 


道路边缘的一部分。

当我们需要对整条道路或仅一条边进行三角测量时,我们需要将其留为TriangulateRoad为此,此方法必须知道道路是否通过当前像元边缘的方向。因此,我们为此添加了一个参数。

  void TriangulateRoad ( Vector3 center, Vector3 mL, Vector3 mR, EdgeVertices e, bool hasRoadThroughCellEdge ) { if (hasRoadThroughCellEdge) { Vector3 mC = Vector3.Lerp(mL, mR, 0.5f); TriangulateRoadSegment(mL, mC, mR, e.v2, e.v3, e.v4); roads.AddTriangle(center, mL, mC); roads.AddTriangle(center, mC, mR); roads.AddTriangleUV( new Vector2(1f, 0f), new Vector2(0f, 0f), new Vector2(1f, 0f) ); roads.AddTriangleUV( new Vector2(1f, 0f), new Vector2(1f, 0f), new Vector2(0f, 0f) ); } else { TriangulateRoadEdge(center, mL, mR); } } 

现在TriangulateWithoutRiverTriangulateRoad当有任何道路穿过该单元时,它将必须调用而且他将必须传送有关道路是否通过当前边缘的信息。

  void TriangulateWithoutRiver ( HexDirection direction, HexCell cell, Vector3 center, EdgeVertices e ) { TriangulateEdgeFan(center, e, cell.Color); if (cell.HasRoads) { TriangulateRoad( center, Vector3.Lerp(center, e.v1, 0.5f), Vector3.Lerp(center, e.v5, 0.5f), e, cell.HasRoadThroughEdge(direction) ); } } 


肋骨完整的道路。

道路平整


道路现已完成。不幸的是,这种方法在细胞中心产生凸起。当左峰和右峰之间有道路时,将左峰和右峰放置在中心和拐角之间的中间位置适合我们。但是,如果不是这样,那么就会有凸起。为避免这种情况,在这种情况下,我们可以将顶点更靠近中心。更具体地,然后用1/4而不是1/2进行内插。

让我们创建一个单独的方法来确定要使用哪些插值器。由于其中有两个,我们可以将结果放入Vector2它的分量X将是左点的插值器,而分量Y将是右点的插值器。

  Vector2 GetRoadInterpolators (HexDirection direction, HexCell cell) { Vector2 interpolators; return interpolators; } 

如果有一条沿当前方向行驶的道路,我们可以将这些点放在中间。

  Vector2 GetRoadInterpolators (HexDirection direction, HexCell cell) { Vector2 interpolators; if (cell.HasRoadThroughEdge(direction)) { interpolators.x = interpolators.y = 0.5f; } return interpolators; } 

否则,选项可能会不同。对于左点,如果有沿前一方向行驶的道路,则可以使用½。如果不是,则必须使用¼。同样适用于正确的点,但要考虑以下方向。

  Vector2 GetRoadInterpolators (HexDirection direction, HexCell cell) { Vector2 interpolators; if (cell.HasRoadThroughEdge(direction)) { interpolators.x = interpolators.y = 0.5f; } else { interpolators.x = cell.HasRoadThroughEdge(direction.Previous()) ? 0.5f : 0.25f; interpolators.y = cell.HasRoadThroughEdge(direction.Next()) ? 0.5f : 0.25f; } return interpolators; } 

现在,您可以使用此新方法来确定使用哪些插值器。因此,道路将变得平坦。

  void TriangulateWithoutRiver ( HexDirection direction, HexCell cell, Vector3 center, EdgeVertices e ) { TriangulateEdgeFan(center, e, cell.Color); if (cell.HasRoads) { Vector2 interpolators = GetRoadInterpolators(direction, cell); TriangulateRoad( center, Vector3.Lerp(center, e.v1, interpolators.x), Vector3.Lerp(center, e.v5, interpolators.y), e, cell.HasRoadThroughEdge(direction) ); } } 



平坦的道路。

统一包装

河流与道路的结合


在目前阶段,我们有功能性道路,但前提是没有河流。如果该单元格中有河流,则不会对道路进行三角测量。


河流附近没有道路。

让我们创建一个方法TriangulateRoadAdjacentToRiver来处理这种情况。我们将其设置为通常的参数。我们将在方法开始时调用它TriangulateAdjacentToRiver

  void TriangulateAdjacentToRiver ( HexDirection direction, HexCell cell, Vector3 center, EdgeVertices e ) { if (cell.HasRoads) { TriangulateRoadAdjacentToRiver(direction, cell, center, e); } … } void TriangulateRoadAdjacentToRiver ( HexDirection direction, HexCell cell, Vector3 center, EdgeVertices e ) { } 

首先,我们将与没有河流的道路一样。我们将检查道路是否通过当前边缘,获取插值器,创建中峰并致电TriangulateRoad但是由于河流会出现在道路上,因此我们需要将道路移开。结果,道路的中心将处于不同的位置。我们使用一个变量来存储这个新位置roadCenter最初,它将等于像元的中心。

 void TriangulateRoadAdjacentToRiver ( HexDirection direction, HexCell cell, Vector3 center, EdgeVertices e ) { bool hasRoadThroughEdge = cell.HasRoadThroughEdge(direction); Vector2 interpolators = GetRoadInterpolators(direction, cell); Vector3 roadCenter = center; Vector3 mL = Vector3.Lerp(roadCenter, e.v1, interpolators.x); Vector3 mR = Vector3.Lerp(roadCenter, e.v5, interpolators.y); TriangulateRoad(roadCenter, mL, mR, e, hasRoadThroughEdge); } 

因此,我们将在具有河流的单元中创建局部道路。河流通过的方向将切穿道路上的缝隙。


有空间的道路。

河的起点或终点


让我们首先看一下包含河流起点或终点的单元格。为了使道路不与水重叠,让我们从河中移开道路中心。要获取流入或流出河流的方向,请添加该HexCell属性。

  public HexDirection RiverBeginOrEndDirection { get { return hasIncomingRiver ? incomingRiver : outgoingRiver; } } 

现在,我们可以使用此属性HexGridChunk.TriangulateRoadAdjacentToRiver沿相反方向移动道路中心。沿这个方向将三分之一移到中间肋骨就足够了。

  bool hasRoadThroughEdge = cell.HasRoadThroughEdge(direction); Vector2 interpolators = GetRoadInterpolators(direction, cell); Vector3 roadCenter = center; if (cell.HasRiverBeginOrEnd) { roadCenter += HexMetrics.GetSolidEdgeMiddle( cell.RiverBeginOrEndDirection.Opposite() ) * (1f / 3f); } Vector3 mL = Vector3.Lerp(roadCenter, e.v1, interpolators.x); Vector3 mR = Vector3.Lerp(roadCenter, e.v5, interpolators.y); TriangulateRoad(roadCenter, mL, mR, e, hasRoadThroughEdge); 


修改过的道路。

接下来,我们需要缩小差距。当我们靠近河流时,我们将通过在道路边缘添加其他三角形来实现此目的。如果在前面的方向上有河流,则我们在道路中心,单元格中心和左中点之间添加一个三角形。如果河流在下一个方向,则在道路中心,右中点和单元格中心之间添加一个三角形。

无论河流的配置如何,我们都会这样做,因此请将此代码放在方法的末尾。

  Vector3 mL = Vector3.Lerp(roadCenter, e.v1, interpolators.x); Vector3 mR = Vector3.Lerp(roadCenter, e.v5, interpolators.y); TriangulateRoad(roadCenter, mL, mR, e, hasRoadThroughEdge); if (cell.HasRiverThroughEdge(direction.Previous())) { TriangulateRoadEdge(roadCenter, center, mL); } if (cell.HasRiverThroughEdge(direction.Next())) { TriangulateRoadEdge(roadCenter, mR, center); } 

您不能使用else语句吗?
. , .


准备好道路。

直河


具有直河的单元格特别困难,因为它们实际上将单元格的中心一分为二。我们已经添加了额外的三角形来填充河流之间的空隙,但是我们还必须断开河流相对两侧的道路。


道路重叠一条直河。

如果单元格没有河流的起点或终点,那么我们可以检查流入和流出的河流是否朝相反的方向。如果是这样,那么我们就有一条直接河。

  if (cell.HasRiverBeginOrEnd) { roadCenter += HexMetrics.GetSolidEdgeMiddle( cell.RiverBeginOrEndDirection.Opposite() ) * (1f / 3f); } else if (cell.IncomingRiver == cell.OutgoingRiver.Opposite()) { } 

为了确定河流相对于当前方向的位置,我们需要检查附近的方向。这条河是左还是右。由于我们在方法末尾执行此操作,因此我们将这些请求缓存到布尔变量中。这也将简化我们的代码的阅读。

  bool hasRoadThroughEdge = cell.HasRoadThroughEdge(direction); bool previousHasRiver = cell.HasRiverThroughEdge(direction.Previous()); bool nextHasRiver = cell.HasRiverThroughEdge(direction.Next()); Vector2 interpolators = GetRoadInterpolators(direction, cell); Vector3 roadCenter = center; if (cell.HasRiverBeginOrEnd) { roadCenter += HexMetrics.GetSolidEdgeMiddle( cell.RiverBeginOrEndDirection.Opposite() ) * (1f / 3f); } else if (cell.IncomingRiver == cell.OutgoingRiver.Opposite()) { if (previousHasRiver) { } else { } } Vector3 mL = Vector3.Lerp(roadCenter, e.v1, interpolators.x); Vector3 mR = Vector3.Lerp(roadCenter, e.v5, interpolators.y); TriangulateRoad(roadCenter, mL, mR, e, hasRoadThroughEdge); if (previousHasRiver) { TriangulateRoadEdge(roadCenter, center, mL); } if (nextHasRiver) { TriangulateRoadEdge(roadCenter, mR, center); } 

我们需要将道路的中心移至一个角度向量,该向量指向与河流相反的方向。如果河流经过先前的方向,则为第二立体角。否则,这是第一个立体角。

  else if (cell.IncomingRiver == cell.OutgoingRiver.Opposite()) { Vector3 corner; if (previousHasRiver) { corner = HexMetrics.GetSecondSolidCorner(direction); } else { corner = HexMetrics.GetFirstSolidCorner(direction); } } 

要移动道路使其与河流相邻,我们需要将道路的中心移至此角的一半距离。然后,我们还必须在该方向上将像元中心移动四分之一距离。

  else if (cell.IncomingRiver == cell.OutgoingRiver.Opposite()) { Vector3 corner; if (previousHasRiver) { corner = HexMetrics.GetSecondSolidCorner(direction); } else { corner = HexMetrics.GetFirstSolidCorner(direction); } roadCenter += corner * 0.5f; center += corner * 0.25f; } 


分开的道路。

我们在该牢房内共享一条道路网络。当道路在河的两边时,这是正常的。但是,如果一侧没有道路,那么我们将有一小段孤立的道路。这是不合逻辑的,所以让我们摆脱这些部分。

确保有当前方向的道路。如果不是,则检查河流同一侧的另一方向是否有道路。如果那里或那里没有通过的道路,那么我们在三角剖分之前退出方法。

  if (previousHasRiver) { if ( !hasRoadThroughEdge && !cell.HasRoadThroughEdge(direction.Next()) ) { return; } corner = HexMetrics.GetSecondSolidCorner(direction); } else { if ( !hasRoadThroughEdge && !cell.HasRoadThroughEdge(direction.Previous()) ) { return; } corner = HexMetrics.GetFirstSolidCorner(direction); } 


截断的道路。

那桥梁呢?
. .

之字形河流


下一类河是之字形。这样的河流不共享路网,因此我们只需要移动路的中心即可。


之字形穿过道路。

检查曲折的最简单方法是比较流入和流出河流的方向。如果它们相邻,那么我们有一个之字形。根据流向,这会导致两个可能的选择。

  if (cell.HasRiverBeginOrEnd) { … } else if (cell.IncomingRiver == cell.OutgoingRiver.Opposite()) { … } else if (cell.IncomingRiver == cell.OutgoingRiver.Previous()) { } else if (cell.IncomingRiver == cell.OutgoingRiver.Next()) { } 

我们可以使用传入河流的方向角之一来移动道路的中心。您选择的角度取决于流向。从该角度将道路中心移动0.2倍。

  else if (cell.IncomingRiver == cell.OutgoingRiver.Previous()) { roadCenter -= HexMetrics.GetSecondCorner(cell.IncomingRiver) * 0.2f; } else if (cell.IncomingRiver == cell.OutgoingRiver.Next()) { roadCenter -= HexMetrics.GetFirstCorner(cell.IncomingRiver) * 0.2f; } 


道路从锯齿形推开。

在弯曲的河流里


最后的河流配置是一条平滑的曲线。与直接河一样,这条河也可以分隔道路。但是在这种情况下,各方将有所不同。首先,我们需要处理曲线的内部。


有铺好的路的弯曲的河。

当我们在当前方向的两边都有一条河时,那么我们就在曲线内。

  else if (cell.IncomingRiver == cell.OutgoingRiver.Next()) { … } else if (previousHasRiver && nextHasRiver) { } 

我们需要将道路的中心移向单元的当前边缘,从而稍微缩短了道路。系数为0.7即可。像元中心也应以0.5的系数移动。

  else if (previousHasRiver && nextHasRiver) { Vector3 offset = HexMetrics.GetSolidEdgeMiddle(direction) * HexMetrics.innerToOuter; roadCenter += offset * 0.7f; center += offset * 0.5f; } 


缩短的道路。

与直河一样,我们将需要切断道路的偏僻部分。在这种情况下,仅检查当前方向就足够了。

  else if (previousHasRiver && nextHasRiver) { if (!hasRoadThroughEdge) { return; } Vector3 offset = HexMetrics.GetSolidEdgeMiddle(direction) * HexMetrics.innerToOuter; roadCenter += offset * 0.7f; center += offset * 0.5f; } 


切断道路。

弯曲的河流外


在检查了所有先前的情况之后,唯一剩下的选择是弯曲河的外部。外面有牢房的三个部分。我们需要找到中间方向。收到它后,我们可以将道路的中心移向该肋骨0.25倍。

  else if (previousHasRiver && nextHasRiver) { … } else { HexDirection middle; if (previousHasRiver) { middle = direction.Next(); } else if (nextHasRiver) { middle = direction.Previous(); } else { middle = direction; } roadCenter += HexMetrics.GetSolidEdgeMiddle(middle) * 0.25f; } 


改变了道路的外面。

最后,我们需要截断这条河的道路。最简单的方法是检查道路相对于中间的所有三个方向。如果没有道路,我们将停止工作。

  else { HexDirection middle; if (previousHasRiver) { middle = direction.Next(); } else if (nextHasRiver) { middle = direction.Previous(); } else { middle = direction; } if ( !cell.HasRoadThroughEdge(middle) && !cell.HasRoadThroughEdge(middle.Previous()) && !cell.HasRoadThroughEdge(middle.Next()) ) { return; } roadCenter += HexMetrics.GetSolidEdgeMiddle(middle) * 0.25f; } 



修剪前后的道路。

处理完所有河流选项后,我们的河流和道路便可以共存。河流忽略道路,道路适应河流。


河流与道路的结合。

统一包装

道路的外观


在此之前,我们将其UV坐标用作道路颜色。由于仅U坐标发生了变化,因此我们实际上显示了道路中间和边缘之间的过渡。


显示UV坐标。

既然已经正确正确地对道路进行了三角剖分,我们就可以更改道路着色器,使其渲染更像道路的东西。就像河流一样,这将是简单的可视化,没有多余的装饰。

我们将从在道路上使用纯色开始。只需使用材料的颜色即可。我把它弄成红色。

  void surf (Input IN, inout SurfaceOutputStandard o) { fixed4 c = _Color; o.Albedo = c.rgb; o.Metallic = _Metallic; o.Smoothness = _Glossiness; o.Alpha = ca; } 


红色道路。

而且看起来已经好多了!但是,让我们继续使用U坐标作为混合因子,将道路与地形混合。

  void surf (Input IN, inout SurfaceOutputStandard o) { fixed4 c = _Color; float blend = IN.uv_MainTex.x; o.Albedo = c.rgb; o.Metallic = _Metallic; o.Smoothness = _Glossiness; o.Alpha = blend; } 

看来这并没有改变任何事情。发生这种情况是因为我们的着色器不透明。现在,他需要Alpha混合。特别是,我们需要用于贴花贴面的着色器。我们可以通过在指令中添加#pragma surface一行来获得所需的着色器decal:blend

  #pragma surface surf Standard fullforwardshadows decal:blend 


混合道路。

因此,我们创建了一个从中间到边缘的平滑线性混合,看起来并不十分漂亮。为了使其看起来像一条道路,我们需要一个坚实的区域,然后快速过渡到不透明的区域。您可以使用此功能smoothstep它将从0到1的线性级转换为S形曲线。


线性进程和平滑步伐。

该函数smoothstep具有一个最小值和最大值参数,可以在任意间隔内拟合曲线。超出范围的输入值受到限制,以保持曲线平坦。让我们在曲线的开头使用0.4,在曲线的结尾使用0.7。这意味着从0到0.4的U坐标将完全透明。从0.7到1的U坐标将完全不透明。过渡发生在0.4和0.7之间。

  float blend = IN.uv_MainTex.x; blend = smoothstep(0.4, 0.7, blend); 


在不透明区域和透明区域之间快速过渡。

有噪音的道路


由于道路网格将变形,因此道路的宽度会变化。因此,边缘处的过渡宽度也将是可变的。有时是模糊的,有时是苛刻的。如果我们认为道路是沙地或泥土,则这种变化适合我们。

让我们迈出下一步,在道路边缘添加噪点。这将使它们更加不均匀且多边形更少。我们可以通过采样噪声纹理来做到这一点。对于采样,您可以使用XZ世界的坐标,就像我们扭曲单元的顶点时一样。

要访问表面着色器中的世界位置,请添加到输入结构float3 worldPos

  struct Input { float2 uv_MainTex; float3 worldPos; }; 

现在我们可以使用该位置surf采样主纹理。还要缩小,否则纹理会经常重复。

  float4 noise = tex2D(_MainTex, IN.worldPos.xz * 0.025); fixed4 c = _Color; float blend = IN.uv_MainTex.x; 

我们通过将坐标U乘以来扭曲过渡noise.x但是,由于噪声值平均为0.5,因此大多数道路都会消失。为避免这种情况,请在相乘前对噪声加0.5。

  float blend = IN.uv_MainTex.x; blend *= noise.x + 0.5; blend = smoothstep(0.4, 0.7, blend); 



道路边缘变形。

为此,我们还将扭曲道路的颜色。这将使道路产生与模糊边缘相对应的污垢感。

将颜色乘以另一个噪声通道,例如noise.y这样我们就可以得到平均一半的颜色值。由于这太多了,我们将稍微降低噪声等级并添加一个常数,以使总和达到1。

  fixed4 c = _Color * (noise.y * 0.75 + 0.25); 


崎ough的道路。

统一包装

Source: https://habr.com/ru/post/zh-CN424491/


All Articles