Skip to content

旁车感知零拷贝链路 ​

旁车(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 + eventObjects

C# 先收集存活实体的 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 --> [*]: 字典清理 + Dispose

Create 走对象池(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 告警

复盘 ​

  1. 高频数据宁拉勿推,攒批胜逐条。 旁车每帧几十个实体,逐条回调的成本远超一次批量拉取;把节奏控制权交给消费方,主线程压力才可预期。
  2. 零拷贝的前提是内存布局两边对齐。 XVector6、X3DEngineSyncDataSet 的结构体定义 C++/C# 必须严格一致,这是联调期最容易踩的坑,和 proto 两端一致是同一个道理。
  3. 双地图差异要收敛到编译开关,而不是 if-else 蔓延。 isGDLD 分支能存在是因为改造没完成,MAP_ENGINE 开关到位后它就该消失——过渡代码要写清退出条件。
  4. 排查 SOP 比链路本身更长寿。 链路会重构,但「现象 → 环节 → 日志锚点」的八项门控排查表在每次重构后只要更新锚点,方法论不变。

罗子初 · AI 座舱专家 · 8年端侧研发

Lv.1