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

游戏中的数学 09:位置函数的数值微分

上一章的弧长参数化提供连续位置函数 p(t),本章从同一时间域估计速度与加速度。动画曲线和路径资源通常只提供位置,而碰撞预测、朝向和积分器需要导数。若位置差分不保留时间间隔,帧率会隐式进入状态定义。验证固定解析位置函数,在 t=0.7 s 使用六档 h 对比前向差分与中心差分。

// $WORK_DIR/.tmp/game-math-lab/Program.cs · 解析运动
static Vector2 Position(double t) => new(
    (float)(2 * t + Math.Sin(3 * t)),
    (float)(t * t + Math.Cos(2 * t)));

static Vector2 Velocity(double t) => new(
    (float)(2 + 3 * Math.Cos(3 * t)),
    (float)(2 * t - 2 * Math.Sin(2 * t)));

static Vector2 Acceleration(double t) => new(
    (float)(-9 * Math.Sin(3 * t)),
    (float)(2 - 4 * Math.Cos(2 * t)));

时间与单位

位置单位为米,速度为米每秒,加速度为米每二次方秒。三者不应存入缺少语义的同一 Vector2 队列并依赖调用顺序区分。数学上 v=p'a=v';系统边界上,位置函数属于路径或动画资源,采样时刻属于时间系统,运动状态保存同一时刻的一组量。

采样时钟与数据时间戳处于同一时间域。服务器时间的位置不能直接减去本地渲染时间的位置,即使两者单位均为米。差分前需要完成排序、去重与统一秒刻度换算;迟到样本由缓冲策略处理,而不改变导数公式。

// Program.cs · Chapter09:两种差分
var hs = new[] { .2, .1, .05, .025, .0125, .00625 };
var forward = new List<P>();
var central = new List<P>();
const double t = .7;

foreach (var h in hs)
{
    var exact = Velocity(t);
    var f = (Position(t + h) - Position(t)) / (float)h;
    var c = (Position(t + h) - Position(t - h)) /
            (float)(2 * h);

    forward.Add(new P(h, Vector2.Distance(f, exact)));
    central.Add(new P(h, Vector2.Distance(c, exact)));
}

推导图同时给出 h=0.18sh=0.045s 两条割线,并以解析速度 x'(0.7)=0.485m/s 构造切线。较短割线更接近切线,从几何上对应差分收敛。

两档步长的割线向解析切线收敛

差分误差阶

前向差分 (p(t+h)-p(t))/h 使用两个样本,截断误差为一阶;中心差分 (p(t+h)-p(t-h))/(2h) 增加一侧样本,截断误差为二阶。数据中 h 每次减半,中心差分误差约缩小到四分之一,直至浮点舍入开始占据主要比例。步长减小同时增加舍入误差敏感性。

h(s)      forward error(m/s)   central error(m/s)
0.20000   0.6898915768         0.1033637822
0.10000   0.3714724779         0.0261422396
0.05000   0.1916515827         0.0065549761
0.02500   0.0972037017         0.0016379354
0.01250   0.0489380546         0.0004047684
0.00625   0.0245467518         0.0001043414

加速度估计

同一差分合同可以作用于解析速度。最小步长 0.00625 s 下,估计加速度为 (-7.7684307, 1.3201475),解析值为 (-7.768884, 1.3201314)。输入仅包含离散位置时,需要先确定滤波与采样时钟,再评估连续求导;直接二阶差分会显著放大输入噪声。

// Program.cs · Chapter09:加速度样本
var h0 = hs[^1];
var estimatedAcceleration =
    (Velocity(t + h0) - Velocity(t - h0)) /
    (float)(2 * h0);

WriteData(9, new
{
    t,
    steps = hs,
    forward_errors = forward,
    central_errors = central,
    acceleration_estimate = V(estimatedAcceleration),
    acceleration_exact = V(Acceleration(t))
});

退化与噪声

最小步长的前向差分仍有 0.02455 m/s 误差,该误差不表示真实速度变化。断言不要求中心差分误差为零,而要求同一输入、同一步长下低于前向误差的 2%,从而约束算法级改进。TryCentralDifference(t,0) 返回 false,零步长不进入除法。

较小的位置抖动除以更小的 h 仍会产生较大的速度尖峰。滤波方案需要声明作用时间域与引入延迟,并保留原始样本供诊断。速度钳制属于差分之后的玩法约束,不能替代导数误差评估。

// Program.cs · Chapter09:回归门槛
Check(
    central[^1].Y < forward[^1].Y * .02,
    "central difference converges faster");

Check(!TryCentralDifference(t, 0, out _),
    "zero finite-difference step fails explicitly");

Done(9,
    $"h={h0:F5}s " +
    $"forward_error={forward[^1].Y:E3}m/s " +
    $"central_error={central[^1].Y:E3}m/s");

步长与导数误差双对数图

实验结果

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

CH09 PASS h=0.00625s forward_error=2.455E-002m/s central_error=1.043E-004m/s
TOTAL pass=15 fail=0 skip=0 chapters=15 elapsed_ms=148.7
exit_code=0
{
  "t": 0.7,
  "steps": [
    0.2,
    0.1,
    0.05,
    0.025,
    0.0125,
    0.00625
  ],
  "acceleration_estimate": {
    "x": -7.7684307,
    "y": 1.3201475
  },
  "acceleration_exact": {
    "x": -7.768884,
    "y": 1.3201314
  }
}

导数方案

离线曲线优先生成解析导数,其精度和求值成本均优于数值差分。仅当资源格式不提供导数,或输入来自采样数据时,采用数值差分。中心差分依赖未来样本,不适用于零延迟输入;实时链路采用后向或前向差分,并接受一阶误差。工具链、渲染预览和服务器可以选择不同算法,但单位与采样时刻保持一致。

输入来源 导数方案 主要约束
解析曲线 解析导数 资源格式必须保存公式
离线采样 中心差分 需要前后样本
实时输入 后向差分 接受一阶误差与噪声

积分接口

本章输出的 position、velocity、acceleration 将进入下一章的固定 dt Tick。导数误差会进一步转化为积分误差,因此步长、积分器和最大追帧限制构成显式协议。