Skip to content

车道级渲染帧录制回放体系:GBLF v5 协议与 ReplayerV2 ​

车道级渲染一帧全量数据动辄几百 KB,直接录原始帧既写不动也存不下。我们借鉴视频编码的 I/P 帧思路设计了 GBLF v5 二进制协议——三级变化检测把纯位移对象从约 5KB 压到约 24 字节,再配上背压丢帧、I 帧对齐切分和独立播放线程的 ReplayerV2,让车道级场景在 PC 上可以像素级复现。

位移对象压缩~200×
帧头上限32B / 256MB
变速范围0.5–5x

背景 ​

车道级(GBL_LD)渲染数据由高德 SDK 逐帧产出,包含相机信息、纹理数组、40 多种渲染对象和 SR 回调。调试车道级问题最大的痛点是场景不可复现:实车跑一遍才出现一次的错位、闪烁,回到工位上没有任何手段还原。这套体系要解决的就是两件事——把 GBL 帧高效地录进 .record 文件,以及在 Windows/macOS 上按原始时间戳精准回放给 Unity。

录制挂在 XData 信号总线上:GBL 帧以 SIGNAL_XPD_MOCK_LD_FATA(140004)、XDATA_NATIVE_RECORD 源进入 SignalDispatcher,由 SignalFileRecorder 落盘;回放由 SignalFileStreamReplayer(v1,流式)和 SignalFileStreamReplayerV2(v2,预加载 + seek)把记录重新 dispatch 回总线。

问题 ​

问题表现约束
全量帧太大每帧全量纹理 + 全量对象,落盘体积和 I/O 都扛不住必须增量编码
录制不能拖累渲染Fetch 路径在渲染帧节奏里跑入队即返回,写盘异步
回放要还原时序离线分析需要原速、变速、逐帧独立播放线程 + 时间戳对齐
回放污染实时系统历史触摸、历史下电帧会被当成真实事件屏蔽清单兜底
跨平台字节序录制在车机 Android,回放在 Windows/macOS统一小端 wire 格式

方案 ​

借鉴视频编码的 I/P 帧增量协议 ​

GblDeltaRecorder 对每帧做三级变化检测:GObjectToken(24 字节 id+tag+flag,FNV-1a 哈希做 key)不存在于上一帧 → 新增全量;dirtyVersion 变了 → 脏对象全量;只有 transform[3] 变了 → 仅写 TRANSFORM_PATCH(mask=0x01,只带 double[3],约 24 字节)。上一帧有、当前帧没有的进 deletedTokens。I 帧只在首帧写一次,之后全是 P 帧,无周期 I 帧;录制停止时 onSignalFileRecordingStopped 会 reset deltaRecorder,保证下一段录制从 I 帧重新开始。

mermaid
flowchart LR
    SDK["高德 SDK<br>逐帧产出"] --> Fetch["GblMapDataHandler<br>FetchOnce"]
    Fetch --> Delta["GblDeltaRecorder<br>三级变化检测"]
    Delta --> XV["XDataPropertyValue<br>140004 NATIVE_RECORD"]
    XV --> Disp["SignalDispatcher<br>dispatch → record"]
    Disp --> Rec["SignalFileRecorder<br>noticeAnySignal"]
    Rec --> Q["有界队列<br>容量 128"]
    Q --> Enc["recordConsumerLoop<br>可选 zlib → 编码"]
    Enc --> File[".record 文件<br>napa5 + v3"]

录制-回放的 SourceType 状态机 ​

dispatch 按 SourceType 分流:*_RECORD 源只走 record() 落盘,不进 inner_dispatch;*_REPLAY 源先 inner_dispatch 注入系统,若此时仍在录制则把 source 改成 RECORD 再落盘(边回放边录制);实时源只 inner_dispatch。回放时 convertSourceType 把 RECORD 映射回 REPLAY,同一套总线语义覆盖三种角色。

mermaid
stateDiagram-v2
    [*] --> 待首帧
    待首帧 --> 全量重建: 收到 I 帧(清空 map 深拷贝)
    全量重建 --> 增量累加: 收到 P 帧(删除+新增+PATCH)
    增量累加 --> 增量累加: 持续 P 帧
    增量累加 --> 全量重建: 新 I 帧
    全量重建 --> 待首帧: 录制停止 reset

Host 端:C# 控制面 + C++ 数据面 ​

Windows/macOS 回放是 Host 专属能力(V2 仅在 WIN32/APPLE 注册)。C# 侧 NativePluginLoader 用反射 + GetProcAddress 把 xdata_native.dll 的导出函数绑成委托,XDataNativePlugins 封装全部 V2 控制接口;C++ 侧 jni_unity.cpp 的导出函数都是对 ReplayerV2 的薄封装。playThread 独立线程按时间戳对齐分发,UI 线程完全不被阻塞。

mermaid
sequenceDiagram
    participant UI as "C# 控制面<br>XDataNativePlugins"
    participant JNI as "jni_unity.cpp<br>导出层"
    participant RP as "ReplayerV2<br>playThread"
    participant DSP as "SignalDispatcher"
    participant CS as "C# 消费<br>SystemServiceHandler"
    UI->>JNI: StartReplayV2()
    JNI->>RP: play() 启动播放线程
    loop 每帧信号
        RP->>RP: calculateWaitTime 时序对齐
        RP->>DSP: 屏蔽判定后 dispatch
        DSP-->>CS: Unity 回调 → OnUpdate 分发
    end

