简介:面向自动驾驶仿真与虚拟测试场景的Unity道路生成器工程,主要服务于C#开发者、环境仿真工程师以及AD算法研究人员。项目核心基于标准OpenDRIVE文件格式,解析道路几何、车道与交叉口等描述信息,并借助EasyRoads3D工具集在Unity中生成完整道路结构及周边设施,包括桥梁、护栏、交通标志等,从而为车辆行为模拟、点云数据生成及复杂AD算法验证提供可控的仿真环境。压缩包共61个文件,以C#脚本、XML道路描述、PNG示意图和说明文档为主,全部代码与示例数据打包后约13.56MB,轻量便于下载和二次开发。目前已有608人浏览学习。资源内提供XODR2PATH、PathCreator等核心功能脚本,并附有多条示例路线与交叉口配置,以及弓形线段等几何可视化参考图,读者可以结合代码和文档完整理解从OpenDRIVE解析到Unity场景构建的转换流程,适合正在搭建驾驶模拟器或需要扩展道路生成工具链的中高阶开发者。
1. Unity 接 OpenDrive:为什么一个标准格式,落地时总在翻车
OpenDrive 在自动驾驶仿真圈子里几乎是高精地图的“普通话”,Unity 里做模拟环境也绕不开它。但凡是亲手把一条 OpenDrive 道路加载进 Unity 的开发者,多半都经历过同一个困惑:明明文档写得清清楚楚,解析出来的路网却乱成一团——车道偏移、路面破损、车辆飞出天际线。问题不在格式本身,而在于 Unity 的左手坐标系、单位尺寸和 OpenDrive 的右手系/米制约定之间存在三道“隐形鸿沟”。
这份 Unity_OpenDrive_SimEnv 项目资源,说白了就是把「OpenDrive 标准 → 几何解析 → 道路网格 → 车辆循迹 → 传感器仿真」这条链路整体打通的一套 C# 实现。它解决的核心矛盾是:让 Unity 里的模型车跑在符合 OpenDrive 语义的道路上,而不是随便铺一条样条曲线上去“看起来像路”。适合谁?打算自建自动驾驶仿真场景、想深入理解高精地图数据结构的开发者,以及被某商业工具的地图导出格式逼疯、决定自己动手解析地图文件的人。
2. 先拆开 OpenDrive 的“三明治”:路、段、车道的数据层级
2.1 OpenDrive 不是一张图,而是一棵有逻辑的树
很多人第一次接触 OpenDrive,以为它就是一张包含所有道路几何的大 XML,解析时直接遍历<road>节点拉线段就能用。这个理解只对了一半。OpenDrive 的原始数据确实以<road>为顶层节点,但每条 road 内部又按<geometry>细分了若干几何段,每个几何段再挂<laneOffset>和<lane>子树。换言之,地图文件描述的不是“道路的样子”,而是“道路的生成规则”。
解析之前,先把 OpenDrive 的层级关系理清楚,Unity_OpenDrive_SimEnv 的资源里解析器就是按这个逻辑组织的:
public class OpenDriveRoot { public List<Road> Roads; } public class Road { public string Id; public List<GeometrySegment> GeometrySegments; public LaneSection[] LaneSections; }逻辑说明:Road是顶层容器,GeometrySegment描述道路中心线的几何形状,LaneSection描述某里程范围内车道的横向排列。三者缺一不可——没有几何段,中心线画不出来;没有车道段,路面宽度和车道标线无从谈起。
参数说明:GeometrySegment里的s表示从道路起点算起的里程偏移,x/y是几何段起点的世界坐标,hdg是航向角。解析时最容易踩的坑是:s和x/y并不是单调递增关系,因为弧线段的起点坐标是绝对坐标,而不是相对上一段末端的偏移。两个相邻几何段的s连续,但起点坐标必须和上一段末端的终点坐标严格重合,否则路网会有裂缝。
2.2 几何段里的“隐藏精髓”:多项式曲线是参数方程
OpenDrive 的几何段有四类:直线、圆弧、螺旋线和三次多项式。Unity_OpenDrive_SimEnv 里重点关注的是前三种,因为绝大多数开放道路的高精地图只用这三种就够表达了。三次多项式通常在城市复杂路口才出现,处理起来麻烦得多。
重点提醒:OpenDrive 的<spiral>螺旋线是曲率随里程线性变化的曲线,它不能直接在 Unity 里按样条生成,需要先数值积分。常见做法是用龙格-库塔法(RK4)按步长采样,每步更新航向角和坐标:
public Vector2 SampleSpiral(double startX, double startY, double startHeading, double startCurv, double endCurv, double length, int steps) { double x = startX, y = startY, heading = startHeading; double stepSize = length / steps; double curvatureStep = (endCurv - startCurv) / steps; double curvature = startCurv; for (int i = 0; i < steps; i++) { heading += curvature * stepSize; x += Math.Cos(heading) * stepSize; y += Math.Sin(heading) * stepSize; curvature += curvatureStep; } return new Vector2((float)x, (float)y); }逻辑说明:这段代码按固定步长把螺旋线离散成折线,每一步基于当前的航向角累加坐标,同时更新曲率。步长越小,采样越密,道路越平滑,但网格顶点数也会成倍增加,性能压力随之上升。
参数说明:steps的取值直接影响精度与性能的平衡。我一般选 20~50 步,具体看弧长。如果弧长超过 100 米,50 步依然会有轻微锯齿;少于 10 步,曲率变化明显的螺旋线段会失真。跑一次测试场景,观察道路边缘是否平滑是判断步长是否合适的直接办法。
2.3 车道层级:从中心线向两侧铺,别被正负号绕晕
OpenDrive 的车道编号有一套独特规则:中心线右侧车道为正数,左侧为负数,中心线本身是 0。这在 Unity 的左手坐标系里特别容易搞混——因为 Unity 的 X 轴向右,Z 轴朝前,OpenDrive 的 y 轴在平面里朝左。如果不做符号映射,右侧车道会渲染到左侧,整个路网镜像翻转。
车道宽度和车道中心偏移分别存在<lane>节点的<width>和<road>节点的<laneOffset>里。解析时要注意:width是横向宽度,不是车道中心线偏移量;要计算车道的实际边界,需要把从中心线到该车道外边缘的所有width累加起来。
public LaneBoundary[] ComputeLaneBoundaries(Road road) { float offset = 0f; List<LaneBoundary> boundaries = new List<LaneBoundary>(); LaneBoundary center = new LaneBoundary { Side = 0, Width = 0f }; boundaries.Add(center); float leftAccum = 0f; float rightAccum = 0f; foreach (var lane in road.Lanes) { if (lane.Id > 0) // 右侧车道 { rightAccum += lane.Width; boundaries.Add(new LaneBoundary { Side = lane.Id, Width = rightAccum }); } else if (lane.Id < 0) // 左侧车道 { leftAccum += lane.Width; boundaries.Add(new LaneBoundary { Side = lane.Id, Width = -leftAccum }); } } return boundaries.ToArray(); }逻辑说明:从中心线开始,车道按编号从内向外累加宽度。右侧车道偏移为正,左侧为负,渲染网格时再把偏移量沿中心线法线方向投影到世界坐标。
参数说明:lane.Width的值来自<width>标签的a属性(通常是一个常数或线性函数)。如果遇到sOffset变化的宽度,需要按里程插值,不能简单取常数,否则变宽路段(如高速匝道)的网格会出现重叠或分离。
3. 从 2D 路网到 3D 网格:把数据“铺”成能行驶的路面
3.1 坐标系映射:ENU 与 Unity 的左手系怎么对齐
OpenDrive 用的是 ENU(东-北-上)坐标系,X 轴向东、Y 轴向北、Z 向上,右手系。Unity 使用左手坐标系,X 轴向右、Y 轴向上、Z 轴向前。不能直接把 OpenDrive 的坐标数值塞进 Unity 的 Transform,必须做一次轴映射。
常见的映射方式是:
Vector3 ConvertToUnityCoordinate(double east, double north, double altitude) { float x = (float)north; // Unity Z 轴朝前,对应北向 float y = (float)altitude; // Unity Y 轴向上,不变 float z = (float)east; // Unity X 轴向右,对应东向 return new Vector3(x, y, z); }这个映射方案在 Unity_OpenDrive_SimEnv 的资源里是默认做法:绕 X 轴旋转 -90°,使右手系的 X-Y 平面落到 Unity 的 X-Z 平面。映射后航向角也要补偿:OpenDrive 的hdg是相对 X 轴(东)的夹角,转到 Unity 后要加上 90° 偏置才能保持道路方向一致。
3.2 路面网格生成:用 Mesh 还是用多个 Plane 拼
把路网变成可行驶路面,有两种主流方案:为每条道路生成独立的 Mesh,或使用多个 Plane 拼接。前者渲染效率高,但编辑困难;后者灵活但顶点数爆炸。做仿真环境,我强烈推荐前者——把一条道路的完整网格(含路面、车道标线、路肩)一次性生成到一个MeshFilter里。
生成的思路是:取中心线采样点,沿法线方向按车道宽度向两侧扩展,形成一组四边形顶点;再按顺序连接成三角面。
public Mesh BuildRoadMesh(List<Vector3> centerLine, float totalWidth) { int vertexCount = centerLine.Count * 2; Vector3[] vertices = new Vector3[vertexCount]; Vector2[] uvs = new Vector2[vertexCount]; int[] triangles = new int[(centerLine.Count - 1) * 6]; for (int i = 0; i < centerLine.Count; i++) { Vector3 forward = (i < centerLine.Count - 1) ? centerLine[i + 1] - centerLine[i] : centerLine[i] - centerLine[i - 1]; Vector3 normal = Vector3.Cross(Vector3.up, forward).normalized; vertices[i * 2] = centerLine[i] - normal * (totalWidth * 0.5f); vertices[i * 2 + 1] = centerLine[i] + normal * (totalWidth * 0.5f); uvs[i * 2] = new Vector2(0, (float)i / (centerLine.Count - 1)); uvs[i * 2 + 1] = new Vector2(1, (float)i / (centerLine.Count - 1)); if (i < centerLine.Count - 1) { int baseIndex = i * 2; triangles[i * 6] = baseIndex; triangles[i * 6 + 1] = baseIndex + 2; triangles[i * 6 + 2] = baseIndex + 1; triangles[i * 6 + 3] = baseIndex + 1; triangles[i * 6 + 4] = baseIndex + 2; triangles[i * 6 + 5] = baseIndex + 3; } } Mesh mesh = new Mesh(); mesh.vertices = vertices; mesh.uv = uvs; mesh.triangles = triangles; mesh.RecalculateNormals(); return mesh; }逻辑说明:每个中心线采样点生成两个顶点,分别向左右偏移半个路面宽度。法线方向用Vector3.Cross(up, forward)求得——这里forward是采样点的前进方向,up是 Unity 的 Y 轴,叉积结果就是道路的横向方向。三角面按每两个连续采样点的四个顶点生成两个三角形。
参数说明:totalWidth是路面总宽度,等于所有车道宽度之和加路肩宽度。如果路网里有变宽路段,这个参数需要按里程动态计算,不能全路网用同一个值。uvs的纵向坐标用采样点索引归一化,保证路面纹理沿道路方向拉伸,不会出现纹理扭曲。这里的细节决定成败——triangles的绕序必须保证 Unity 正面朝上(逆时针),否则路面会被剔除或光照反向。
3.3 在 Unity 里组织场景层级:道路分段、分组与碰撞体
加载出来的道路网格不是一个巨大的 GameObject,而是按 OpenDrive 的 road id 拆分,每个 road 对应一个子物体,统一挂在 “RoadNetwork” 空物体下面。这样做的直接好处是:调试时能快速隐藏某条路、高亮某条路,按路名筛选,也能为后续每路的独立碰撞体编辑留出空间。
碰撞体我一般用MeshCollider,配合convex = false。但要注意:大范围路网的MeshCollider在物理引擎里性能开销很大,自动驾驶仿真中车辆通常并不依赖物理碰撞来感知“路面”,而是靠射线检测。我建议把这个 MeshCollider 的isTrigger设为 true,只用来做路面探测和车辆越界判断,不用来参与物理碰撞模拟,能省下不少物理开销。
4. 让车跑起来:从参考线到循迹算法的闭环
4.1 参考线提取:把 OpenDrive 的中心线变成可追踪的路径点序列
车辆在仿真里要沿道路行驶,控制器需要一个“参考线”——也就是目标路径点序列。OpenDrive 的中心线几何段恰好就提供了这个:把道路中心线的geometry采样成点列,再转成 Unity 世界坐标,就是现成的参考线。
但光有中心线还不够,车辆需要按车道行驶。要按车道偏移生成参考线,就得把中心线采样点沿法线方向偏移车道累积宽度。这个偏移方向与前面生成网格时的法线方向一致,左转右转要区分清楚。
public List<Vector3> BuildLaneReferenceLine(Road road, int laneId) { List<Vector3> path = new List<Vector3>(); float lateralOffset = ComputeLaneOffset(road, laneId); foreach (Vector3 centerPoint in SampledCenterLine(road)) { Vector3 forward = GetForwardAt(centerPoint, road); Vector3 normal = Vector3.Cross(Vector3.up, forward).normalized; path.Add(centerPoint + normal * lateralOffset); } return path; }逻辑说明:ComputeLaneOffset根据车道编号计算中心线到目标车道中心的横向距离,正值向右、负值向左;GetForwardAt通过比较前后两个采样点的位置差来估算当前位置的切向。
参数说明:参考线的采样密度直接影响控制效果。我一般按 2 米一个采样点,弯道曲率大的区域额外加密到 0.5 米。采样点过疏,循迹控制器在弯道上的表现会明显锯齿化——车辆会“切弯”,因为控制点之间的轨迹是直线。
4.2 循迹控制:纯追踪还是 PID,或者两者配合
Unity_OpenDrive_SimEnv 里提供了两种循迹方案:纯追踪(Pure Pursuit)和横向 PID。纯追踪是自动驾驶仿真里最常用的方案,实现简单、参数少、在弯道上表现稳定;PID 则更适合需要精确车道保持的场景。
纯追踪的核心原理是:在参考线上找一个目标点,这个点距车辆后方轴心的弧长距离等于“前视距离”;然后计算车辆当前航向与目标点方向的偏差,输出转向角。
public float PurePursuitSteering(Vector3 vehiclePos, float vehicleHeading, List<Vector3> path, float lookAhead) { Vector3 target = FindLookAheadPoint(vehiclePos, path, lookAhead); float alpha = Mathf.Atan2( target.z - vehiclePos.z, target.x - vehiclePos.x) - Mathf.Rad2Deg * vehicleHeading; float steering = Mathf.Atan2(2f * vehiclePos.magnitude * Mathf.Sin(alpha), lookAhead); return steering; }逻辑说明:FindLookAheadPoint遍历路径点,找到第一个与车辆距离超过lookAhead的点作为目标点,然后计算车辆航向角与目标方向夹角的差值,用几何关系换算成前轮转角。
参数说明:lookAhead是前视距离,典型的取法是vehicleSpeed * 0.3 + 1.5(米)。车速越高,前视距离越要拉长;前视距离过短,车辆会震荡;过长老实说会“切弯”——车辆会走内线,在连续弯道中偏出车道。多跑几圈观察轨迹,再微调这个参数。
PID 控制器则是对“横向偏差”做比例-积分-微分控制,我一般把纯追踪作为主控制器,PID 只在高速场景下做纠偏辅助,二者叠加输出。PID 的 P 取 0.8,I 取 0.05,D 取 0.2 起步,按车辆动力学模型再调。
4.3 传感器仿真的位置摆放:摄像头、激光雷达与车辆坐标系的耦合
循迹只是让车“能开”,仿真环境的真正价值在传感器层面。摄像头摆哪、激光雷达装在什么高度、坐标系怎么换算,这些都直接关系到采集的数据质量。Unity_OpenDrive_SimEnv 里传感器挂载点的默认布局是:摄像头在前挡风玻璃中线偏上位置,激光雷达在车顶中心,两个传感器都需要跟随车体做刚体变换。
在 Unity 里,这不需要自己写坐标变换,因为Camera和雷达对象直接作为车辆 GameObject 的子物体就行。但有一个坑:摄像头的localPosition要小心,不能放在车辆的旋转中心(底盘中心),否则车辆转向时摄像头跟随车身一起转,画面会有莫名的“甩尾感”。正确做法是挂在车辆坐标系的车顶或前轴上,并避免让摄像头参与车辆的物理旋转层。
激光雷达的仿真我推荐用射线发射方案:按设定的水平/垂直角分辨率打射线,命中碰撞体就记录距离。这个方案在 Unity 中用Physics.Raycast实现,开销可控,能真实反映传感器的遮挡关系。
5. 实战避坑:OpenDrive 加载与仿真的最常见问题排查指南
5.1 路面有裂缝,左右两段路在拼接处断开
现象:加载进 Unity 后,两条道路网格在连接点处有明显的缝隙,车辆行驶到接缝时轮胎悬空或抖动。
原因:OpenDrive 的几何段之间s连续不代表坐标连续,圆弧段末端的坐标需要精确衔接,但原始文件里常出现微小舍入误差;另一个更常见的原因是:两条道路在交叉口处共用节点,但各自的geometry起点计算方式不同,导致在世界坐标里产生了毫米级偏差。
解决:在生成网格后,把所有连接点做一次“吸附”处理——遍历所有采样点,如果两点间距小于 0.05 米,强制合并成一个点。用 Mesh 的mesh.CombineMeshes把多条道路网格合成一个整体时,也能顺带把缝隙焊上。
5.2 车辆在直线路段上蛇形行驶,时左时右
现象:在完全没有曲线的路段上,车辆的航向角持续小幅振荡,轨迹呈蛇形,看起来像在醉驾。
原因:参考线的采样点间距太大,导致纯追踪在两点之间计算的目标方向突变;或者是车辆坐标系的朝向与参考线坐标系的对齐不正确——vehicleHeading的单位混用了度与弧度。
解决:把参考线采样间距强制设为 1 米以内,同时在PurePursuitSteering内统一用弧度制。我见过最多的翻车现场是:Unity 的transform.eulerAngles.y返回角度,而Mathf.Atan2输出弧度,两者直接相减导致控制量爆炸。统一用Mathf.Deg2Rad做一次转换,问题立刻消失。
5.3 路面渲染发黑或被剔除,只能从一侧看到路面
现象:生成的 Mesh 在场景视图中部分面正常、部分面黑色,旋转视角后黑色区域变化,像是“两面派”。
原因:三角面的绕序不一致。Unity 默认只渲染正面的三角形,绕序为顺时针的三角面会被背面剔除,在 Scene 视图里看起来就是黑的。
解决:检查triangles数组的索引顺序。Unity 使用左手坐标系,三角面的顶点顺序必须为顺时针才能朝上。手动构造三角面时,先画一个简单 Quad 做测试,确认绕序后再批量生成。如果确定网格本身没问题,也可以给材质 Shader 设置Cull Off临时渲染双面来验证是否为绕序问题。
5.4 OpenDrive 文件解析报错,s坐标回退或重复
现象:解析器遍历<geometry>时抛出异常,提示子节点的s小于父节点。
原因:某些地图工具导出的 OpenDrive 文件中,s在几何段内是局部坐标,在几何段之间才是累积里程坐标,部分解析器混用了这两个概念。<geometry>标签上的s是绝对里程,而分段内部的参数(如length)是相对长度,不能直接相加。
解决:在每个GeometrySegment内部记录一个localS,它从 0 开始按length累加;在Road层面再维护一个globalS,它等于上一段的globalS加当前段的length。解析时不要把globalS写进几何段内部的采样循环里。
5.5 循迹车辆在环岛或匝道处突然冲出道路
现象:车辆行驶到曲率变化剧烈的区域(如环岛、高速匝道出口),突然偏离参考线,冲进隔离带或建筑。
原因:OpenDrive 的螺旋线段在采样时步长不够,导致参考线的几何失真——曲率突变区域的采样点间距过大,车辆拿到的路径变得“棱角分明”,控制器的前视距离又覆盖不到这种突变。
解决:对曲率变化大的螺旋线和圆弧线段,按曲率自适应采样——曲率越大采样越密。我一般用“曲率半径 / 10”作为采样步长,能有效把曲率突变的区域包住。另外,纯追踪的前视距离在低速弯道里要相应缩短,可以在控制器里加入速度自适应逻辑。
6. 复现验证与提速技巧:把仿真场景变成能跑数据的工具
把整个资源解压并放入 Unity 工程后,强烈建议按以下顺序验证,而不是一上来就加载整张地图。先跑最小的单条直线路,确认坐标换算和网格生成无误;再加载一条带弧度和车道变宽的测试路——某开源地图里的环形测试道往往就是最好的调试样本;最后再加载完整场景,并关掉不必要的网格碰撞。
调试关键帧渲染性能时,注意路网网格的顶点数量。1 公里双臂双向四车道,如果按 2 米步长采样,顶点数大概在 2 万个左右,在普通机器上没问题。但如果你按 0.5 米步长采样,顶点数会飙升到 8 万以上,场景一拖拽就掉帧。
一个提速技巧是:把路网网格的MeshCollider在编辑器模式下生成出来后,立刻导出为AssetBundle或场景内嵌资源,运行时直接Instantiate,而不是在运行时重新解析 OpenDrive 和重新生成网格。解析一个 200 条道路的地图 XML 平均耗时 200 毫秒到 2 秒,运行时卡顿对仿真帧率的影响远大于加载资源。
另一个习惯是:在 Editor 模式下用[ExecuteInEditMode]标记路网生成脚本,改完 OpenDrive 文件立刻在 Scene 视图里看到更新。这对于快速迭代地图调试价值极大,尤其是验证坐标偏移和车道宽度时,边改边看比“改完-编译-运行”快好几倍。但这些脚本打包时必须加上#if UNITY_EDITOR条件编译,否则发布到真机上会在路径上直接抛异常。
有一次我把整套资源跑通之后,才发现最耗时间的其实不是解析器调优,而是坐标系映射的验证。从那以后,我每次接新的 OpenDrive 资源都会强制走一遍“单路验证 → 环形路验证 → 完整地图运行”的流程,并且在验证时把采样步长、坐标映射、车道偏移三个参数打印到 Console 面板上对照检查。这个习惯帮我省掉了无数拆开修路网的时间。希望帮到你。
本文还有配套的精品资源,点击获取