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

游戏中的数学 09:速度和加速度从哪里来

动画曲线和路径系统通常只提供某时刻的位置;碰撞预测、朝向和固定步长积分却需要速度与加速度。如果用“本帧位置减上帧位置”而不记录时间间隔,帧率就偷偷进入状态定义。本章固定解析位置函数,在 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)));
}

位置曲线、割线与切线

误差阶

前向差分 (p(t+h)-p(t))/h 使用两个样本,截断误差是一阶;中心差分 (p(t+h)-p(t-h))/(2h) 多取一侧样本,截断误差是二阶。实验中 h 每次减半,中心差分误差大约缩到四分之一,直到浮点舍入开始占比。减小 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=112.8
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。导数误差将转化为积分误差,因此步长、积分器和最大追帧限制都必须成为显式协议。