相机临界阻尼:分帧一致性依赖完整的位置与速度

把镜头从目标右侧十米拉回原点,要求一秒后的位置不随这一秒被拆成多少帧而变化。这是一条可以直接验收的相机需求。只在位移后面乘上 deltaTime 并不够:弹簧加速度依赖当前位置和速度,下一帧取到的状态又依赖上一帧的积分结果。长帧会把这份依赖暴露出来。

相机滑轨上的弹簧与阻尼器概念示意,表示向固定目标收敛;不代表实测轨迹

本文把跟随问题收窄为一条直线:位置单位是米,速度是米每秒,目标在当前积分区间内保持不动。独立 C# 参考核使用双精度数值,验证运动方程和状态接口,不把它当作 Unity 实机镜头效果或引擎内置平滑函数的测试。这个范围足以回答一个接入层问题:相机控制器究竟应跨帧保存哪些量,才能使调好的响应不被帧划分改写?

一秒之后,镜头应该在哪里

设目标为 g,当前位置为 x,速度为 v,相对位移 y=x-g。采用临界阻尼方程:

y'' + 2ω y' + ω² y = 0
ω > 0,单位 s^-1;当前区间内 g 与 ω 恒定

这里将质量归一化,ω² 控制回复加速度, 控制速度衰减。临界阻尼表示该二阶方程的特征根重合,不应直接翻译成“无论如何都不越过目标”。本例初始速度为零,确实单调收敛;若初始速度已经猛烈指向目标另一侧,越界仍然可能发生。

重复根给出 y(t)=(A+Bt)exp(-ωt)。代入区间起点的位置与速度,得到 A=yB=v+ωy。令 j=v+ωy,完整的一步为:

xNext = g + (y + j dt) exp(-ω dt)
vNext = (v - ω j dt) exp(-ω dt)

固定输入 x=10、v=0、g=0、ω=4、dt=1,输出为 x=50exp(-4)=0.915782 米、v=-160exp(-4)=-2.930502 米每秒。把区间拆成六十步,并让每步继承上一份完整状态,会得到同一结果,只有浮点舍入差异。数学上的依据是同一恒定系数方程的解可以在中间时刻继续演化;它并不依赖帧号或固定刷新率。

这比“选一个足够小的步长”提供了更清晰的接口合同。若只是无碰撞的表现层跟随,可以直接使用这一解析响应;若相机还需要逐段进行碰撞检测,仍可能必须细分区间,但细分理由来自碰撞路径,不能混同于这条方程的积分稳定性。

速度被丢掉,响应就换了一条曲线

CameraSpring.csSpringState 把位置和速度放在同一个返回值里;调用链是 Program.cs 的测试或 Integrate 循环调用 CameraSpring.Step,再把返回值交给下一步。完整核心如下:

namespace SpringLab;

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

public static class CameraSpring
{
    public static SpringState Step(SpringState state, double target, double omega, double dt)
    {
        if (!double.IsFinite(state.Position) || !double.IsFinite(state.Velocity) ||
            !double.IsFinite(target) || !double.IsFinite(omega) || omega <= 0 ||
            !double.IsFinite(dt) || dt < 0)
            throw new ArgumentException("Invalid spring input");
        if (dt == 0) return state;
        double y = state.Position - target;
        double j = state.Velocity + omega * y;
        double u = omega * dt;
        double a = y + j * dt;
        double b = state.Velocity - omega * j * dt;
        // 先检查中间量,拒绝溢出而不提交半份状态。
        if (!double.IsFinite(y) || !double.IsFinite(j) || !double.IsFinite(u) ||
            !double.IsFinite(a) || !double.IsFinite(b))
            throw new ArithmeticException("Spring intermediate overflow");
        double decay = Math.Exp(-u);
        var next = new SpringState(target + a * decay, b * decay);
        if (!double.IsFinite(next.Position) || !double.IsFinite(next.Velocity))
            throw new ArithmeticException("Spring output overflow");
        return next;
    }
}

输入与中间量都做有限性检查。极大的有限数也可能在减法或乘法时溢出,因此不能只检查参数是否为 NaN。这里选择拒绝,且不修改传入状态;调用层可保留上次有效状态并记录异常,或在显式切镜流程中重建状态。把异常结果悄悄钳到目标会让非法输入看起来像一次正常收敛。

