《游戏服务端编程实践》8.3.1 帧号与预测机制

帧同步战斗里,帧号既是时间轴也是状态版本。本篇讨论帧号回绕、服务器帧与客户端本地帧的关系、先执行后校正的预测流程,以及预测错误为何表现为抖动和瞬移,并给出带容量上限、支持回滚重放的 FrameBuffer 实现与回绕、过期帧测试,最后总结常见边界。

一、帧号是战斗的时钟,也是状态的版本号

帧同步战斗把时间切成固定长度的逻辑帧,服务器每帧收集所有玩家输入、按固定顺序执行,再把结果广播给客户端。帧号因此承担两个职责:它既是时间轴上的位置,也是“这份状态属于哪一版”的版本号。

只要帧号只增不减,比较大小就很自然。但网络协议里的帧号通常是 32 位无符号整数,连续运行会回绕。一局持续数小时的战斗不一定跑到 40 亿帧,可跨战斗复用进程、压测脚本快进、或者把帧号当作全局递增序号时,回绕就会真实出现。

更常见的问题不是回绕本身,而是代码里散落的 a < b。一旦帧号从 0xFFFFFFFF 变成 0,所有直接比较都会得出相反结论:新帧被当成旧帧丢弃,回滚目标被判断为“未来帧”,战斗在毫无征兆的情况下卡死。

二、回绕比较:不要直接比大小

正确做法是把差值解释为有符号数,只使用“半个编号空间”以内的距离。int32(a-b) > 0 表示 a 在 b 之后;只要两帧真实距离小于 2^31,结论就与回绕无关。

写法回绕时的行为是否可用
a < b0 小于最大值,新帧被判为旧帧否
int64(a) < int64(b)同样把回绕当成倒退否
int32(a-b) > 0差值在半个空间内仍然正确是
每次回绕时重置计数器需要全局协调,容易漏掉旧包不推荐

前提是“同一时刻比较的两帧距离远小于 21 亿”。如果消息在缓冲区里躺了几年,或者帧号被随机化,这个前提不成立;那种场景应改用 64 位帧号加会话纪元,而不是继续修补 32 位比较。

三、服务器帧与客户端本地帧不是同一个东西

服务器帧是权威时间轴,客户端本地帧是渲染与预测用的本地计数。两者在正常运行时保持一个稳定偏移:客户端通常领先服务器若干帧,用这点提前量掩盖网络延迟。

必须明确三条规则。第一,输入包携带的是“这笔输入属于哪个服务器帧”,不是客户端当前帧。第二,客户端本地帧只用于驱动预测和插值,不能写进存档或战斗结果。第三,服务器只相信输入里的目标帧,并且只接受合理窗口内的帧,过期输入直接丢弃。

如果两端都把“当前帧”当成同一个概念,重连、暂停、追帧之后偏移就会漂移。表现是玩家看到自己在原地来回抖,服务器却认为一切正常。

还有一个常见误解:客户端收到权威帧就立刻把自己的本地帧“对齐”过去。这会让本地时间轴突然倒退或跳跃,预测输入的目标帧全部错位。正确做法是保持稳定的本地领先量,只在累计偏差超过阈值时缓慢调整,而不是每帧硬对齐。

四、客户端预测:先执行,再校正

没有预测时,玩家按下技能要等一个往返延迟才看到动作,手感像在泥里操作。预测的做法是:客户端在本地帧立即用“自己的输入 + 对他人输入的估计”执行一遍,先渲染结果;收到服务器权威帧后,如果本地预测与权威状态不一致,就回滚到最后一个确认帧,重新应用确认输入和仍在途的本地输入。

流程可以拆成五步:

  1. 客户端记录每个本地帧的输入,并保留最近若干帧的状态。
  2. 本地立即执行输入,渲染预测结果,同时把输入发给服务器。
  3. 服务器按帧号收集输入,执行权威逻辑,广播“帧号 + 权威状态或校验值”。
  4. 客户端收到权威帧后,找到自己最后一个被确认的帧。
  5. 若预测状态与权威状态不一致,回滚到确认帧,按顺序重放其后的输入。

