· 6 分钟阅读 · 2168 字

镜头沿曲线越走越快:匀速推进需要反查弧长

镜头沿同一条曲线移动,每帧给参数加上相同数值,后半程却明显比前半程快。把更新搬进固定时间步也不会改变这个结果:时间采样可以等间隔,曲线参数对应的行程却未必等间隔。本文固定曲线的八个等参数步长,真实行程从 0.505161 米增加到 1.062636 米,末步已经超过首步两倍。

要把这种路径用于轨道镜头、沿线移动的载具或技能表现,运行时应推进距离,再反查曲线参数。这里用独立 C# 探针验证这条调用链,未接入引擎相机、碰撞或朝向系统;结果只覆盖位置采样和弧长误差。

木块沿弯曲路径排列的实物摄影风格概念图,不代表精确采样或实验现场

参数走了一半,路程没有走到一半

取二次贝塞尔控制点 A=(0,0)、B=(2,0)、C=(4,4),坐标单位为米。曲线展开为 P(t)=(4t,4t²),参数范围为 [0,1]。速度向量对参数求导得到 (4,8t),其长度为 4√(1+4t²);所以即使 dt/dtime 恒定,实际速度也随 t 增长。

这条曲线特意选成可解析积分的形状。从起点到 t 的真实弧长为 s(t)=2t√(1+4t²)+asinh(2t),总长为 5.915771430 米。参数中点位于 (2,1),并不等于半程位置。64 段查表反算出的半程参数约为 0.610733366,说明不能把动画归一化时间直接当作几何参数。

通用路径未必有便于反求的弧长公式。可在路径建立时等参数采样,累加相邻采样点的距离,形成从 t 到累计距离的单调表。运行时先把目标距离定位到相邻两项,再在该段反算 t。最后仍用原曲线求位置,避免角色直接沿折线拐弯。

查表近似有两处误差

表中存的是弦长,弯曲段的弦短于真实曲线;反查时又假设段内距离与参数线性对应。即使累计总长非常接近,局部反查也不一定同样准确。因此本次回归同时比较总长和各进度位置的真实弧长,不能只用“总长度误差很小”证明匀速成立。

源码位于 .tmp/daily-labs/2026-10-23-2000/ArcPath.cs。调用链是 Program → ArcPath 构造表,随后 Parameter → Evaluate 取得位置。完整核心实现采用 double;Evaluate 是原始曲线求值器,调用者负责提供有效参数,运动入口的有限值与区间检查在 Parameter 中完成。

public readonly record struct Point(double X, double Y)
{
    public static Point operator +(Point a, Point b) => new(a.X + b.X, a.Y + b.Y);
    public static Point operator *(Point a, double k) => new(a.X * k, a.Y * k);
    public double Distance(Point b) => Math.Sqrt((X-b.X)*(X-b.X)+(Y-b.Y)*(Y-b.Y));
}

public sealed class ArcPath
{
    private readonly Point a, b, c;
    private readonly double[] lengths;
    public double Length => lengths[^1];

    public ArcPath(Point a, Point b, Point c, int segments)
    {
        foreach (var p in new[] { a, b, c })
            if (!double.IsFinite(p.X) || !double.IsFinite(p.Y) ||
                Math.Abs(p.X) > 1e6 || Math.Abs(p.Y) > 1e6)
                throw new ArgumentOutOfRangeException(nameof(a));
        if (segments < 2 || segments > 4096)
            throw new ArgumentOutOfRangeException(nameof(segments));
        this.a = a; this.b = b; this.c = c;
        lengths = new double[segments + 1];
        var previous = Evaluate(0);
        for (int i = 1; i <= segments; i++)
        {
            var current = Evaluate((double)i / segments);
            var step = previous.Distance(current);
            if (step <= 1e-10) throw new ArgumentException("采样段退化");
            lengths[i] = lengths[i-1] + step;
            previous = current;
        }
    }

    public Point Evaluate(double t) => a*((1-t)*(1-t)) + b*(2*(1-t)*t) + c*(t*t);

    public double Parameter(double fraction)
    {
        if (!double.IsFinite(fraction) || fraction < 0 || fraction > 1)
            throw new ArgumentOutOfRangeException(nameof(fraction));
        if (fraction == 0) return 0;
        if (fraction == 1) return 1;
        double distance = fraction * Length;
        int low = 0, high = lengths.Length - 1;
        while (high-low > 1)
        {
            int middle = (low+high)/2;
            if (lengths[middle] < distance) low = middle;
            else high = middle;
        }
        double alpha = (distance-lengths[low])/(lengths[high]-lengths[low]);
        return (low+alpha)/(lengths.Length-1);
    }
}

