跳到正文

曲线约束插值:让稀疏的点位跑成一条顺滑的线

· 约 4 分钟

服务端每秒推一次车辆位置,两次快照之间相距十几到二十几米。直接线性插值的结果是弯道切角、朝向抖动。把它们投影到线路折线上按弧长插值,问题就消失了——代价是所有几何都要预处理。

现象:车在弯道上「抄近路」

俯视视角下,一切正常。切到贴近地面的视角看,问题立刻暴露:

  • 拐弯时车辆切角,从弯道内侧穿过去,而不是沿道路走;
  • 车身朝向一跳一跳的,尤其在缓弯上,看起来像在左右摇摆。

原因很简单:服务端按固定频率推送位置,两次快照之间车辆移动了大约 11 到 28 米。前端拿到两个点,做一次线性插值 —— 而直线永远不可能贴合一条弯曲的道路。

为什么不能靠「推得更快」解决

第一反应是提高推送频率。这条路走不通,有三个理由:

  1. 带宽。位置数据本身不大,但推送频率线性上升,而出口带宽很窄(实测 3Mbps 级别)。
  2. 仍然不能解决根本问题。只要两次采样之间的距离超过几何曲率允许的误差,切角就还在。在弯道半径小的地方,即使 10Hz 也不够。
  3. 服务端的坐标本身就是从折线算出来的。前端明明拿得到那条折线,却只用了两个端点 —— 信息被浪费了。

关键认识是:位置点不是唯一的约束,线路几何是更强的约束。 车一定在道路上,那就把道路当成已知条件。

方案:投影到折线,在弧长维度插值

核心只有四步。

① 预处理线路几何为线性参考系统

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 对应的距离)。任一上限触发就停止外推,让车辆停在最后一次可信位置,等数据恢复。

代价与收益

直接线性插值曲线约束插值
弯道表现切角贴路
朝向剧烈抖动平滑(含低通)
网络依赖点越密越好与推送频率解耦
实现成本几行几何预处理 + 投影 + 采样 + 滤波
额外依赖无需要线路折线(懒加载 + 缓存)

多出来的成本集中在 几何的获取与预处理 上,而不是每帧的计算量。这部分是一次性的、可缓存的 —— 而且预处理出来的线性参考系统,在别的地方还能复用(比如算站点在折线上的位置、算两个点之间的线路里程)。

一个通用一点的结论

这类问题的通用形式是:你有一串离散观测点,同时知道这些点必然落在某个已知曲线上。

此时最优解通常不是「想办法把观测点变密」,而是「把曲线拿来,把观测点作为曲线上的位置参数」。观测点只负责告诉你「在曲线的哪个位置」,具体位置由曲线本身给出。

同样的思路在别的地方也成立:轨迹回放、路径动画、任何「沿已知路线运动」的可视化。

这篇文章对应的作品

曲线约束插值:让稀疏的点位跑成一条顺滑的线 · 巴迷科技