关键实现 ​

GBLF v5 线格式。 帧头 32 字节:magic 0x47424C46("GBLF")、version=5、flags 位(LITTLE_ENDIAN / COMPRESSED / HAS_DYNAMIC / DELTA_FRAME / HAS_CAR_INFO)、totalSize、frameIndex、textureCount、objectCount、srCnt。P 帧紧随其后是 16 字节 GblDeltaHeader(删除数量 + 参考 I 帧的 referenceFrameIndex),然后是 Profiler/Camera/Meter2P20Scale 三段 POD 全量、删除列表、变更纹理和 v5 变更对象列表(token + deltaMask + 条件载荷)。所有字段统一小端存储,hostToLittle/littleToHost 处理跨平台。

防御性反序列化。 所有读取走 readDataSafe + safeAdd,防越界和整数溢出;单帧 MAX_FRAME_SIZE=256MB 上限;动态数据全部 shared_ptr<uint8_t[]> RAII 管理,反序列化失败自动清理。回放端 GblFrameAccumulator 消费 I/P 帧:I 帧清空 objectMap/textureMap 后深拷贝重建,P 帧应用删除、覆盖和 TRANSFORM_PATCH(writeTransformIntoSubtype 原地改 transform),最后 rebuildOutputArrays 输出完整场景。

落盘工程化细节。 .record 容器极简:6 字节文件头("napa5" + version 3),之后每条记录是 SignalHeader(mTime/source/mSignalId/mSignalFormat/mContentLength)+ payload。Fetch 路径只入队,有界队列 RECORD_QUEUE_CAPACITY=128,满了直接丢新帧并打 [BackPressure] 日志——宁可丢帧也不阻塞渲染。I 帧触发 requestNewFile 切文件,新文件通常以 I 帧开头;按 MAX_RECORD_FILE_SIZE=64MB 体积切分时新文件首条可能是 P 帧(delta 状态跨文件延续),这是已知的回放衔接风险。时间戳与墙钟偏差超过 1 小时会被修正为当前时间(常见于边回放边录)。

可选 zlib 压缩。 FLAG_COMPRESSED 容器:帧头后加 uint32 压缩长度 + zlib 体,只在 recordConsumerLoop 出队后、encode 前对 140004 尝试压缩,压缩后体积不小于原 body 就放弃保持明文——无效压缩自动回退。开关默认关(系统属性或环境变量开启),回放端 materializePlainGblPayload 透明解压。

ReplayerV2 时序对齐。 预加载 mAllSignals 到内存支持随机 seek;SignalReplayTimingController 用 8 个时间变量算 waitTime = (signalElapsed − playElapsed × speed) / speed,mPlaySpeed 支持 0.5–5.0 变速,速度切换有专门的时间补偿变量;seek 用 std::scoped_lock 与播放线程互斥。已知限制:seek 不自动重置 GblFrameAccumulator,落到 P 帧而未从对应 I 帧重建时画面会错,需要业务层约束 seek 用法。

双屏蔽清单根治幽灵事件。 Host 屏蔽 36001(MCU_IG_ST:历史下电帧会触发整机下电逻辑,打断回放会话)和 102002(UNITY_PROJECT:历史 Surface 投射污染编辑器 Surface 状态机);Android 端另有 6 个信号清单,包括 103006(历史触摸注入——幽灵触摸的直接来源)、102033(分屏状态覆盖)、190021(假进度)、105015/105016(车道级状态覆盖实时链路)、140004(混录污染)。

指标 ​

指标数值说明
纯位移对象体积~5KB → ~24 字节TRANSFORM_PATCH,约 200 倍
帧头 / 单帧上限32 字节 / 256MB小端 wire 格式,readDataSafe 校验
录制背压队列 128,满则丢帧Fetch 路径只入队不阻塞
单文件切分64MB,I 帧对齐体积切分可能以 P 帧开头(已知限制)
回放变速0.5–5.0xseek + 逐帧步进 + 暂停续播

复盘 ​

  1. 借鉴视频编码要借鉴得清醒:I/P 帧拿到了约 200 倍的位移对象压缩,但「无周期 I 帧 + seek 不重置累加器」意味着 seek 到 P 帧就是错画面,这个取舍要用文档和工具(gbl 帧扫描脚本)兜住,不能指望使用方自觉。
  2. 录制侧第一原则是绝不阻塞渲染线程。128 容量背压丢帧看着粗暴,但它把「录制开销」变成了有确定上限的常数,这比任何优雅的无锁队列都重要。
  3. 二进制协议的安全边界要在第一天就写好:readDataSafe、safeAdd、256MB 上限,让损坏文件最多损失后续记录,而不是越界读崩进程。
  4. 回放系统最大的敌人不是性能而是「历史事件重放成真实事件」。双屏蔽清单(Host + Android 各一份)是被幽灵触摸、误下电这些事故喂出来的底线,新接入的信号类型要先过清单再过功能。

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

Lv.1