关键在第五步:回滚不能只把位置改回去,还必须重放输入。否则状态里会残留“已经放过技能”的冷却、已经扣除的蓝量,下一次校正时误差更大。

五、回滚需要保存什么

最小集合是“帧号 + 完整可恢复状态”。位置、朝向、血量、冷却、Buff、随机数种子都算状态;只存位置的回滚是不完整的。输入日志则单独保存,按帧号索引,回滚后从确认帧的下一帧开始逐帧重放。

保存窗口要有上限。客户端不可能永久保留所有历史帧,服务器也不会为每个玩家保留整场战斗。窗口大小由“最大可接受回滚距离”决定:超过窗口的校正只能走全量快照或重连流程,不能假装还能逐帧重放。

判断一份数据要不要进回滚状态,可以问三个问题:它是否影响后续帧的结算?它是否能从其他数据推导?丢失它是否会造成可见差异?三个问题里有一个答案是“是”,就应该保存。

  • 位置、速度、朝向:影响碰撞和技能判定,必须保存。
  • 血量、蓝量、冷却:影响合法性判断,必须保存。
  • Buff 层数与剩余时间:影响伤害公式,必须保存。
  • 随机数种子与已消费次数:影响暴击和掉落,必须保存。
  • 纯展示特效、摄像机偏移:可由状态推导,不必保存。
数据用途典型保留范围
每帧状态回滚起点最近 1 到 2 秒
本地输入回滚后重放与状态窗口相同或略长
权威帧摘要判断是否需要校正最近若干秒
全量快照窗口外的兜底恢复低频、独立保存

六、失败场景:预测错误为什么表现为抖动和瞬移

假设客户端预测角色向右移动了 3 帧,服务器因为丢包只收到第 1 帧输入。权威帧到达后,客户端回滚到确认帧,重放时发现后两帧输入没被服务器接受,角色被拉回原位;下一批输入到达后又向前跳。玩家看到的是角色每 100 毫秒抖一下。

瞬移通常来自更大的偏差:客户端在本地执行了一个服务器判定不合法的手术(距离不够、目标已死亡),权威结果直接否定,回滚距离超过一帧。若此时没有插值,画面会在两帧之间硬切。

掩盖手段不是篡改权威状态,而是渲染层插值:逻辑状态仍然按帧跳变,渲染位置用短时间平滑逼近目标。插值时间要小于一个校正周期,否则输入延迟会被放大。对无法插值的离散事件,例如技能被拒绝,应播放明确的失败反馈,而不是让角色悄悄回弹。

还要区分“预测错误”和“显示错误”。前者是本地逻辑真的算错了,必须回滚重放;后者只是渲染插值参数不合适,逻辑状态其实一致。把显示问题当预测问题处理,会频繁回滚,画面反而更抖;把预测错误当显示问题处理,则会积累误差直到瞬移。

监控上至少记录三个量:每秒回滚次数、平均回滚帧数、回滚后仍需二次校正的比例。回滚次数突然上升通常意味着输入延迟或丢包恶化;平均回滚帧数持续变大,说明客户端领先量设置得不合理。

这三个指标要按战斗、按玩家维度聚合。只看全局平均值,会被大量正常玩家稀释,个别持续回滚的玩家很难被发现。

七、Go 实现:带容量上限的 FrameBuffer

下面的 FrameBuffer 保存最近 N 帧权威状态,Put 只接受连续的下一帧,Rollback 对过期帧和超前帧分别报错。帧号运算全部走 After 和 Sub,因此天然支持回绕。

// file: ch08/lockstep/frame.go
// tier: L1
// verify: go test ./ch08/lockstep/

package lockstep

import "errors"

