本文属于系列「游戏中的数学:从坐标到可同步运动」第 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。视觉阻尼可以使用递进 Lerp,但其状态限于渲染层,不反馈到逻辑姿态。

// 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 的角步长对照

实验结果

输出包含 101 个采样点和两种角步长标准差,可用于判断速度合同是否成立。该实验未测量 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=148.7
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;由于等参数不对应等路程,运动系统还需要弧长表将期望路程反查为曲线参数。