固定步长 30Hz:一个让 bug 可复现的决定
为什么选 30Hz 而不是 60Hz,累加器怎么写才不会死亡螺旋,以及插值为什么只能发生在表现层。
AI 摘要
确定游戏逻辑层的固定模拟频率并正确实现:30Hz是在低端机余量与手感之间算出的工程折中点;累加器需钳制deltaTime防死亡螺旋、限制每帧补tick数防卡死;插值只能在渲染层做且绝不能写回Sim,否则确定性破产;输入需按按下沿在tick边界采样。
上一篇讲了为什么要把逻辑抽成独立的 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
相关阅读
基于语义相似度推荐