动画 Hermite 曲线:每秒速度必须换成区间切线

移动平台的动画从位置 0 走到 10,区间长两秒,起点速度设为每秒 2,终点速度为零。两端位置完全正确,起步却只有每秒 1。把同一组数据放进一秒区间,问题又消失了。若验收只检查关键帧位置,这条曲线会一直通过;错误藏在编辑器保存的速度与运行时求值参数之间。

这里采用独立 C# 数值工程,把位置限定为一条轴上的标量,时间统一为秒。它验证 Hermite 区间的值和导数,没有接入引擎动画导入器,也不把任何引擎的 tangent 字段预设成相同单位。资产接入前,需要明确字段究竟保存每秒速度,还是对区间参数的导数。

移动平台关键帧、端点方向与曲线弯曲的概念示意,不代表下文数值采样

两秒区间为什么会把速度除以二

求值通常先把区间内时间 t 换成 u = t / T,其中 T 是区间时长,u 从零变化到一。三次 Hermite 的四个基函数只认识 u:

h00 =  2u³ - 3u² + 1
h10 =   u³ - 2u² + u
h01 = -2u³ + 3u²
h11 =   u³ -  u²
x(u) = h00*p0 + h10*m0 + h01*p1 + h11*m1

在起点,对 u 求导得到的恰好是 m0。但播放系统需要的是对秒求导的速度,链式法则给出 dx/dt = (dx/du)/T。因此,如果输入 v0、v1 是每秒速度,就必须使用 m0=T*v0m1=T*v1。m 的量纲与位置相同,v 则比位置多一个每秒;两者数值只在 T 恰好为一秒时碰巧相等。

开篇数据应得到 m0=4、m1=0。在一秒处,u=0.5,位置为 0.5*10 + 0.125*4 = 5.5。若直接把 2 放进 m0,就会得到 5.25,起点物理速度也变成 2/2=1。这不是曲线精度不足,也不需要提高采样频率;每一次采样都在求同一条错误曲线。

两秒区间的正确与错误曲线、切线过大的越界样本,以及速度与区间切线的换算

求值接口一起交付位置和速度

HermiteSegment.cs 保留完整核心实现。构造函数接收每秒速度,只在构建区间时换算一次;Evaluate 接收区间内已经过的秒数,同时返回位置和每秒速度。这样测试和下游消费者不必再猜导数的单位。

public readonly record struct Sample(double Position, double Velocity);

public sealed class HermiteSegment
{
    private readonly double p0, p1, m0, m1;
    public double Duration { get; }

    public HermiteSegment(double start, double end, double startVelocity,
        double endVelocity, double duration)
    {
        if (!double.IsFinite(start) || !double.IsFinite(end) ||
            !double.IsFinite(startVelocity) || !double.IsFinite(endVelocity) ||
            !double.IsFinite(duration) || duration <= 0)
            throw new ArgumentException("Invalid segment");
        p0 = start; p1 = end; Duration = duration;
        // 每秒速度换成对归一化参数的导数。
        m0 = duration * startVelocity;
        m1 = duration * endVelocity;
        if (!double.IsFinite(m0) || !double.IsFinite(m1))
            throw new ArgumentException("Unrepresentable tangent");
    }

    public Sample Evaluate(double elapsed)
    {
        if (!double.IsFinite(elapsed) || elapsed < 0 || elapsed > Duration)
            throw new ArgumentOutOfRangeException(nameof(elapsed));
        double u = elapsed / Duration, u2 = u * u, u3 = u2 * u;
        double position = (2*u3-3*u2+1)*p0 + (u3-2*u2+u)*m0
            + (-2*u3+3*u2)*p1 + (u3-u2)*m1;
        double velocity = ((6*u2-6*u)*p0 + (3*u2-4*u+1)*m0
            + (-6*u2+6*u)*p1 + (3*u2-2*u)*m1) / Duration;
        if (!double.IsFinite(position) || !double.IsFinite(velocity))
            throw new ArithmeticException("Unrepresentable sample");
        return new Sample(position, velocity);
    }
}

