Appearance
旁车感知零拷贝链路
旁车(Obstacle)是 SR 里更新最频繁的动态元素。这条链路的关键决策是:XPU 感知数据不走传统信号链路的序列化分发,而是 C++ 侧 GroupSync 攒批进三缓冲,C# 每帧用 P/Invoke 拿裸指针直读共享内存——从传感器到 Transform 全程零拷贝,回放和实车跑的还是同一套代码。
旁车判定窗口1~6m × -15~30m
同步机制三缓冲无锁
链路复用回放 = 实车
背景
旁车数据来自 XPU 智驾计算平台,以 Protobuf(SDOverallMsg,信号 SIGNAL_HOST_ICM_SD_PERIOD_DATA)经 Android SDK、JNI 进入 C++ 的 module_xdata。传统做法是走 SignalDispatcher → ProtocolManager → XParser → ObjEvent 的完整信号链路,但旁车每帧几十个实体、每个实体都要更新位置和 Yaw,序列化 + 逐条回调的成本不划算。最终落地的方案是两条并行数据流在 C# 合并:链路 A 传 XPU 原始感知(裸指针直读),链路 B 传地图引擎的 LD 位置修正(百度/高德差异很大)。
问题
| 问题 | 表现 | 根因 |
|---|---|---|
| 信号链路成本高 | 高频实体逐条回调打进 C# | 传统链路为低频离散信号设计 |
| 跨线程读写竞争 | C++ 写感知数据、C# 读位置渲染 | 共享数据需要同步机制,加锁又伤性能 |
| 旁车闪烁/跳变 | LD 模式下位置瞬移 | XPU 原始坐标无高程(Z 硬编码为 0),LD 修正时序不齐 |
| 双地图行为分裂 | 百度要做世界→自车旋转 + Lerp,高德直通 | 两家 LD 障碍物数据的坐标系和缓存位置不同 |
| 问题定位难 | 「旁车不显示」可能挂在 8 个环节任意一个 | 门控分散在 C# 反传 + C++ 过滤两层 |
方案
mermaid
flowchart TD
subgraph CPP["C++ module_xdata"]
A["XPU SDOverallMsg<br>Protobuf over JNI"] --> B["DrivingSensorObstacleParser<br>Protobuf → FlatBuffer"]
B --> C["DrivingObstacleLayerProcessor<br>8 项门控过滤"]
C --> D["SREntityObstacle<br>UseGroupSync 攒批"]
D --> E["X3DTripleBuffer<br>三缓冲无锁"]
end
subgraph Bridge["C# 每帧拉取(非信号链路)"]
F["XPUModel.OnUpdate"] --> G["ReadSynchroData"]
G --> H["GameObject_ReadGroupSyncData<br>P/Invoke → IntPtr"]
H --> I["Marshal.PtrToStructure<br>freshObjects + eventObjects"]
end
subgraph CS["C# 渲染"]
I --> J["isGDLD 判断<br>LD 坐标修正"]
J --> K["UpdateBindEntitys<br>直写 Unity Transform"]
I --> L["ObjEvent.Broadcast<br>Create / Update / Remove"]
end
E -->|裸指针直读| H核心决策三条:拉取而非推送(节奏由消费方控制,和 信号链路推拉改造 是同一个思路)、攒批而非逐条(Create/Update/Remove 攒进 XEngineObjectGroup,一帧一次 P/Invoke 全拿走)、三缓冲而非加锁(C++ 写 Back Buffer,交换后 C# 读 Front Buffer)。
关键实现
1. 解析层:坐标转换与旁车判定
DrivingSensorObstacleParser::Parse 把动态感知物解析成 FlatBuffer 的 XObjEntityObstacleInfo。Protobuf 的 (x, y) 是(纵向, 横向),x3d 坐标系是(横向, 纵向),所以做 (-y, x, 0) 映射——Z 高程硬编码为 0,这是 XPU 原始数据天生缺高程,后面 LD 修正存在的意义。旁车判定(IsSideCandidate)卡得很死:车辆类型(轿车/SUV/卡车等 17 种)+ 横向 1~6m(相邻车道范围)+ 纵向 -15~30m。静态感知物 ID 加 200000 偏移、井盖/减速带加 500000 偏移,避免和动态 ID 撞车。
2. GroupSync 攒批 + 三缓冲
旁车实体 UseGroupSync() = true,生命周期事件不立即回调 C#:
cpp
void X3DProxy_GameObject::SyncCreateData() {
if (UseGroupSync()) {
objectGroup_->addSyncCreate(this); // 攒入组中,等 C# 统一拉取
} else {
// 非组同步:立即回调(旁车不走这条)
}
}每个实体内部挂 X3DTripleBuffer:C++ 写线程写 Back Buffer,交换后 C# 读线程从 Front Buffer 读,读写全程无锁。C++ 侧其实还注册过 Push 回调路径(setX3DEngineObjectCallback → SPSCRingBuffer),但 DispatchObstacleEvents() 已被注释,当前唯一激活的就是拉取路径。
3. C# 每帧裸指针直读
XPUModel.OnUpdate() 每帧依次调用各 DataModel 的 ReadSynchroData()(行车障碍物、泊车障碍物、车位、轨迹、泊车地图)。旁车这条的核心流程:
mermaid
sequenceDiagram
participant CS as "C# 主线程<br>XPUModel.OnUpdate"
participant API as "P/Invoke 导出<br>i_proxy_engine_api"
participant TB as "C++ 三缓冲<br>XEngineObjectGroup"
participant WT as "C++ 写线程<br>感知数据更新"
WT->>TB: 写 Back Buffer(攒批 Create/Update/Remove)
CS->>API: GameObject_ReadGroupSyncData(handles, count)
API->>TB: 交换并读 Front Buffer
TB-->>API: X3DEngineSyncDataSet 裸指针
API-->>CS: IntPtr
CS->>CS: Marshal.PtrToStructure 直读<br>freshObjects + eventObjectsC# 先收集存活实体的 Handle 列表(mDicDynamicEntity.Keys)传给 C++,拿回 X3DEngineSyncDataSet:freshObjects 是 XVector6 位置/Yaw 数组,eventObjects 是生命周期事件队列。全程没有序列化、没有内存拷贝——Marshal.PtrToStructure 直接按结构体布局读 C++ 内存。
4. 实体生命周期:Create / Update / Remove
mermaid
stateDiagram-v2
[*] --> Created: Create 事件<br>对象池取预制体 + LD 修正
Created --> Bound: 注册 mDicDynamicEntity<br>绑定 Handle
Bound --> Updated: Update 事件<br>Merge 数据 + 更新位置
Updated --> Bound: UpdateBindEntitys 驱动
Bound --> Removed: Remove 事件<br>解绑 + 回收对象池
Removed --> [*]: 字典清理 + DisposeCreate 走对象池(SRObstacleManager.getPool 按类型取预制体)、挂场景、注册进 mDicDynamicEntity[info.Id] 和 mHandleToEntityId[handle] 两个字典;Update 合并数据后更新 Transform,并处理长期 P 场景隐藏;Remove 解绑、回收、移到 HIDE_POS 远处兜底。坐标映射是 X3D(X横, Y纵, Z高)→ Unity(X横, Y高, Z纵),即 Y/Z 交换,旋转 Euler(0, Yaw, 0)。
5. 双地图 LD 修正差异与平台化改造
LD(车道级)场景下旁车坐标要用地图引擎数据覆盖 XPU 原始坐标(isGDLD = IsGaodeLane && IsLDState()):
| 百度 LD(engineType 1) | 高德 LD(engineType 2) | |
|---|---|---|
| C++ 缓存 | 已缓存(XLDObstaclesInfo) | 未缓存,SR 回调直通 C# |
| 坐标系 | 世界坐标(Mercator,含高程) | P20 Mercator,已是相对坐标 |
| 修正方式 | 世界→自车旋转矩阵 + Lerp 平滑 | 直通返回,C# 侧 90 - course 修航向 |
| 高程 | pos_z - egoPos.z | 外部提供 + ldOffsetHight |
创建时也要补一次 LD 修正——UpdatePosYaw 的 bInit 分支设置初始位置时没用 LD 坐标,不补会闪一下。高德 LD 模式下整个障碍物 root 还要旋转 (-90, 0, 0) 对齐坐标系。平台化改造已在进行:MapEngineConfig.cmake 里已有 MAP_ENGINE_BD / MAP_ENGINE_GBL 编译开关,目标是 C++ 统一缓存两家地图数据、在 C++ 侧完成修正,届时 C# 的 isGDLD 分支整体消失。当前状态:百度已缓存,高德未完成。
6. 回放与实车一套链路
Windows 回放(SignalFileStreamReplayerV2)读录制文件,playSignal() 先做 convertSourceType:RECORD(2)→REPLAY(4)、NATIVE_RECORD(3)→NATIVE_REPLAY(5)、UNITY_RECORD(6)→UNITY_REPLAY(7)。转换后走 inner_dispatch()——后续 Observer → Parser → 门控 → GroupSync → C# 拉取与实车逐行一致,C# 完全无法区分数据来源。回放还支持 0.5~5.0 倍变速(SetReplaySpeedV2)、暂停/单步、seek 和文件夹轮播;回放时还能同时再录制(source 翻转回 RECORD 变体)。
7. 八项门控排查 SOP
「旁车不显示」按顺序查这八道闸:定位状态、NEDC 驾驶模式、泊车/驾培阶段、充电场景、手势缩放中、相机标定中、显示距离(100~150m 上限,单个丢弃)、LD 场景井盖过滤。门控状态由 C# 通过 SetNativeStateBool 反传给 C++ 做最终过滤,是双向同步。配套排查表直接按现象定位:完全不显示查 dispatch 日志和门控字段;显示但不更新查 freshObjects 的 XVector6;闪烁跳变查 isGDLD 和创建时的 LD 修正;消失不恢复查 mDicDynamicEntity 数量和 mCountdownTime(2 秒无数据自动清除)。
指标
| 指标 | 传统信号链路 | 零拷贝链路 |
|---|---|---|
| 数据传输 | 序列化 + 逐条回调 | IntPtr 裸指针 + PtrToStructure 直读 |
| 事件分发 | 每实体每事件一次回调 | GroupSync 攒批,一帧一次拉取 |
| 线程同步 | 锁或队列拷贝 | 三缓冲交换,无锁 |
| 回放复用 | 需单独回放链路 | convertSourceType 后完全复用 |
| 超时兜底 | — | 2s 无更新自动清除 + 40ms dispatch 告警 |
复盘
- 高频数据宁拉勿推,攒批胜逐条。 旁车每帧几十个实体,逐条回调的成本远超一次批量拉取;把节奏控制权交给消费方,主线程压力才可预期。
- 零拷贝的前提是内存布局两边对齐。 XVector6、X3DEngineSyncDataSet 的结构体定义 C++/C# 必须严格一致,这是联调期最容易踩的坑,和 proto 两端一致是同一个道理。
- 双地图差异要收敛到编译开关,而不是 if-else 蔓延。 isGDLD 分支能存在是因为改造没完成,MAP_ENGINE 开关到位后它就该消失——过渡代码要写清退出条件。
- 排查 SOP 比链路本身更长寿。 链路会重构,但「现象 → 环节 → 日志锚点」的八项门控排查表在每次重构后只要更新锚点,方法论不变。