免费获取学习方案
ARTICLE DETAIL

资讯详情

深耕编程基础知识与建站技术分享的一线实战洞察。

Unity3d十字路口交通模拟:车辆AI与信号灯协同实现

Unity3d十字路口交通模拟:车辆AI与信号灯协同实现 做这个项目之前我手头正好有段时间在帮朋友做一套智慧园区的可视化Demo里面需要一段十字路口的车流模拟。当时我第一反应是很简单搭个路口、放几辆车、让它们绕着路点转圈不就行了。真正动手之后才发现一个看起来像“教学示例”的十字路口模拟里面藏着大量的细节车道怎么规划、车辆怎么走直线、怎么转向、信号灯怎么和车辆联动、排队时怎么不穿模、前方车辆停车后后方车辆怎么减速……这些加在一起项目复杂度立刻上来了。我用的引擎是Unity3d整套系统从场景搭建到车辆逻辑、红绿灯控制、碰撞避让全部用Unity3d的基础组件完成没有引入额外的交通仿真插件。这篇先分享第一版的做法以一个双向四车道的十字路口为对象把车辆AI、信号灯相位、排队跟车全部跑通。适合有一定Unity3d基础、想尝试仿真类项目、或者正在准备作品集和毕设的人。做完这一版至少你对Unity3d的场景管理、触发器、状态机和简单车辆运动控制都会有比较完整的把握。1. 拆解“十字路口模拟”表面是画场景本质是仿真逻辑1.1 十字路口到底在模拟什么很多第一次接触这个项目的人容易走偏以为重点是把十字路口“画得像”。于是花大量时间在3ds Max里抠路灯、抠树、抠建筑贴图结果放到Unity3d一看车不会走、灯不会变路面做得再漂亮也是个“雕塑”。这个项目名字的核心不是“十字路口”而是“模拟”。我们要模拟的不是静态场景而是一套动态交通流的运行规律信号灯按照时间相位自动切换红灯停、绿灯行。车辆沿固定车道行驶直行、左转、右转有各自的路径。车辆遇到红灯或前车减速时能停在停止线前不会插到路口中间。绿灯亮起后排队车辆按顺序起步不追尾、不穿模。整体循环稳定运行不会因为哪辆车没触发检测点就卡住整个路口。所以我在动手写代码之前先把需求拆成了表现层和逻辑层。表现层是路面、车道线、信号灯模型、车辆模型这些看得见的东西逻辑层是车辆状态机、信号灯状态机、距离检测、路径索引这些看不见的东西。第一版开发时我建议你把80%的精力放在逻辑层表现层能用简单几何体代替就先代替跑通了再慢慢换美术资源。1.2 为什么选Unity3d而不是直接做动画有人问过既然只是要一段车流效果为什么不用C4D或者AE直接渲染一段视频因为视频是死的。真实场景下的仿真需求通常需要交互、实时变化、数据驱动。比如说领导临时说“把晚高峰车流加大一倍”视频你得重新渲但Unity3d里只需要改一个车辆生成间隔参数。再比如说规则变化“左转车辆需要等直行车辆通过后再进入待转区”这类逻辑只有代码才能表达。Unity3d在这个场景里的优势很明显C#脚本开发效率高触发器和碰撞器加上简单的距离判断就能模拟大部分交通规则场景渲染自带光照、阴影和后期效果不需要额外的渲染管线配置项目打包后可以在Windows上跑也能输出WebGL版本放到网页里展示。用UE做当然也能做但对这类轻量级仿真来说UE的工程体量和硬件消耗都太大了没必要。我选型时定的原则是只用Unity3d内置组件不引第三方交通插件不写复杂的车辆动力学方程车辆移动用“路点导航速度控制插值转向”这套组合。这套方案的好处是逻辑简单、容易调试、代码量小而且后续如果要接入真实的红绿灯配时方案或者车流量数据改动起来非常方便。2. 场景搭建先定坐标、车道和材质后面才不会返工2.1 道路模型的三种来源怎么选做项目第一步是搭场景但选择场景搭建方案时就要想好后面怎么改。道路模型我实际对比过三条路手工建模用Blender或ProBuilder自己拉路面。优点是模型干净、面数可控、想怎么改就怎么改缺点是费时间而且大部分人拉出来的车道线透视角度不对放进去观感很怪。Asset Store资源包商城里有大量城市道路模型包导入即用。优点是节省时间缺点是风格不统一这家包的路面和那家包的信号灯放一起可能有明显的画风差异而且贴图精度参差不齐。程序化生成在Unity3d里用代码创建Plane作为路面配上车道线贴图。优点是完全可控、参数化修改方便缺点是效果相对朴素路面细节需要靠贴图和后期撑起来。我的选择是第三种为主、第一种为辅路面和车道线用程序化生成斑马线、停止线、信号灯用简单模型手动摆放。原因很现实第一版项目最重要的是“参数可调”。路面宽度、车道数量、路口尺寸这些数据如果都写成Inspector里的公开字段后面调车辆路径、停止线位置会省非常多事。2.2 原点放在路口中心坐标规划减少80%的麻烦这是一个我建议你一开始就做对的决定把场景原点0,0,0放在十字路口的正中心。这样四条路的延伸方向就是坐标轴的正负方向东、南、西、北四条路的中心线分别落在X轴和Z轴上。你写路点坐标的时候所有数据都是对称的比如东向北左转的路径点就是一组“从X正方向来、先沿X减速、再朝Z负方向转”的坐标算起来清晰了很多。车辆尺寸方面家用车模型我按长4.5米、宽1.8米处理单向车道宽设为3.5米双向四车道总宽14米再加上两侧的人行道各2米路口整体宽度在18米左右。停止线距离路口边缘3米斑马线宽度4米、距离停止线2米。这些数据不是随手写的都是参照国内城市道路常见标准缩放的目的就是让车辆排队时“一辆车占一条车道、车头对齐停止线”的视觉效果真实。场景里所有关键物件包括停止线、信号灯、车辆生成点、车辆销毁点我都建议放在一个空的“Config”父物体下并写清楚名字。后续调参时你在Hierarchy里一眼就能找到对应对象不用满场景找。2.3 材质透视上层遮挡下层时怎么调试这个点是我在实际调试中踩出来的。场景搭好后路面、车道线、模型建筑之间经常出现互相遮挡的问题从主摄像机看过去建筑物挡住了你要观察的车道近距离看车辆排队时车顶又挡住了车头的停止线位置。这时候场景视图的默认透视角度很难看清下层结构。Unity3d的Scene视图中有一个X-Ray模式左上角渲染模式按钮选择Wireframe或者X-Ray。开启后物体会变成线框透出你可以直接看到路面贴图、车道线位置和车辆模型的骨骼结构。还有一种更实用的办法给关键调试点做“半透明材质”。比如把停止线做成一个淡绿色的半透明长方体把信号灯检测区域做成一个淡蓝色的半透明Box Collider这样在Game视图中也能看到触发区域的位置调试起来非常直观。需要提醒的是半透明材质在Unity3d里要注意渲染队列如果材质Shader用的是默认的Standard透明度调低了可能会出现遮挡关系错乱。解决方法是把Shader换成Legacy Shaders/Transparent/Diffuse或者给材质设置Render Queue为Transparent3000。这个小细节在热词里也反复有人提到说明确实是新手高频问题。3. 车辆移动核心实现路点导航真实转向3.1 为什么我不用NavMesh来做车辆导航很多Unity3d开发者做角色移动时第一反应是NavMesh但这个项目里我强烈建议不要用。NavMesh是为“角色自由寻路”设计的它解决的是“从A点到B点怎么绕过障碍物”的问题但十字路口的交通流恰恰相反我们不需要车自己找路我们要求每辆车必须在自己的车道里精准行驶。NavMesh做车辆行驶有两个硬伤一是烘焙出的网格路径会在路口拐角处“切内角”车辆实际行驶轨迹会压到对向车道二是车辆转向时很难以固定的转弯半径走出一条平滑弧线看起来像在漂移。而十字路口的左转、右转轨迹是有明确曲率要求的用路点系统可以让车辆按照预先设计好的路径点走轨迹完全可控。所以我的方案是给每条可能的行驶路线直行、左转、右转配置一组路点车辆生成时选定路线之后每一帧都朝着当前路点前进到达指定范围后切换到下一个路点。车辆从头到尾都知道自己要走什么路径不需要运行时计算。3.2 车辆沿路点行驶位置移动和旋转插值车辆控制脚本核心就两个任务一是向目标路点移动二是根据移动方向平滑旋转车身。我先把第一版可用的代码贴出来注释我已经写得很详细了。using UnityEngine; public class CarController : MonoBehaviour { [Header(路点路径)] public Transform[] waypoints; // 当前车辆需要依次经过的路点 [Header(运动参数)] public float moveSpeed 6f; // 最高行驶速度米/秒 public float arriveDistance 1.5f; // 到达路点的判定距离 public float rotateSpeed 5f; // 转向插值速度 public float acceleration 3f; // 加速度控制起步和刹车的平顺度 public float stopDistance 4f; // 检测到前方停车时保持的距离 [HideInInspector] public bool isWaiting false; // 是否因红灯或前车而等待 private int currentIndex 0; private float currentSpeed 0f; void Update() { if (waypoints null || waypoints.Length 0) return; Transform target waypoints[currentIndex]; // 判断是否到达当前路点 float distance Vector3.Distance(transform.position, target.position); if (distance arriveDistance) { currentIndex (currentIndex 1) % waypoints.Length; if (currentIndex 0) { // 路径走完可以交给对象池或直接销毁 gameObject.SetActive(false); return; } target waypoints[currentIndex]; } // 目标方向看向下一个路点 Vector3 direction (target.position - transform.position).normalized; // 平滑旋转注意这里用的是Slerp球面插值转向轨迹更自然 Quaternion targetRotation Quaternion.LookRotation(direction); transform.rotation Quaternion.Slerp(transform.rotation, targetRotation, rotateSpeed * Time.deltaTime); // 速度控制如果正在等待则缓慢减速到0否则加速到最高速度 if (isWaiting) { currentSpeed Mathf.MoveTowards(currentSpeed, 0f, acceleration * 2f * Time.deltaTime); } else { currentSpeed Mathf.MoveTowards(currentSpeed, moveSpeed, acceleration * Time.deltaTime); } // 朝正前方移动 transform.Translate(Vector3.forward * currentSpeed * Time.deltaTime); } // 供外部调用的重新设定路线方法 public void SetPath(Transform[] path) { waypoints path; currentIndex 0; } }这里三个参数决定了车辆开起来的手感arriveDistance、rotateSpeed、acceleration。到达距离如果太大车辆会在路点前方较远就转向下一个目标转弯轨迹会变得很“愣”太小又会来回抖动。我实测下来十字路口场景中1.2到1.8米是合理的范围。旋转速度决定了转向的灵敏度左转这种大角度转弯旋转速度太低会冲出车道太高又像点了一下头我一般调到5到7之间。3.3 车辆如何正确“看”信号灯并停车车辆移动逻辑跑通后下一个核心问题是车辆怎么知道前方是红灯还是绿灯。我给每一条进口道的停止线前放置了一个Box ColliderIsTrigger设为true长度约一个车身位触发区域覆盖当前车道宽度。当车辆进入这个Trigger区域时会去读取对应信号灯的状态。这里有个我第一版走过弯路的地方最初我在Trigger里面用OnTriggerEnter判断信号灯状态发现有时候车已经压线了才触发。原因是Trigger区域放得太靠路口。正确的做法是Trigger区域从停止线往后延伸一个车身加一点缓冲的长度比如3到4米这样车辆在到达停止线之前就收到“该停车”的信号有充足的减速距离。车辆进入Trigger后如果信号灯是红灯就进入等待状态如果是绿灯则正常通过。但还有一个更常见的情况前方已经有车在排队等待后车看到的虽然是绿灯但前车挡着走不了。我用的方案是在车辆移动逻辑里加一个前车距离检测。每帧从前车中心向正前方发射一条短距离的雷达射线实际就是Physics.BoxCast或者直接比较位置检测到前方3米内有其他车辆时无论信号灯是否绿灯都减速停车。这个逻辑虽然简单但足够模拟基本的跟车排队效果。4. 信号灯控制系统两相位也要设计好状态机4.1 信号灯状态机怎么设计信号灯是整个系统的时间轴中心。车辆的一切行为都围绕信号灯状态变化。第一版我不建议一上来就做左转专用相位、直行待行区、行人过街按钮这些复杂功能先把最基本的两相位跑通南北方向绿灯时南北直行和右转通行东西方向绿灯时东西直行和右转通行。左右转的放行规则先简化处理不影响主干逻辑。状态机我用枚举加协程实现每个相位就是一个状态状态之间用时间驱动转换。代码结构如下public enum TrafficLightPhase { NorthSouthGreen, // 南北绿灯 EastWestGreen, // 东西绿灯 AllRed // 全红用于清空路口 } public class TrafficLightController : MonoBehaviour { public TrafficLightPhase currentPhase; public float greenDuration 30f; // 绿灯时长 public float yellowDuration 3f; // 黄灯时长简化配置用于视觉表现 public float allRedDuration 1.5f; // 全红时长 public GameObject northSouthGreenLight; // 南北方向绿灯模型 public GameObject eastWestGreenLight; // 东西方向绿灯模型 private void Start() { StartCoroutine(RunTrafficCycle()); } private IEnumerator RunTrafficCycle() { while (true) { // 南北绿灯状态 currentPhase TrafficLightPhase.NorthSouthGreen; SetNorthSouthLights(true); yield return new WaitForSeconds(greenDuration); // 切换前的过渡可以用黄灯显示 SetNorthSouthLights(false); yield return new WaitForSeconds(yellowDuration); // 全红清空路口 currentPhase TrafficLightPhase.AllRed; yield return new WaitForSeconds(allRedDuration); // 东西绿灯状态 currentPhase TrafficLightPhase.EastWestGreen; SetEastWestLights(true); yield return new WaitForSeconds(greenDuration); // 过渡 SetEastWestLights(false); yield return new WaitForSeconds(yellowDuration); // 全红清空路口 currentPhase TrafficLightPhase.AllRed; yield return new WaitForSeconds(allRedDuration); } } private void SetNorthSouthLights(bool isGreen) { /* 切换灯组模型或材质 */ } private void SetEastWestLights(bool isGreen) { /* 切换灯组模型或材质 */ } }4.2 黄灯和全红时间的作用别看这个状态机简单里面有一个很容易被忽略但很关键的细节全红时间。我最早做这版的时候没配全红南北绿灯直接切到东西绿灯结果发现上一相位最后一辆车还在路口中央下一相位的第一辆车已经起步了两辆车直接撞在一起。出现这个问题的原因不是车辆AI错了而是路口本身的物理特性决定的。车辆从停止线加速到进入路口需要时间上一相位绿灯结束后路口内可能还有未完全清空的车辆。全红时间就是给这个清空过程留出的安全缓冲区间让上一相位的车完全驶出冲突区后下一相位的车再起步。现实中十字路口的全红时间一般是1到3秒我按1.5秒配置实测下来车辆穿插的情况完全消失了。如果你想做得更细致还可以单独给左转车设置一条相位南北绿灯先亮同时南北左转箭头灯也亮直行车辆结束后再让左转车辆走。这类多相位控制在状态机里加几个枚举和几个延迟等待就行逻辑上完全兼容只是需要额外配置左转专用信号灯模型和车辆路径。我建议第一版先不加等基础跑通了再试试扩展。4.3 信号灯的视觉反馈和代码解耦信号灯的逻辑归逻辑显示归显示这两部分我建议一定要分开。代码里只管状态枚举和相位切换显示的模型或材质变化单独用方法处理。不要直接在协程里写greenLight.GetComponentRenderer().material.color Color.green这样粗糙的代码。我实际用的是将灯组看作一个整体每种相位执行时把所有相关灯的状态一次设置好。比如南北绿灯亮时同时设置南北方向的三个灯组直行灯变绿、左转灯变绿如果有、右转灯变绿常绿或跟随。这样做的好处是后续如果想加视觉倒计时、声音提示、或者接入真实红绿灯配时方案只需要改显示层不需要动逻辑层。5. 常见问题与调试技巧实录5.1 车辆起步抖动和卡顿Fixed Timestep与Update的坑这是我调试中遇到的第一个玄学问题。车辆明明按照代码思路在走但到了信号灯切换的时候排队的车辆会前后抖动起步时像手动挡熄火一样一卡一卡的。排查后发现两个原因。第一个原因是车辆移动代码放在了Update里而Unity3d的Update调用间隔和渲染帧率相关。帧率波动时车辆每一步移动的距离不稳定看起来就发飘。后来我把所有带物理效果的移动逻辑统一放到FixedUpdate里执行配合Unity3d的固定时间步长Project Settings - Time - Fixed Timestep默认0.02秒车辆的运动就平滑了。第二个原因是起步加速度设置过小。我的第一版acceleration只有1起步时从0加速到6米/秒要6秒这个时间太长了每帧都处于低速状态再加上FixedUpdate的步长肉眼看就是爬行。把加速度调到3左右起步时间压缩到2秒以内感觉正常了。这个参数不同机器可能效果略有区别建议调完在目标机器上跑一遍看看。5.2 车辆扎堆、穿模和“幽灵刹车”车辆排队时最常见的穿模场景是前车已经停在停止线前等红灯后车没有及时减速直接顶到前车车尾。我加的前车距离检测是在Update里不断比较车辆间距但仅靠间距判断有几个坑如果停止线距Trigger区域太近后车检测到前车时已经来不及减速。如果Trigger区域过大车辆在很远的地方就开始减速造成“幽灵刹车”现象明明前方没车也一顿一顿地走。如果检测距离太远排队时车辆之间留的间隙过大整个路口的通行效率看起来很低。我调整后的参数是Trigger区域的长度设为4米从停止线往车道后方延伸前车距离检测设为3米左右同时把车辆的arriveDistance尽量调到比Trigger长度小。这样车辆进Trigger开始减速减到前车位置时刚好停在2到3米的安全距离外整个排队过程平顺且紧凑。如果你发现车辆转弯时穿模严重除了检查路径点还可以看看车身模型的碰撞器是否合理。车辆模型给个Box Collider就好但别用车子默认的网格碰撞器网格碰撞性能差而且边界复杂容易在转向时卡住。用Box Collider尺寸刚好包住车身足够了。5.3 材质透视和上层遮挡调视角效率提升技巧开头提到的材质透视问题这里再给个实战技巧。当车道线贴图被路面模型遮住、或者信号灯被树木模型挡住时不用反复改模型位置。直接在Hierarchy选中想看的下层物体把Inspector面板中的Layer改为“Ignore Raycast”或者在场景视图中开启“X-Ray”渲染模式同时配合按键盘上的V键抓取顶点可以快速确认下层物体的边界位置。如果某个物体只是想临时看内部结构选中它然后按F键聚焦在场景视图顶部将渲染模式切换为Wireframe。这一招在调整车道线贴图和停止线模型时非常高效。另外注意半透明材质调试完记得恢复原来的不透明材质不然打包后界面会透出奇怪的东西。5.4 性能优化和后续扩展方向第一版项目车辆数量控制在20辆以内时Unity3d完全无压力。但如果要做车流量放大测试性能优化就必须上心。我的经验是车辆移动脚本里尽量减少GetComponent调用Update里频繁取的组件在Start里缓存。车辆碰撞检测不要全部用物理碰撞器排队和跟车用Distance判断就够了。只有车辆和行人的碰撞才需要真正的物理碰撞这也是我把前车检测做成位置比较的原因。场景中大量车辆模型共用一个材质球开启GPU Instancing可以显著降低Draw Call。Unity3d的Standard Shader自带Instancing勾选项直接把材质球勾上即可。后续扩展的话我准备做三个方向一是加入行人过街系统行人根据信号灯状态在斑马线上行走车辆遇到行人要礼让这需要在斑马线周围加行人检测区域二是做真实的跟车模型把加速、滑行、刹车的速度曲线做得更细腻而不是现在简单的匀速加减速三是接入实时数据源把真实的信号灯配时方案和车流量数据导入系统做一个简单的数字孪生路口。这些内容篇幅不小我打算放在系列的第二篇里写。回到第一版的核心目标车辆能按车道和路径正常行驶信号灯按时切换排队停车不穿模整体跑起来稳定不卡死这就已经达标了。我在实际调这个版本时花时间最多的地方反而不是代码逻辑是停止线Trigger区域的位置和大小。这个区域放得太靠路口第一辆车总是压线放得太靠后后面排队空间不够左转车道跟直行车道的车位互相干扰。最后我把Trigger区域的起点对准停止线所在位置长度设成4米车辆停止后车头距停止线大约半米左右视觉和逻辑都达到了我预期的效果。这个项目做完之后我有几个心得体会Unity3d做这类交通模拟难点不在单个功能而在“多个车辆个体同时按规则运行时的协同效果”。同一套代码一辆车跑起来没问题十辆车一起跑问题就全出来了——要么车速不均造成追尾要么路点切换时车辆互相插队。建议你第一版就把调试数据可视化做起来在Game视图角落用OnGUI或者TextMesh显示每辆车的当前速度、信号灯状态、等待还是通行能帮你省下大量排查时间。
返回列表