· 5 分钟阅读 · 1827 字
接触求解多迭代一次,反弹为何消失:恢复目标必须锁定碰前速度
同一个球以每秒 4 米的速度撞向静态地面,恢复系数为 0.5。一次接触求解后,它以每秒 2 米的速度向上运动;增加第二次迭代,球却停在地面。更多迭代本应改善约束收敛,这里却改变了碰撞材料的表现。问题出在第二轮读取了第一轮已经修改的速度,并据此重新决定是否需要反弹。

把这个现象缩成一条法向接触行:法线从地面指向球,负速度表示接近,正速度表示分离。质量为 2 千克,只考虑竖直平移,等效逆质量 k 为 0.5。碰前速度 v₀ 为 −4,恢复速度目标 b 为 −e×v₀=2。首次求解需要的冲量为 (2−(−4))/0.5=12 N·s。这组输入足以隔离恢复目标的生命周期,不需要重力、穿透修正或完整场景。
第二轮改变的是目标,不是收敛精度
顺序冲量求解器通常把接触行写成目标速度约束:当前法向速度 v 应至少达到 b,累计法向冲量 λ 不得为负。每轮先计算候选增量,再投影累计量:
Δλ = (b − v) / k
λ新 = max(0, λ旧 + Δλ)
v新 = v + k × (λ新 − λ旧)
第一轮结束后 v=2, λ=12。若 b 仍是 2,第二轮增量为零,球保持反弹。若用当前速度重新准备接触行,检测到 v>0 后便把恢复目标改成 0;求解器随即算出 Δλ=−4,累计量从 12 降为 8,速度从 2 降为 0。投影逻辑没有违反非负约束,错误来自两轮实际求解了不同的问题。
因此,不能用“分离中不再施加正冲量”解释这次修正。第二轮正在撤回先前的冲量。只要求解器允许累计冲量回退,恢复目标的改变就会通过合法的负增量传回速度。
准备阶段交付不可变的恢复目标
研究探针的 ContactRow.cs 将两类状态分开:Prepare 读取碰前速度、恢复系数和阈值,返回不可变的 Row;Solve 只读取该行的目标与等效逆质量,更新速度和本次求解的累计冲量。完整核心实现如下,单位分别为米、秒、千克及其组合。
public readonly record struct Row(double Target, double EffectiveInverseMass);
public readonly record struct ContactState(double Velocity, double Lambda);
public static class ContactRow
{
public static Row Prepare(double preVelocity, double restitution,
double threshold, double effectiveInverseMass)
{
if (!double.IsFinite(preVelocity) || !double.IsFinite(restitution) ||
restitution < 0 || restitution > 1 || !double.IsFinite(threshold) ||
threshold < 0 || !double.IsFinite(effectiveInverseMass) ||
effectiveInverseMass <= 0)
throw new ArgumentOutOfRangeException(nameof(preVelocity));
double target = preVelocity < -threshold ? -restitution * preVelocity : 0;
return new Row(target, effectiveInverseMass);
}
public static ContactState Solve(Row row, ContactState state)
{
if (!double.IsFinite(row.Target) || row.Target < 0 ||
!double.IsFinite(row.EffectiveInverseMass) || row.EffectiveInverseMass <= 0 ||
!double.IsFinite(state.Velocity) || !double.IsFinite(state.Lambda) ||
state.Lambda < 0)
throw new ArgumentOutOfRangeException(nameof(state));
double delta = (row.Target - state.Velocity) / row.EffectiveInverseMass;
double next = Math.Max(0, state.Lambda + delta);
double velocity = state.Velocity + (next - state.Lambda) * row.EffectiveInverseMass;
if (!double.IsFinite(next) || !double.IsFinite(velocity))
throw new ArithmeticException("Non-finite contact result");
return new ContactState(velocity, next);
}
}
调用顺序是:完成本步外力的速度积分,固定接触几何并读取接触相对法向速度,执行 Prepare,再进入速度约束迭代。碰前快照必须早于本轮接触冲量的施加;若有热启动,也应在热启动改变速度之前读取恢复输入。这里 preVelocity 的含义是求解前相对速度,不是上一帧落盘的速度。
阈值也在准备阶段决定。探针使用 0.2 米每秒:v₀ < −0.2 才启用恢复,等于阈值或更慢时 b 为零。该规则避免把低速接触中的微小接近持续变成反弹目标。它是显式材料策略,不是浮点容差;阈值改变会改变可观察行为,应该进入回归样本。

