曲线约束插值:让稀疏的点位跑成一条顺滑的线
服务端每秒推一次车辆位置,两次快照之间相距十几到二十几米。直接线性插值的结果是弯道切角、朝向抖动。把它们投影到线路折线上按弧长插值,问题就消失了——代价是所有几何都要预处理。
现象:车在弯道上「抄近路」
俯视视角下,一切正常。切到贴近地面的视角看,问题立刻暴露:
- 拐弯时车辆切角,从弯道内侧穿过去,而不是沿道路走;
- 车身朝向一跳一跳的,尤其在缓弯上,看起来像在左右摇摆。
原因很简单:服务端按固定频率推送位置,两次快照之间车辆移动了大约 11 到 28 米。前端拿到两个点,做一次线性插值 —— 而直线永远不可能贴合一条弯曲的道路。
为什么不能靠「推得更快」解决
第一反应是提高推送频率。这条路走不通,有三个理由:
- 带宽。位置数据本身不大,但推送频率线性上升,而出口带宽很窄(实测 3Mbps 级别)。
- 仍然不能解决根本问题。只要两次采样之间的距离超过几何曲率允许的误差,切角就还在。在弯道半径小的地方,即使 10Hz 也不够。
- 服务端的坐标本身就是从折线算出来的。前端明明拿得到那条折线,却只用了两个端点 —— 信息被浪费了。
关键认识是:位置点不是唯一的约束,线路几何是更强的约束。 车一定在道路上,那就把道路当成已知条件。
方案:投影到折线,在弧长维度插值
核心只有四步。
① 预处理线路几何为线性参考系统
interface LineGeometry {
points: Cesium.Cartesian3[];
/** 与 points 等长:每个顶点的累积弧长(米) */
cum: Float64Array;
length: number;
}
② 把每个快照投影到折线上,求它的弧长
对折线的每一段做点到线段的最近点投影,取距离最小的一段,得到弧长 s:
function projectToArc(geo: LineGeometry, p: Cesium.Cartesian3): number {
let bestArc = 0;
let bestDist = Number.POSITIVE_INFINITY;
for (let i = 1; i < geo.points.length; i += 1) {
const a = geo.points[i - 1];
const b = geo.points[i];
const closest = closestPointOnSegment(a, b, p);
const d = Cesium.Cartesian3.distance(closest, p);
if (d < bestDist) {
bestDist = d;
bestArc = geo.cum[i - 1] + Cesium.Cartesian3.distance(a, closest);
}
}
return bestArc;
}
于是两个快照不再是一对端点,而是一对弧长:sA(上一帧)、sB(本帧)。
③ 60Hz 沿弧长插值
前端每帧不需要新的网络数据,只需要在 sA 与 sB 之间按时间比例推进:
const t = clamp((now - tA) / (tB - tA), 0, 1);
const s = sA + (sB - sA) * t;
const position = sampleGeometryAt(geo, s); // 沿折线按弧长取点
const heading = tangentAt(geo, s); // 该处的切向
sampleGeometryAt 用累积弧长做二分查找,定位到具体线段后在段内线性插值 —— 这是 O(log n),对每帧几十辆车来说开销可以忽略。
④ 航向不能直接用相邻帧差分
有了 tangentAt,最自然的想法是每帧算一次切线就完事。但切线的方向对位置噪声极其敏感:s 上几十厘米的抖动,在长直路段上会让切线明显左右摆。
做法是对航向做一阶低通滤波(时间常数约 0.25 秒):
// 指数平滑:alpha 由帧间隔决定,保证不同帧率下效果一致
const alpha = 1 - Math.exp(-dt / TAU);
smoothed = smoothed + shortestAngleDelta(smoothed, target) * alpha;
注意这里必须用最短角度差(处理 ±180° 环绕),否则车辆会在正北方向附近突然掉头。
三个必须处理的现实问题
线路几何的懒加载
一座城市有几千条线路,全量加载折线是不可能的。做法是:只加载玩家/视口附近需要的线路,按需拉取并缓存。
关键是失败记忆:某条线路的几何如果拉取失败(数据缺失、请求超时),要记下来,不要每帧重试 —— 否则一条坏线路会让整个渲染循环持续打请求。
几何缺失时的兜底
不是所有车辆都能拿到线路几何。此时必须有降级路径:退回直线插值。
降级在视觉上会看出切角,但总好过车辆卡在原地不动或直接消失。这里的原则是「有降级路径的优雅退化 优于 「全有或全无」。
外推的边界
快照流偶尔会断(网络抖动、切页重连)。如果只是等下一帧,车辆会僵住;如果无限外推,车辆会飞出城市。
折中方案是根据最近几帧的速度估计做有限外推,并设两个上限:时间上限(约 15 秒)与距离上限(约 25m/s 对应的距离)。任一上限触发就停止外推,让车辆停在最后一次可信位置,等数据恢复。
代价与收益
| 直接线性插值 | 曲线约束插值 | |
|---|---|---|
| 弯道表现 | 切角 | 贴路 |
| 朝向 | 剧烈抖动 | 平滑(含低通) |
| 网络依赖 | 点越密越好 | 与推送频率解耦 |
| 实现成本 | 几行 | 几何预处理 + 投影 + 采样 + 滤波 |
| 额外依赖 | 无 | 需要线路折线(懒加载 + 缓存) |
多出来的成本集中在 几何的获取与预处理 上,而不是每帧的计算量。这部分是一次性的、可缓存的 —— 而且预处理出来的线性参考系统,在别的地方还能复用(比如算站点在折线上的位置、算两个点之间的线路里程)。
一个通用一点的结论
这类问题的通用形式是:你有一串离散观测点,同时知道这些点必然落在某个已知曲线上。
此时最优解通常不是「想办法把观测点变密」,而是「把曲线拿来,把观测点作为曲线上的位置参数」。观测点只负责告诉你「在曲线的哪个位置」,具体位置由曲线本身给出。
同样的思路在别的地方也成立:轨迹回放、路径动画、任何「沿已知路线运动」的可视化。