跳到正文
Virok

固定步长 30Hz:一个让 bug 可复现的决定

为什么选 30Hz 而不是 60Hz,累加器怎么写才不会死亡螺旋,以及插值为什么只能发生在表现层。

AI 摘要

确定游戏逻辑层的固定模拟频率并正确实现:30Hz是在低端机余量与手感之间算出的工程折中点;累加器需钳制deltaTime防死亡螺旋、限制每帧补tick数防卡死;插值只能在渲染层做且绝不能写回Sim,否则确定性破产;输入需按按下沿在tick边界采样。

5 分钟
目录

上一篇讲了为什么要把逻辑抽成独立的 Sim 层。这篇讲 Sim 层跑起来之后,第一个必须做的决定:步长定多少,以及怎么让时间真正固定下来

为什么是 30Hz

不是拍脑袋,是算出来的。

《WOTW3D》是 Roguelike 割草,单局同屏敌人峰值在 300 个左右。每个敌人每 tick 要做:移动积分、空间网格查询、碰撞检测、伤害结算。假设每 tick 总耗时 T

步长 每秒 tick 数 单 tick 预算 300 敌人下单 tick 实测
60Hz 60 16.6ms 9.2ms
30Hz 30 33.3ms 9.2ms
20Hz 20 50ms 9.2ms

单 tick 成本跟步长无关,所以降频直接换来预算。30Hz 下我有 33ms 的预算,实测只用 9.2ms,留了 3.6 倍余量给低端机和 GC 尖峰

60Hz 不是不行,但余量只有 1.8 倍。割草类游戏的特征是“大部分时候很闲,偶尔同屏爆炸”,我不想在最热闹的波次里掉帧。

那为什么不再降到 20Hz?因为手感。30Hz 下输入到反馈的延迟是 33ms,配上表现层插值,主观上跟 60Hz 差别很小。20Hz 开始能感觉到“钝”,尤其是快速转向的时候。

所以 30Hz 是个工程余量与手感之间的折中点,不是一个理论最优值。你的项目参数不同,结论也会不同——但决策过程是一样的:测出单 tick 成本,算出预算,看哪个频率的余量能让你睡得着觉。

累加器,以及死亡螺旋

固定步长的标准写法是累加器:

private const float FixedDelta = 1f / 30f;
private const float MaxFrameTime = 0.25f; // 关键:单帧最多补 7~8 个 tick

private float _accumulator;

private void Update()
{
    // 钳制!否则一次卡顿会导致无限补帧
    float frameTime = Mathf.Min(Time.deltaTime, MaxFrameTime);
    _accumulator += frameTime;

    int steps = 0;
    while (_accumulator >= FixedDelta && steps < MaxStepsPerFrame)
    {
        _sim.Step(_inputBuffer.Consume());
        _accumulator -= FixedDelta;
        steps++;
    }

    float alpha = _accumulator / FixedDelta;
    _view.Sync(_sim.Capture(), alpha);
}

这里有两个必须有的保护,少一个都会出事:

1. Mathf.Min(Time.deltaTime, MaxFrameTime) 的钳制。

不加这个会发生“死亡螺旋”:某帧卡了 500ms → 累加器攒下 500ms → 需要补 15 个 tick → 补 tick 本身又耗时 → 下一帧卡得更久 → 累加器攒得更多 → 帧率彻底崩掉,永不恢复。

钳制之后,卡顿时最坏情况是游戏逻辑变慢(慢动作)而不是卡死。玩家看到的画面是“卡了一下然后继续”,而不是“游戏死了”。这是一个明确的取舍:宁可逻辑时间跟真实时间脱节,也不能让主线程被打死

2. steps < MaxStepsPerFrame 的上限。

钳制之后通常不会触发,但这是最后一道保险。如果某天单 tick 成本因为某个 bug 涨了十倍,这个上限保证主线程还能喘口气,而不是彻底僵死。

插值只能发生在表现层

这是整套设计里最容易被破坏的一条。

渲染可能跑在 60Hz、120Hz 甚至 144Hz,Sim 只有 30Hz。如果直接把 Sim 的最新状态画出来,画面会以 30Hz 跳动——高速运动的敌人会有明显的顿挫感。

解法是插值:渲染时用上一 tick 和当前 tick 的状态按 alpha 混合。

public void Sync(in SimSnapshot snapshot, float alpha)
{
    foreach (var (id, prev, curr) in _interpBuffer.Pairs(snapshot))
    {
        Vector3 pos = Vector3.Lerp(prev.Position, curr.Position, alpha);
        Quaternion rot = Quaternion.Slerp(prev.Rotation, curr.Rotation, alpha);
        _views[id].SetPositionAndRotation(pos, rot);
    }
}

但这里有个陷阱:插值的结果绝不能写回 Sim

我见过的最隐蔽的一个 bug 长这样:

// 错误示例:插值结果回流到了逻辑层
_view.Sync(snapshot, alpha);
_enemy.SimPosition = _view.transform.position; // ← 灾难

表面上一切正常,画面丝滑。但 transform.position 已经被插值污染,写回 Sim 之后,Sim 的状态就依赖于渲染时的 alpha 值了。而 alpha 依赖 Time.deltaTime,也就是依赖帧率。

于是:同一个种子,在 60Hz 的机器和 144Hz 的机器上,跑出不同的战斗结果。确定性彻底破产,而且这种 bug 在单机下几乎不可能被发现——你只会在做重放或者联机的时候,突然发现对不上。

防御方法很简单:Sim 层的数据结构根本不暴露 setter

public readonly struct SimTransform
{
    public readonly Vector2 Position;
    public readonly float Rotation;
    // 没有 setter —— 想改只能通过 Sim 层内部逻辑
}

用类型系统把这条路堵死,比写一百行注释管用。

输入要在 tick 边界上采样

另一个容易忽略的点:玩家输入是在渲染帧产生的,但 Sim 只在 tick 边界消费。

如果一帧内玩家快速点了两次攻击,而这一帧刚好跨了两个 tick,那么这两次输入必须分别进入对应的 tick,不能合并,也不能丢失。

public struct SimInput
{
    public Vector2 MoveAxis;
    public ButtonFlags Buttons;      // 本 tick 内"是否按下"
    public ButtonFlags PressedEdge;  // 本 tick 内的"按下沿"
}

public sealed class InputBuffer
{
    private readonly Queue<InputEvent> _events = new();

    public void Push(InputEvent e) => _events.Enqueue(e);

    public SimInput Consume()
    {
        // 把这一 tick 期间累积的所有事件归约成一份 SimInput
        var input = SimInput.Empty;
        while (_events.Count > 0) input = input.Merge(_events.Dequeue());
        return input;
    }
}

关键是 PressedEdge:连击判定靠的是“按下沿”而不是“持续按住”。如果只记录 Buttons,玩家按住不放就会一直触发攻击,或者在某 tick 里丢失一次点击。

检查清单

如果你也要上固定步长,这几条是我踩过的坑:

  • Time.deltaTime 必须钳制上限,防止死亡螺旋
  • 每帧补 tick 的次数必须有硬上限
  • 插值只在表现层,Sim 数据结构不可外部写入
  • 随机源自己实现,种子记录在事件日志里
  • 输入按“按下沿”归约,不在渲染帧上直接消费
  • CI 里静态检查 Sim 层不得引用 UnityEngine

最后一条最重要。前五条你写的时候都会记得,第六条防的是三个月后的自己和别人。

Related · AI

相关阅读

基于语义相似度推荐