· 5 分钟阅读 · 1868 字
粒子速度已经指数衰减,位移为何仍随帧率变化
粒子的减速曲线已经改成指数衰减,30 帧与 144 帧运行一秒后,速度都落在 1.353353 m/s,但位置仍然对不上:前者走了 4.180814 m,后者走了 4.293370 m。把速度更新成 v *= exp(-k * dt),只修复了速度状态的分帧关系。接着执行 x += v * dt,使用的仍是整段运动结束时的速度。
这个差异会影响减速尘埃的覆盖范围、弹壳落点和粒子离开特效区域的时间。本文用一个独立 C# 数值探针检查这条更新链:初始位置 0 m,初速 10 m/s,线性阻力系数 2/s,不含重力和碰撞。探针实际执行的是运动方程,没有把示意图当作引擎运行截图。

末速度相同,走过的面积仍然不同
取速度相关的线性阻力 dv/dt = -kv,其中 k 的单位是每秒。一个更新区间内 k 保持不变,速度和位移分别为:
v1 = v0 * exp(-k * dt)
x1 = x0 + v0 * (1 - exp(-k * dt)) / k (k > 0)
x1 = x0 + v0 * dt (k = 0)
位移来自速度曲线下的面积。先更新速度再乘整个 dt,是在每个区间用右端矩形代替这块面积。对于正速度、正阻力,右端速度小于区间平均速度,所以每一步都少走一段。增加帧数缩小了矩形误差,却没有改变算法的性质。若改用旧速度,矩形换成左端,误差方向会反过来。
同一个一秒区间里,解析结果为 10 * (1 - exp(-2)) / 2 = 4.323323583816936 m。末速度 10 * exp(-2) 不携带此前经过的面积,无法单独恢复位移。位置更新必须读取本区间的初速度,并与速度衰减共享同一组 k 和 dt。
下面是探针 DragMotion.cs 中的完整实现,Program.cs 直接调用 DragMotion.Step 验证解析值与分帧关系。位置与速度放在同一个返回值里,调用端一次替换两者,避免后续系统把已经衰减的速度再用于本次积分。
public readonly record struct Motion(double Position, double Velocity);
public static class DragMotion
{
public static Motion Step(Motion state, double drag, double dt)
{
if (!double.IsFinite(state.Position) || !double.IsFinite(state.Velocity)
|| !double.IsFinite(drag) || !double.IsFinite(dt)
|| drag < 0 || dt < 0)
throw new ArgumentOutOfRangeException();
if (dt == 0) return state;
double z = drag * dt;
if (!double.IsFinite(z)) throw new ArgumentOutOfRangeException();
double decay = Math.Exp(-z);
// 小量时直接相减会丢失有效数字,改算比值的展开式。
double travel = z < 1e-4
? dt * (1 - z / 2 + z * z / 6 - z * z * z / 24)
: (1 - decay) / drag;
var next = new Motion(state.Position + state.Velocity * travel,
state.Velocity * decay);
if (!double.IsFinite(next.Position) || !double.IsFinite(next.Velocity))
throw new OverflowException();
return next;
}
}
这里的分支有数值原因。k * dt 很小时,exp(-k * dt) 接近 1,直接做减法会损失有效数字。实现对 (1-exp(-z))/z 使用三阶展开,再乘 dt;小于 1e-4 的阈值下,下一项约为 z^4/120,不足以改变当前测试容差。k 为零自然走到这条分支,不需要除零,也不需要用一个人为最小阻力改变无阻力轨迹。
检查有限数不仅是防止异常传播。负阻力会把衰减变成加速,负时间会反向演化,均不属于这条粒子更新合同。有限输入的乘积和位置结果也可能溢出,因此实现检查中间的 z 及最终状态。调用者收到异常时没有获得半份位置或速度,原状态仍可保留,由特效系统选择拒绝配置或终止该粒子。
把一秒切成不同帧数
在 .NET SDK 9.0.202、Release 配置下,当天执行以下命令;构建与运行退出码均为 0,构建零警告、零错误。测试耗时是检查程序本身的计时,不包含构建,也不构成性能对比。
dotnet build "$PROBE_PROJECT/DragLab.csproj" -c Release
dotnet run --project "$PROBE_PROJECT/DragLab.csproj" -c Release --no-build
passed=24 failed=0 skipped=0 elapsedMs=47.159
randomCases=10000
maxPartitionError=6.73594513500575E-12 m
AnalyticPosition 与 AnalyticVelocity 对照方程;PartitionPosition 和 PartitionVelocity 把一秒分别切成 1、30、60、144 份,检查两项状态。固定种子 20261020 的 Semigroup 再比较一次推进 a+b 与先推进 a 再推进 b,覆盖 10,000 组位置、正负速度、阻力和时间组合。比较容差为 2e-10 * max(1, abs(expected));最大位置差如上,不能把浮点结果描述为逐位一致。
| 一秒内帧数 | 精确积分位移 / m | 帧末速度乘 dt 的位移 / m |
|---|---|---|
| 1 | 4.323324 | 1.353353 |
| 30 | 4.323324 | 4.180814 |
| 60 | 4.323324 | 4.251668 |
| 144 | 4.323324 | 4.293370 |

失败样本使用 Program.cs 的同一段帧末积分循环:
double oldX = 0, oldV = 10;
for (int i = 0; i < frames; i++)
{
oldV *= Math.Exp(-2.0 / frames);
oldX += oldV / frames;
}
失败回归保留了原来的顺序,单帧结果 1.353353 m 与正确位移相差约 2.969971 m。它验证的是一条确实会产生错误位置的实现,而非只比较两份相似公式。图中的 1 帧与 4 帧折线均由同一更新顺序计算,绿色曲线由解析式逐点生成。
边界检查另含零时间、零阻力、k=1e-20、大阻力、负速度、负参数、非有限状态及乘积和输出溢出。零阻力推进两秒得到位置 20 m、速度 10 m/s;k=1000/s 推进一秒得到约 0.01 m 位移。后者的速度可下溢到零,但位移仍保留从初速衰减下来的积分,不能因为帧末速度为零就判定整帧没有移动。
应当由哪一层提交运动
对于每帧使用同一常量 k、无外力且没有中途接触的区间,粒子模拟层可以用这对解析更新直接推进;增加子步不再是消除线性阻力积分误差的必要条件。相同总时长的不同分帧只剩浮点舍入差异,低帧率下也不会因这一项系统性缩短轨迹。三维情况下对速度向量各分量使用同一标量系数即可,位置和速度必须使用同一坐标系与时间单位。
这个结论依赖区间内方程不变。粒子穿越风场边界、阻力依赖速度平方、受到重力或发生碰撞时,都不能把整个 dt 当作同一个纯衰减区间。若已知接触时间,应先积分到接触点,处理速度变化,再推进剩余时间;仅把最终位置夹回碰撞面,会保留一份不对应实际路径的速度状态。随时间变化的外力需要重新推导或采用适合该方程的积分器。
生产接入时,渲染插值读取模拟层提交的位置快照,发射器负责初始条件,碰撞层负责打断区间;不要让三个模块各自对速度再衰减一次。只有速度、位移和区间边界共用一份运动合同,指数衰减才能从一个局部公式变成可跨帧率复用的粒子轨迹。