调用链是 Program.cs 构造区间,再由 HermiteSegment.Evaluate 产生 Sample。在实际动画管线中,轨道负责选出相邻关键帧并计算局部时间,区间只负责数值求值,播放控制器再把结果提交给目标属性。重复时间戳应在建轨道时合并、拒绝或声明为跳变;本实现拒绝零时长,不会用一个极小正数掩盖它。

相邻区间如果要保持同一轨道时间下的速度连续,应让前段终点速度与后段起点速度一致。两个区间的时长可以不同,换算后的 m 也理应不同。直接复制归一化切线,反而会在不等长区间接缝处改变速度。这里保证的是一阶导数的单位,未约束二阶导数,所以不能据此承诺加速度连续。

时间拉伸还需区分两种编辑意图。若保持每秒速度不变,只移动关键帧时间,就要根据新 T 重建 m,曲线形状可能随之改变;若希望同一条形状仅播放得更慢,T 乘 k 时,输入速度应除以 k,使 m 保持不变。运行时若通过播放倍率改变轨道时间,返回的 Velocity 仍是对轨道秒的导数,换成现实时间速度还要乘该倍率。当前探针不负责播放时钟。

端点速度正确,仍不能保证平台留在区间内

再把两端位置改为 0 和 1,时长一秒,速度设为 6 和 -6。中点位置会达到 2:曲线先越过终点,再回来满足负的终点速度。单位换算没有错误,端点也全部匹配,但它不适合要求单调移动的平台。

不能把输出位置简单夹到 [0,1] 后继续报告原导数。进入夹紧平台期后,实际位置导数为零,原曲线速度却未必为零,接口会自相矛盾。若玩法要求不越界,应在资产阶段限制切线、选择单调插值,或改用另一份明确的运动约束;那会改变允许的端点速度,属于上层政策。本实现保留原曲线,方便暴露这个冲突。

Program.cs 中两个回归分别固定漏乘时长与越界现象。Near 检查绝对误差不超过 1e-9

Test("missing-duration-regression", () => {
    // 错误公式直接把每秒速度当成归一化切线,等价于缩小输入速度。
    var wrong = new HermiteSegment(0,10,1,0,2);
    Near(wrong.Evaluate(0).Velocity,1);
    Near(wrong.Evaluate(1).Position,5.25);
});
Test("overshoot", () => Near(new HermiteSegment(0,1,6,-6,1).Evaluate(.5).Position,2));

当天验证还使用独立参照 f(t)=a*t³+b*t²+2*t-3 及其解析导数,取整数 a、b 各为 -2 至 2,T 为 1 至 8 秒,每段采样 101 点,共 20,200 点。测试只把参照多项式的端点值和端点导数交给区间,再比对整个内部曲线;它没有把 Hermite 基函数复制一遍充当答案。另有中央差分检查实际秒尺度上的导数。

2026-09-24 的运行环境为 .NET 9.0.3。$PROBE_ROOT 表示包含工程及源码的目录:

dotnet build "$PROBE_ROOT/HermiteTimeLab.csproj" -c Release
dotnet run --project "$PROBE_ROOT/HermiteTimeLab.csproj" -c Release --no-build
build: exit=0 warnings=0 errors=0 elapsed=8.09s
run: exit=0 passed=17 failed=0 skipped=0
samples=20200 elapsedMs=34.2735
T=2 p=(0,10) v=(2,0): x(1)=5.5 velocity(0)=2 velocity(1)=7
missing duration: x(1)=5.25 velocity(0)=1
T=1 p=(0,1) v=(6,-6): x(0.5)=2

计时包含断言执行,不是动画帧耗时基准。测试覆盖正常端点、内部值、常量曲线、非有限输入、切线溢出与区间外时间。实现明确拒绝外推;巨大位置仍可能令中间运算溢出,此时求值抛错,而不是承诺所有有限输入都有可表示输出。这些数值检查也不能替代真实动画资产的量级与压缩精度验收。

平台现在能按声明的每秒 2 起步,但是否允许越过轨道端点仍须由运动设计决定。资产接口值得固定的是切线单位和时间拉伸含义:保存每秒速度时,由区间时长完成换算;保存归一化导数时,则不能再重复乘时长。把这一约定连同位置、速度一起验证,才能让导入、编辑、重采样和播放共享同一条曲线。