游戏中的数学 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。导数误差将转化为积分误差,因此步长、积分器和最大追帧限制都必须成为显式协议。