固定步长追帧达到上限后,剩余时间该怎么处理

固定更新每次推进 20 毫秒,渲染每帧最多允许它追赶四次。一次 200 毫秒长帧过后,角色的位置插值却越过了最新模拟位置。循环次数确实没有超限,问题藏在退出循环之后:累加器里仍有好几个完整步长,代码却把整份余量除以 20,当成了两个状态之间的插值系数。

固定步进、被舍弃的时间积压与保留余量的概念示意;块数和精确数值以技术图为准

给这个场景补上前一帧留下的 15 毫秒,错误就很具体了。可用时间共 215 毫秒,四步只消耗 80 毫秒,剩余 135 毫秒。若直接计算 alpha = accumulator / step,结果是 6.75。没有钳制的线性混合会变成外推;带钳制的混合虽然停在最新状态,背后的 120 毫秒完整积压仍然存在。

追帧预算之后还需要一次时间裁决

本文选择允许局部模拟减速的运行时策略:每个渲染帧最多安排四个固定步,超出的完整步时间直接丢弃,只保留不足一步的余数。这里的预算是步数上限,并不保证四步能在某个 CPU 毫秒预算内完成;单步代价还需要单独测量。

设本帧累计时间为 T,固定步长为 h,最大步数为 M。所有时间以整数毫秒计,不涉及时间缩放,输入来自调用方提供的非负经过时间。三个去向可以一次算清:

steps     = min(T / h 的整数商, M)
remainder = T % h
dropped   = T - remainder - steps * h
T         = steps * h + dropped + remainder

由于余数严格小于 h,插值系数 remainder / h 才能保持在零到一之间。本例得到四步、丢弃 120 毫秒、保留 15 毫秒,alpha 为 0.75。直接把累加器清零也能去掉积压,却会连这 15 毫秒一起抹掉:下一帧只过 5 毫秒时,本来已经凑齐一步,清零方案还要继续等。

代价同样明确。那 120 毫秒没有被模拟,不应靠把逻辑 tick 从 4 改成 10 来补账,也不能把四次 20 毫秒更新改成一次 200 毫秒更新;后者已经改变了固定积分步长。丢弃量需要独立返回,供调用者记录模拟与墙钟之间的差额。

累加器只安排步数,调用方推进状态

最小工程 FixedStepLab.csproj 在 .NET 9 中运行,核心为 FixedStep.csAdvance,测试入口为 Program.cs。这是 C# 的 CPU 调度数值切片,没有调用引擎物理求解器,也没有测量角色渲染效果。完整实现如下:

public readonly record struct StepBatch(int Steps, long DroppedMs,
    long RemainderMs, double Alpha, long Tick);

public sealed class FixedStep
{
    private readonly long stepMs;
    private readonly int maxSteps;
    public long RemainderMs { get; private set; }
    public long Tick { get; private set; }

    public FixedStep(long stepMs, int maxSteps)
    {
        if (stepMs <= 0 || maxSteps <= 0)
            throw new ArgumentOutOfRangeException();
        this.stepMs = stepMs;
        this.maxSteps = maxSteps;
    }

    public StepBatch Advance(long elapsedMs)
    {
        if (elapsedMs < 0) throw new ArgumentOutOfRangeException(nameof(elapsedMs));
        long total = checked(RemainderMs + elapsedMs);
        int steps = (int)Math.Min(total / stepMs, maxSteps);
        long nextTick = checked(Tick + steps);
        long remainder = total % stepMs;
        long dropped = total - remainder - steps * stepMs;
        RemainderMs = remainder;
        Tick = nextTick;
        return new(steps, dropped, remainder, (double)remainder / stepMs, Tick);
    }
}

Advance 先算出全部候选值,再更新两个字段。负时间、累加溢出或 tick 溢出都会在赋值前失败,原余量与原 tick 保持不变。乘积 steps * stepMs 不会超过已验证非负的 total,因为 steps 不大于其整数商;计算丢弃量也因此不会产生负数。

这里的 Tick 是累计安排的步数。调用方必须在下一次 Advance 之前同步完成本批 Steps 次固定更新,且不能中途失败,才能把它解释为已完成模拟 tick。当前接口没有实现模拟事务;若更新可能抛异常或被异步取消,应把安排与完成确认拆开,不能拿这份计数冒充成功进度。