累计量仍允许下降。假设另一条约束把首次求解后的速度从 2 推到 5,而本行仍记录 λ 为 12;本行再执行时,增量为 −6,累计冲量降为 6,速度回到目标 2。如果把每次增量独立钳制为非负,这次必要的撤回就会被阻断。不可变的是本次碰撞的恢复目标,允许修正的是该行已经施加的累计冲量。
失败回归与互补条件分别检查
Program.cs 保留了逐轮重算的错误路径,明确断言它会得到零反弹;正确路径还检查连续八轮结果不变。下面的片段与实际测试使用相同输入,可直接复核失败机制。
var row = ContactRow.Prepare(-4, .5, .2, .5);
var first = ContactRow.Solve(row, new ContactState(-4, 0));
var fixedTarget = ContactRow.Solve(row, first);
var changedRow = ContactRow.Prepare(first.Velocity, .5, .2, .5);
var changedTarget = ContactRow.Solve(changedRow, first);
// first: Velocity=2, Lambda=12
// fixedTarget: Velocity=2, Lambda=12
// changedTarget: Velocity=0, Lambda=8
2026-11-08 的 .NET 9 Release 构建与运行结果如下。执行位置是探针根目录 $LAB_ROOT;源文件、输入、运行输出和技术图使用同一组数据。
$ dotnet restore RestitutionLab.csproj -p:NuGetAudit=false
$ dotnet build RestitutionLab.csproj -c Release --no-restore --nologo
$ dotnet run --project RestitutionLab.csproj -c Release --no-build
Build succeeded. 0 Warning(s), 0 Error(s)
tests=18 passed=18 failed=0 skipped=0 elapsedMs=49.624
randomCases=10000 disagreements=0
fixed target=2 firstVelocity=2 firstLambda=12
fixed secondVelocity=2 secondLambda=12
recomputed target=0 secondVelocity=0 secondLambda=8
released velocity=2 lambda=6
restoreExitCode=0 buildExitCode=0 runExitCode=0
随机对照固定种子为 11081930,输入包含负碰前速度、恢复系数 [0,1)、正 k、任意正负当前速度与非负旧冲量。独立参照先移除本行旧冲量的影响,得到 v自由=v−kλ旧,再直接解出 λ=max(0,(b−v自由)/k)。10,000 组均检查输出与参照一致、v≥b、λ≥0、λ(v−b)=0,以及同一行重复求解不再改变结果。固定回归另含分离、零恢复、完全弹性、阈值相等、累计量归零和非法参数拒绝。耗时只是此次回归运行记录,不构成性能比较。
这个内核只验证固定几何、正等效逆质量下的一条速度约束。完整刚体求解器需要把角速度、力臂和惯量纳入相对速度与 k;零等效逆质量应在准备阶段跳过,不送进除法。接触岛多行耦合时,某行的速度可能被其他行反复扰动,整体仍需收敛判据;单行幂等不等于整个接触岛一次收敛。
跨步保存恢复目标也会出错。新的子步或新的碰撞事件必须重新读取对应的求解前状态,持续接触是否再次启用恢复则应由接触事件策略决定。穿透位置修正若与速度求解共用接口,还需区分位置误差目标和材料恢复目标,不能让每轮变化的穿透偏置覆盖这份碰前快照。准备阶段保存碰撞所需的历史输入,迭代阶段只修正当前约束状态,才能让迭代次数控制收敛,而不意外重定义反弹。