// Frame 是逻辑帧号,允许自然回绕。比较必须使用 After/Sub,不能直接用 <。
type Frame uint32

// Sub 返回 f-o 的有符号差值,跨回绕仍然正确。
func (f Frame) Sub(o Frame) int32 { return int32(f - o) }

// After 报告 f 是否严格晚于 o。
func (f Frame) After(o Frame) bool { return int32(f-o) > 0 }

var (
	ErrFrameStale   = errors.New("frame: stale or duplicate frame")
	ErrFrameExpired = errors.New("frame: requested frame expired")
	ErrFrameAhead   = errors.New("frame: requested frame ahead of latest")
)

// FrameBuffer 保存最近 capacity 帧的权威状态,帧号必须连续递增。
type FrameBuffer[T any] struct {
	capacity int
	latest   Frame
	started  bool
	states   map[Frame]T
	order    []Frame
}

func NewFrameBuffer[T any](capacity int) *FrameBuffer[T] {
	if capacity < 1 {
		capacity = 1
	}
	return &FrameBuffer[T]{capacity: capacity, states: make(map[Frame]T)}
}

// Put 写入下一帧状态。帧号必须严格等于 latest+1,否则返回 ErrFrameStale。
func (b *FrameBuffer[T]) Put(f Frame, state T) error {
	if b.started && !f.After(b.latest) {
		return ErrFrameStale
	}
	if b.started && f != b.latest+1 {
		return ErrFrameStale
	}
	b.states[f] = state
	b.order = append(b.order, f)
	b.latest = f
	b.started = true
	for len(b.order) > b.capacity {
		old := b.order[0]
		b.order = b.order[1:]
		delete(b.states, old)
	}
	return nil
}

// Get 返回指定帧的状态,已被容量淘汰时 ok 为 false。
func (b *FrameBuffer[T]) Get(f Frame) (T, bool) {
	s, ok := b.states[f]
	return s, ok
}

// Latest 返回最新帧。
func (b *FrameBuffer[T]) Latest() (Frame, bool) { return b.latest, b.started }

// Rollback 返回确认帧的权威状态;过旧返回 ErrFrameExpired,超前返回 ErrFrameAhead。
func (b *FrameBuffer[T]) Rollback(f Frame) (T, error) {
	var zero T
	if !b.started {
		return zero, ErrFrameExpired
	}
	if f.After(b.latest) {
		return zero, ErrFrameAhead
	}
	s, ok := b.states[f]
	if !ok {
		return zero, ErrFrameExpired
	}
	return s, nil
}

Put 要求帧号连续,是刻意的限制:允许跳跃会让回滚拿到不连续的状态,调用方必须显式补齐缺失帧。容量淘汰最旧帧,Get 对已淘汰帧返回 false,Rollback 则把它视为过期,要求上层改走快照恢复。

八、测试:回绕、回滚重放与过期拒绝

测试覆盖三类边界:帧号从最大值回绕到 0、客户端预测到第 8 帧后回滚到第 5 帧重放、以及容量为 3 时请求已被淘汰的帧。

// file: ch08/lockstep/frame_test.go
// tier: L1
// verify: go test ./ch08/lockstep/

package lockstep

import (
	"errors"
	"math"
	"testing"
)

func TestFrameWrapComparison(t *testing.T) {
	last := Frame(math.MaxUint32)
	if !Frame(0).After(last) {
		t.Fatalf("frame 0 should be after %d", last)
	}
	if Frame(0).Sub(last) != 1 {
		t.Fatalf("wrap diff = %d, want 1", Frame(0).Sub(last))
	}
	if Frame(last).After(Frame(0)) {
		t.Fatal("last frame must not be after frame 0")
	}
	if Frame(10).After(Frame(10)) {
		t.Fatal("equal frame must not be after itself")
	}
}

