13.1 输入缓冲与本地预测
第 12 章把服务端的同步模型讲清楚了:服务端是权威,它算出来的世界状态才是真的。但「权威」有一个物理代价——玩家的每一次操作都要先传到服务端、等它算完、再传回来,才在屏幕上看到结果。这个往返就是 RTT。在一条 60ms 的链路上,按 60Hz 出包算,玩家按下方向键到角色动起来,中间隔了将近 4 个 tick。
如果什么都不做,游戏的手感就是「黏」的:移动延迟一帧就能被玩家感觉到,射击游戏里更是致命。解决办法只有一个方向——让客户端先动起来,服务端事后纠正。这一节做前半件事:客户端本地预测,以及支撑它的输入缓冲与确认号。
与站内 practice 文章的分工
本书从零写代码、每一段都能真跑;站内既有的 practice 文章讲的是架构方案与取舍。本章对应的是下面几篇,建议对照阅读:
| 站内文章 | 它负责回答 | 本书这一章负责 |
|---|---|---|
authoritative-movement-validation-in-game-servers | 移动为什么必须服务端权威、校验点放在哪一层 | 写出一份可运行的 Step,让客户端能精确复现服务端的每一步 |
game-server-authoritative-state-architecture | 权威状态的边界与数据归属 | 输入序号、确认号如何与权威状态一起下发 |
game-server-snapshot-delta-sync-architecture | 快照与增量编码怎么省带宽 | 预测与和解所需的最小状态字段 |
另外,站内的 game-networking-rollback
写的是客户端视角的回滚:GGPO 式的帧同步、确定性模拟、观战重放。那是「客户端自己回滚再重算」的一半。本书这一章写的是服务端那一半:服务端如何持有权威、下发确认号、以及客户端拿到确认号后如何和解——两边合起来才是完整的预测-校正闭环。
为什么要客户端先走一步
预测的本质是把「等待」换成「重放」。客户端收到玩家输入后,不等服务端确认,直接在本机跑一遍移动逻辑,把角色画到新位置。服务端稍后回来的权威状态如果和本地一致,什么都不用做;如果不一致,就以服务端为准,然后把尚未确认的输入再跑一遍——因为那些输入服务端还没算过,它们的效果应该叠加在权威状态之上。
这里有一个前提条件,而且是整章唯一不可动摇的假设:移动逻辑必须是确定性的纯函数。客户端和服务端对同一个 (位置, 输入) 必须得到逐位相同的输出。只要有任何一处用了本机时间、随机数、或者浮点运算顺序不一致,预测就会持续发散,玩家会看到角色被反复拽回来。
用一个具体数字说明「先走一步」值多少。假设 60Hz、RTT 60ms:
| 方案 | 按下方向键到角色动 | 手感 |
|---|---|---|
| 纯服务端权威 | 约 60ms(一个 RTT) | 明显发黏 |
| 本地预测 | 下一帧(约 16ms) | 跟手 |
省下的约 44ms 全部来自「不等服务端」。代价是服务端随时可能推翻这个预测——而推翻了怎么收场,就是 13.2 的事。
要强调的是,本地预测只是「让画面先动」,它不改变一个事实:服务端的位置才是真的。客户端预测出来的位置,在服务端眼里可能从未存在过——如果服务端因为丢包没收到某条输入,或者因为校验否决了它。所以预测的位置只能用来画,不能用来做任何需要「绝对正确」的判定,比如「我到底有没有走进安全区」。这类判定必须等服务端确认。
服务端要记什么:输入序号与确认号
客户端每次产生输入,都要给它编一个单调递增的序号 Seq,从 1 开始。序号有两个用途:一是让服务端能按顺序处理、丢弃重复或迟到的输入(这和第 11 章的命令去重是同一套思路,只是粒度更细,是每 tick 一次);二是让服务端能在回包时告诉客户端「我已经处理到第几条了」。
这个回包里的数字就是确认号(ack)。客户端拿到 ack 之后,把缓冲里所有 Seq <= ack 的输入丢掉,因为它们的效果已经体现在服务端下发的权威状态里了;剩下的输入是「服务端还没见过」的,需要在新状态上重放。
缓冲区里剩下的条数不是随时间增长的,而是由 RTT 决定的:RTT 是几个 tick,就大约有几条输入在飞。这一点在下一节会被用来给回滚的代价定上界,这里先把它跑出来。
注意别把两个 seq 搞混。帧头里有一个 4 字节的 seq(第 3 章定的传输层消息序号,用于帧的乱序重组),输入序号是业务层的,躺在 payload 里。两者独立递增,不要复用一个字段。用 §3.5 的帧格式,移动相关的两条消息长这样:
CmdMoveReq 0x0201 客户端 -> 服务端,payload 6 字节
inputSeq(4, BE) | moveX(1, int8) | moveY(1, int8)
CmdMoveResp 0x0202 服务端 -> 客户端,payload 16 字节
ackSeq(4, BE) | posX(4, float32) | posY(4, float32) | tick(4, BE)
帧 = length(4) | cmd(2) | seq(4) | flags(1) | payload // 固定头 11 字节
length = 7 + N,帧总长 = 11 + N,HeaderSize = 11
CmdMoveResp 里同时带了 ackSeq 和权威位置:客户端不需要先收 ack 再收位置,一条消息里两样都有,和解时一次到位。tick 是服务端处理这条输入的世界帧号,留给第 14 章的延迟补偿用。
从零实现:Predictor
下面这段是 L1,go test 可以直接跑。它同时是客户端预测器和服务端的移动函数——两端共用同一份 Step,这正是确定性的来源:
// file: internal/predict/predict.go
// tier: L1
// verify: go test ./internal/predict/ -run TestPredictor -v
package predict
// Vec2 is a position in world units. The client and the server step it with the
// same Step function, so a client replay reproduces the server exactly.
type Vec2 struct {
X, Y float64
}
// Input is one tick of player intent. Seq is assigned by the client and is
// monotonic per connection; the server echoes back the highest Seq it applied,
// which is the acknowledgement number the client reconciles against.
type Input struct {
Seq uint32
MoveX int8
MoveY int8
}
// Speed is the number of world units moved per tick for a full-deflection input.
const Speed = 5.0
// Step applies one input to a position. It is pure: the same (pos, in) always
// yields the same result. That purity is the whole reason prediction works --
// the client can run the server's step function on its own machine and trust
// that replaying the same inputs lands on the same position.
func Step(pos Vec2, in Input) Vec2 {
return Vec2{
X: pos.X + float64(in.MoveX)*Speed,
Y: pos.Y + float64(in.MoveY)*Speed,
}
}
// Predictor is the client half of prediction. It keeps the locally predicted
// position plus a buffer of inputs the server has not acknowledged yet.
type Predictor struct {
Pos Vec2
Pending []Input // ordered, oldest first; the replay set
lastSeq uint32
}
// NextSeq returns the next input sequence number. Sequences start at 1 so that
// "0" can be used as "nothing acked yet" without a sentinel.
func (p *Predictor) NextSeq() uint32 {
p.lastSeq++
return p.lastSeq
}
// ApplyLocalInput predicts the move locally and remembers the input so it can
// be replayed after a correction. The caller sends the same Input to the server.
func (p *Predictor) ApplyLocalInput(in Input) Vec2 {
p.Pos = Step(p.Pos, in)
p.Pending = append(p.Pending, in)
return p.Pos
}
// PendingCount is the number of inputs that are still in flight -- sent to the
// server but not yet acknowledged. It grows with the round-trip time.
func (p *Predictor) PendingCount() int { return len(p.Pending) }
注意 Step 里没有任何时间项:一个 tick 走多少格由 Speed 常量决定,不由「上一帧到这一帧过了多少毫秒」决定。这是有意的。真实项目里如果要做变速移动,也应该把「本 tick 的有效速度」作为输入的一部分下发,而不是让两端各自读时钟——一旦两端读了不同的时钟,确定性就没了。
真跑:确定性回放
验证预测是否成立,只需要一个断言:当服务端同意客户端时,两者位置逐字节相等。下面的 L1 测试用固定种子(rand.NewSource(42))生成 500 步随机输入,客户端本地预测、服务端同步施加,最后比较两个 Vec2:
// snippet: internal/predict/predict_test.go(节选)
// TestPredictorMatchesServerWhenInSync checks the core promise of prediction:
// when the server agrees with the client, the locally predicted position equals
// the authoritative one, step for step, over a long deterministic input stream.
func TestPredictorMatchesServerWhenInSync(t *testing.T) {
rng := rand.New(rand.NewSource(42))
p := &Predictor{}
var server Vec2
for i := 0; i < 500; i++ {
in := Input{
Seq: p.NextSeq(),
MoveX: int8(rng.Intn(3) - 1),
MoveY: int8(rng.Intn(3) - 1),
}
p.ApplyLocalInput(in)
server = Step(server, in)
}
if p.Pos != server {
t.Fatalf("client %+v != server %+v", p.Pos, server)
}
if p.PendingCount() != 500 {
t.Fatalf("pending = %d, want 500 (nothing acked yet)", p.PendingCount())
}
}
本机(Go 1.26,macOS arm64)实测:500 步全随机输入下 p.Pos == server 恒成立,PendingCount() 精确等于 500——因为这一步里服务端一条都没确认。第二个断言同样重要,它说明缓冲区会无上界地增长,直到服务端开始回 ack。这正是下一节要处理的:一旦服务端把确认号发回来,Pending 就会被裁剪到「在途输入数」这个量级。
一个容易踩的坑:Vec2 用的是 float64。在单一平台上、同一段代码、同一输入顺序,浮点结果是确定的,所以这个断言成立。但跨平台(x86 与 arm64 的 FMA 行为不同)就未必了。竞技类游戏会改用定点数,把移动算在整数域里,让「确定性」不再依赖浮点实现。本书这一卷用浮点,够用,但你要知道边界在哪里。
预测的边界:什么能预测、什么不能
预测不是万能的,把它用在错的地方比不用还糟。判断标准只有一条:这个效果能不能被客户端精确复现,且不依赖服务端的私有信息。
- 可以预测:移动、转向、镜头、本地特效、UI 反馈。它们的输入来自玩家自己,规则简单且确定,客户端能独立算出结果。
- 不能预测:伤害结算、掉落、技能命中、货币变动、他人状态。它们依赖服务端的权威数据(血量、命中判定、随机数),客户端猜错就会看到「打完没掉血」再被回滚,体验更差。
- 要谨慎:冷却、施法。客户端可以「预显示」冷却转圈,但能不能放得由服务端说了算,本地要能接受被否决。
一条实用规则:预测只覆盖「玩家自己的、可逆的、纯本地的」状态。凡是会让别人看到差异的、或者牵涉到资源的,一律等服务端。这条规则能挡掉 90% 的预测误用。
客户端状态机的骨架大致是这样,每个 tick 走一遍:
每 tick(本地 60Hz):
1. 采样输入 -> in{Seq: ++lastSeq, moveX, moveY}
2. p.ApplyLocalInput(in) // 本地预测,立刻可渲染
3. send CmdMoveReq(0x0201, in) // 发给服务端
收到 CmdMoveResp(0x0202) 时:
4. p.Reconcile(resp.Pos, resp.Ack) // 和解(13.2)
5. render: 把 p.Pos 交给渲染层,配合平滑(13.3)
注意第 2 步和第 5 步是分开的:预测只更新逻辑位置,渲染怎么表现是 13.3 的事。把这两件事揉在一起,是很多预测实现出问题的根源。
输入缓冲的容量与背压
缓冲区不能真的无上界。三种情况会让 Pending 异常膨胀,每一种都要有兜底:
- 服务端长时间不回 ack(网络断、服务端卡顿):客户端应该给
Pending设一个长度上限,比如 64 条。超过就丢弃最旧的输入——它们本来也过期了,重放它们没有意义。 - 客户端疯狂发包(外挂、或者客户端卡顿后追帧):服务端要按 tick 限流,每 tick 最多消费一条输入,多余的排队或丢弃。这和第 11 章的幂等窗口是同一类防护。
- 序号回绕:
Seq是uint32,理论上会绕回 0。实战里按 60Hz 算要两年多才绕一圈,一个会话基本撑不到,但比较时用>而不是>=,并约定「序号回绕即视为新会话」,可以省掉一整类诡异 bug。
这三条都属于「工程兜底」,不影响主逻辑,但上线前必须有——不然弱网或者恶意客户端能把你的缓冲区撑爆。
三个常见误区
实现预测时,下面三个坑几乎每个人都会踩一次:
- 把输入序号当成消息序号用。帧头的
seq管的是帧的乱序重组,输入序号管的是业务去重与确认,两者生命周期不同。混用会导致「丢了一个帧就丢了一段输入」这种难查的 bug。 - 在预测里读本地时钟。
Step必须是纯函数;一旦它内部读了time.Now()(比如做加速度积分),两端就算不齐,预测必然发散。需要时间相关的逻辑,就把时间当参数传进去。 - 和解后立刻重置渲染位置。逻辑位置可以精确复位,但渲染位置要平滑过去(13.3)。直接
render = p.Pos会让玩家每次收到服务端包都看到一次跳动。
这三条的共同点是:逻辑层要精确,渲染层要平滑,两者不要互相污染。整章后半段(13.2、13.3)其实都是在给这条原则做展开。
关键数字速查
| 量 | 值 | 来源 |
|---|---|---|
| 60Hz 下单 tick 位移 | 5 单位 | Speed = 5.0 |
| RTT 100ms 的在途输入 | 约 6 条 | 60Hz × 0.1s |
| 缓冲长度上限(建议) | 64 条 | 工程兜底 |
| 500 步随机输入一致性 | 逐位相等 | 本机实测 |
本节小结
- 预测把「等一个 RTT」换成「本地先算 + 事后重放」,前提是移动逻辑是确定性纯函数。
- 客户端给每次输入编号
Seq,服务端回一个确认号ack;Seq <= ack的输入已被权威状态吸收,可以从缓冲区丢弃。 - 业务层的输入序号和帧头里的传输层
seq是两个独立字段,别复用。 - 缓冲区里剩下的条数由 RTT 决定,不随时间增长——这是下一节回滚代价上界的基础。
- 浮点确定性只在同平台同代码下成立;要跨平台确定性,得换定点数。
- 缓冲区要有容量上限、服务端要有每 tick 限流、序号回绕要有约定。
阅读导航:上一节:目录 · 下一节:13.2 服务器回滚与和解 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。