本文属于系列「游戏中的数学:从坐标到可同步运动」第 7 / 15 篇

游戏中的数学 07:插值不是让数字变平滑

插值系统拥有的是“如何从已知的两端取中间值”,不是时间源,也不是网络真值。若渲染帧直接把 Lerp 当成万能平滑器,帧率、缓冲时间和旋转速度会混成一个隐式状态。本章固定 t∈[0,1],把标量曲线和姿态插值分别采样 101 点,再用相邻角度差检查速度是否稳定。

// $WORK_DIR/.tmp/game-math-lab/Program.cs · Chapter07
var q0 = Quaternion.Identity;
var q1 = Quaternion.CreateFromAxisAngle(
    Vector3.UnitY, (float)Math.PI);

for (var i = 0; i <= 100; i++)
{
    var t = i / 100.0;
    linear.Add(new P(t, t));
    smooth.Add(new P(t, t * t * (3 - 2 * t)));

    nAngles.Add(QuatAngle(Quaternion.Normalize(
        Quaternion.Lerp(q0, q1, (float)t))));
    sAngles.Add(QuatAngle(
        Quaternion.Slerp(q0, q1, (float)t)));
}

参数合同

Lerp(a,b,t)=a+(b-a)t 只定义值域中的直线;InverseLerp 才把实际值恢复为参数。SmoothStep(t)=t²(3-2t) 改变参数随时间的推进方式,但不会自动解决采样时间抖动、丢包或状态回滚。参数由时间系统或快照缓冲拥有,插值器保持无状态,才能复算同一输入。

这条边界也决定了失败时谁负责恢复:网络层缺少右端快照时,应保持、外推或等待;动画层找不到下一关键帧时,应按片段规则收尾;插值器本身不猜测缺失状态。只要输入端点和参数相同,它就必须返回相同结果。这样回放、客户端预测校正和编辑器预览可以共享函数,而不共享隐藏计时器。

// Program.cs · 参数函数与旋转角读取
static bool TryInverseLerp(
    double a, double b, double value, out double t)
{
    var span = b - a;
    if (Math.Abs(span) < 1e-9) { t = 0; return false; }
    t = (value - a) / span;
    return true;
}

static double QuatAngle(Quaternion q) =>
    2 * Math.Acos(
        Math.Clamp(Math.Abs(q.W), 0, 1));

static double[] Differences(
    IReadOnlyList<double> values) =>
    Enumerable.Range(1, values.Count - 1)
        .Select(i => values[i] - values[i - 1])
        .ToArray();

线性插值与 SmoothStep 参数曲线

正常路径

位置可直接线性插值;单位四元数的等角速度路径使用 Slerp。实验用 180° 旋转放大差异:Slerp 的相邻角步长标准差为 2.9627e-7 rad,接近浮点噪声;归一化 Lerp 的标准差为 6.4299e-3 rad。两者端点相同,运动过程却不是同一合同。

// Program.cs · Chapter07:角步长统计
var nSteps = Differences(nAngles);
var sSteps = Differences(sAngles);
var nStd = Std(nSteps);
var sStd = Std(sSteps);

static double Std(IEnumerable<double> source)
{
    var values = source.ToArray();
    var mean = values.Average();
    return Math.Sqrt(values.Sum(x =>
        (x - mean) * (x - mean)) / values.Length);
}
metric                         value                 unit
samples_per_path               101                   points
nlerp_angular_step_std          0.006429866664812347  rad
slerp_angular_step_std          0.000000296269524928  rad
adjacent_steps_per_path         100                   steps
acceptance_ratio                slerp < nlerp * 0.01  boolean
result                          PASS                  state

失败路径

失败样本是把 Nlerp 的端点正确误判为角速度正确。它通常更便宜,也能保持最短表示附近的连续姿态,但在大角度区间会产生非匀速角步长。本章断言要求 Slerp 的标准差低于 Nlerp 的百分之一;若有人更改实现或角度读取,使速度合同退化,实验立即失败。TryInverseLerp(10,10,15) 也被固定断言为 false,零区间不会产生无穷或 NaN 后继续传播。

还有一种常见失败是每帧执行 current=Lerp(current,target,k)。此时起点持续变化,k 不再是整段运动的统一参数,抵达时间会依赖帧数。若玩法需要确定时长,应保存固定起点、终点与开始 Tick,再由统一时钟求 t;若只需视觉阻尼,也要把它明确归渲染状态,不能反馈进逻辑姿态。

// Program.cs · Chapter07:回归门槛
Check(
    sStd < nStd * .01,
    "slerp angular steps are effectively constant");

Check(TryInverseLerp(10, 20, 15, out var t)
    && Math.Abs(t - .5) < 1e-9,
    "inverse lerp recovers parameter");

Check(!TryInverseLerp(10, 10, 15, out _),
    "zero interval fails explicitly");

WriteData(7, new
{
    samples = 101,
    nlerp_step_std_rad = nStd,
    slerp_step_std_rad = sStd,
    smoothstep_midpoint = .5
});

Nlerp 与 Slerp 的角步长对照

当天实测

输出同时给出样本数、两种标准差和进程级统计。这里测的是数值稳定性,不是 CPU 性能,因此不从整套运行耗时推导某个插值器更快。

$ cd $WORK_DIR
$ dotnet run --project .tmp/game-math-lab/GameMathLab.csproj -- --out .tmp/game-math-lab/out

CH07 PASS samples=101 nlerp_step_std=6.430E-003rad slerp_step_std=2.963E-007rad
TOTAL pass=15 fail=0 skip=0 chapters=15 elapsed_ms=112.8
exit_code=0
{
  "samples": 101,
  "nlerp_step_std_rad": 0.006429866664812347,
  "slerp_step_std_rad": 2.9626952492849927E-07,
  "smoothstep_midpoint": 0.5
}

架构取舍

UI 数值、短距离位置和小角度姿态可以优先使用便宜的 Lerp/Nlerp;相机轨道、长弧旋转或需要可预测角速度的动画使用 Slerp。选择条件应写在调用点,不能藏在“平滑组件”里。若网络快照间隔变化,先由缓冲器计算 t,再调用插值器,避免渲染帧率成为模拟状态。

场景 默认选择 升级触发条件
UI 数值、位置 Lerp 需要非线性时间曲线
小角度姿态 Nlerp 角速度误差超预算
长弧姿态 Slerp 批量成本经实测不可接受

下一章

下一章把同一个 t 送入三次 Bezier。届时会看到等参数并不产生等路程;本章的参数合同仍保留,但需要增加弧长表,把期望路程反查成曲线参数。