
项目P_LidarMapUE 5.3 机器人激光雷达实时建图插件关键词UE LidarPointCloud、八叉树、坐标系、第三方库副作用TL;DR我们缓存了一个引擎会在背后偷偷修改的值LocationOffset。 引擎每次刷新包盒时都会把整棵八叉树的点平移重居中并累加该偏移而我们仍按旧偏移插入新点—— 于是每一批新点都被整体推到几百米外形成一簇持续生长的幽灵点云。修复插入平移量从缓存一次改为每次插入前实时推导让坐标往返成为恒等式。1. 现象机器人在工厂里边走边扫点云地图实时生成。运行一段时间后✅ 机器人周围的点云位置完全正确❌ 400 米开外的天上悬浮着一簇点云且持续生长❌ 停车不动幽灵仍在长日志坐标印证了异常状态建图中 点数32659 累积器云LidarPointCloud_1 包盒Min(15001, -4084, 24) Max(59910, 3651, 5582)cm机器人在X18750雷达量程 40m4000cm。 X 下界 15001 ≈ 18750 − 4000 ✅ 合理。X 上界 59910 ❌—— 比机器人所在位置远 400 多米物理上不可能扫到。2. 排查过程这次排查的价值不在答案而在三个看似都成立的假设被逐一证伪的过程。假设 1LAS 存档加载时的自动居中 ❌为什么合理读引擎源码时发现了一段确凿的可疑代码——// LidarPointCloud.cpp:906 LocationOffset bCenter ? FVector::ZeroVector : OriginalCoordinates;LAS 导入默认开启自动居中点被移到本地居中坐标渲染偏移被置 0 真实世界位置只留在OriginalCoordinates里。这确实会让加载的地图整体搬到世界原点渲染—— 和天上那团在原点附近完全吻合。怎么证伪一个提问就够了——你调用过 Save/Load 吗 答案是没有全程只调过LidarMap.Start。 代码搜索进一步确认全插件的加载只有一条链路LidarMapFileIO::LoadAsync 唯一入口是控制台命令必须显式触发启动时不会自动加载。教训源码里找到一段能解释现象的代码不等于它被执行了。先确认调用路径。 附带收获这个居中问题是真的只是当时没触发——后来在AdoptCloud里一并修了。假设 2世界里有两台机器人在同时扫描 ❌为什么合理LidarMap.Start的实现是找世界里第一个扫描器。 如果 GameMode 设了默认 Pawn、关卡里又手摆了一个就会有两台机器人同时自动开扫 而LidarMap.Start随机接上其中一台——另一台在远处扫的点就成了幽灵。怎么证伪给LidarMap.Start加日志把接入的扫描器曝光出来并在发现多台时告警接入扫描器Scanner所属 BP_LidarRobotPawn_C_0 (18750, -160, 147)只有一台且正是玩家驾驶的那台。世界大纲搜索也确认只有一个LidarRobot。教训与其猜不如让程序自己把状态说出来。这条日志永久留在了代码里。假设 3MaxRange 单位滑倒 ❌ ——最危险的假设为什么合理MaxRange单位是厘米。如果误填了40000想写 40 米 实际量程就是400 米——这个数字恰好能解释幽灵的距离。 再看 StatsX 上界 59910 ≈ 机器人到过的最东位置 40000。严丝合缝。 甚至连为什么只有东边有幽灵都能解释东边是条笔直长街射线畅通无阻飞出几百米。怎么证伪用户发来蓝图截图——Max Range 4000.0从未改过。⚠️这是本次排查最大的陷阱。数字对得上、方向对得上、连场景细节都对得上 但结论是错的。我差点让用户去修正一个本来就正确的配置。教训数字吻合 ≠ 因果成立。巧合在数字上是廉价的—— 40000 能解释 400m但LocationOffset漂移≈18800×2同样能。3. 取证工具三件套三个假设全灭之后停止猜测改为造工具取证。这三件都永久留在了代码里。①LidarMap.Stats增强Actor 清单打印世界里所有点云 Actor、各自绑定的云对象、点数、包盒并标出哪个是累积器的云状态建图中 点数32659 累积器云LidarPointCloud_1 包盒Min(15002,-4073,24) Max(59888,3652,5571) Actor[0] LidarPointCloudActor_0 (0,0,0) 云LidarPointCloud_1 点数32659 ... ←累积器的云 共 1 个点云 Actor排除场景里有第二个点云 Actor、两朵云。锁定幽灵和正常点云在同一朵云里——问题在数据内部不在对象管理。② 距离护栏入图前的物理合理性校验在HandlePointsScanned里丢弃任何到扫描器距离超过MaxRange × 1.5的点并限频告警。结果零告警。这是决定性线索。护栏的沉默和告警一样有信息量 点在插入前坐标完全合法 →污染发生在插入之后。 排查范围一下从整条链路收缩到插入之后的引擎内部。③LidarMap.Probe点级取样LidarMap.Probe X Y Z 半边长打印指定区域内的点数和坐标样本。对幽灵区取样Probe 中心(56000, 0, 2800) 半边长 6000命中 14790 个点 样本[0] (50438.6, -1745.6, 2807.9) 样本[1] (50408.5, -1630.9, 3323.2) 样本[2] (50436.2, -1590.0, 2903.0) 样本[3] (50433.8, -1589.2, 3218.1) ...注意样本的形态相近的 XY、不同的 Z——这是垂直扇面击中墙面的规整结构。结论幽灵是真实的扫描数据不是内存坏值、不是野指针。 它是好点被放错了地方。4. 真凶RefreshBounds的隐藏副作用线索汇总后凶手只可能在插入之后、引擎内部。翻到这段// LidarPointCloudOctree.cpp:925-950 void FLidarPointCloudOctree::RefreshBounds() { FBox Bounds(EForceInit::ForceInit); ITERATE_NODES_CONST({ FOR_RO(Point, CurrentNode) { Bounds (FVector)Point-Location; } }, true); Extent (FVector3f)Bounds.GetExtent(); const FVector3f Offset (FVector3f)Bounds.GetCenter(); if (!Offset.IsNearlyZero(0.1f)) { Owner-LocationOffset (FVector)Offset; // ← 渲染偏移被累加 Owner-OriginalCoordinates (FVector)Offset; // Shift the points back to the relative position ITERATE_NODES( { CurrentNode-Center - Offset; FOR(Point, CurrentNode) { Point-Location - Offset; // ← 全树已存点被改写 } }, true); } }一个叫刷新包盒的函数实际上会把整棵树所有已存点的坐标改写平移到居中位置把差值累加进LocationOffset渲染时统一加回顺带改OriginalCoordinates和每个节点的Center而我们每次节流刷新150ms 一次都传bRefreshPointsBoundstrue—— 于是LocationOffset随着建图在不断变化。我们却把插入平移量当常量缓存了Initialize时算一次。可视化 1坐标漂移时序可视化 2算术账本时刻事件存储坐标LocationOffset渲染位置 存储 OffsetT1插入第 1 批世界 18750平移 018750018750 ✅T2RefreshBounds重居中01875018750 ✅ 旧点无恙T3插入第 2 批世界 18800平移仍是缓存的 0188001875037550❌T4再次重居中…继续累加越漂越远 → 59910关键T2 的重居中对旧点是无害的存储 −18750渲染 18750抵消。 受害的只有之后插入的新点——它们没有减去偏移却在渲染时被加上了。这解释了所有反直觉的现象幽灵是新点不是旧点—— 与直觉完全相反停车后幽灵仍在长—— 因为新扫的点全部落进幽灵区护栏零告警—— 点在插入前是合法的越漂越远—— 每次重居中都会累加5. 修复方案核心洞察不要算对一次要让它成为恒等式❌ 缓存版 渲染位置 世界坐标 LocationOffset(漂移中) └─ 引擎随时在改缓存值追不上 → 恒错位 ✅ 现算版 渲染位置 (世界坐标 − LocationOffset) LocationOffset └── 插入平移【现算】──┘ └─ 引擎渲染时加回 ─┘ 世界坐标 ← 恒等式与 LocationOffset 取什么值【无关】修复的本质不是算出正确的偏移值而是让往返自动抵消—— 无论引擎把LocationOffset改成什么只要插入时现取一次等式就恒成立。代码// MapAccumulator.cpp FVector UMapAccumulator::ComputeInsertTranslation() const { // 详见头文件注释引擎 RefreshBounds 会随建图不断重居中并改 LocationOffset // 平移量必须在每次插入前现取不能缓存。 // 存储坐标 世界坐标 - LocationOffset渲染时引擎再加回 → 恒等与偏移当前值无关。 return Cloud ? -Cloud-LocationOffset : FVector::ZeroVector; }这里有个小插曲值得单独说 第一版实现不是这样的。当时我以为LocationOffset读不到它确实没有 getter 于是用两个公开包盒的中心差把它推导了出来// 第一版——能跑但绕了远路 return -(Cloud-GetBounds().GetCenter() - FVector(Cloud-Octree.GetBounds().GetCenter()));写文档核对事实时才发现LocationOffset是public 成员LidarPointCloud.h:122 只是没有 getter 而已。而GetBounds()的实现正是Octree.GetBounds().ShiftBy(LocationOffset)—— 所以那个推导在数学上恒等于直接读这个字段绕一大圈算出了一个可以白拿的值。教训「没有 getter」≠「读不到」。写推导代码之前先确认真的没有直接途径。两个调用点都改为现场推导// KickAsyncFlush()异步刷新 // 现算平移此刻无插入在飞Tick 的 bFlushInFlight 闸包盒稳定。 const FVector Translation ComputeInsertTranslation(); // FlushBlocking()停止建图/保存前的冲刷 Cloud-InsertPoints( Pending.GetData(), Pending.Num(), ELidarPointCloudDuplicateHandling::Ignore, /*bRefreshPointsBounds*/true, ComputeInsertTranslation()); // 上面已等在飞任务结束此刻包盒稳定。线程安全两个调用点都保证了此刻没有插入任务在飞bFlushInFlight闸门 偏移处于稳定状态取值可信。防回退头文件注释里把为什么不能缓存和引擎行为的源码位置写死了 避免将来有人把它优化回缓存版。/** * 实时推导本次插入的平移量世界坐标 → 八叉树本地坐标。 * * ⚠️ 不许缓存引擎的 FLidarPointCloudOctree::RefreshBounds * LidarPointCloudOctree.cpp:925只要数据中心偏离原点就会把【全树已存点】 * 整体平移重居中并把差值累加进 LocationOffset——我们每次刷新都传 * bRefreshPointsBoundstrue所以 LocationOffset 会随建图不断变化。 * 若用缓存值新点会整体错位一个数据中心的偏移表现为几百米外 * 持续生长的幽灵点云。 * * 只能在没有插入任务在飞时调用此刻偏移稳定 * KickAsyncFlush / FlushBlocking 的调用点都满足。 */ FVector ComputeInsertTranslation() const;顺带修掉的另一个 bug假设 1 虽然被证伪当时没触发但那个问题是真实存在的 LAS 加载后如果不还原世界位置地图会整体搬到世界原点渲染。 一并在AdoptCloudLoadMap 后修了——用引擎现成的 API// 恢复世界位置引擎 LAS 导入默认自动居中渲染偏移被置 0 // 真实世界中心只留在 OriginalCoordinates 里。 Cloud-RestoreOriginalCoordinates();又一个先查 API 再造轮子的例子 这里我最初同样是手工推导OriginalCoordinates再SetLocationOffset四行。 核对时才发现引擎早就提供了RestoreOriginalCoordinates()—— 一个BlueprintCallable的公开 API注释就写着 Restores original coordinates 干的正是这件事。四行变一行。6. 为什么这个 bug 难抓特征造成的误导幽灵是新点不是旧点违背直觉新点应该在机器人身边一直往旧数据/加载方向找旧点始终正确掩盖了坐标系出问题这个方向——看起来大部分数据都好好的护栏零告警数据在插入前完全合法源头排查一无所获停车后仍生长曾被误判为内存泄漏或去重失效数字巧合400m ≈ MaxRange 单位滑倒制造了一个高度可信的假线索函数名RefreshBounds严重低估了副作用——谁会想到刷新包盒会改写全树点坐标