同一批更新里,调用方在每一步前把当前状态保存为 previous,再以固定 h 计算 current。渲染只读取最后一对已完成状态,以 alpha 做混合;初始化时两份状态应相同。这里沿用的插值约定通常滞后于当前模拟时间一个步长,alpha 不是预测未来的许可。探针只验证调度输出,没有把物理回调封装进类中,避免将时间策略与特定场景的积分过程耦合。

215 毫秒拆分为执行、丢弃和余量,以及错误插值路径的对照

图中的六个丢弃块表示时间预算被放弃,不表示六次物理更新被执行后隐藏。调度器也没有决定带时间戳输入该归入哪一步;如果输入队列仍按墙钟分桶,必须同时约定丢弃区间的输入是合并、保留还是拒绝,否则时间合同与输入合同会分离。

让长帧反例与普通帧共同约束实现

StallRegression 固定复现 6.75 的错误值,并检查正常结果;NextFrame 再输入 5 毫秒,确认只安排一步、余量归零且 tick 到 5。决定性回归代码为:

var clock = new FixedStep(20, 4);
clock.Advance(15);
var batch = clock.Advance(200);
if (batch.Steps != 4 || batch.DroppedMs != 120 ||
    batch.RemainderMs != 15 || batch.Alpha != 0.75 || batch.Tick != 4)
    throw new Exception("Stall policy changed");
if ((15 + 200 - 4 * 20) / 20.0 != 6.75)
    throw new Exception("Counterexample changed");

2026-09-06 的 Release 构建零警告、零错误;构建和运行退出码均为 0。以 $LAB_DIR 表示最小工程所在目录,本次命令和决定性输出为:

cd "$LAB_DIR"
dotnet build FixedStepLab.csproj -c Release --no-restore
dotnet run --project FixedStepLab.csproj -c Release --no-build
steps=4 dropped_ms=120 remainder_ms=15 alpha=0.75 wrong_alpha=6.75 tick=4
passed=12 failed=0 skipped=0 grid_cases=6020
elapsed_ms=25.300
exit_code=0

耗时仅为本次测试执行时间,不是单步模拟成本。ConservationGrid 枚举初始余量 0 至 19 与本帧时间 0 至 300,共 6,020 组,检查时间守恒、步数上限、余量范围和丢弃量为整步倍数。其余回归覆盖恰好一步、恰好预算、无过载的分帧一致性、负输入拒绝、累加溢出保留状态、非法配置和最大有效整数时间。

FramePartitionIsPolicy 给出了不能忽略的另一面:一次输入 200 毫秒只安排四步;十次各输入 20 毫秒则安排十步。两种输入的总墙钟时间相同,结果却不同。这不是整数误差,而是“每渲染帧最多四步”的策略本身依赖帧划分。PartitionWithoutOverload 则确认,在没有触发上限时,60 毫秒整体输入与 7、13、15、25 毫秒分开输入得到相同 tick 和余量。

哪一只时钟有权慢下来

如果目标是非权威的局部模拟或可接受减速的离线玩法,可以明确接受丢弃积压,再通过 dropped 指标观察退化。不能把墙钟计时的比赛截止、联网快照时间和这只模拟时钟混为一个变量;持续丢弃时,它们会不断拉开距离。

要求逐 tick 处理输入的锁步系统没有同样的自由。它可以暂停渲染以赶上进度、控制负载或进入协议定义的恢复流程,但不能让各客户端依据各自长帧独立丢步。若必须保留积压,则应把完整待执行步数与用于表现的小数余量分别建模,接受并处理模拟落后,不能再把整份 backlog 直接交给插值函数。

本实现使用毫秒只是为了让固定样本精确可复算;需要表达非整数毫秒周期时,应在统一的更细整数时间单位或有理步长合同下实现,不能把 1/60 秒随手截成 16 毫秒。整数量纲减少了本例的舍入干扰,并没有替整个游戏建立确定性。

长帧后的角色外推,修复点因此落在追帧循环与渲染插值之间:先明确超预算的完整时间是保留还是丢弃,再把真正的小数步余量交给表现层。这里采用的丢弃策略保持了固定步长和合法插值区间,同时公开承认 120 毫秒没有被模拟。只有产品和网络时间合同允许这份损失,四步上限才是一项成立的运行时决策。