func TestFrameBufferRollbackReplay(t *testing.T) {
	deltas := []uint64{0, 3, 5, 2, 7, 4, 6, 1, 2}
	buf := NewFrameBuffer[uint64](8)
	var pos uint64
	for f := Frame(1); f <= 5; f++ {
		pos += deltas[f]
		if err := buf.Put(f, pos); err != nil {
			t.Fatal(err)
		}
	}
	// 客户端本地已经预测执行到第 8 帧。
	local := pos
	for f := Frame(6); f <= 8; f++ {
		local += deltas[f]
	}

	confirmed, err := buf.Rollback(5)
	if err != nil {
		t.Fatal(err)
	}
	replay := confirmed
	for f := Frame(6); f <= 8; f++ {
		replay += deltas[f]
	}
	if replay != local {
		t.Fatalf("replay = %d, local prediction = %d", replay, local)
	}
	if got, ok := buf.Get(4); !ok || got != 17 {
		t.Fatalf("frame 4 = %d, %v; want 17, true", got, ok)
	}
}

func TestFrameBufferRejectsExpiredAndAhead(t *testing.T) {
	buf := NewFrameBuffer[uint64](3)
	for f := Frame(1); f <= 6; f++ {
		if err := buf.Put(f, uint64(f)*10); err != nil {
			t.Fatal(err)
		}
	}
	if _, err := buf.Rollback(1); !errors.Is(err, ErrFrameExpired) {
		t.Fatalf("want ErrFrameExpired, got %v", err)
	}
	if _, err := buf.Rollback(9); !errors.Is(err, ErrFrameAhead) {
		t.Fatalf("want ErrFrameAhead, got %v", err)
	}
	if _, ok := buf.Get(2); ok {
		t.Fatal("frame 2 should be evicted by capacity 3")
	}
	if got, ok := buf.Get(6); !ok || got != 60 {
		t.Fatalf("frame 6 = %d, %v; want 60, true", got, ok)
	}
	if err := buf.Put(6, 0); !errors.Is(err, ErrFrameStale) {
		t.Fatalf("want ErrFrameStale, got %v", err)
	}
	if err := buf.Put(8, 0); !errors.Is(err, ErrFrameStale) {
		t.Fatalf("gap write: want ErrFrameStale, got %v", err)
	}
}

回滚重放的测试不是比较“回滚后的状态等于第 5 帧”,而是比较“从第 5 帧重放 6 到 8 帧的结果,等于客户端最初预测的结果”。这才是回滚正确性的实际判据;只恢复起点而不重放,测试会通过,线上却会漂移。

九、运行测试

cd .local/checks/gs-practice-lab && gofmt -l ch08/lockstep && go vet ./ch08/lockstep/ && go test ./ch08/lockstep/ -count=1
ok  	gspractice/ch08/lockstep	0.371s

小结

帧号必须用差值比较来支持回绕;服务器帧是权威时间轴,客户端本地帧只服务预测和渲染。预测的正确流程是“本地先执行、收到权威帧后回滚到确认帧、重放其后的输入”,而不是只把位置改回去。FrameBuffer 用连续写入和容量淘汰守住状态窗口,过期与超前都显式报错,让上层决定走重放还是快照恢复。

常见错误与边界

  • 用 < 比较帧号,回绕后把新帧当旧帧,战斗静默卡死。
  • 回滚只恢复位置,不重放输入,冷却、Buff 和资源残留误差。
  • 把客户端本地帧写进存档或战斗结果,重连后时间轴漂移。
  • 状态窗口无限增长,客户端内存随战斗时长线性上涨。
  • 允许 Put 跳帧,回滚时拿到不连续状态,重放结果与权威不一致。
  • 把超过窗口的校正也交给逐帧重放,实际数据早已被淘汰。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「game」更多文章

  1. 《游戏服务端编程实践》附录 D:参考资料与社区索引
  2. 《游戏服务端编程实践》附录 C:示例项目目录结构
  3. 《游戏服务端编程实践》附录 B:性能调优 Checklist