游戏中的数学 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();

正常路径
位置可直接线性插值;单位四元数的等角速度路径使用 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
});

当天实测
输出同时给出样本数、两种标准差和进程级统计。这里测的是数值稳定性,不是 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。届时会看到等参数并不产生等路程;本章的参数合同仍保留,但需要增加弧长表,把期望路程反查成曲线参数。