如果每次调用前都把速度清零,相机仍会向目标移动,画面甚至显得平滑,但它已不再延续同一条二阶运动。六十次 1/60 秒后,本次测试得到 8.801273 米,远高于正确的 0.915782 米。每帧都从静止重新起步,等于反复改变初始条件,帧数由此进入响应。

更直接的长帧反例来自半隐式欧拉:先按旧状态计算加速度,再更新速度,用新速度更新位置。对上述输入推进一秒,会得到下面的结果:

double x = 10, v = 0, g = 0, omega = 4, dt = 1;
double acceleration = -2 * omega * v - omega * omega * (x - g);
v += acceleration * dt;
x += v * dt;
// 此固定样本得到 x=-150、v=-160。

这个反例证明当前步长下的近似失败,不表示半隐式欧拉对所有参数都不可用。解析步进省去了本方程的时间离散误差,却没有消除浮点误差,也没有解决外部约束。

解析位置曲线、完整状态与清零速度的端点对比,以及目标改变后的分段结果

在目标改变的时刻切开时间

“与帧划分无关”必须带上恒定目标这个条件。测试让目标在前半秒为零,后半秒跳到五米。先以旧目标积分半秒,再以新目标积分半秒,最终位置是 3.885753 米;把新目标五米用于整个一秒,则得到 5.457891 米。后者把尚未发生的目标变化提前应用,计算的根本不是同一条输入时间线。

因此,参考核只接收一个区间的目标,目标历史由相机调用层拥有。如果游戏逻辑能提供变更时间,调用层可以在边界处分段;若只能拿到当前渲染帧的目标采样,零阶保持的输入也会随采样频率改变,不能再承诺整个跟随系统严格分帧一致。连续移动目标需要另一份输入模型,例如区间内线性运动及对应求解,不能靠复用本式默认覆盖。

Program.csEqualPartitions 枚举一秒内等分一至二百四十步;UnequalPartitions 枚举百分之一至百分之九十九秒作为切分点,共检查 339 组划分的位置和速度,绝对误差容限为 1e-10。这覆盖固定场景的多种时间安排,不是对所有浮点数的穷举。NonzeroVelocityComposition 还保留了一组非零初速度检查,避免测试只依赖静止起点。

正常路径之外,测试包含零时间、平衡位置、非法参数、中间溢出、速度重置、欧拉长帧和目标变化。LargeInitialVelocityMayCrossTarget 固定 x=1、v=-100、g=0、ω=4、dt=0.1,断言输出位置小于零,防止把临界阻尼错误包装为任意初速度下的防越界机制。

2026-09-08 使用 .NET 9 Release 执行。$LAB_DIR 表示包含 SpringLab.csprojCameraSpring.csProgram.cs 的研究目录。构建耗时 7.86 秒,下列数值按日志字段整理并保留六位小数;运行耗时仅统计程序内部检查区间,不是相机更新的性能基准。

dotnet build "$LAB_DIR/SpringLab.csproj" -c Release
dotnet run --project "$LAB_DIR/SpringLab.csproj" -c Release --no-build
build_exit=0 warnings=0 errors=0
position=0.915782 velocity=-2.930502
split60_position=0.915782 reset60_position=8.801273
euler_position=-150 euler_velocity=-160
boundary_position=3.885753 all_new_target_position=5.457891
passed=13 failed=0 skipped=0 cases=339 elapsed_ms=44.8466
run_exit=0

让镜头规则决定何时重建状态

接入三维平移时,在固定坐标基底中对三个轴使用同一个方程和各自速度分量即可形成各向同性跟随;本次运行没有覆盖三维引擎接入。旋转不能直接逐分量套到四元数上。若碰撞修正把位置推回墙前,下一帧应如何处理被阻挡方向的速度,也要与约束求解共同决定,否则保留的速度会持续把镜头推向障碍物。

切镜是另一种明确的状态边界。若产品要求立即对齐,就同时设定新位置与零速度;若要求保留惯性,则转换并继承完整状态。普通帧中的无条件速度清零无法代替这项选择。暂停、慢动作与回放还必须约定 dt 来自哪一只时钟,同一公式不会替调用层决定时间语义。

对于开头的一秒回位需求,验收可以落到两个可执行检查上:恒定目标区间的任意已测分帧应保持位置和速度一致,目标变化必须进入正确的时间边界。解析步进给出了前者的依据,完整状态和目标时间线则把这个依据保留到相机控制器里。遇到碰撞、切镜或输入采样变化时,应重新声明对应规则,再判断分帧一致性是否仍是合理目标。