CARLA Basics: Simulation & the Ego Vehicle
CARLA 进程被显式分成两端:
CarlaUE4.sh / CarlaUE5.sh),独占 GPU,负责场景渲染、刚体物理、Actor 状态机。它本身不知道”自动驾驶”是什么,只是一个被驱动的世界。carla.Client(host, port) 连进来,承载你要跑的算法。两端之间是两条独立的通道,用途和延迟特性完全不同:
| 通道 | 默认端口 | 协议 | 方向 | 内容 |
|---|---|---|---|---|
| RPC | 2000 | TCP(rpcmc/rpclib 序列化) | 双向请求-应答 | spawn_actor、world.tick()、apply_control、set_weather … |
| Streaming | 2001(=RPC+1) | TCP | Server→Client 单向推流 | 相机图像、LiDAR 点云等高频传感器数据 |
关键区分:RPC 是”问一句答一句”的同步调用,每次都要等 Server 返回;Streaming 是 Server 主动推的持续字节流,Client 注册回调后被动接收。把 6 路相机的 1920×1080 图像塞进 RPC 通道会立刻把 RPC 打爆——这正是传感器走独立 streaming 通道的原因。
RPC 通道要保证命令的强一致性和顺序性(你 spawn 完一辆车才能拿到它的 ID 去 apply_control),所以走的是请求-应答模式,每次往返都有 RTT。传感器数据是吞吐密集、可丢、不需要全局排序的,正适合走另一条无阻塞的推流通道。两者解耦后,渲染卡顿不会反压到控制指令,控制指令也不会阻塞图像回传。
进入同步模式时,Client 发 world.tick() 这个 RPC,Server 推进一帧后返回新的 frame ID。同一帧里所有传感器的 streaming 数据都带这个 frame ID。Client 端用 frame ID 把”同一仿真瞬间”的 6 张图像归拢——这就是后续能做帧级对齐的前提。
CarlaUE4.sh -carla-rpc-port=2002 显式换端口。Client 不等于 World:client 只负责连接,真正操作世界的是 client.get_world() 返回的 world 对象。换了地图要重新 get_world(),否则拿到的是旧 world 的句柄。.listen(callback) 注册一次回调,之后数据由 Server 推过来;不要在循环里轮询。carla.World 是 Python 端访问一切仿真状态的入口。Server 内部那个 UE 世界是单例,对应到 Client 就是 world = client.get_world()。下面这些操作都必须经过它:
world = client.get_world()
world.spawn_actor(blueprint, transform) # 生成实体
world.get_map() # 拿到 OpenDRIVE 地图对象
world.get_weather() / world.set_weather(w) # 天气
world.tick() # 同步模式推进一帧(同步模式专属)
world.wait_for_tick() # 异步模式等待下一帧
world.get_actors(ids) # 按 ID 批量取 Actor
换地图后 world 句柄会失效——get_world() 内部带缓存但带版本号,跨地图要重新调用,否则操作的还是已销毁的旧世界。官方推荐用 client.on_world_tick(callback) 注册回调,由 Server 在每帧开始时主动推新 world 状态。
CARLA 里不能直接 new 一个 Actor,所有实体都从 BlueprintLibrary 选模板生成。Blueprint 是参数化模板:同一种车(如 vehicle.tesla.model3)的轮距、质量、bbox 都是定义好的,你能改的只是它暴露出来的属性(vehicle.light_state、vehicle.color 等)。
blueprint_library = world.get_blueprint_library()
bp = blueprint_library.find('vehicle.tesla.model3')
bp.set_attribute('color', '0,0,0') # RGB 整数串
if bp.has_attribute('driver_id'): # AI 司机模板,决定 TM 行为
bp.set_attribute('driver_id', random.choice(bp.get_attribute('driver_id').recommended_values))
image_size_x)、相机 FOV(fov)。在 spawn 时一次性传入,之后改不了。sensor_tick:传感器专用,决定采样间隔(仿真秒)。0.0 表示每帧都采;0.1 表示每 0.1 仿真秒采一次。注意它单位是仿真时间,不是墙钟时间——同步模式下仿真时间和墙钟时间解耦,sensor_tick=0.1 + fixed_delta=0.05 实际是每 2 帧采一次。生成位置由 carla.Transform(location + rotation)指定:
transform = world.get_map().get_spawn_points()[0]
ego = world.spawn_actor(bp, transform) # 位置被占就失败,返回 None
ego = world.try_spawn_actor(bp, transform) # 同上但不抛异常
spawn_actor 用的碰撞检测只查生成瞬间的重叠,不保证之后不撞——所以 Ego 车常 spawn 在 get_spawn_points() 返回的预定义安全点上。
vehicle.tesla.model3 不是 Tesla Model 3,大小写和点都不能错。可用 bp_library.filter('vehicle.*') 列出全部。set_attribute 在 spawn_actor 之后再调对相机分辨率无效。hero 角色:bp.set_attribute('role_name', 'hero') 只是个标签字符串,让 Bench2Drive 等工具识别哪辆是 Ego,对 CARLA 物理本身没有任何影响。CARLA 里所有可被仿真、有状态、能被 RPC 操作的实体统称 Actor(车辆、行人、传感器、交通灯、观察者都算)。它们共享同一套生命周期接口:
actor.get_location() / actor.set_location(loc)
actor.get_transform() / actor.set_transform(t) # location + rotation
actor.get_velocity()
actor.destroy() # 从仿真世界移除
world.get_actors().find(actor_id)
之所以统一抽象,是因为这些实体的状态变更都要走 RPC 通道发到 Server——抽象层屏蔽了”它是车还是相机”的差异。
| 类 | Python 类型 | 关键接口 | 物理是否参与 |
|---|---|---|---|
| Vehicle | carla.Vehicle | apply_control(vc):油门/转向/刹车 | 是(受 PhysX 车辆模型驱动) |
| Walker | carla.Walker + carla.WalkerAIController | AI 控制器走导航点 | 是 |
| Sensor | carla.Sensor(RGB/Depth/LiDAR…) | listen(callback) 注册回调 | 否(只观察不施力) |
其余还有 TrafficLight、TrafficSign、StaticObject,对 SparseDriveV2 流程影响小。
Vehicle 通过 carla.VehicleControl 施加控制,这是 Ego 车唯一的物理输入接口:
control = carla.VehicleControl(
throttle=0.0, # [0, 1]
steer=0.0, # [-1, 1],正为左转
brake=0.0, # [0, 1]
hand_brake=False,
reverse=False,
)
ego.apply_control(control)
值域是关键坑点:
throttle / brake 是 归一化值,不是物理踏板力。steer 是 ,正方向是向左(CARLA 用左手坐标系,y 向左为正)。算法输出右转要传负值。UWheeledVehicleMovementComponent 里,受 bp 里的 mass、drag_coefficient、center_of_mass 等参数影响。role_name='hero' 只是字符串标签,让 Bench2Drive 识别主车,不影响物理。
传感器最大的特殊性是它不参与物理交互,只产生数据流。生成时必须附着到某个父 Actor 上(车或地图原点),传感器跟随父对象运动:
cam_bp = bp_library.find('sensor.camera.rgb')
cam_bp.set_attribute('image_size_x', '1920')
cam_bp.set_attribute('image_size_y', '1080')
transform = carla.Transform(carla.Location(x=1.5, z=2.4)) # 相对父车坐标
cam = world.spawn_actor(cam_bp, transform, attach_to=ego)
def callback(image):
# image.frame 是世界帧 ID,用于多传感器对齐
process(image)
cam.listen(callback)
回调是异步的——Server 一边渲染一边通过 streaming 推帧,回调在 Client 的 IO 线程里触发,不在 tick 线程里。所以同步模式下要注意:你 world.tick() 之后,本帧的图像回调可能还没到,需要用 image.frame 字段去队列里匹配,不能假设”tick 返回就能拿到图像”。
ego.destroy() 后 cam 还在推流,必须显式 cam.stop() + cam.destroy()。listen 不能阻塞:回调里做重活(如跑模型)会堵死 streaming 的 IO 线程,导致后续帧全部迟到。正确做法是回调里只把数据塞队列,由另一个线程消费。controller.ai.walker 并 attach_to 它,Walker 才会按 agent.start() / agent.go_to_location() 行走。CARLA 的每张 Town 在底层由两套互相独立但绑定在一起的数据组成:
.xodr 文件:纯 XML 描述的道路逻辑网络——参考线、车道、交叉路口、连接关系、限速、信号灯相位。给算法和交通生成器用。这两套数据必须几何对齐:3D 路面的实际位置和 OpenDRIVE 参考线的坐标在厘米级一致,否则车按 OpenDRIVE 算的导航点会”飘”在路面外。CARLA 用 RoadRunner 制作地图时由工具保证对齐;自己用 standalone 模式导入 .xodr 时,CARLA 会自动生成一个最小网格,但美术细节要自己补。
OpenDRIVE(ASAM 标准,.xodr 后缀)用 XML 描述道路网络,核心概念:
<road>:一段道路,由**参考线(reference line)**沿弧长 参数化。所有几何(车道、坡度、超高)都以 为坐标。<lane>:车道,编号规则是中心 #0、向左 +1/+2…、向右 −1/−2…。laneSection 沿 分段。<junction>:路口,列出连接哪几条 <road> 的哪几条 <connection>。<signal> / <object>:信号灯、标志、障碍物,按 坐标放在路上。CARLA 把 OpenDRIVE 抽象成 carla.Map,对外提供 carla.Waypoint——一个 3D 有向点,对应 OpenDRIVE 某条车道的某段 :
mp = world.get_map()
wp = mp.get_waypoint(ego.get_location()) # 把世界坐标投到最近的车道
wp.next(5.0) # 前方 5 米的下一个 waypoint(返回列表,路口会分叉)
wp.get_left_lane() # 左邻车道(None 表示没有)
wp.lane_id # OpenDRIVE 车道编号(#0 是中线,左正右负)
wp.right_lane_type # 右边界的类型(shoulder/sidewalk/...)
所有 waypoint 计算都在 Client 端——Server 不参与。这意味着即使关掉渲染、Server 不在前台跑,纯用 .xodr 也能做路径规划查询。Traffic Manager 和 ScenarioRunner 的路径生成都建立在 next() / get_left_lane() 这套接口上。
读 OpenDRIVE 最容易踩的坑是车道变更的可达性。不是任意相邻车道都能换:能不能 laneChange 取决于 OpenDRIVE 里两个 lane 之间的 <lane> 标记和 <rule> 属性。Bench2Drive 的 cut_in_right 场景之所以在某些弯道段失效,根本原因就是弯道处的 OpenDRIVE 没有合法 LaneChange 拓扑,CARLA 拒绝生成变道 waypoint——这是 EP07 反复出现的同一类问题。
CARLA 的 Town 命名有约定,越靠后越大越复杂:
| 地图 | 风格 | 备注 |
|---|---|---|
| Town01/02 | 网格状城市 | 基础测试 |
| Town03 | 复杂城市,含环岛、隧道、五岔路口 | 经典难例 |
| Town04/05 | 高速公路环线 | 高速场景 |
| Town07 | 乡村小路 | 窄路 |
| Town10HD | 高清市中心 | 早期高清资产 |
| Town11/12 | 10×10 km 大世界(UE5) | Bench2Drive v2 用 |
Bench2Drive 在这些 Town 上预定义了路线(routes)——起点、终点、沿途触发的场景(Scenario),评测时就跑这些路线。
这是所有 CARLA 实验的”hello world”模板,看懂每一行为什么这么写:
import carla, random, queue
client = carla.Client('localhost', 2000)
client.set_timeout(20.0) # 1. 连 Server
world = client.get_world()
settings = world.get_settings()
settings.synchronous_mode = True # 2a. 开同步模式
settings.fixed_delta_seconds = 0.05 # 2b. 固定步长 20 Hz
world.apply_settings(settings)
bp_lib = world.get_blueprint_library()
ego_bp = bp_lib.find('vehicle.tesla.model3') # 3. 选 Ego 模板
ego_bp.set_attribute('role_name', 'hero')
transform = random.choice(world.get_map().get_spawn_points())
ego = world.spawn_actor(ego_bp, transform)
try:
while True:
ego.apply_control(carla.VehicleControl(throttle=0.4)) # 4. 给控制
world.tick() # 5. 推进一步(关键!)
finally:
ego.destroy()
settings.synchronous_mode = False
world.apply_settings(settings) # 6. 还原异步,否则 Server 卡死
world.tick()同步模式下,Server 不会自己往前走。它停在当前帧等 Client 发 tick()。一旦 Client 算法卡死、忘了 tick,整个 Server 也卡住——这也是为什么连接断开或脚本崩溃后,Server 必须手动重启,因为没人 tick 它就永远停着。
tick() 的语义是”推进一个 fixed_delta_seconds,把这一步的物理算完、传感器采完,然后返回新的 frame ID”。它是同步模式下唯一的”心跳”。
fixed_delta_seconds 与物理稳定性settings.fixed_delta_seconds = 0.05 # 20 Hz
这一行决定仿真频率。不要追求过高频率——CARLA 的物理引擎(PhysX 子步)和车辆动力学有时间常量,超过 30 Hz 后真实感下降。常见取值:
| 频率 | delta | 用途 |
|---|---|---|
| 20 Hz | 0.05 | SparseDriveV2 / Bench2Drive 标准 |
| 30 Hz | ~0.033 | 部分传感器密集采集 |
注意 fixed_delta_seconds 必须配合 synchronous_mode=True 才生效。异步模式下即使设了也会被渲染帧率覆盖。
finally 里这两行是新手最容易漏、后果最严重的步骤:
settings.synchronous_mode = False
world.apply_settings(settings)
如果不还原,Server 仍然在同步模式等 tick,但脚本已经退了——下一次连接同一个 Server 时它会永远卡住。生产代码里务必把还原放进 try/finally,连异常和 Ctrl+C 都要覆盖。
后面所有扩展都在 tick() 之前、之后插东西:
spawn_actor 后挂 6 个相机,在 tick 之后从队列拿图像。apply_control。主线就是这一行循环:
tick() → 采数据 → 推理 → apply_control → tick() → ...
set_timeout 太短:第一帧 Server 还在加载资产,连不上。20s 比较稳。spawn_actor 失败返回 None:spawn 点被占会抛异常或返回 None,要 try_spawn_actor 或 catch。apply_control 持续有效:apply_control 设一次后持续生效直到下次调用。开环测试时记得在每帧都设,或显式 brake=1.0 停车。