· 5 分钟阅读 · 1849 字
镜头弹簧跟随随帧率变形:位置和速度必须一起精确推进
镜头从位置 0 追向位置 10,弹簧参数不变,跑满一秒后却因刷新率不同停在不同位置。研究程序采用先更新速度、再更新位置的半隐式欧拉法,30 Hz 得到 9.929777,120 Hz 得到 9.959065。两次都没有穿过目标,看起来都足够平滑,但它们没有执行同一条连续运动曲线。

这种差异会影响镜头切换后的构图、跟随延迟和回放对齐。给加速度乘上帧时间,只保证单位正确,并不保证积分结果与分帧方式无关。对于目标在当前区间内固定、参数不变的临界阻尼弹簧,可以直接求解区间末的完整状态,无须靠缩短步长逼近。
一秒钟的运动不能由位置独自描述
用 x 表示镜头位置、g 表示目标、v 表示速度,采用单位质量模型:x'' + 2ωx' + ω²(x-g) = 0。ω 的单位为每秒,刚度系数为 ω²,阻尼系数为 2ω;这里没有另外一个可任意调节的阻尼比。
令 y=x-g,初值为 y₀、v₀。临界阻尼的重复实根为 −ω,因而区间 t 内有:
c = v₀ + ωy₀
y(t) = (y₀ + ct) exp(-ωt)
v(t) = (v₀ - ωct) exp(-ωt)
x(t) = g + y(t)
位置和速度都必须从同一个区间起点求值。如果先把新位置写回,再用它计算本次速度,实际上已经换了方程。跨帧只保留位置、每次把速度置零,也无法沿用这条解:它会在每个帧边界人为丢掉动量,刷新率越高,重置次数越多。
固定输入取 x₀=0、v₀=0、g=10、ω=8、t=1。于是 y₀=-10、c=-80,直接得到 x=10-90exp(-8)=9.969808363489,v=640exp(-8)=0.214696081858。30、60、120 次分段求值应回到这个状态,而不只是最终位置看起来接近。

将完整状态留在跟随控制器
研究目录 .tmp/daily-labs/2026-11-02-1930/ 的 SpringStep.cs 用 SpringState 保存位置和速度,Advance 消费旧状态、目标、ω 和时间间隔,返回新状态。完整核心如下,使用 .NET 9 的 double 验证数值关系:
public readonly record struct SpringState(double Position, double Velocity);
public static class SpringStep
{
public static SpringState Advance(SpringState state, double target,
double omega, double dt)
{
if (!double.IsFinite(state.Position) || !double.IsFinite(state.Velocity) ||
!double.IsFinite(target) || !double.IsFinite(omega) ||
!double.IsFinite(dt) || omega <= 0 || dt < 0)
throw new ArgumentException("Invalid spring input");
double y = state.Position - target;
double c = state.Velocity + omega * y;
double decay = Math.Exp(-omega * dt);
// 位置与速度由同一个区间起点求值,不能串行覆盖旧状态。
double position = target + (y + c * dt) * decay;
double velocity = (state.Velocity - omega * c * dt) * decay;
if (!double.IsFinite(position) || !double.IsFinite(velocity))
throw new ArgumentException("Spring state overflow");
return new(position, velocity);
}
}
在游戏中,跟随控制器应持有返回状态,视图只消费位置;三维平移可以对三个轴分别执行同一标量方程,并保留三维速度。这个探针验证的是标量控制内核,没有完成 Unity 或 Godot 的相机更新顺序、遮挡避让与物理交互集成。
半隐式欧拉分支同样使用正确量纲,但对区间内加速度只取一次样本:
double v = state.Velocity +
(64 * (10 - state.Position) - 16 * state.Velocity) * dt;
state = new SpringState(state.Position + v * dt, v);
当前固定目标问题有闭式解,因此可以直接替换这一数值近似。若系统同时施加碰撞、速度上限或非线性约束,闭式步进仅解决自由跟随区间;不能把受约束的整段运动继续当作同一个线性系统。尤其是碰撞把镜头位置推回以后,应明确速度是否也要沿接触法线投影,否则下一帧仍可能携带朝墙内运动的速度。
分帧一致的前提是区间内目标不变
Program.cs 的 Run 对相同初值分别执行 30、60、120 次更新,并将位置和速度与单次一秒解比较。测试还用固定种子 71102 生成 10,000 组初值和两段时间:位置、目标在 [-20,20),速度在 [-10,10),ω 在 [1,16),两段时间各在 [0,1)。每组验证 Advance(Advance(s,a),b) 与 Advance(s,a+b)。
这条组合关系依赖两次调用使用相同目标和参数。若目标在 0.5 秒时从 10 跳到 −10,必须在切换点拆成两段;运行结果为位置 −8.198628、速度 −11.507313。把目标冻结为 10 运行整秒,仍会得到位置 9.969808,两者描述不同输入,不能拿来证明步进不稳定。
对连续移动的目标,每帧取一次目标再冻结,只是对输入轨迹的零阶保持。闭式解消除了这个冻结区间内的积分误差,却没有消除目标采样误差。若要求不同渲染刷新率下严格复现,应让目标在固定逻辑时刻采样,或为目标轨迹建立可积分模型;不能只把欧拉公式换掉就承诺回放一致。
临界阻尼也不意味着任意初值都不会越过目标。固定 x₀=0、v₀=120、g=10、ω=8,半秒后位置为 10.183156。此时 y(t)=(-10+40t)exp(-8t),在 0.25 秒越过零点;这是初始速度带来的解,并非欠阻尼振荡。切换镜头时若继承旧速度,就必须接受这条运动,或显式设计速度重置规则。
用末态和拒绝行为做回归
2026-11-02 在 .NET 9 Release 下运行以下命令。Check 覆盖解析末态、三种分帧、零时间、静止平衡、高初速度与目标切换;Reject 检查非正 ω、负时间、非有限输入和溢出,随机组合关系另外以位置、速度误差各计一个断言。
cd "$WORK_DIR"
dotnet build .tmp/daily-labs/2026-11-02-1930/SpringLab.csproj -c Release --no-restore
dotnet run --project .tmp/daily-labs/2026-11-02-1930/SpringLab.csproj -c Release --no-build
build: exit=0, warnings=0, errors=0
run: exit=0, passed=23, failed=0, skipped=0
elapsed_ms=45.8485
fixed_target=10, omega=8, duration=1
Hz exact_position exact_velocity euler_position euler_velocity
30 9.969808363489 0.214696081858 9.929777308788 0.401271265796
60 9.969808363489 0.214696081858 9.948853922087 0.313480707611
120 9.969808363489 0.214696081858 9.959065102344 0.265592773645
random_count=10000
max_position_error=1.4210854715202004e-14
max_velocity_error=5.684341886080802e-14
耗时是本轮正确性验证的总时间。随机验证使用 double 和有限数值范围;引擎接入若改用 float、加入超大世界坐标或极端参数,需要重新确定误差容限。当前实现拒绝非有限输出,不能把这一保护等同于对所有病态数值组合都具有足够精度。
镜头弹簧的接口应同时交付位置与速度,并说明目标在什么时间点取样。固定目标和固定参数之间可以合并或拆分时间步;目标切换、碰撞修正和速度重置则是必须保留的状态事件。这样才能区分积分误差与输入差异,避免用刷新率相关的参数补偿去掩盖不同的运动合同。