游戏中的数学 08:曲线参数化与弧长重参数化
上一章将 t 定义为插值参数,本章进一步区分曲线参数与实际路程。路径资源给出位置函数,运动系统消费的则是累计距离。三次 Bezier 的 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;
}

弧长离散化
三次 Bezier 的解析弧长不适合作为通用运行时求值方法。离散方法使用分段折线累计距离并记录 (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=148.7
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)。下一章在同一时间域中估计速度与加速度,并将差分步长纳入误差合同;曲线参数、路程和时间由此保持相互独立。