游戏中的数学 08:曲线参数为什么不等于路程
路径系统给移动体的是位置函数,运动系统需要的却是“走了多少米”。三次 Bezier 的 t 只是混合权重;控制点分布不均时,每增加相同的 t,世界距离并不相同。本章用固定四个控制点建立 4,001 项弧长表,再比较 100 个等参数段和 100 个等弧长段。
// $WORK_DIR/.tmp/game-math-lab/Program.cs · Chapter08
Vector2 p0 = new(0, 0);
Vector2 p1 = new(2, 6);
Vector2 p2 = new(8, -2);
Vector2 p3 = new(10, 4);
var table = BuildArcTable(p0, p1, p2, p3, 4000);
var uniformT = Enumerable.Range(0, 101)
.Select(i => Bezier(p0, p1, p2, p3, i / 100f))
.ToArray();
var uniformS = Enumerable.Range(0, 101)
.Select(i => Bezier(p0, p1, p2, p3,
(float)ArcToT(table, i / 100.0)))
.ToArray();
曲线合同
三次 Bezier 是四个点的加权和:
[ B(t)=(1-t)^3P_0+3(1-t)^2tP_1+3(1-t)t^2P_2+t^3P_3 ]
它保证端点和连续曲线,不保证 |B'(t)| 恒定。因此路径资源拥有控制点与参数域,运动系统拥有米每秒和目标路程,弧长表是两者之间的适配数据。表只依赖曲线几何,可离线构建或在资源变更时重建。
// Program.cs · Bezier
static Vector2 Bezier(
Vector2 p0, Vector2 p1,
Vector2 p2, Vector2 p3, float t)
{
var u = 1 - t;
return u * u * u * p0
+ 3 * u * u * t * p1
+ 3 * u * t * t * p2
+ t * t * t * p3;
}

弧长表
解析弧长一般不值得塞进运行时。实验用分段折线累计距离,记录 (t,s);反查时对目标距离二分定位相邻表项,再在线性区间内求参数。时间复杂度是每次 O(log N),表大小固定为 4,001 项。若同一曲线被大量实体复用,表也应随资源共享,而不是每个实体复制。
// Program.cs · BuildArcTable
static List<Arc> BuildArcTable(
Vector2 p0, Vector2 p1,
Vector2 p2, Vector2 p3, int segments)
{
var result = new List<Arc> { new(0, 0) };
var previous = p0;
double sum = 0;
for (var i = 1; i <= segments; i++)
{
var t = i / (double)segments;
var point = Bezier(p0, p1, p2, p3, (float)t);
sum += Vector2.Distance(previous, point);
result.Add(new Arc(t, sum));
previous = point;
}
return result;
}
反查路径
二分查找只决定所在区间,区间内仍按累计距离插值。这样输入 fraction=0.5 表示走过总长的一半,而不是取 t=0.5。本章固定曲线总长近似为 12.433628 m;表的分辨率也是误差预算的一部分,控制点变化后必须使缓存失效。
// Program.cs · ArcToT
static double ArcToT(IReadOnlyList<Arc> table, double fraction)
{
if (table.Count < 2 || table[^1].S < 1e-9)
return 0;
var target = table[^1].S * Math.Clamp(fraction, 0, 1);
var lo = 0;
var hi = table.Count - 1;
while (lo + 1 < hi)
{
var mid = (lo + hi) / 2;
if (table[mid].S < target) lo = mid;
else hi = mid;
}
var a = table[lo];
var b = table[hi];
var span = b.S - a.S;
if (span < 1e-9) return a.T;
var f = (target - a.S) / span;
return a.T + (b.T - a.T) * f;
}
失败对照
等参数采样的段长标准差是 0.019488 m,说明移动体在曲线上会周期性加减速;等弧长反查后降为 0.00000527 m。回归断言要求后者小于前者的 5%。实验还把四个控制点都设为 (1,1) 建立零长表,ArcToT(table,0.5) 必须返回起点参数 0,不会因零分母生成 NaN。
反查结果还应保持单调:目标路程增加时,返回的参数不能倒退。若累计表因并发更新、精度截断或资源损坏而失序,二分查找仍可能返回一个数,却不再代表正确区间。加载阶段验证首项为零、累计距离非递减、末项等于总长,比让每个跟随器在运行中猜测异常更可靠。
// Program.cs · Chapter08:统计与断言
var dt = SegmentLengths(uniformT);
var ds = SegmentLengths(uniformS);
var degenerate = BuildArcTable(
Vector2.One, Vector2.One,
Vector2.One, Vector2.One, 8);
Check(
Std(ds) < Std(dt) * .05,
"arc-length lookup equalizes segment lengths");
Check(ArcToT(degenerate, .5) == 0,
"zero-length curve maps to start parameter");
WriteData(8, new
{
samples = 101,
lookup_samples = 4001,
parameter_mean = dt.Average(),
parameter_std = Std(dt),
arclength_mean = ds.Average(),
arclength_std = Std(ds),
total_length = table[^1].S,
degenerate_length = degenerate[^1].S,
degenerate_t = 0
});

当天实测
$ cd $WORK_DIR
$ dotnet run --project .tmp/game-math-lab/GameMathLab.csproj -- --out .tmp/game-math-lab/out
CH08 PASS segments=100 t_std=0.019488m arc_std=0.000005m length=12.433628m
TOTAL pass=15 fail=0 skip=0 chapters=15 elapsed_ms=112.8
exit_code=0
{
"samples": 101,
"lookup_samples": 4001,
"parameter_mean": 0.12433057606220245,
"parameter_std": 0.019488149363260005,
"arclength_mean": 0.12433023974299431,
"arclength_std": 0.000005271764799195839,
"total_length": 12.433628018479794,
"degenerate_length": 0,
"degenerate_t": 0
}
架构取舍
更密的表减少折线近似误差,却增加构建与存储成本;本章先用 4,000 段建立可复算基线,真正资源管线应按允许的最大段长误差自适应细分。只有控制点实时变化且查询很少时,逐帧重建才可能合理。正常规模优先共享不可变表,路径跟随器只保存当前路程。
表的版本还必须与曲线资源绑定。编辑器移动控制点后若只更新可视曲线、没有更新累计距离,运行时会得到几何正确但速度错误的路径。可靠的资源管线应在同一次构建中产出控制点、弧长表和误差摘要,并以内容版本整体替换;读取者不能看到新控制点配旧表的中间状态。
| 所有者 | 保存状态 | 失效条件 |
|---|---|---|
| 路径资源 | 控制点、弧长表 | 控制点变化 |
| 跟随器 | 当前路程 | 路径切换 |
| 运动系统 | 米每秒 | 玩法输入变化 |
下一章
弧长采样器交付连续位置 p(t)。下一章不再直接改位置,而是从同一函数用差分估计速度与加速度,并把采样步长写入误差合同。