TrafficManager: Injecting Traffic into the Simulation, Part 1
Traffic Manager(TM,交通管理器)不是 world 对象的一部分,而是一个按端口寻址的独立服务:CARLA server 的主 RPC(远程过程调用)端口(本项目约定 UE5 侧 2021、UE4 侧 2141)负责世界管理,TM 则单独占用一个端口,默认值是 8000。客户端显式按端口取句柄:
# tools/ue5harness/route_executor.py(同步模式设置,真实代码)
settings = self.world.get_settings()
settings.synchronous_mode = True
settings.fixed_delta_seconds = self.args.dt
self.world.apply_settings(settings)
# TM 同步:按端口拿句柄,而不是从 world 里取
self.tm = self.client.get_trafficmanager(self.args.tm_port)
self.tm.set_synchronous_mode(True)
按官方文档的架构划分,TM 构建在 client 侧:它有自己的线程组(每个流水阶段一个线程),通过 RPC 与 server 交换状态,再把批量控制指令回灌给 server。所以它既不是 server 的内建模块,也不是你脚本里的普通对象——它是夹在中间的第三个进程级角色。
同步模式(synchronous mode)必须 world 和 TM 各设一次,漏掉任何一边节拍就错位:
settings.synchronous_mode = True + world.tick() 推进仿真;tm.set_synchronous_mode(True),TM 才跟着 world 的 tick 逐帧计算控制指令。脚本退出前还要把两边都关回异步,否则 server 会一直阻塞等下一个 tick。
同一个端口上先连接的 TM 成为 TM-Server,后连上的成为 TM-Client,行为由 TM-Server 主导。多仿真(multi-simulation)场景下——同机跑两个 CARLA server——每个仿真的 TM 必须用不同端口,否则后连的 TM 会挂到别人的端口上。这一点正是串台坑的根源:RPC 端口记得错开、TM 端口却都停在默认 8000,控制指令就会发进别人的仿真世界。
TM 的 autopilot 可以按”规划—决策—控制”三层理解,对应到官方实现的五阶段控制环(control loop):
| 三层视角 | 官方阶段 | 干什么 |
|---|---|---|
| 规划层 | Localization Stage | 从 In-Memory Map(内存中的 waypoint 网格地图)取附近 waypoint(路点),拼出近未来路径,存进 PBVT(Path Buffer & Vehicle Tracking) |
| 决策层 | Collision Stage / Traffic Light Stage | 沿路径外扩包围盒检查碰撞风险;红绿灯、stop 标志、路口优先级转成一个”hazard”信号 |
| 控制层 | Motion Planner Stage | 汇总路径与 hazard,用 PID 算出油门/刹车/方向盘,翻译成 carla.VehicleControl |
| (附加) | Vehicle Lights Stage | 按环境与意图自动打转向灯、刹车灯 |
两个工程细节值得记住:
VehicleControl 收进 command array,用 apply_batch_sync() 一帧一次性发给 server,避免帧内指令撕裂。PID(比例-积分-微分,Proportional–Integral–Derivative)控制器把”目标值与当前值的差”转成执行器输入:
其中 是误差(例如目标车速减当前车速), 是输出(油门/刹车开度);、、 分别是比例、积分、微分增益。比例项管”差多少补多少”,积分项消除长期残差,微分项抑制超调。TM 在 Motion Planner Stage 里用 PID 估计达到目标 waypoint/车速所需的油门、刹车与转向。
整条流水线没有任何学习组件:方向在路口随机选、目标车速默认限速的 70%、跟车/变道/路权全靠显式规则。这带来两个对评测至关重要的性质:确定性(同 seed 下行为逐帧可复现)和永不疲劳(不会路怒、不会冒险博弈)——它扮演的不是”聪明的司机”,而是”一群守规矩、可复现的其他司机”。
TM 的 Traffic Light Stage(信号灯阶段)把交通规则统一抽象成 hazard(危险信号)——一旦某辆车的路径上立起 hazard,Motion Planner 就会给出刹车:
一个官方文档明说的限制:TM 的路口优先级不遵循真实交通法规,用的是自己的一套优先级系统(环岛内车辆给想进入环岛的车辆让路这类反直觉行为都可能出现)。造评测场景时别把现实交规的直觉代入 TM 的行为预期。
TM 允许逐辆车设置违规概率,这是造”恶意场景”的旋钮:
# tools/ue5harness/t26_inject_violations.py(违规注入,真实代码)
executor.ego.set_autopilot(True, args.tm_port)
executor.tm.ignore_lights_percentage(executor.ego, 0.0) # 闯红灯概率 0%:完全守规矩
executor.tm.ignore_signs_percentage(executor.ego, 0.0) # 无视标志概率 0%
ignore_lights_percentage(vehicle, p) 的含义是:这辆车每个决策点有 的概率无视红绿灯。设 0 是”百分之百守规矩”,调高就能得到随机闯红灯的背景车。同族旋钮还有 ignore_signs_percentage、ignore_vehicles_percentage、vehicle_percentage_speed_difference(超速/慢速百分比)、auto_lane_change(开关变道)等。
注意这些概率是 TM 行为随机性的一部分,由 TM 行为种子(traffic-manager-seed / set_random_device_seed)控制,与 spawn 抽样种子相互独立。
渲染管线用 LOD(细节层次,Level of Detail)按距离降级几何与材质;hybrid physics(混合物理)模式把同一个思路搬到动力学上:英雄车(hero)附近的车算完整物理,远处的车关掉物理、改走传送。
官方语义(默认参数):
role_name='hero' 的车为中心、半径 50 m(可用 set_hybrid_physics_radius(r) 改);Actor.set_simulate_physics(True),完整动力学;leaderboard 评测器在评测开始时打开、复位世界时关闭:
# leaderboard_evaluator.py L268 / L295(官方评测的用法)
traffic_manager.set_hybrid_physics_mode(True) # 评测开始:开
traffic_manager.set_hybrid_physics_mode(False) # 复位世界:关
动机很直接:十几辆背景车全量物理仿真会把 server 的 CPU 预算吃光(物理是 CARLA 的瓶颈之一),而 50 m 外的车撞不撞、悬架怎么跳,英雄车的相机根本看不见——看不见的细节就是可以省掉的细节。
hybrid physics 是”省算力但不改可观测行为”的近似:近处交互(跟车、避让、碰撞)仍是真物理,远处只是布景。对比实验两侧要么都开、要么都关,且半径一致——否则”物理精度”就混进了”渲染保真度”的归因里。
纯视觉(vision-only)端到端模型的全部输入就是那一帧 RGB。遮挡(occlusion)与加性噪声有本质区别:噪声是信号上叠干扰,遮挡是信号整块不存在——前车车身后面的行人、信号灯、路口几何,在这帧图像里没有对应的像素,再强的网络也无从恢复。
从针孔相机几何看,一个宽 、距离 的遮挡物,张角为
被它挡住的锥形区域随深度线性扩张:遮挡物越近( 小), 越大,后面被抹掉的视野越宽。一辆 2 m 宽的 SUV 在 5 m 处张角约 ,在 20 m 处只剩约 ——近处切入的邻车对视野的破坏远大于远处同尺寸车辆。
激光雷达(LiDAR)是视线(line-of-sight)传感器:激光打不到的地方就没有点。车体后方形成点云阴影(occlusion shadow),被挡区域点密度骤降为零。所以”上 LiDAR 就免疫遮挡”不成立,只是遮挡的几何形态不同(相机是视锥截面上的投影遮挡,LiDAR 是沿射线的逐方向遮挡)。
静态遮挡可以靠先验补全,闭环里的遮挡逐帧都在变:跟车距离、邻车变道、路口错车,每一帧的可见集合都不同。对纯视觉模型,这等价于输入分布在实时漂移;对时序模型,历史帧里见过的目标这一帧可能突然消失,下一帧又出现。这就是为什么”空城高分”不能外推到”车流里高分”——背景车多一辆,遮挡事件率就高一截,感知误差随之被放大。
计算机科学里,**死锁(deadlock)**指一组进程互相持有对方需要的资源、全部永久阻塞;**活锁(livelock)**更隐蔽:各方都在”动”(不断退让、重试),但整体没有任何进展。
礼让陷阱在交通里的形态介于两者之间:模型车正确礼让(行为完全合规),但被让的背景车自己被别的背景车堵死、永远不走——模型车的”礼貌”被无限放大成”一直等下去”。从模型视角看,它每一帧都在做局部正确的决策;从系统视角看,全局零进展。
闭环评测的评分器(scorer)按结果记账:路线超时(route timeout)就是罚分,不区分”模型不会开”还是”路被堵死”。这带来一个评测效度(validity)问题:
同一模型,背景交通正常时拿满分、交通互堵时拿超时罚分——分数差异里没有一分来自模型本身的变化。这就是”礼让陷阱”比碰撞罚分更危险的地方:碰撞多少还反映模型的避障能力,而礼让陷阱的罚分与模型无关却记在模型头上,直接污染对比实验的归因。
UE4 侧 leaderboard 的 BackgroundBehavior(行为树,约 2600 行,vendored 自 ScenarioRunner)用 source/sink(源/汇) 机制让背景交通”围着英雄车流动”:
英雄车前进,source 和 sink 跟着滚动:交通像地毯一样在 ego 前方铺开、在身后收起。瞬时在场车辆数因此有界——不管路线多长,同时在场的车由源的参数决定,而不是由地图大小决定。
source/sink 是交通工程里 OD(起讫点,Origin–Destination) 思想的工程化:只需要为”与 ego 可能交互的 OD 对”生成车流——从 ego 前方的来车方向(O)到 ego 途经的路口(D)。与 ego 八竿子打不着的街区不需要真车,那里的交通对评测没有任何可观测影响。
对比”铺满地图”的朴素做法:
| 铺满地图 | source/sink | |
|---|---|---|
| 瞬时 actor 数 | 随地图线性增长 | 常数上界 |
| 与 ego 的交互密度 | 被稀释(车都在别处) | 集中在 ego 周围 |
| 物理/渲染开销 | 爆炸 | 可控 |
source/sink 用常数级的算力,换来”交通无处不在”的主观密度——这正是评测需要的:被测模型看到的每一帧都有车流,而 server 只为真正可能交互的那十几辆车付算力。
BackgroundBehavior 的 junction source 由三个旋钮控制,上游 scenario_runner 默认值如下:
# background_activity.py L233-241(机制参数,上游默认值)
self._junction_sources_dist = 40 # ① 源离路口入口 40 m
self._junction_sources_max_actors = 6 # ② 每个源同时在场车上限
self._junction_spawn_dist = 15 # 同源车间距 15 m
self._junction_source_perc = 80 # ③ 源激活概率 80%
# 更新循环(L1239 / L1316)
if 100 * self._rng.random() > self._junction_source_perc:
source.active = False # 逐 tick 掷骰子:这个源开不开
if len(source.actors) >= self._junction_sources_max_actors:
continue # 在场车满了就不再放
逐个拆解:
按默认值粗算:一个路口 4 个入口方向、期望 个源激活、每源最多 6 辆,叠加上”驶离路口后被 sink 回收”的流动平衡,2026-08-30 的 smoke 实测瞬时 15 辆、8 个车型混合(见 tools/bg_traffic.py 模块文档与 AGENTS.md 坑录 T3.0)。
这组默认值是为官方几公里长路线调的密度:车散在几公里路网里,每个路口平均摊不到几辆。把它照搬到 Town10 一两百米的对齐短路线上,15 辆车全挤在必经的那一个路口——密度参数与路线尺度错配。本项目后来的互堵事故根因正在于此,修复补丁改的也就是这两个旋钮(perc 80→30、max_actors 6→3)。
UE5 侧的背景交通用固定 spawn 近似动态 source/sink。整个模块(tools/bg_traffic.py)不到一百行,因为开车这件事外包给了 TM——脚本只管”放哪些车、放在哪”,驾驶行为由 set_autopilot 移交 TM。
def spawn(self):
import carla # 两侧各自 env 的 carla 包(0.9.15 与 0.10 永不混装)
bpl = self.world.get_blueprint_library()
pool = [bp for bp in (bpl.find(n) for n in self.pool) if bp is not None]
if not pool:
raise RuntimeError("背景车型池在当前 server 全部不可用,先探测再传入")
sps = self.world.get_map().get_spawn_points()
self.rng.shuffle(sps) # 洗牌出生点,seed 可复现
for sp in sps:
if len(self.actors) >= self.n:
break
if self.hero_location is not None:
d = sp.location.distance(self.hero_location)
if d < self.min_dist:
continue # 25 m 内跳过:不堵 hero 出生门口
bp = self.rng.choice(pool) # 从车型池抽一款
try:
v = self.world.spawn_actor(bp, sp)
except RuntimeError:
continue # 位置被占:静默换下一个点
v.set_autopilot(True, self.tm_port) # 驾驶移交 TM(注意端口参数)
self.actors.append(v)
return len(self.actors)
几个值得注意的设计决策:
rng.shuffle(sps) 后顺序扫,天然保证同一辆不会重复占点;min_dist_to_hero=25.0,出生点离英雄车太近直接跳过,避免开门杀式 spawn;spawn_actor 抛 RuntimeError(位置被占)时 continue 换下一点,不报错——目标是”凑够 N 辆”,个别点失败不影响口径;set_autopilot(True, self.tm_port) 把车辆控制权注册到指定端口的 TM;不传就默认连 8000,多 server 同机时这是串台入口。收尾对称:destroy() 逐辆销毁并吞掉 RuntimeError(车辆可能已被场景回收),保证脚本退出不留残车。
一次带背景交通的评测里,随机性来自两个独立的层,各由自己的种子控制:
| 层 | 控制什么 | 种子入口 |
|---|---|---|
| spawn 抽样 | 哪个出生点、抽哪款车型 | random.Random(seed)(--bg-seed) |
| TM 行为 | 跟车距离、变道时机、违规掷骰 | traffic-manager-seed / set_random_device_seed |
第一层是布景随机:random.Random(seed) 洗牌 spawn 点、抽样车型,同 seed 重跑得到一模一样的初始交通布局。第二层是驾驶风格随机:TM 内部的路口方向选择、变道判定、ignore_lights_percentage 的逐车掷骰,由 TM 自己的随机设备驱动。
拆开是为了可归因(attributable):
--bg-seed、固定行为种子——驾驶风格不变,差异只来自布局;--bg-seed、换 TM 种子——同一批车在同一些位置,但开得不一样。两层若共用一个种子,“这次分数低是因为车流布局变了还是开车风格变了”就分不清了。对比实验里每种混杂都是未来的排查成本。
set_random_device_seed(seed) 要求 world 与 TM 都已设为同步,且每次 reload_world() 后要重新设;set_random_device_seed 会触发 C++ terminate 直接崩掉进程,UE5 侧只能用 --bg-seed 控制 spawn 抽样,行为种子差异如实写进口径声明。