12.2 快照与增量编码
上一节确认了 MMO 走状态同步:服务端每帧算完全场,再把结果发给客户端。那么问题立刻变成发多少。最直白的做法是每帧把整个场景编码一遍发出去——正确,但贵得离谱;一个 500 实体的副本按 20 tick 跑,光是位置和速度就够把上行打满。
真实服务端都做增量:只发这一帧变了的实体、变了的字段。但增量引入了一个新问题——它依赖一份双方都记得的基线。客户端一旦丢了一帧,它的基线和服务器就对不上,后面的增量会把它越推越远。所以这一节有两件事:把增量编码写出来,把基线失配的恢复规则定下来。
脏标记:只把变了的字段算出来
增量的第一步不是编码,是判定什么变了。最省事也最危险的写法是「实体动过就整个重发」,但一个只掉了 1 点血的实体不该重发它的坐标。我们要的是字段级脏标记:逐字段比较,变了的位进 FieldMask。下面这段是 L1,可直接跑:
// file: internal/sync/snapshot.go
// tier: L1
// verify: go test ./internal/sync/ -run 'TestFullVersusDeltaBytes|TestBaselineMismatchFallsBackToFull' -v
package sync
// FieldMask marks which fields of an entity differ from the baseline.
type FieldMask uint8
const (
FieldPos FieldMask = 1 << iota
FieldVel
FieldHP
FieldKind
)
// Snapshot is the full authoritative view at a tick.
type Snapshot struct {
Tick uint32
Entities []EntityState
}
// Delta carries only the entities whose fields changed since Baseline.
type Delta struct {
Tick uint32
Baseline uint32
Entries []DeltaEntry
}
// DeltaEntry is one entity's changed fields.
type DeltaEntry struct {
ID uint32
Mask FieldMask
St EntityState
}
// Diff computes the delta between two full snapshots. A field is dirty when its
// wire bytes differ; nothing is inferred from "the entity moved a bit".
func Diff(base, cur Snapshot) Delta {
prev := make(map[uint32]EntityState, len(base.Entities))
for _, e := range base.Entities {
prev[e.ID] = e
}
d := Delta{Tick: cur.Tick, Baseline: base.Tick}
for _, e := range cur.Entities {
old, ok := prev[e.ID]
var mask FieldMask
if !ok {
mask = FieldPos | FieldVel | FieldHP | FieldKind // newly visible: full entry
} else {
if e.Pos != old.Pos {
mask |= FieldPos
}
if e.Vel != old.Vel {
mask |= FieldVel
}
if e.HP != old.HP {
mask |= FieldHP
}
if e.Kind != old.Kind {
mask |= FieldKind
}
}
if mask != 0 {
d.Entries = append(d.Entries, DeltaEntry{ID: e.ID, Mask: mask, St: e})
}
}
return d
}
两个决定值得点名。其一,用「字节是否相等」而不是「数值变化是否超过阈值」判脏。阈值方案看起来更省,但它引入了只有服务端知道的历史,客户端无法独立复现,一旦实现有偏差就是永久错位;字节相等是双方都能算的。其二,Delta 里带上了 Baseline 版本号——这是后面恢复规则的全部依据。
全量快照 vs 增量帧:同一段变更的字节对照
有了脏标记,编码就是把两种帧写到同一套帧头下面。两者共用第 3 章定下的 11 字节头(length(4) | cmd(2) | seq(4) | flags(1))与 CmdAttrSync 0x0103。下面这段是 L1:
// file: internal/sync/encode.go
// tier: L1
// verify: go test ./internal/sync/ -run 'TestFullVersusDeltaBytes|TestBaselineMismatchFallsBackToFull' -v
package sync
import (
"encoding/binary"
"math"
)
// CmdAttrSync is the world-module command used to push entity state, taken from
// the volume-wide protocol table (world module 0x01, command 0x03).
const CmdAttrSync uint16 = 0x0103
// HeaderSize is the shared frame header: length(4) | cmd(2) | seq(4) | flags(1).
const HeaderSize = 11
// putHeader writes the shared 11-byte frame header. n is the payload length.
func putHeader(dst []byte, cmd uint16, seq uint32, n int) {
binary.BigEndian.PutUint32(dst[0:], uint32(7+n))
binary.BigEndian.PutUint16(dst[4:], cmd)
binary.BigEndian.PutUint32(dst[6:], seq)
dst[10] = 0
}
// entityStateSize is the fixed cost of one entity in a full snapshot:
// id(4) + pos(8) + vel(8) + hp(2) + kind(1).
const entityStateSize = 23
// EncodeFull renders a full snapshot: header, tick, count, then every entity.
func EncodeFull(s Snapshot, seq uint32) []byte {
payload := 4 + 2 + len(s.Entities)*entityStateSize
buf := make([]byte, HeaderSize+payload)
putHeader(buf, CmdAttrSync, seq, payload)
off := HeaderSize
binary.BigEndian.PutUint32(buf[off:], s.Tick)
off += 4
binary.BigEndian.PutUint16(buf[off:], uint16(len(s.Entities)))
off += 2
for _, e := range s.Entities {
off = putEntity(buf, off, e)
}
return buf
}
func putEntity(dst []byte, off int, e EntityState) int {
binary.BigEndian.PutUint32(dst[off:], e.ID)
off += 4
binary.BigEndian.PutUint32(dst[off:], math.Float32bits(e.Pos.X))
off += 4
binary.BigEndian.PutUint32(dst[off:], math.Float32bits(e.Pos.Y))
off += 4
binary.BigEndian.PutUint32(dst[off:], math.Float32bits(e.Vel.X))
off += 4
binary.BigEndian.PutUint32(dst[off:], math.Float32bits(e.Vel.Y))
off += 4
binary.BigEndian.PutUint16(dst[off:], uint16(e.HP))
off += 2
dst[off] = e.Kind
off++
return off
}
// EncodeDelta renders only the changed fields of only the changed entities.
func EncodeDelta(d Delta, seq uint32) []byte {
payload := 4 + 4 + 2
for _, e := range d.Entries {
payload += 5 + fieldBytes(e.Mask)
}
buf := make([]byte, HeaderSize+payload)
putHeader(buf, CmdAttrSync, seq, payload)
off := HeaderSize
binary.BigEndian.PutUint32(buf[off:], d.Tick)
off += 4
binary.BigEndian.PutUint32(buf[off:], d.Baseline)
off += 4
binary.BigEndian.PutUint16(buf[off:], uint16(len(d.Entries)))
off += 2
for _, e := range d.Entries {
binary.BigEndian.PutUint32(buf[off:], e.ID)
off += 4
buf[off] = byte(e.Mask)
off++
if e.Mask&FieldPos != 0 {
binary.BigEndian.PutUint32(buf[off:], math.Float32bits(e.St.Pos.X))
off += 4
binary.BigEndian.PutUint32(buf[off:], math.Float32bits(e.St.Pos.Y))
off += 4
}
if e.Mask&FieldVel != 0 {
binary.BigEndian.PutUint32(buf[off:], math.Float32bits(e.St.Vel.X))
off += 4
binary.BigEndian.PutUint32(buf[off:], math.Float32bits(e.St.Vel.Y))
off += 4
}
if e.Mask&FieldHP != 0 {
binary.BigEndian.PutUint16(buf[off:], uint16(e.St.HP))
off += 2
}
if e.Mask&FieldKind != 0 {
buf[off] = e.St.Kind
off++
}
}
return buf
}
// fieldBytes is the payload size of one entity's dirty fields.
func fieldBytes(m FieldMask) int {
n := 0
if m&FieldPos != 0 {
n += 8
}
if m&FieldVel != 0 {
n += 8
}
if m&FieldHP != 0 {
n += 2
}
if m&FieldKind != 0 {
n++
}
return n
}
全量帧每个实体固定 23 字节,增量帧每个实体 5 字节头加脏字段。跑一段「每 tick 两个新实体开始移动、且不再停下」的变更序列,本机实测(Go 1.26,macOS arm64):
| 场景 | 全量总字节 | 增量总字节 | 倍率 |
|---|---|---|---|
| 500 实体 × 60 tick,每 tick 2 个新实体移动 | 691020 B | 2716 B | 254.4× |
增量确实省得夸张,但这个数字有个前提:它是同一段变更序列下的对照,不是「增量永远省 254 倍」。如果每帧全场都在动(大混战),增量会退化成接近全量;如果全场都静止,增量接近零而全量纹丝不动。真正的规律是——增量的成本正比于「每帧变化量」,全量的成本正比于「场景规模」。
基线失配:丢包之后怎么恢复
增量的代价就在这里。服务端发的是「相对基线 tick=N 的变化」,客户端必须手里正好有 tick=N 的那份快照才能还原。丢了一帧、重连、或者客户端 ack 落后了,基线就断了。
规则只有一条:基线对不上就发全量。实现上用一个只保留一帧的滑动窗口就够了(L1 节选):
// snippet: internal/sync/replicator.go(节选)
// FrameFor returns the next frame for one client and whether it was a delta.
// The second return value is the honest answer to "did this client pay full
// price this tick?" and is what the bandwidth budget in 12.3 counts.
func (r *Replicator) FrameFor(clientID uint32, seq uint32) ([]byte, bool) {
if !r.ready {
return nil, false
}
if r.acked[clientID] == r.prev.Tick {
return EncodeDelta(Diff(r.prev, r.cur), seq), true
}
return EncodeFull(r.cur, seq), false
}
r.prev 是上一帧快照,r.cur 是当前帧。客户端的 ack 落在 prev.Tick 上,说明它正好差一帧——发增量;否则一律发全量。本机实测这条规则的两侧:
| 客户端状态 | 收到的帧 | 大小 |
|---|---|---|
| 正好落后一 tick | 增量帧 | 34 B |
| 落后两 tick(丢了一帧) | 回退全量帧 | 11517 B |
回退一次的代价是一次全量,但它把「客户端永远追不上」变成了「客户端多花一帧」。这里有个工程取舍值得记住:窗口留一帧最省内存但最容易回退,留 N 帧能容忍更多丢包却要为每个客户端存 N 份快照。多数 MMO 选 1~2 帧,因为客户端真正需要重连时本来就会走全量流程,增量窗口只是覆盖偶发丢包。
本节小结:全量的成本正比于场景规模,增量的成本正比于每帧变化量,而增量的正确性押在一份双方共有的基线上。基线断了就回退全量——这是唯一需要背下来的规则。下一节我们把「发多少」这件事从单客户端放大到全场:兴趣过滤会把每帧的增量裁到只剩玩家看得见的那一小块。
阅读导航:上一节:12.1 状态同步与帧同步的服务端视角 · 下一节:12.3 兴趣过滤与带宽预算 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。