做交通模拟或者城市场景程序化建模时,最容易让人卡住的往往不是直道,而是路口。直道说白了就是一条拉伸的矩形带,可一到十字路口,两条路交叉处的路面网格要怎么生成,转角要不要带圆弧,贴图方向怎么才不会乱,网格怎么和四条路无缝拼起来,这些问题能劝退一大半新手。我最近把项目里的路口网格生成逻辑整理成了一套尽量简单的方案:只要给道路中心线方向、路宽和转弯半径,就能在Unity里动态生成一个带圆角的路口Mesh。这套方法不考虑导航网格,不做地形融合,只解决渲染路面本身,非常适合城市沙盘、驾驶模拟Demo、交规演示这类需要快速铺路网原型的场景。
下面把思路、公式和完整流程都拆开讲,附带我踩过的坑和排查技巧。
1. 为什么建议用程序化方式生成路口网格
1.1 手工建模路口的真实痛点
很多人一开始做路口,最自然的想法是找美术同学拉一块路面模型,或者用Unity自带的基础模型拼一拼。拼十字路口其实还行,但一旦项目需要调整道路宽度、转弯半径、车道数量,或者要生成几十个不同夹角的路口,手工方案就完全失控了——改一个参数等于重新摆一遍模型,而且每个路口都要保证和路段无缝衔接,靠人肉对齐几乎不可能。
我见过一个做城市仿真的同事,早期就是用一堆Cube和Plane拼路口,结果路上车辆跑起来,轮胎不是悬空就是陷进路面,后来他干脆放弃美术拼接,转为程序生成。程序化最大的优势不是“快”,而是“参数可调”:路宽、半径、道路数量全是输入变量,改代码里的几个数字,整个路口网格就能重新生成,省下的时间不是一倍两倍。
1.2 程序化生成为什么适合路口
路口本身是一种高度规律的几何体:几条道路交汇,路面由一个中心多边形和若干条延伸出去的带状路段组成。这种结构非常适合用算法描述。只要定义好每条道路的中心线、半宽和转弯半径,就能推导出路口的所有边界顶点,再把这些顶点三角化成Mesh。
我经常把这个过程类比成“剪纸”:把每条路想象成一张纸带,交叉的地方折起来,然后沿着外围轮廓剪一刀。程序要做的事情就是计算出这条“裁剪线”上的所有顶点,剩下的交给三角剖分。相比正儿八经的CAD或GIS工具,Unity里自己写一套轻量生成逻辑,代码量不大,依赖少,效果也很稳定。
1.3 “简单方案”的适用边界
这套方法适合以下情况:
- 路面是平面,或者高度变化不大,不需要严格贴合复杂地形
- 路口以正交和近似正交为主,T形、Y形可以支持,但要额外处理(后面会讲)
- 目标是驾驶模拟、车流仿真、城市白模,而不是测绘级高精度模型
- 需要批量生成多个路口,且希望保留参数化调整能力
如果你要做的是非常复杂的异形路口、带渠化岛和导流线的那种,这套方法只能作为基础底子,还需要叠加其他几何处理。但先把简单路子跑通,再往复杂扩展,路会顺很多。
2. 核心原理:路口网格是怎么一步步算出来的
2.1 从道路中心线推导路面边界
所有路面网格生成的第一步,都是把“路”抽象成可计算的几何体。一条路可以简化为:
- 一条中心线:用起点和方向向量表示
- 一个半宽:等于车道宽度乘以车道数再除以2
比如一条双向四车道城市道路,单车道宽3.5米,四条车道就是14米,半宽就是7米。把中心线左右各平移半宽,就得到路面的两条边界线。
在Unity的XZ平面上,如果中心线方向是direction,那么边界线的法线方向是Vector2(-direction.y, direction.x)。左右边界分别是中心点沿法线正方向和负方向偏移半宽得到。这套逻辑既简单又可靠,是整个路口生成的基础。
2.2 路口的“控制点”与截断范围
两条道路交叉时,如果只把边界线无限延伸,路面会重叠成一片混乱。所以路口必须有一个明确的范围:离交叉中心一定距离处,把每条路的边界线截断,截断位置就是路口网格和路段网格的接缝。
实际操作时,我习惯先求所有道路中心线两两的交点,取几何中心作为路口中心;然后以每个交点为中心,沿道路方向向外延伸“半宽 + 转弯半径 + 预留值”作为截断位置。这个预留值是为了保证圆角曲线不会超出路口边界,一般取转弯半径再保守加一点。
边界线的延长交点就更关键了:路口外轮廓的每个角点,本质上是相邻两条道路各自某条边界线的交点。两条路交叉出一个十字路口,外轮廓有四个角;三条路交叉则可能出五六个角。把这些角点找出来,路口的大致轮廓就出来了。
2.3 转弯半径如何变成圆角顶点
车辆在路口转弯不能走直角,所以路口的角点不能直接当成顶点来用,否则车辆轨迹会出现明显的“切角”。一般做法是在每个角点处用一段圆弧代替原来的尖角。
圆弧参数主要看转弯半径R。设计规范里,城市主干道交叉口的转弯半径常见在8到12米,次干道在5到8米,但这个数值不需要死记,可以根据路口大小和车辆模型调节。圆弧的细分段数决定网格平滑度:段数太少,圆弧变的多边形折线,车跑起来会有顿挫;段数太多,顶点数暴涨,没必要。
我一般按“每段弦长不超过0.5米”来算段数。
int segments = Mathf.CeilToInt(Mathf.PI * turnRadius / 2f / 0.5f); segments = Mathf.Clamp(segments, 2, 12);比如转弯半径6米,90度圆弧长度约9.4米,算下来需要19段左右。实际我通常限制最多8段,视觉上已经够平滑,顶点数也省。
圆弧圆心怎么求?这里有个简洁做法:以角点沿两条边界线方向各取半径距离,得到圆弧的起点和终点,然后分别沿两条边界线的内法线方向偏移半径,两条偏移线的交点就是圆心。用两个直线相交函数就能算出来,不需要处理复杂的夹角三角函数。
2.4 顶点排序与三角剖分
有了路口外轮廓的所有顶点(直线截断点加圆弧点)之后,不能直接拿去生成三角形。三角剖分要求顶点按顺时针或逆时针顺序排列,否则面会乱飞。
我的排序方式是:先求所有顶点的平均位置作为中心点,然后对每个顶点计算相对中心的角度,按角度排序。
sortedPoints.Sort((a, b) => Mathf.Atan2(a.y - center.y, a.x - center.x) .CompareTo(Mathf.Atan2(b.y - center.y, b.x - center.x)));这里要注意:排序后的绕序必须和Unity的三角形正面方向一致。Unity中Y轴向上的坐标系,从上方往下看,逆时针绕序的三角形法线朝上。我从角度升序排序,得到的就是逆时针顺序。
如果多边形轮廓是凸的,三角剖分最简单的方式就是扇形剖分:以第一个顶点为根,每三个连续顶点构成一个三角形。路口带圆角的外轮廓基本是凸多边形,这条捷径可以直接用。放心,我后面会讲遇到凹形轮廓时该怎么处理。
2.5 UV与法线同样重要
生成顶点和三角形之后,如果直接丢给Mesh,材质会呈现一片混乱的颜色。还需要给每个顶点配UV坐标。
最简单也最实用的方法是:用顶点世界坐标除以一个缩放值得到UV。这样路面贴图的格子方向始终与场景坐标对齐,路段和路口拼接处的纹理方向也不会歪。
uv = new Vector2(worldPos.x / uvScale, worldPos.z / uvScale);法线方面,因为路口平面是平的,所有顶点法线直接指向Vector3.up就行。但如果用了Mesh.RecalculateNormals(),会自动根据三角形绕序生成法线,面朝上,省事。我一般会显式设置法线,再调一次RecalculateBounds,防止相机剔除把网格裁剪掉。
3. 手把手实现:一个可运行的生成器
3.1 数据结构的准备
先定义道路输入。每条路用起点、方向、半宽三件套就够了:
[System.Serializable] public class RoadInput { public Vector2 start; // 中心线起点 public Vector2 direction; // 进入路口的方向(归一化) public float halfWidth; // 道路半宽 }在场景里,我习惯让这些数据通过Inspector手动填,或者用几个空物体作为道路端点,代码自动读取位置和方向。后者更直观:在路口周围放几个空物体,每条路的起点指向路口中心,方向就是物体到路口的向量。
生成器的入口是一个组件,挂在空物体上:
public class CrossRoadGenerator : MonoBehaviour { public List<RoadInput> roads; public float turnRadius = 6f; public float uvScale = 2f; public MeshFilter meshFilter; public void Generate() { // 1. 求出所有边界线的交点 // 2. 对每个角点生成圆角圆弧 // 3. 排序所有顶点 // 4. 扇形三角剖分 // 5. 生成Mesh赋值给MeshFilter } }3.2 关键函数拆解
第一个核心函数是直线相交。两个点沿方向延伸,求交点:
bool LineIntersect(Vector2 a0, Vector2 aDir, Vector2 b0, Vector2 bDir, out Vector2 point) { float cross = aDir.x * bDir.y - aDir.y * bDir.x; if (Mathf.Abs(cross) < 1e-6f) { point = Vector2.zero; return false; } float t = (b0.x - a0.x) * bDir.y - (b0.y - a0.y) * bDir.x; t /= cross; point = a0 + aDir * t; return true; }第二个核心函数是生成圆弧顶点。拿到角点、两条边界线方向、半径以后,先求圆弧起点终点,再求圆心,然后按弧长细分生成一串点:
List<Vector2> GenerateArc( Vector2 corner, Vector2 dirA, Vector2 dirB, float radius, int segments) { Vector2 p1 = corner + dirA * radius; Vector2 p2 = corner + dirB * radius; Vector2 nA = new Vector2(-dirA.y, dirA.x); Vector2 nB = new Vector2(-dirB.y, dirB.x); LineIntersect(p1, nA, p2, nB, out Vector2 center); Vector2 v1 = p1 - center; Vector2 v2 = p2 - center; float startAngle = Mathf.Atan2(v1.y, v1.x); float endAngle = Mathf.Atan2(v2.y, v2.x); List<Vector2> arcPoints = new List<Vector2>(); for (int i = 0; i <= segments; i++) { float t = (float)i / segments; float angle = Mathf.LerpAngle(startAngle, endAngle, t); arcPoints.Add(center + new Vector2(Mathf.Cos(angle), Mathf.Sin(angle)) * radius); } return arcPoints; }注意这里Mathf.LerpAngle能自动处理角度绕一圈的情况,90度角、270度补角都能正确插值。我在第一次实现时就是用普通Lerp,结果非正交路口圆弧角度反了,后来换成LerpAngle才稳定。
第三个核心函数是三角化。假设顶点列表已经按角度排好序:
int[] BuildTriangles(List<Vector2> points) { List<int> tris = new List<int>(); for (int i = 1; i < points.Count - 1; i++) { tris.Add(0); tris.Add(i); tris.Add(i + 1); } return tris.ToArray(); }把圆弧点和直线截断点混在一起,统一排序后,扇形剖分生成三角形的效果在Orthographic视图下看非常干净,每个三角形都是沿着中心向外辐射。
3.3 参数选择经验
参数这块很多人会对turnRadius和uvScale怎么取感到困惑。turnRadius在简单方案里取多少并不需要严格遵循规范,主要看视觉和运动效果。如果项目里车辆模型转弯需要更真实的半径,就让半径匹配车辆最大转向角,公式是半径 = 轴距 / tan(前轮转角)。但我作为简单方案的默认值,直接给6到8米,视觉上很舒服。
uvScale决定了路面贴图一个格子对应多少米。我在项目里常用2,也就是一个贴图周期对应2米。如果你的路面贴图上标线宽度需要精确对应车道线实际宽度,那就按贴图像素比例反推,这个值通常要单独调。
3.4 验证与调试
生成完网格,Unity里验证时我一般:
- 创建一个空物体,挂
MeshFilter和MeshRenderer - 给
MeshRenderer一个简单的路面材质,比如带网格线的基础材质 - 点击Generate按钮,然后切换到Scene视图,用线框模式看三角形排布
第一次跑通时,场景里出现一个四四方方的带圆角十字路口,车辆路径顺着圆角走,贴图也正常衔接路段。这个时候不要着急接入复杂场景,先跑通最简单的双路直角交叉,再逐步加路数、调半径。
4. 常见问题与排查技巧
4.1 高频问题速查
| 症状 | 原因 | 解决方向 |
|---|---|---|
| 网格显示成紫色或黑面 | 材质缺失或法线反了 | 检查材质球,确认三角形绕序是逆时针 |
| 路口出现尖角而不是圆弧 | 圆弧细分段数太少 | 增大segments或改用弦长公式计算 |
| 路面和路段之间有裂缝 | 两个网格共享边界点不一致 | 坐标复用同一套数据源,不重复计算 |
| 车跑起来路面闪烁抖动 | 路面与其他地面重叠 | 将路面略微抬高0.02到0.05米 |
| 网格在特定视角消失 | 包围盒没有更新 | 调RecalculateBounds |
| 三角形四面乱飞 | 顶点排序失败 | 检查中心点是否偏离,检查凹多边形情况 |
4.2 三角剖分失败的深层原因
最容易翻车的地方是排序。顶点绕中心按角度排序的假设前提是“外轮廓相对中心点是星形的”,大多数路口满足这个条件,但遇到T形路口或某条路特别窄、中心点偏离严重时,排序结果会出错,三角形就会穿插。
我的解决思路有两个:
第一个思路是检查生成结果,如果发现三角形穿插,就把中心点从所有顶点的平均位置改成道路中心线交点的中心,排序更稳定。第二个思路是干脆加自己的耳切法三角化,它不依赖排序,对凹多边形也能稳定切出三角形。耳切法代码量不大,但属于复杂方案,简单版本里不太需要。
4.3 拼接与裂缝问题
路段网格和路口网格的边界如果不共享顶点,即使视觉上贴合,也容易出现微小裂缝。最稳妥的做法是:路段生成函数接收路口返回的边界点坐标,直接使用同一条边界线数据。也就是“谁生成边界点,谁作为权威数据源”。
比如路段mesh需要知道它在路口端部的边界坐标,这个坐标应该在路口生成器里算好,再传给路段生成函数。不要在两边分别计算,因为浮点运算的舍入差异就可能让裂缝出现。
如果项目里两个网格必须分开生成,那就在运行时把两者的边界点进行“缝合”:用一段窄条形网格覆盖接缝。这个做法能兜底,但不优雅,我一般只在补历史遗留方案时才用。
4.4 贴图UV异常怎么调
UV最常见的问题是路口内部贴图与路段方向不一致。用世界坐标UV基本能避免这个问题,因为路段也是用世界坐标UV,两边纹理是连续的。如果发现路口内部图案像被拉斜了,多半是圆弧顶点生成时使用的平面坐标轴和世界坐标轴不一致,检查一下输入的方向向量是否有归一化,以及是否把XZ平面的坐标正确放进了Vector2。
还有一个经验:如果路面贴图里有车道线,比如白色虚线,用平面UV会完美地把虚线延伸到路口,但路口内部没有车道线,因此贴图里不要画太多标线,否则路口中心会出现一堆断点。简单方案里最好用纯色路面贴图,标线等后期用额外几何或者纹理叠加再处理。
4.5 运行性能注意
网格生成是一次性操作,但在城市级场景里,几十个路口的网格顶点加起来会很可观。我的经验是两个优化点:
- 生成后立即设置
Mesh.UploadMeshData(true),把网格从CPU上传到GPU后释放CPU侧数据,能明显减少内存占用 - 相邻多个路口合并成一个
Mesh,减少DrawCall。路口网格和路段网格如果用同一材质,直接用Mesh.CombineMeshes合并,静态批处理不用开,渲染性能就很理想
5. 从“简单路口”到“够用路口”的扩展思路
5.1 支持多车道路口
RoadInput里其实可以顺手加一个laneCount字段。边界依然用总路宽,但圆角半径可以按最外侧车道取,生成完路面网格后,再单独生成车道标线,比如停止线、斑马线。
做法是:把路口的中心多边形顶点,沿每条道路方向偏移一定距离画一些短线网格,作为停止线;斑马线则用若干小长条网格叠在路面上。这样在简单路面基础上,视觉上就能接近真实路口。
5.2 支持T形路口和Y形路口
严格来说,T形路口也可以用角点加圆弧的思路,难点在于其中一条路可能只有三个外角,且有一个角是直角贴边。我的方案是:先把所有道路中心线延长,求出主干道和支路的交点,然后支路的边界截断在交点处;主干道沿着支路两侧的方向继续前进直到路口边界。这样计算出来的外轮廓还是凸多边形,扇形三角化依然能用。
Y形路口类似,只要保证所有边界线参与求交集、排序后按凸包处理,通常也能直接生成。实在出现凹形轮廓时,一个快速兜底方案是把凹进去的那个角直接去掉,视觉影响不大,车辆行驶反而更顺。
5.3 高程贴合与斜坡路口
简单方案默认路面是平的,如果地形有明显坡度,路口路面会和地形产生裂缝或穿插。一个低成本做法是:生成网格后,对每个顶点用地形高度采样作为Y坐标,并且让路口周围的边界顶点高度与对应路段端部高度严格一致。如果路口内部有横坡或纵坡,这种方法就不够精确了,需要把平面几何换成三维几何来算。
我在项目里的做法是:路口中心区域拍平到平均高度,从中心到各个路段的边界点做渐变过渡。这样接缝处不会出现高度突变,车辆跑起来也不会有明显的台阶感。
5.4 参数化批量生成
把RoadInput列表做成可配置数据后,批量生成就变得很简单。可以写一个Editor窗口,填好路网配置,一键生成整个区域的路口。配置可以存成ScriptableObject,随时调整。
“简单”方案最大的价值在于它是一块干净的跳板:先用它跑通基础功能,再根据实际需求扩展车道、交通标识、地形贴合。我在项目里就是这样一步步把路口从“能看”做到“够用”的。
最后分享一个我实际操作中养成的小习惯:每次改完生成代码,不要在Scene视图放大看模型就完事,而是把生成结果运行起来,让一辆车按正常车速路过路口,盯着车轮和地面。很多网格问题在静态视图下看不出来,车一跑就原形毕露。如果你按这套方法实现,第一次跑通圆角路口的时候,试着把turnRadius从0拉到20,那种参数化调整带来的掌控感,手工摆模型永远给不了。