《MMO 服务器开发》12.2 快照与增量编码

全量快照每帧都要把整个场景编码一遍,增量只发变了的字段——但增量一旦丢包就会和基线错位。本节从零写字段级脏标记与基线窗口,用同一段变更序列实测全量 691 KB 对增量 2.7 KB 的 254 倍差距,并给出基线失配后回退全量的恢复规则与触发条件。

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 B2716 B254.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 兴趣过滤与带宽预算 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「mmo」更多文章

  1. 《MMO 服务器开发》工程化与运营 · 目录
  2. 《MMO 服务器开发》9.3 上线检查清单
  3. 《MMO 服务器开发》9.2 性能预算达标