资讯动态

Unity程序化道路网格生成:路口衔接与耳切法三角剖分实践

发布时间:2026/10/17 14:57:17 来源:尧图企业网站定制
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 ListRoadEnd 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度顺时针排列也可以用方向向量的叉积判断相对位置来排序。我建议直接用角度排序因为实现最简单还不会因为两条路方向恰好接近而产生排序抖动。ListVector2 SortPointsAroundIntersection(Vector2 center, ListVector2 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 Listint EarClippingTriangulate(ListVector2 polygon) { Listint indices new Listint(); ListVector2 verts new ListVector2(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(ListVector2 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折腾了整整一个周末某天深夜把耳切法换成独立轮廓点后才彻底想通的。过程中踩坑不要紧重要的是把每个坑的成因记录清楚这套流程才能真正变成你自己的工具箱。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