幽灵点云:一次由“缓存“引发的坐标漂移
项目:P_LidarMap(UE 5.3 机器人激光雷达实时建图插件)
关键词:UE LidarPointCloud、八叉树、坐标系、第三方库副作用
TL;DR
我们缓存了一个引擎会在背后偷偷修改的值(LocationOffset)。 引擎每次刷新包盒时都会把整棵八叉树的点平移重居中并累加该偏移,而我们仍按旧偏移插入新点—— 于是每一批新点都被整体推到几百米外,形成一簇持续生长的"幽灵点云"。
修复:插入平移量从"缓存一次"改为"每次插入前实时推导",让坐标往返成为恒等式。
1. 现象
机器人在工厂里边走边扫,点云地图实时生成。运行一段时间后:
✅ 机器人周围的点云位置完全正确
❌ 400 米开外的天上,悬浮着一簇点云,且持续生长
❌ 停车不动,幽灵仍在长
日志坐标印证了异常:
状态=建图中 点数=32659 累积器云=LidarPointCloud_1 包盒Min=(15001, -4084, 24) Max=(59910, 3651, 5582)cm
机器人在X=18750,雷达量程 40m(4000cm)。 X 下界 15001 ≈ 18750 − 4000 ✅ 合理。X 上界 59910 ❌—— 比机器人所在位置远 400 多米,物理上不可能扫到。
2. 排查过程
这次排查的价值不在答案,而在三个看似都成立的假设被逐一证伪的过程。
假设 1:LAS 存档加载时的自动居中 ❌
为什么合理:读引擎源码时发现了一段确凿的可疑代码——
// 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。
💡教训:与其猜,不如让程序自己把状态说出来。这条日志永久留在了代码里。
假设 3:MaxRange 单位滑倒 ❌ ——最危险的假设
为什么合理:MaxRange单位是厘米。如果误填了40000(想写 40 米), 实际量程就是400 米——这个数字恰好能解释幽灵的距离。 再看 Stats:X 上界 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 一次)都传bRefreshPointsBounds=true—— 于是LocationOffset随着建图在不断变化。
我们却把插入平移量当常量缓存了(Initialize时算一次)。
可视化 1:坐标漂移时序
可视化 2:算术账本
| 时刻 | 事件 | 存储坐标 | LocationOffset | 渲染位置 = 存储 + Offset |
|---|---|---|---|---|
| T1 | 插入第 1 批(世界 18750,平移 0) | 18750 | 0 | 18750 ✅ |
| T2 | RefreshBounds重居中 | 0 | 18750 | 18750 ✅ 旧点无恙 |
| T3 | 插入第 2 批(世界 18800,平移仍是缓存的 0) | 18800 | 18750 | 37550❌ |
| 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——我们每次刷新都传 * bRefreshPointsBounds=true,所以 LocationOffset 会随建图不断变化。 * 若用缓存值,新点会整体错位一个"数据中心"的偏移,表现为几百米外 * 持续生长的"幽灵点云"。 * * 只能在没有插入任务在飞时调用(此刻偏移稳定), * KickAsyncFlush / FlushBlocking 的调用点都满足。 */ FVector ComputeInsertTranslation() const;
顺带修掉的另一个 bug
假设 1 虽然被证伪(当时没触发),但那个问题是真实存在的: LAS 加载后如果不还原世界位置,地图会整体搬到世界原点渲染。 一并在AdoptCloud(LoadMap 后)修了——用引擎现成的 API:
// 恢复世界位置:引擎 LAS 导入默认"自动居中",渲染偏移被置 0, // 真实世界中心只留在 OriginalCoordinates 里。 Cloud->RestoreOriginalCoordinates();
📌又一个"先查 API 再造轮子"的例子: 这里我最初同样是手工推导
OriginalCoordinates再SetLocationOffset(四行)。 核对时才发现引擎早就提供了RestoreOriginalCoordinates()—— 一个BlueprintCallable的公开 API,注释就写着 "Restores original coordinates", 干的正是这件事。四行变一行。
6. 为什么这个 bug 难抓
| 特征 | 造成的误导 |
|---|---|
| 幽灵是新点不是旧点 | 违背直觉("新点应该在机器人身边"),一直往"旧数据/加载"方向找 |
| 旧点始终正确 | 掩盖了"坐标系出问题"这个方向——看起来大部分数据都好好的 |
| 护栏零告警 | 数据在插入前完全合法,源头排查一无所获 |
| 停车后仍生长 | 曾被误判为"内存泄漏"或"去重失效" |
| 数字巧合(400m ≈ MaxRange 单位滑倒) | 制造了一个高度可信的假线索 |
函数名RefreshBounds | 严重低估了副作用——谁会想到"刷新包盒"会改写全树点坐标? |