简介:针对自动驾驶与AD算法验证场景,这份Unity3D道路生成器工具包以OpenDRIVE标准文件为输入,在Unity中重建道路结构、动态交叉口、桥梁、护栏与交通标识等仿真环境,并结合车辆行为与点云数据模拟,适合从事仿真测试的Unity开发者或算法研究人员。压缩包共61个文件、约13.56MB,核心为6个C#脚本及配套XML道路定义文件,另有31张PNG效果图、meta配置、txt/md说明与zip辅助包,可覆盖从OpenDRIVE解析、路径生成到运行时挂接的主要流程。内容已获608人学习关注。通过示例路网可快速理解XODR2PATH转换逻辑,结合EasyRoads3D预制件完善道路周边对象;附带多组OpenDRIVE示例与可视化截图,便于对照调参,也为后续扩展多车道、复杂交叉口和点云数据接入提供了可复用的工程基础。
1. Unity_OpenDrive_SimEnv:把OpenDRIVE高精地图变成Unity里能跑的仿真场景
手里拿到一份xodr高精地图文件,想着在Unity里跑一台车做仿真实验,很多做智能驾驶相关开发的读者应该都遇到过这道坎。OpenDRIVE格式把道路几何、车道宽度、路口连接、限速规则全部写成了结构化XML,但Unity并不认识这类文件——地图拿在手,场景里却铺不出能开车的路面。这套Unity_OpenDrive_SimEnv工程解决的就是这件事:它用C#实现了一条从xodr文件解析、参考线采样、道路Mesh生成到车辆循迹行驶的完整链路,让一份标准OpenDRIVE地图在编辑器里跑起来的代价,只是一次导入操作。项目自带示例地图、车辆底盘和传感器挂载点,适合自动驾驶仿真测试、智慧交通场景推演,以及想在Unity里做道路重建但不想手工建模的开发者。
2. 技术选型与整体架构:从xodr文件到Unity场景的坐标换算链路
2.1 为什么用OpenDRIVE标准加Unity组合
先说我为什么看好这套技术组合。OpenDRIVE是一个公开的高精地图描述标准,道路连续几何用参考线(reference line)表达,直线、圆弧、回旋线(spiral)都能精确描述;每条路再按车道宽度把横截面拆成车道段。相比直接用3D建模软件手拉道路模型,OpenDRIVE数据具备可重复性:同一份地图可以反复用于感知、规划、控制不同模块的测试,不需要因为场景改动重做美术资源。另一头,Unity的C#脚本开发效率高,自带物理引擎和渲染管线,URP下做批量道路渲染时性能足够,还能借用编辑器做可视化的导入工具,不需要在游戏循环和渲染细节上重新造轮子。
实际工程里,Unity Asset Store上虽然有一些OpenDRIVE插件,但很多时候要么只覆盖了标准的一小部分,要么API设计得和项目的数据流对不上。常见做法是自己写一个精简版解析器——只处理“直线、弧线、螺旋线”这三种核心几何,以及车道宽度和道路连接关系,已经能覆盖大多数测试场和城市片区地图的仿真需求。这套Unity_OpenDrive_SimEnv就是这么设计的:把解析器、生成器、控制器分散成独立模块,后续想加交叉口、交通灯、坡道逻辑时,每一层都能单独扩展。
2.2 工程目录结构与数据流向
打开工程后,先看一遍目录布局,整个数据流就清晰了:
Assets/ ├─ Scenes/ │ └─ MainSimulation.unity ├─ Scripts/ │ ├─ Runtime/ │ │ ├─ Xodr/ │ │ │ ├─ XodrLoader.cs │ │ │ ├─ XodrRoad.cs │ │ │ ├─ XodrLane.cs │ │ │ └─ CoordinateTransform.cs │ │ ├─ RoadBuilder/ │ │ │ ├─ RoadMeshBuilder.cs │ │ │ └─ RoadMaterial.cs │ │ ├─ Vehicle/ │ │ │ ├─ VehicleController.cs │ │ │ └─ WaypointFollower.cs │ │ └─ Sensors/ │ │ ├─ CameraSensor.cs │ │ └─ RaycastLidar.cs │ └─ Editor/ │ └─ XodrImportWindow.cs ├─ Data/ │ └─ Maps/ │ └─ example.xodr └─ Prefabs/ ├─ Sedan.prefab └─ RoadSurface.prefabXodrLoader负责把.xodr文件里的XML节点转成内存里的XodrRoad和XodrLane列表;CoordinateTransform把OpenDRIVE的全局大地坐标换算成Unity局部坐标,这一步不处理好,后面所有路面都会偏到几公里外;RoadMeshBuilder拿到坐标系修正后的参考线和车道宽度,生成路面网格;最后WaypointFollower让车辆沿着中心线采样点行驶。Editor目录下的XodrImportWindow是编辑器扩展,负责选文件、预览道路数量、触发导入,省得每次手动拖文件。
数据流向严格是单向的:文件→解析器→坐标变换→Mesh构建→车辆控制,每个环节只和上一层的数据模型打交道。这样做的直接好处是排查问题容易——路面偏了查坐标变换,路面烂了查Mesh生成,车不走了查采样点,不用从头开始翻代码。
2.3 坐标换算:大地坐标转Unity局部坐标
OpenDRIVE文件里的x、y坐标通常是米制的大地坐标,数值可以到几十万甚至上百万,而Unity场景原点接近(0,0,0),如果直接把大地坐标塞进Transform,浮点精度很快就会不够用。我见过不止一次地图整体飘走的情况,原因都是这一层没做处理。
我一般的做法是:读取第一个geometry点的坐标作为anchor,所有后续采样点先减去anchor,再乘一个轴变换矩阵转成Unity坐标。OpenDRIVE的y轴指向北,Unity的z轴指向北或南取决于场景朝北设置,所以通常要做一次90度或-90度的Y轴旋转。高度方向同理,OpenDRIVE的z对应Unity的y。
下面是CoordinateTransform里的核心换算逻辑,我习惯把它写成静态工具方法:
public static class CoordinateTransform { private static Vector3 anchor; public static void SetAnchor(Vector3 worldOrigin) { anchor = worldOrigin; } public static Vector3 ToUnity(Vector3 openDrivePos) { // 先平移到锚点附近,避免大地坐标带来的浮点精度损失 Vector3 localPos = openDrivePos - anchor; // OpenDRIVE的X直接映射到Unity的X float unityX = localPos.x; // OpenDRIVE的Y(北方向)映射到Unity的Z轴 float unityZ = localPos.y; // OpenDRIVE的Z(高程)映射到Unity的Y轴 float unityY = localPos.z; // 如果场景朝向不一致,这里再旋转一次 return new Vector3(unityX, unityY, unityZ); } }这段代码里有三个关键点:第一,anchor必须取整份地图里所有geometry点的公共原点,不能取某个建筑坐标,否则局部区域会翘起;第二,旋转方向要根据实际导入的地图测试结果调整,多试一次就能找到正确的方向;第三,锚点一旦确定,整个会话内不要再变,否则车辆控制器和道路网格会参照不同原点,出现行驶轨迹对不上路面。
坐标约定整理成一张表,方便接入新地图时对照:
| OpenDRIVE坐标 | Unity坐标 | 说明 |
|---|---|---|
| x | x | 经度方向,减anchor后使用 |
| y | z | 北方向映射到Unity的Z轴 |
| z(高程) | y | 高程映射到Unity的Y轴 |
| heading(航向角) | — | 根据场景朝向额外旋转Y轴 |
3. 核心模块拆解:xodr解析、道路Mesh构建与循迹控制
3.1 XodrLoader:XML解析与道路数据模型
解析器的输入是.xodr文件,输出是一组Road对象,每个Road包含道路ID、长度、几何段列表。几何段是OpenDRIVE的核心,一条道路可以由多个geometry拼接而成,每个geometry有起点位置、起始航向和类型。下面这段代码是按XML节点逐层读取的典型实现方式:
public class XodrLoader { public List<XodrRoad> Load(string filePath) { var doc = XDocument.Load(filePath); var roads = new List<XodrRoad>(); foreach (var roadNode in doc.Descendants("road")) { var road = new XodrRoad { id = roadNode.Attribute("id")?.Value, length = float.Parse(roadNode.Attribute("length")?.Value ?? "0"), geometry = new List<RoadGeometry>() }; // 遍历这条道路下的所有geometry子节点 foreach (var geoNode in roadNode.Descendants("geometry")) { var geo = new RoadGeometry(); geo.s = float.Parse(geoNode.Attribute("s").Value); geo.x = float.Parse(geoNode.Attribute("x").Value); geo.y = float.Parse(geoNode.Attribute("y").Value); geo.hdg = float.Parse(geoNode.Attribute("hdg").Value); // 判断geometry的真实类型:line / arc / spiral if (geoNode.Element("line") != null) { geo.type = "line"; } else if (geoNode.Element("arc") != null) { geo.type = "arc"; geo.curvature = float.Parse( geoNode.Element("arc").Attribute("curvature").Value); } else if (geoNode.Element("spiral") != null) { geo.type = "spiral"; } road.geometry.Add(geo); } roads.Add(road); } return roads; } }这段代码把XML到内存对象的转换做得很直白:用XDocument.Load读取文件,用Descendants("road")拿到所有道路节点,再逐个子节点解析geometry属性。注意s表示该几何段在参考线上的累积长度,x/y是几何段起点的世界坐标,hdg是起点航向角。解析时一定要把真实类型落到curvature参数上,因为arc的curvature直接决定弯道采样时的弯曲半径。
进入实际工程后,我一般还会在这个类里补两个方法:一个解析车道宽度(读取laneSection下的width节点),一个解析道路连接关系(读取linkage节点的predecessor和successor)。这两个数据在车辆循迹和环岛轨迹修正时非常重要。
3.2 RoadMeshBuilder:从参考线到可驾驶路面
解析出参考线坐标后,下一步是生成道路Mesh。这里的核心思路是:沿参考线按固定间隔采样,取每个采样点的切线和法线方向,用车道总宽度向左右两侧偏移出路面边缘顶点,再把相邻采样点的左右顶点连成三角形。
public Mesh BuildRoadMesh(XodrRoad road, float sampleInterval, float laneWidth) { // 对参考线做等距采样,得到道路中心线点序列 var centerPoints = SampleCenterLine(road, sampleInterval); var vertices = new List<Vector3>(); var triangles = new List<int>(); for (int i = 0; i < centerPoints.Count; i++) { var pos = centerPoints[i]; // 计算当前点的切线方向,用于求法线 Vector3 forward; if (i < centerPoints.Count - 1) { forward = (centerPoints[i + 1] - pos).normalized; } else { forward = (pos - centerPoints[i - 1]).normalized; } // 法线 = 垂直方向向量 cross 切线方向 var right = Vector3.Cross(Vector3.up, forward).normalized; // 左右两个边缘顶点 vertices.Add(pos - right * laneWidth); vertices.Add(pos + right * laneWidth); // 每两个采样点组成一个四边形,拆成两个三角形 if (i < centerPoints.Count - 1) { int i0 = i * 2, i1 = i * 2 + 1; int i2 = (i + 1) * 2, i3 = (i + 1) * 2 + 1; triangles.Add(i0); triangles.Add(i2); triangles.Add(i1); triangles.Add(i1); triangles.Add(i2); triangles.Add(i3); } } var mesh = new Mesh(); mesh.vertices = vertices.ToArray(); mesh.triangles = triangles.ToArray(); mesh.RecalculateNormals(); return mesh; }这段代码里有个容易忽视的细节:计算forward向量时,最后一个采样点没有后继点,所以必须用前一个点回推方向,否则会得到零向量,导致法线计算崩掉。至于为什么用Vector3.Cross(Vector3.up, forward)来求右侧方向,这是Unity左手坐标系的惯用操作,配合Y轴向上的设计能把道路横向展开到水平面上。
SampleCenterLine是另一个方法,它根据geometry的类型做不同的采样策略:直线段直接用s的线性插值,弧线段用curvature计算圆心和夹角,螺旋线则需要按曲率渐变来处理。sampleInterval的选择直接影响效果:间隔太大,弯道处的路面会呈现折线感,像“割肉”一样一块一块的;间隔太小,一段几公里长的道路会产生几十万个顶点,运行时DrawCall飙升。常见折中做法是直线段间隔用2米,弯道段间隔用0.5米。
3.3 WaypointFollower:让车辆沿着道路中心线跑起来
有了路面之后,最直观的验证就是让一辆车沿着道路跑。WaypointFollower的做法是:从道路中心线上采一批点作为路径点,车辆朝下一个目标点移动,到达后切到下一个。控制逻辑精简到一个Update循环里。
public class WaypointFollower : MonoBehaviour { public List<Vector3> waypoints; public float speed = 12f; public float arriveDistance = 1.5f; private int index = 0; void Update() { if (waypoints == null || waypoints.Count == 0) return; var target = waypoints[index]; var offset = target - transform.position; // 让车辆始终贴着地面移动,忽略高度差 offset.y = 0f; if (offset.magnitude < arriveDistance) { // 到达当前点,切换到下一个,循环走 index = (index + 1) % waypoints.Count; return; } var dir = offset.normalized; var targetRotation = Quaternion.LookRotation(dir, Vector3.up); // 平滑转向,避免模型瞬间拧过来 transform.rotation = Quaternion.Slerp( transform.rotation, targetRotation, Time.deltaTime * 8f); transform.position += dir * speed * Time.deltaTime; } }这里有两个值得解释的细节。第一,offset.y强制归零,是因为采样点可能存在微小的高程波动,如果不归零,车辆在水平路上也会产生俯仰抖动,影响传感器数据的稳定性;第二,用Slerp而不是直接赋targetRotation,是为了让转向看起来连续,同时避免在环岛处因为方向突变导致车头乱甩。arriveDistance的典型值在1到2米之间,太小会让车辆在路径点附近反复振荡,太大则会在弯道处切弯,走出一条割草机一样的轨迹。
这套“采样点+跟随”模型虽然朴素,但足够作为仿真的基础。真要让车辆行驶得更自然,可以在MovingAverage前后加一个速度曲线,让它在直线阶段加速、弯道阶段减速,再把加速度限制加上,物理表现会好很多。
4. 运行与调试:从导入地图到跑通全流程
4.1 环境准备与最小场景搭建
先把工程用Unity Hub打开,建议使用Unity 2021 LTS及以上版本,管线用URP或内置渲染管线都行。导入完成后,把Data/Maps下的example.xodr拖到Assets/Data/Maps目录里,如果之前看过别的版本,注意先删除旧的Mesh缓存,避免编辑器导入冲突。
接着打开MainSimulation.unity,这个场景里已经摆好了道路根节点、车辆预制体和方向光。如果从零自己搭建,最小配置共三步:建一个空物体挂XodrLoader,建一个空物体挂RoadMeshBuilder,把一个带Rigidbody和Collider的车辆预制体放进场景,然后把WaypointFollower挂到车上。先不接传感器,保证路面出现、车辆能动,再逐步加摄像头和雷达。
编辑器菜单栏里选XodrImportWindow,选择example.xodr文件,点击Import后,Hierarchy下会生成RoadRoot节点,每个道路ID对应一个子物体并挂有MeshFilter和MeshRenderer。跑起来之后,可以看到车辆沿着道路中心线匀速前进。
4.2 运行时参数与调试面板
运行时需要调整的参数主要集中在Inspector上:
| 参数 | 所属脚本 | 作用 |
|---|---|---|
| sampleInterval | RoadMeshBuilder | 参考线采样间隔,直线段2m,弯道0.5m |
| laneWidth | RoadMeshBuilder | 单侧路面宽度,城市道路3.5~3.75m |
| speed | WaypointFollower | 车辆行驶速度(m/s) |
| arriveDistance | WaypointFollower | 路径点到达判定距离 |
我习惯给WaypointFollower加一个OnDrawGizmos方法,在Scene视图里把waypoints用线连起来,这样一眼就能看出采样点是否连续、是否有跳变。如果某段路线的点出现交叉或反跳,说明解析阶段读取的连接关系有问题,不用等车辆跑起来才发现轨迹不对。
调试阶段还可以在Update里写一行条件Log,输出当前车辆所在的road id,与.xodr文件里的id顺序做对照,能快速定位车辆是否按预期进入目标路段。这个方法听着笨,但在现场排查时比看一堆断点高效得多。
4.3 多地图切换与链路验证
实际工程中往往需要测试多张地图,频繁导入xodr文件再解析很浪费时间。常见做法是把导入结果烘焙成ScriptableObject缓存:第一次加载时解析文件,把道路顶点、三角形索引、路径采样点序列存进一个MapAsset;之后运行时,只需要换掉MapAsset的引用就能完成地图切换。
我一般会在场景里写一个简单的验证脚本,让车辆跑完一整圈后统计总里程和经过的道路ID序列,和OpenDRIVE里记录的参考线长度做对比。如果里程偏差超过1%,优先检查采样间隔和坐标变换那两步,因为这是数据链路里最容易出数值误差的地方。
5. 避坑记录:坐标系、浮点精度与几何断裂
5.1 地图整体偏移几百米甚至消失
现象:导入地图后,Scene视图里找不到路面,或者路面出现在离原点几公里外的空中。
原因:xodr文件里的x/y是大地坐标,数值很大,直接赋给Transform后,浮点精度在几十万米的量级上丢失严重,网格顶点发生漂移,视觉上路面直接飞了。
解决:在CoordinateTransform里第一个geometry点设为anchor,所有采样点先减anchor再进Mesh,同时确认没有在这个过程里多做一次缩放。记住,单位保持一比一,不要用缩放因子强行把大地坐标压到小范围,否则物理碰撞和后续传感器参数全对不上。
5.2 相邻道路Mesh接缝处有黑线或裂缝
现象:两条道路拼接处路面有细缝,或者离近看能看到三角形缺角。
原因:两条道路分别生成Mesh,各自采样顶点,接缝处两侧顶点位置不一致,三角形没有焊接;另外,左右路宽如果读的是各自的lane width,也可能导致路面宽度过渡不平滑。
解决:在RoadMeshBuilder里规定统一的采样间隔,弯道和直线衔接处采用同一套采样点插值方案;生成Mesh后,在编辑器阶段做一次顶点焊接,容差设为0.05米左右。还有一个土办法是让相邻两条道路的Mesh重叠10到20厘米,用路面覆盖把缝盖住,视觉上几乎察觉不到。
5.3 车辆高速行驶时穿模抖动
现象:速度超过30m/s后,车辆要么钻到路面底下,要么车身剧烈抖动,转向时尤其明显。
原因:用transform.position直接移动车辆时,帧率波动会导致步长不均匀,物理检测跟不上高速下的位移;Rotation瞬间切换也会让视觉出现“拧麻花”的抖动。
解决:把车辆运动逻辑全部移到FixedUpdate里,用Rigidbody.MovePosition和Rigidbody.MoveRotation驱动,并把Rigidbody的Interpolate选项设为Interpolate。注意,Update里不要再写transform.position相关的逻辑,否则会和物理引擎打架。
5.4 加载城市级大地图时编辑器卡死
现象:点Import后,编辑器直接卡住,几十分钟无法操作,退出重来也一样。
原因:所有道路在主线程上一次性生成Mesh,顶点数量太大导致CPU被占满。
解决:把导入逻辑改成协程分帧处理,每帧只生成2到3条道路的Mesh,其余时间让出主线程。更进一步,在Editor导入时把Mesh烘焙进MapAsset,运行时不再重新解析文件,只做一次反序列化加载。
5.5 环岛或U-turn路段车辆轨迹反跳绕圈
现象:车辆在普通道路正常,一到环岛或掉头路段,目标点突然跳到对向车道,车辆原地打转或逆行走了一圈。
原因:很多精简版解析器只读取了道路的线性顺序,没有还原junction的连接关系,导致路径点在路口处断了,直接跳到下一条道路的起点。
解决:解析时读取road的successor和predecessor连接信息,根据连接关系生成一段连续的路口过渡路径,让waypoints保持连续。我一般在编辑器下先把所有waypoints用Gizmos画出来再跑车,连接关系有没有跳变,一眼就能看出来,这算是我踩过这个坑之后养成的习惯。
6. 二次开发:换车、加传感器、扩展自定义道路的常用落地路径
6.1 替换车辆模型
把Prefabs/Sedan.prefab换成自己的模型时,注意三点:车辆模型的forward方向要和Unity的Z轴一致,不一致就先把模型旋转到对齐;Rigidbody挂好后,重新设置Center Of Mass和Collider的包围盒;把原车上的VehicleController和WaypointFollower一起挪到新车上。只要车辆控制仍走Rigidbody.MovePosition,这套改动不需要动任何道路代码。
6.2 加摄像头和雷达仿真
摄像头传感器的常规做法是:在车头位置挂一个Camera,设置TargetTexture为1280x720的RenderTexture,CameraSensor.cs每帧把它保存成图片或喂给视觉网络。雷达则可以用多根Raycast射线模拟扫描线,射线命中路面或障碍物后,记录距离和命中点法线,这也是较常见的一种低成本雷达仿真方式。
6.3 扩展高程和自定义道路属性
想支持坡道地形,需要在解析时读取elevationProfile里的elevation节点,把高度写进道路采样点的Y值。想在特定车道加上限速或红绿灯,先扩展XodrRoad数据模型,在解析阶段赋默认值,再在RoadMeshBuilder里用不同材质或路面标线区分出来,逻辑上不用动渲染主流程。
我之前接入一份城市级地图测试时,因为锚点处理漏掉了一次更新,结果车辆全飞到海里去,排查了大半天。从那以后我每次接入新地图都强制走一遍“先画waypoints连线、再开Debug.DrawLine、最后跑车”的三步检查。希望帮到你。
本文还有配套的精品资源,点击获取