Tick 与时间推进
Category
server / sync 子树。节点定位:时间比协议更底层——四种时间拆开、固定 Tick 仍是主流、多时钟分层与客户端服务端共享哪一种时间。sync 子树总纲。
Definition
实时战斗里最容易被低估的,不是协议,也不是带宽,而是时间本身。只要多个客户端和服务端要共同参与同一局逻辑,就必须先回答几个基础问题:系统按什么节奏推进,谁定义“现在是第几帧”,渲染和逻辑怎样脱钩,网络抖动和掉帧时又怎样不把整局节奏拖散。很多所谓“同步问题”,根因其实是时间模型先乱了。
Problem
所谓「同步问题」根因常是时间模型先乱:现在是第几帧谁定义、渲染与逻辑怎么脱钩、网络抖动掉帧时整局节奏怎么不散。时间不先定,协议怎么设计都会反复返工。
为什么时间比协议更底层
玩家的输入是连续发生的,机器执行却总要落在离散步长上。你按下技能键的那个瞬间,对玩家来说是一个连续时间点;对系统来说,它必须被映射到某个逻辑帧、某个输入窗口、某个技能起手点、某个服务端裁决时刻。如果这个映射在客户端、服务端和回放链路里不一致,后面无论发的是输入、状态还是事件,都会逐步漂移。
战斗系统里几乎所有关键对象都依赖时间:
- 技能前摇、命中点、后摇。
- Buff 持续时间、周期 Tick、驱散窗口。
- 投射物飞行、碰撞判定、延迟爆炸。
- 动画事件、特效触发、镜头震动。
- 托管接管、掉线恢复和结算超时。
所以时间推进不是运行时细节,而是战斗系统和同步体系共享的底座。
先把四种时间拆开
很多团队说“这一帧”时,实际上把几种不同时间混在一起了。更稳妥的做法是一开始就区分:
- 墙钟时间:真实世界时间,来自系统时钟。
- 渲染时间:屏幕刷新和动画播放节奏,可能是 30 FPS、60 FPS、120 FPS,也可能波动。
- 逻辑时间:战斗模拟推进的步长,通常用固定 Tick 表示。
- 网络时间:消息发送、接收、排队、补发和追帧窗口这些链路时间。
这四者不应该互相替代。渲染掉到 40 FPS,不代表逻辑一定要跟着变;网络抖了一下,也不代表技能持续时间应该跟着拉长。成熟系统通常会让逻辑时间尽量稳定,把渲染和网络波动约束在外围层处理。
固定 Tick 为什么仍然是主流
战斗模拟最常见的选择仍然是固定 Tick。原因很直接:固定 Tick 最容易定义顺序,最容易记录回放,最容易做状态哈希,也最容易处理跨端一致性。只要每一步推进的输入和状态边界明确,很多问题都能转化成“第 N 帧发生了什么”。
固定 Tick 的直接收益包括:
- 技能、Buff、投射物的时序都能落到离散帧号上。
- 客户端和服务端更容易共享调试语言,例如“第 2136 帧命中异常”。
- 回放、观战和状态校验更容易落地。
- 帧同步、锁步和部分预测都更容易建立在同一逻辑基座上。
它的代价也同样明确:
- 某一帧超预算时,需要补帧、追帧或进入降级模式。
- Tick 频率越高,CPU 压力越大,网络输入窗口也越紧。
- 如果周边系统没分层,整个项目都会被高频逻辑推进一起拉重。
所以常见做法不是“所有东西都跑固定 Tick”,而是把真正影响裁决的核心逻辑放进固定步长,把表现和外围系统留在更松的节奏上。
为什么可变步长在战斗裁决上经常出问题
可变步长看上去灵活,实际上对多人战斗很危险。时间步长一旦跟着帧耗时波动,碰撞、插值、随机触发、技能持续时间和物理近似都会开始变形。单机里这有时还能接受;多人同步或回放体系里,这种漂移很快就会变成结构性成本。
最常见的问题不是“偶尔不准”,而是:
- 相同输入在不同机器上演化出不同结果。
- 某些技能命中窗口被低帧率放大或缩小。
- 回放重演和线上实战不一致。
- 客户端和服务端共享部分逻辑时逐步分叉。
因此,哪怕渲染层和动画层使用可变步长,战斗裁决层通常也会尽量保持固定 Tick。
真实项目里常见的是多时钟分层
更贴近工程现实的设计,不是单一时钟,而是多层时钟:
- 战斗逻辑时钟:固定 Tick,决定裁决与技能时序。
- 渲染插值时钟:根据屏幕刷新补插值,不直接影响权威状态。
- 网络发送时钟:按包频、合包策略或输入窗口组织消息。
- UI 与外围系统时钟:按钮冷却显示、倒计时展示、通知动画等。
这套分层的关键不是复杂,而是明确谁能改谁不能改。UI 倒计时可以晚几十毫秒,战斗裁决帧不能乱;渲染卡一下可以少画几张图,逻辑帧不应该顺势丢掉;网络发送慢一拍可以补包,但不能让每个模块自己重新发明一套时间。
客户端和服务端到底要共享哪一种时间
多人战斗里,最重要的是共享一份可追责的逻辑时间。通常表现为:
- 服务端维护权威战斗时钟或权威帧号。
- 客户端维护本地逻辑时钟,但持续向权威帧收敛。
- 所有关键事件都能落到明确帧号或 Tick 序号上。
即使采用服务端权威状态同步,而不是纯锁步,战斗事件也最好带清晰的逻辑时间标识。否则到了重连、追帧、举报复盘和状态纠正时,系统根本说不清这是一条旧状态、当前状态还是未来预测状态。
补帧、追帧和帧预算必须提前设计
固定 Tick 真正的工程难点,不是写一个 tick = 33ms,而是在系统跟不上时怎么办。常见情况有三类:
- 本地渲染慢,但逻辑线程还能跟上。
- 本地逻辑也慢,开始积压。
- 网络恢复后需要追赶服务端帧号。
所以 Tick 体系必须提前设计补帧和追帧策略,而不是默认“慢了就慢了”。常见手段包括:
- 单帧最多补多少逻辑步,避免进入死亡螺旋。
- 追帧时关闭部分表现或降级特效。
- 落后太多时转向快照恢复,而不是无限逐帧硬追。
- 明确哪些系统允许跳表现,哪些绝不能跳裁决。
没有统一战斗时钟,暂停就只是 UI 停住;没有追帧预算,重连就很容易变成永远在加载。
Tick 不只是每秒跑多少次
成熟团队通常不会把 Tick 只看成一个频率常量,而会关心更具体的运行事实:
- 一帧的输入收集窗口有多长。
- 逻辑更新顺序是否稳定。
- 技能命中点和动画事件如何绑定到逻辑帧。
- Buff 周期 Tick 是按帧数还是按时间片。
- 跨线程消息是在本帧消费还是下一帧消费。
这些问题如果没有统一规则,所谓的 Tick 系统就只是一个全局定时器名字。
可观测性应该从时间开始
Tick 体系如果不能被观察,所有同步问题最后都会变成猜。至少应该持续监控:
- 当前逻辑帧耗时。
- 最近一段时间的补帧次数。
- 单帧最多追赶了多少步。
- 房间内客户端与服务端的帧差分布。
- 因超时使用默认输入的次数。
- 重连后追帧耗时和成功率。
这些指标能把“玩家说卡”“技能慢半拍”“重连一直转圈”变成可量化链路,而不是靠肉眼看录像猜。
常见误区
- 让不同系统各自维护自己的“时间感觉”,最后技能、特效、补偿和裁决互相错位。
- 把渲染帧率当逻辑时间,机器一掉帧,战斗节奏就跟着变。
- 只在正常运行时看 Tick,从不在补帧、追帧、后台挂起和重连恢复时验证时间一致性。
时间体系一旦松散,后面的同步模型、暂停恢复、技能时序和回放体系都会一起失真。
实战案例:一次「低端机技能总是慢半拍」的渲染时间与逻辑时间错位排查
案例为教学示例(非摘自真实项目),数字经过取整简化。它把本页「先把四种时间拆开」「真实项目里常见的是多时钟分层」「补帧、追帧和帧预算必须提前设计」用到深水区:高端机一切正常、低端机整体慢半拍——这是渲染帧率被当成逻辑时间的排查,也是「怎样让逻辑时间稳定、把渲染波动约束在外围层」的完整展开。
背景与现象:某 MOBA 手游,日活 30 万,低端机(30 FPS 档)占 22%。上线两周后低端机玩家投诉「技能慢半拍」「明明按了没反应」,日 420 单,高端机(60 FPS 档)几乎没有。同期命中率、胜率等业务指标全绿——又是业务指标看不出的时间问题。
第一步:现象与口径——先按机型档位分账
机型分档口径:低端机技能表现比服务端权威命中晚 3–5 帧,高端机不超过 1 帧;投诉量与机型档位强相关。
帧号口径:客户端上报的本地逻辑帧与服务端权威帧的差值,高端机稳定在 1–2 帧,低端机达 12 帧——差值随渲染帧率走,说明逻辑推进跟着渲染在跑。
业务口径:命中率、伤害统计全部正常——因为裁决在服务端,服务端没慢,慢的是客户端的表现与本地逻辑。
第二步:分层归因——按本页四种时间逐层拆
低端机帧差 12 帧 归因
渲染帧率被当逻辑时间,逻辑按渲染帧推进 68% → 8.2 帧
插值时钟与逻辑时钟未分层 22% → 2.6 帧
无补帧预算,掉帧后不回补 10% → 1.2 帧排查路径:先看网络(低端机与高端机同城同运营商,链路差异排除);再看服务端(服务端帧号稳定,排除);最后看客户端循环——逻辑更新挂在渲染回调里,渲染掉到 30 FPS,逻辑推进就跟着从 60 Hz 掉到 30 Hz,技能前摇、命中点这些按帧数计的时序被整体拉长。
第三步:根因——渲染帧率与逻辑步长没有脱钩
逻辑更新和渲染共用一个循环,没有独立的固定 Tick,也没有独立的渲染插值时钟。渲染掉帧直接拖慢逻辑推进,正是本页说的「把渲染帧率当逻辑时间,机器一掉帧,战斗节奏就跟着变」。插值时钟也没分层,低端机上表现插值又叠加了一层滞后。
第四步:处置与回填——三处改动,逻辑与渲染解耦
- 逻辑层拆出固定 Tick:逻辑固定 33 毫秒一步,与渲染帧率脱钩,渲染掉到 30 FPS 时逻辑仍按固定步长推进,必要时一个渲染帧内跑两步逻辑。
- 渲染层独立插值时钟:表现按屏幕刷新补插值,低端机降级特效与粒子,但不改逻辑步长。
- 补帧预算:单次渲染循环最多补 4 步逻辑,超预算时转快照恢复,避免低端机进入死亡螺旋。
复核账:低端机帧差 12 帧 → 2 帧;技能慢半拍投诉日 420 单 → 30 单;低端机与高端机的命中表现差收敛到 1 帧以内。
回填清单:
| 回填项 | 动作 | 回填处 |
|---|---|---|
| 四种时间拆分 | 墙钟、渲染、逻辑、网络四种时间分别定义,评审时逐条说明谁不能替代谁 | 先把四种时间拆开 |
| 渲染插值时钟 | 渲染按屏幕刷新补插值,逻辑按固定 Tick 推进,两者互不改步长 | 真实项目里常见的是多时钟分层 |
| 补帧预算 | 单帧最多补 4 步逻辑,超预算转快照,避免死亡螺旋 | 补帧、追帧和帧预算必须提前设计 |
| 帧差可观测 | 持续监控房间内客户端与服务端帧差分布,按机型档位分组 | 可观测性应该从时间开始 |
案例的三个教训
- 业务指标全绿不代表时间模型是对的。裁决在服务端、指标在服务端,慢的是客户端时间推进——时间问题要按帧号看,不能只看业务结果。
- 渲染帧率绝不能当逻辑时间用。逻辑与渲染共用循环,低端机的掉帧就会变成整体节奏变慢;固定 Tick 加独立插值时钟是分界线。
- 补帧预算要提前定,不然掉帧会变成死亡螺旋。单帧最多补几步、什么时候转快照,定下来才扛得住低端机。
Used By
时间与时钟模型评审;Tick 与帧预算设计;客户端服务端时间共享约定。
- skynet(commit
64391f7):时间源由运行时统一发放——skynet_now返回定时器线程维护的 tick 计数而不是系统时钟(skynet-src/skynet_timer.c:268),业务定时器挂在同一根轴上(skynet_timeout,同文件:205)。帧推进与超时调度共用一个时间口径,业务侧就不必各自判断「现在几点」
Related
关系链:时间底层 → 四种时间 → 固定 Tick → 多时钟分层 → 共享口径 → 补帧预算。上游:无(sync 入口)。下游:sync/02(同步模型选型)、runtime/03(Tick 作为运行时底座)。
Reference
本页为工程经验归纳;页内实战案例为教学示例(数字取整简化)。