每条采样弦必须大于 1e-10 米,确保累计表严格递增,反查分母非零。全重合控制点会被拒绝;A=(0,0), B=(1,0), C=(0,0) 使用三段采样时,中间两点重合,也会被拒绝。实现没有把这些情况悄悄变成停在起点,调用层可以明确选择隐藏路径、保留上一条有效轨道或报告资产错误。

这里的退化判断不构成所有回折曲线的识别器。采样弦非零仍可能漏掉小范围回折;若资产允许尖点、窄环或高曲率变化,应加密采样或采用带误差控制的积分与求根。当前坐标分量限制为绝对值不超过 1e6,段数限制为 2 至 4096,这些都是本探针的资源和数值合同。

固定曲线上等参数与弧长反查的位置对照,以及不同分段数量的采样弧长误差

用解析行程检查反查位置

八个时间步对应归一化距离 u=i/8,再以 Parameter(u) 反算 t。正确路径的每步真实弧长为 0.739390 至 0.739678 米,理想值为总长除以八,即 0.739471429 米。比较的是沿曲线的行程,不是两帧位置的直线距离;后者在转弯处本来就会缩短,不能据此判定速度不均。

Program.cs 中的关键回归同时保留错误实现、正常路径和失败输入:

Check("WrongSpeedVaries",wrong.Max()/wrong.Min()>2);
Check("CorrectStepSpread",correct.Max()-correct.Min()<.001);
Reject("ZeroCurve",()=>new ArcPath(new(0,0),new(0,0),new(0,0),64));
Reject("SampledReversal",()=>new ArcPath(new(0,0),new(1,0),new(0,0),3));

误差扫描对每张表取 10,001 个固定进度 u=i/10000,计算 |s(Parameter(u))-u·s(1)|。五张表共检查 50,005 个位置,并验证反查参数单调。结果如下,最大值仅指这些固定采样点,不是任意曲线或连续区间的证明上界。

分段数 累计弦长(米) 最大弧长位置误差(米)
8 5.911111941 0.013436486
16 5.914606748 0.003425417
32 5.915480272 0.000864836
64 5.915698641 0.000217283
128 5.915753233 0.000054455

64 段的误差门禁设为 0.00022 米,当前固定样本通过。把分段数翻倍后,误差在本曲线上约缩小到四分之一;不能把这个实测规律直接当成所有路径的统一预算。长路径、控制点分布和曲率都会改变需要的采样密度。

当天以 .NET SDK 9.0.202、Release 配置执行:

dotnet build "$PROBE_PROJECT/CurveLab.csproj" -c Release --nologo
dotnet run --project "$PROBE_PROJECT/CurveLab.csproj" -c Release --no-build

构建退出码 0,零警告、零错误。运行退出码 0,通过 23 项、失败 0、跳过 0,程序计时 1386.6025 ms。除误差扫描外,还覆盖直线、起终点、退化采样、非法段数、越界坐标与非有限进度。耗时只记录这次验证,不用于推断引擎帧耗或路径吞吐。

距离状态与路径版本必须一起确定

静态路径可保存一份表,运动层累计 distance += speed * deltaTime,再用 u=distance/Length 查询。本文入口拒绝越界进度,因此调用层必须明确结束时截停、循环还是折返。循环时是距离取模,折返还要维护运动方向;这些行为不应隐藏在几何求值器里。

由于 Length 也是近似值,这套推进只能近似维持指定米速。本例用真实总长归一化检查位置误差,已经把长度估计与反查误差一并暴露。需要严格时刻到达或沿路径触发事件时,应围绕可接受的距离误差设计门禁,不能只观察画面平滑。

表的坐标空间也决定“米速”的含义。局部曲线建立的表遇到非均匀世界缩放后,世界行程比例会随方向改变。要维持世界米速,应在实际世界空间控制点上建立表;控制点或影响距离的变换改变时,整张表也随之失效。几何层宜交付控制点与表的一致版本,运动层再决定旧距离保留、归一化进度保留或投影到新路径,避免一次编辑造成无定义的跳位。

这个镜头问题无需每帧重建路径:先固定几何版本和距离空间,在可接受的误差内反查参数,再把朝向、碰撞与切轨政策交给各自拥有状态的层。等参数更新仍适合需要参数节奏的动画;一旦需求明确写成米每秒,进度状态就应当由距离承担。