1. 先说清楚:路口网格到底解决什么问题
在Unity里做道路网格,绕不开一个让人头疼的点:路口。你可能沿着路网跑了一整圈生成逻辑,接到十字路口时网格乱了,接缝对不上,重叠面满天飞。我试过好几套方案,最后稳定下来的这套“路口网格生成”做法,思路不复杂,不依赖第三方库,直接在编辑器里跑,适合用在程序化街道、赛车游戏赛道、甚至简单的城市沙盘里。如果你正卡在“路的相交处怎么把网格拼干净”这个问题上,这篇文章大概率对你有用。
1.1 直接拉伸道路线,为什么到路口就翻车
很多人做程序化道路的第一反应是:沿着道路中心线两侧偏移出边线,然后生成一条带状的网格。单段路这么干完全没问题,但一旦两条路相交,问题就全冒出来了。
拿最常见的十字路口来说,两条中心线在交点附近交叉,如果你各自生成自己的带装网格,两个网格会在路口区域互相穿插。地面看起来还好,但只要你给路面一点点起伏,或者路本身有坡度,两片网格就会在相交处出现明显的裂缝、Z-fighting(深度冲突导致的闪烁)以及三角形穿模。就算你强行把交点处的顶点拉到同一个高度,网格拓扑仍然是乱的,因为两边生成的面片并没有真正形成闭合的区域。
我之前试过加一个“盖帽”方案:在路口扔一个圆盘或四边形平面上去盖住交叉点。样子倒是能看,但路口的边缘是圆滑的,一个纯方的盖板死活对不齐斜交道路的边线;更别提路面纹理的UV会乱,路沿接缝处出现拉伸和撕裂。到这一步你就明白了:路口不能靠“修补”去解决,得把它当作一个独立的网格来生成,这就是整套方案的核心出发点。
1.2 核心思路:把相交区域单独拎出来生成
我的做法很简单:不再让每一条道路往路口里硬塞,而是先找出路口的影响范围,再根据所有进入路口的道路方向,生成一个闭合的多边形轮廓,最后对这个多边形做三角剖分,输出一张完整独立的路口网格。
这个方案的直接好处有三个:
- 路口网格和道路网格各管各的,不存在穿插和错位。
- 路口的外轮廓完全由道路的宽度、方向决定,天然与路段接缝对齐,斜交路口也能很好适配。
- 三角剖分只针对单个简单多边形,算法稳定,顶点数量可控,性能开销极小。
后面我会把这个过程拆成四步讲:数据准备、轮廓点计算、三角剖分、网格后处理,每一步都给可复用的代码和参数。
2. 先说数据:中心线怎么组织,路口范围怎么算
网格生成是数学活,但前提是输入数据得干净。我做这套方案时,输入数据的灵活度决定了后续代码能不能复用,所以这一步值得认真设计。
2.1 中心线数据的组织方式
我没有用很复杂的路网数据结构,就是一个点序列列表。每条路存下起点、终点、宽度,路中间如果有拐弯,就存多个中间点。关键要求是:所有进入路口的道路,在交点附近的局部方向必须是可靠的直线段,因为后面计算轮廓点要靠这个方向做垂直偏移。
举个例子,如果你有两条路相交,它们的中心线数据大概是这个结构:
public struct RoadData { public Vector2[] points; // 按顺序排列的中心线点,世界坐标 public float width; // 道路宽度 }在生成路口网格前,我通常会做一步预处理:把每条路的末端点延伸或裁剪到实际交点。这里说的交点就是两条中心线的交点。实际项目中你可能会遇到多条路交于一点,或者一条路的端点落在地脉中间,这些情况都会在预处理阶段被统一成“一个交点 + N条进入方向”的形式。
预处理函数长这样:
public class RoadIntersection { public Vector2 intersectionPoint; // 中心交点 public List<RoadEnd> roads; // 进入路口的每条路 public struct RoadEnd { public Vector2 direction; // 指向路口的单位向量 public Vector2 outwardDirection; // 指向路口外的单位向量 public float halfWidth; // 半路宽 public float length; // 本轮有效截取长度 } }重点在于 direction 统一指向路口中心,outwardDirection 反过来。两个方向向量都会在轮廓点计算时用到。预处理做得好,后面的三角剖分基本不太需要操心方向。
2.2 求交与路口影响范围的确定
路口的“影响范围”其实就是所有进入道路的外扩轮廓围出来的区域。计算这个区域前,需要先获得交点坐标。如果路网里有十字路口,交点就是两条中心线段的直接交点;如果是三条以上道路共点,我建议直接取这些中心线段的平均接触点,误差会小很多。
伪代码思路:
Vector2 GetIntersection(RoadData a, RoadData b) { // 取a的最后一段和b的最后一段作为线段 Vector2 p1 = a.points[a.points.Length - 2]; Vector2 p2 = a.points[a.points.Length - 1]; Vector2 p3 = b.points[b.points.Length - 2]; Vector2 p4 = b.points[b.points.Length - 1]; // 利用线段交点公式计算 Vector2 deltaA = p2 - p1; Vector2 deltaB = p4 - p3; float cross = Cross2D(deltaA, deltaB); if (Mathf.Abs(cross) < 1e-4f) { // 近乎平行,取中点兜底 return (p2 + p4) * 0.5f; } float t = Cross2D(p3 - p1, deltaB) / cross; return p1 + deltaA * t; }这个阶段要注意一个问题:预处理的期限选择。很多路网工具直接在生成路段时就把交点算好了,这种数据源最省心。如果数据源本身就是稀疏顶点,中间缺少精确交点,那就要多做一步线段求交和插值。我自己的做法是专门写了一个路网预处理脚本,对所有路段的端点做最近邻匹配,并把匹配到的交点回填到每条路中心线的末尾,保证每条路最后一段都是“直直地插进路口”的。
路口影响范围的另一种确定方式是用半路宽做外扩。得到交点后,每条路在交点附近取一个半宽偏移量,顺着垂直方向展开,就得到了一个轮廓多边形的顶点。多边形的形状和朝向完全由各道路的宽度和方向决定,这也是后面轮廓点计算的基础。所以影响范围不用特意去“求”,它会随着轮廓点的出现自然成形。
3. 轮廓点计算:让每个路口都“圆滑”起来
这部分是整个生成流程的重头戏。轮廓点算得好,路口网格的形就定了一大半;算得不好,后面再怎么修网格都白搭。
3.1 基于垂直偏移的轮廓点算法
每一条进入路口的道路,都有一个方向向量和半路宽。轮廓点其实就是从交点出发,沿着道路方向的反方向走一小段(这个距离可以等于半路宽),再在垂直方向左右各偏移半路宽,得到两个点。
写成 C# 就能直观地看:
Vector2 GetParallelOffset(Vector2 direction, Vector2 normal, float halfWidth) { Vector2 left = intersectionPoint + outwardDir * halfWidth + normal * halfWidth; Vector2 right = intersectionPoint + outwardDir * halfWidth - normal * halfWidth; return (left, right); }这样每条路会贡献两个轮廓点,N条路就得到 2N 个轮廓点。把这些点按角度排序,围绕交点形成闭合的环形多边形。
关键点在于:排序顺序必须统一。你可以规定所有轮廓点按角度从0到360度顺时针排列,也可以用方向向量的叉积判断相对位置来排序。我建议直接用角度排序,因为实现最简单,还不会因为两条路方向恰好接近而产生排序抖动。
List<Vector2> SortPointsAroundIntersection(Vector2 center, List<Vector2> points) { points.Sort((a, b) => { float angleA = Mathf.Atan2(a.y - center.y, a.x - center.x); float angleB = Mathf.Atan2(b.y - center.y, b.x - center.x); return angleA.CompareTo(angleB); }); return points; }轮廓点数量很关键。十字路口是 4 个点,三条路的 T 型路口是 6 个点。如果你把每条路的左右两个点都算进去,可能会得到更多点数(比如十字路口就是 8 个点),这时需要去重或者统一到多边形的“外凸点”上去。我在实践中更倾向于只计算每条路的两个外轮廓点,然后对它们做共线合并:如果某两个点几乎重合(距离小于阈值,比如 0.01 倍半路宽),就合并成一个,避免生成退化三角形。
3.2 路口的圆角过渡与导角处理
实际道路的路口,尤其是城市道路,很少是尖角的。四条路相交,路口会有一个圆弧状的路缘石。为了做真实感,你可以对轮廓多边形做一次“圆角化”处理。
具体做法:在每个轮廓点处,用相邻两条边的方向构造一个内切圆弧,圆弧半径可以用道路宽度的 1/4 到 1/2 之间取一个固定值,也可以按路口大小动态算。圆角越平滑,需要的顶点数越多,但一般生成 2~4 个圆弧分段点就够用了,再多性价比就很低了。
Vector2[] RoundCorner(Vector2 p, Vector2 prev, Vector2 next, float radius, int segments) { // p是尖角点,prev和next是相邻轮廓点 // 算出两条边的方向,向外偏移radius取得圆心 // 然后圆弧采样segments个点,返回新点列 }注意圆弧的朝向。轮廓多边形是逆时针还是顺时针,影响着圆弧是凸出去还是凹进来。我在调试时吃过亏,方向反了,好端端的十字路口变得跟花瓣一样,后来统一规定:所有轮廓点排序为逆时针,圆弧偏向多边形外侧,这样生成的路口是向外圆润兜边的,看起来最自然。
圆角处理后,原来的尖角轮廓变成了一系列小线段形成的弧线,弧线点也会依次参与后面的三角剖分,所以轮廓点总数会从 2N 变成 2N + N 乘以每段圆弧点数。城市规模的路口我一般设圆弧分为 3 个点,性能完全没问题。
有了闭合轮廓点集后,下一步就可以准备三角化。
4. 网格生成的三角化:从轮廓多边形到三角形面片
得到轮廓点集只是第一步,真正的网格生成核心是:如何把闭合多边形划分为三角形列表。这一步选错算法,后续就全是坑。
4.1 三角剖分的可选方案与选择
我在Unity里试过三种做法:耳切法、Unity自带的MeshUtility、以及自己写Delaunay。最终长期使用的是耳切法,因为它在“凸多边形”和“简单凹多边形”上都稳,而且实现很轻量。
耳切法的思路一句话概括:找到多边形的一个“耳朵”(一个凸顶点,且由它形成的三角形落在多边形内部),切掉它,剩下少一条边的多边形,继续重复,直到只剩一个三角形。
public static List<int> EarClippingTriangulate(List<Vector2> polygon) { List<int> indices = new List<int>(); List<Vector2> verts = new List<Vector2>(polygon); int n = verts.Count; while (n > 3) { bool earFound = false; for (int i = 0; i < n; i++) { int prev = (i - 1 + n) % n; int next = (i + 1) % n; Vector2 a = verts[prev], b = verts[i], c = verts[next]; if (IsConvex(a, b, c) && IsPointInsideTriangle(verts, a, b, c)) { indices.Add(prev); indices.Add(i); indices.Add(next); verts.RemoveAt(i); n--; earFound = true; break; } } if (!earFound) { // 说明多边形退化或顶点顺序有问题,直接放弃退出 Debug.LogWarning("耳切法失败,请检查轮廓点顺序"); break; } } if (n == 3) { indices.Add(0); indices.Add(1); indices.Add(2); } return indices; }这里有两个坑必须提:
- 顶点顺序必须一致。如果多边形是顺时针排列,IsConvex 的判断符号要对调。我的经验是统一用逆时针,所有脚本数据出入口都做一次标准化,避免不同功能模块间方向不一致。
- IsPointInsideTriangle 不只是判断点是否在三角形内,还需要排除那些落在三角形边上或非常靠近边界的点,否则容易出现退化三角形和网格噪点。
4.2 法线计算与顶点顺序统一
三角剖分输出的 indices 是三角形顶点的索引,它的环绕顺序决定了三角形的法线方向。Unity 的 Mesh 默认使用左手坐标系 + 顺时针为正面,如果你想要正面朝上,三角形的索引顺序应该是顺时针(从顶视图看)。
一般做法是先按逆时针生成三角形,然后通过一次翻转修正。最简单可靠的方式是:生成Mesh后,遍历所有三角形,判断法线是否与期望方向一致,不一致就交换三角形的第一和第三个索引。
Vector3 normal = Vector3.Cross(vertices[tri[1]] - vertices[tri[0]], vertices[tri[2]] - vertices[tri[0]]); float dot = Vector3.Dot(normal, Vector3.up); if (dot < 0f) { int tmp = tri[0]; tri[0] = tri[2]; tri[2] = tmp; }我踩过的坑是:耳切法在凹多边形上容易生成倒转的三角形,修正が必要的,上面这段代码基本是每次生成后必跑的。
三角形数量估算:一个十字路口的轮廓点若为 8 个,耳切法大约生成 6 个三角形;若做了圆弧化,轮廓点 20 个,三角形大约 18 个。这个量级非常轻量,千级路口也扛得住。
5. 后处理:UV贴图、法线重算与合并细节
网格几何有了,但离烘焙美术还差两步:UV贴图和法线平顺。这两个做不好,贴出来的路沿会撕裂,路面高光会乱跳。
5.1 UV映射:别让纹理在你路口转圈圈
路口网格的 UV 映射不能简单用世界坐标整数平铺,因为 V 方向如果跟着轮廓点走,纹理会在环形处拧起来。
我的做法是二次映射:先给每个顶点计算一个基于到交点距离的径向 UV。具体来讲,以交点为中心,设一个世界空间到UV空间的缩放系数(比如 1 米对应 0.1 个 UV 单位),然后每个顶点的 UV 是它相对交点的偏移量乘上这个系数。这样路面纹理在路口区域是铺开而不是旋转的,整体衔接起来视觉比较统一。
Vector2 uv = new Vector2( (vertex.x - intersectionPoint.x) * uvScale, (vertex.y - intersectionPoint.y) * uvScale );对于圆弧段,这样UV会有点拉伸,通常公路贴图拉伸一点点根本看不出来。如果你要特别精细,可以在圆弧段做单独的环向UV,但收益没想象中高,非影视级项目不必纠结。
5.2 法线计算与路面平滑
光滑的路面不能只有朝上的一个法线。因为路口和路段之间存在坡度,法线必须根据实际地形起伏来重算。
最简单的方法是:法线由自身三角形的法线平均而来;或者直接根据相邻顶点的高度差计算切线——前者实现简单,后者更贴合地形。如果整个路口是平面,直接统一 Vector3.up 就行。但如果你在山区修路,路口附近地形有起伏,那建议用叉积算每个三角形的面法线,然后按顶点累加归一化。
Vector3[] RecalculateNormals(Vector3[] verts, int[] tris) { Vector3[] normals = new Vector3[verts.Length]; for (int i = 0; i < tris.Length; i += 3) { int i0 = tris[i], i1 = tris[i + 1], i2 = tris[i + 2]; Vector3 v0 = verts[i1] - verts[i0]; Vector3 v1 = verts[i2] - verts[i0]; Vector3 n = Vector3.Cross(v0, v1).normalized; normals[i0] += n; normals[i1] += n; normals[i2] += n; } for (int i = 0; i < normals.Length; i++) normals[i].Normalize(); return normals; }5.3 网格合并与道路衔接
最后一步是让路口网格和路段网格无缝衔接。方式很简单:在合并前,把每条道路在路口附近的端点精确对齐到路口轮廓外沿的对应顶点,从而保证两个Mesh共享公共边。
我在实际工程里会做这样一个操作:先生成路口网格,再根据路口轮廓外沿反推路段网格的端点位置,让路段网格的边与路口轮廓完全重合。这样合并后不会出现缝隙,也不会有重叠三角形。如果项目里需要整张路网Mesh合并,我建议用Unity的Mesh.CombineMeshes按区域分块合并,不要一上来就合全世界。分块合,碰撞体还能省着配。
6. 实操中常见的坑和排查思路
这一节我整理了自己做路网生成时遇到最多的几类问题,并且给出排查方向。很多问题不是算法错,而是输入数据或网格拓扑的不规范。
6.1 网格闪烁和接缝处撕裂
现象:路口与路段交界处,渲染时能看到亮线闪烁或裂缝。
排查顺序:
- 先检查重叠度。如果把接口处顶点严格对齐,接缝闪烁基本就是两个Mesh有共边但顶点顺序不一致导致的。
- 再检查深度阈值。烘焙光照或贴花时,共边顶点必须完全一致(float精度),否则Z-fighting就会出现。可以在生成后对所有接口顶点做一次 Snap 取整,精度设到毫米级。
Vector3 SnapToGrid(Vector3 v, float gridSize = 0.001f) { return new Vector3( Mathf.Round(v.x / gridSize) * gridSize, Mathf.Round(v.y / gridSize) * gridSize, Mathf.Round(v.z / gridSize) * gridSize ); }- 最后看是否有独立的重合面片。有可能是耳切法对凹多边形产生了一些“额外”的面,导致两层Mesh叠着。
6.2 法线方向“阴阳脸”问题
现象:同一片路面,正着看是亮的,反着看是暗的,甚至能看穿地面。
我做完三角化后,第一件事必然是整体检查三角形面积符号。手工看的话,在Scene视图开启“Face Orientation”显示模式,一眼就能看出哪些三角形是反的。修正时别一行一行改,直接遍历全部三角形做全局翻转,然后逐个剔除面积过小的退化三角形,安全得很。
6.3 耳切法偶尔失败
耳切法在极窄长凹多边形上可能因为“耳判定”找不到合法耳朵而提前退出。遇到这种情况,我把轮廓点稍微圆角化,把尖角磨钝一点,耳切法就正常了。如果还不能解决,就得检查是否有自交多边形——轮廓点顺序在经历过圆弧化后偶尔会出现自交,这是最麻烦的,推荐用多边形自交检测提前排查。
bool IsSelfIntersecting(List<Vector2> polygon) { for (int i = 0; i < polygon.Count; i++) { int i2 = (i + 1) % polygon.Count; for (int j = i + 1; j < polygon.Count; j++) { int j2 = (j + 1) % polygon.Count; if (i == j || i2 == j || i == j2 || i2 == j2) continue; if (SegmentIntersect(polygon[i], polygon[i2], polygon[j], polygon[j2])) return true; } } return false; }6.4 碰撞体与NavMesh会失效
生成完路口网格后,如果不做手动合并,Road的MeshCollider和NavMesh都会因为多个Mesh交叠而生成失败。我的习惯是:把路口和关联路段合并成一个独立的MeshCollider,或者用一个自定义凸包碰撞体去近似。某些程序化跑酷路网里,我甚至直接用一组BoxCollider铺在路面上,省得MeshCollider每次合帧都重构。
7. 从路口到更复杂形态:适配与后续扩展
这套简单路口生成方法最大的好处是基础稳固,稍微变一变就能适配更多形态。
7.1 适配T型路口、五岔路口
从一开始的“交点 + N条方向”的结构设计,决定了它天然支持多路相交。你只要把RoadData列表里加两条路,T型路口和五岔路口就自动出来了。T型路口的轮廓点不是完全对称的,耳切法处理起来也没问题;五岔路口的轮廓点数量增加到10个以上,圆弧化后点会更多,三角形数量上升但依旧可控。
7.2 环岛、立交的扩展方向
如果你做更大规模的城市路网,路口网格可以继续演进出环岛和立交。环岛无非是把交点改为一个中心圆,然后把进入道路的轮廓点与圆形外沿做“扇形”网格连接;立交则要把平面路口升级为多层网格,让各行其道的道路层通过坡道衔接。基础的顶点计算和三角化思路完全复用,差别主要在数据分层。
我个人最近在做的一个城市沙盘项目里,已经把普通十字路口、T型路口、斜交路口都统一成了这套生成器,后续只需要扩展路口的装饰物生成(红绿灯、斑马线、停止线),整个路网系统就完全够用了。
在做扩展时,我的建议一直是保留路口数据模型与生成逻辑的分离。不要为了省事把车辙、人行横道直接写在网格生成脚本里,而是让网格生成器专注于“几何结构输出”,装饰部分交给后续的实例化系统去处理。这样哪怕以后换渲染管线,核心逻辑一行都不用动。
如果文章看到这里,你已经有了一个能跑通的路口网格生成流程。接下来把它接进自己的路网系统,把真实数据喂进去看看效果。等你自己跑过一圈十字路口和五岔路口,再回头看这个方案,应该会觉得“原来路口也没那么难”。我当初也是被一段错误的路口UV折腾了整整一个周末,某天深夜把耳切法换成独立轮廓点后才彻底想通的。过程中踩坑不要紧,重要的是把每个坑的成因记录清楚,这套流程才能真正变成你自己的工具箱。