《游戏服务端编程实践》7.3.3 实时位置信息同步

位置同步不需要每帧全量广播。本篇讨论服务器 10/20/30Hz 的 tick 选择、客户端插值、位置包的字段设计、定点量化与差分发送,并给出一个按实体比较上次已发送值、阈值内不发包的 Encoder 实现与往返精度、方向变化、帧内去重测试。

一、同步频率:服务器 tick 与客户端插值

位置同步的第一条规则是“服务器按固定 tick 发,客户端按到达时间插值”。服务器不能跟着渲染帧走,也不能每收到一次输入就发一次位置;频率不稳定会让客户端插值忽快忽慢。

服务器 tick间隔常见用途取舍
10Hz100ms大世界、SLG、低对抗带宽低,快速转向明显滞后
20Hz50msMMORPG、MOBA通用折中,插值平滑
30Hz33ms射击、动作、强同步包量高,弱网更容易抖动

客户端收到位置后不要直接瞬移,而是保留一小段缓冲,在旧位置和新位置之间插值;缓冲长度至少覆盖两个 tick 间隔。服务器侧则用固定步长推进,即使某一帧耗时变长,也要按固定 dt 补算,避免不同机器上速度不一致。

“实时”不等于“每一帧都发”。同格内的小幅移动可以按阈值降频,真正需要低延迟的是 enter/leave、技能和命中判定;位置更新只需要让客户端有足够的数据做平滑表现。

示例值:20Hz 下每个客户端每秒最多收到 20 次位置更新,周边 20 个实体就是 400 条实体更新每秒;如果每个实体每帧都发,包量还会乘以字段大小。先合并再差分,通常能把大部分静止实体过滤掉。

插值缓冲不是越大越好:缓冲越长,画面越平滑,但输入到表现的延迟越高。20Hz 下常见做法是保留 100ms 左右缓冲,并允许短时间外推;外推超过阈值就停下,避免角色穿墙后才被拉回。

二、位置消息的字段

一条位置更新至少需要实体 ID、坐标、朝向、速度和时间基准。缺少时间戳时,客户端无法判断包是否过期,弱网下旧包晚到会把角色拉回过去。

字段类型作用量化建议
IDEntityID定位实体不做量化
X / Yint32世界坐标1/16 单位定点
Diruint16朝向弧度65535 等分一圈
Speeduint16当前速度1/100 单位每秒
Tickuint64服务器帧号单调递增

字段顺序固定、整数对齐,解析端就不需要反射或按名字查找。示例值:1/16 单位的坐标精度意味着 16 单位长的位移最多 256 个刻度,对角色移动足够;如果地图尺度达到数万单位,仍可用 int32 覆盖。

协议还需要版本号或字段长度前缀。位置包一旦上线,字段顺序、量纲和 tick 语义就不能悄悄改;服务端灰度时同时兼容两个版本,比事后猜客户端为什么把速度解析成朝向便宜得多。

位置包只携带表现需要的字段,不要顺手塞背包、属性或任务状态。不同数据有不同的可靠性和频率要求,混在一个包里会让位置更新被大字段拖慢,也会让丢包重传放大无关数据。

三、量化与差分

浮点全精度传输有三个问题:包更大、不同平台的舍入结果可能不同、NaN/Inf 需要额外校验。定点量化把坐标乘以比例后四舍五入成整数,解码时再除回去,往返误差不超过半个刻度。

数据量化方式最大往返误差边界
坐标round(x * 16)1/32 单位超出 int32 时截断
朝向round(dir / 2π * 65535)π/65535 弧度先归一化到 [0, 2π)
速度round(speed * 100)1/200 单位每秒负数归零,上限 655.35

差分的比较对象是“上次已经发送的值”,不是“上一帧的服务器状态”。如果拿上一帧状态比较,微小移动每帧都被判定为变化,阈值永远挡不住;只有和上次发送值比较,累积位移才会在超过阈值时触发一次更新。

量化误差不会跨帧累积:每次编码都基于绝对坐标重新量化。但如果客户端把量化后的值当成新的权威状态再上报,误差就会滚雪球;服务端必须始终保存未量化的权威坐标,量化只发生在出站路径。

四、延迟补偿与作弊边界

服务端必须决定自己接收什么:客户端上报“位置”,还是上报“输入/速度”。直接采信位置等于把移动权威交给客户端,改包就能瞬移、穿墙、加速。

输入模型服务端职责抗作弊代价
客户端上报位置校验距离、速度、阻挡弱,只能做事后检测实现简单,弱网补偿容易
客户端上报输入/速度服务端推进位置强,位置始终由服务端算需要输入缓冲和回滚
混合客户端预表现,服务端校验关键段中等两套逻辑要保持一致

失败场景:服务端直接采用客户端坐标,攻击者把包里的 X 改成 +1000,下一次广播就把角色送到地图另一端。即使加了“每帧最大位移”校验,攻击者仍可按上限逐步加速。正确做法是服务端根据速度和输入推进位置,客户端位置只用于校正表现;命中判定需要延迟补偿时,回滚的是服务端历史状态,而不是相信客户端的历史坐标。

回滚窗口要覆盖合理的最大 RTT,而不是无限保存历史。示例值:保留最近 1 秒、每 50ms 一个快照,约 20 份状态;超出窗口的命中请求直接按当前状态判定,并记录异常比例。窗口越大,作弊者能利用的历史越多,内存也越高。

五、增量、压缩与丢包

只发差量能省带宽,但差量依赖基线。UDP 丢一个 update,客户端就停在旧位置,直到下一次变化或关键帧把它拉回来。因此位置同步通常要配周期性关键帧,或者把拓扑事件放到可靠通道,把连续量放到不可靠通道。

机制作用代价与边界
增量 update只发变化实体丢包后基线漂移
周期性关键帧重建完整可见状态包大,需要控制频率
位掩码/批量编码一包携带多个实体实体集合大时掩码膨胀
可靠与不可靠分通道拓扑可靠、位置低延迟两套顺序语义要协调
frame(tick, entities):
    updates = []
    for entity in sort_by_id(entities):
        q = quantize(entity)
        if last[entity.id] missing or changed(last, q):
            q.tick = tick
            updates.append(q)
            last[entity.id] = q
    if tick % keyframeInterval == 0:
        return full_snapshot(entities)
    return updates

关键帧不是“每隔几秒全量广播”那么简单:它必须和增量共享同一个基线 tick。例如每 60 tick 一次关键帧,其余 tick 发增量;客户端收到关键帧后重置基线,然后丢弃早于基线的增量。基线不统一,关键帧和增量之间就会出现重复或空洞。

压缩要先做量化再做通用压缩。位置包字段少、重复多,量化后的整数适合 varint 或位打包;不要对已经很短的包盲目套 zlib,头部开销可能大于收益。丢包处理上,leave 丢失会让客户端留下幽灵实体,因此拓扑事件要可靠传输或在后续关键帧中纠正;update 丢失只影响平滑度,下一次变化会自然补齐。

六、Go 实现

Encoder 把实体量化成 WireUpdate,与上次已发送值比较;坐标差、朝向差、速度差都低于阈值时返回 false。EncodeFrame 按实体 ID 排序,保证同一输入产出稳定;Forget 用于实体离开视野后清理差分基线。

// file: ch07/map/sync.go
// tier: L1
// verify: go test ./ch07/map/

package worldmap

import (
	"math"
	"sort"
)

const (
	DefaultPosScale   = 16.0
	DefaultSpeedScale = 100.0
)

// WireUpdate 是位置同步的最小增量包,坐标为定点整数。
type WireUpdate struct {
	ID    EntityID
	X     int32
	Y     int32
	Dir   uint16
	Speed uint16
	Tick  uint64
}

type Encoder struct {
	posScale   float64
	speedScale float64
	moveEps    float64
	dirEps     float64
	speedEps   float64
	last       map[EntityID]WireUpdate
}

func NewEncoder() *Encoder {
	return &Encoder{
		posScale:   DefaultPosScale,
		speedScale: DefaultSpeedScale,
		moveEps:    0.05,
		dirEps:     math.Pi / 90, // 2 度
		speedEps:   0.05,
		last:       make(map[EntityID]WireUpdate),
	}
}

func (e *Encoder) Quantize(ent Entity) WireUpdate {
	return WireUpdate{
		ID:    ent.ID,
		X:     quantize(ent.Pos.X, e.posScale),
		Y:     quantize(ent.Pos.Y, e.posScale),
		Dir:   quantizeDir(ent.Dir),
		Speed: quantizeSpeed(ent.Speed, e.speedScale),
	}
}

func (e *Encoder) Decode(u WireUpdate) Entity {
	return Entity{
		ID:    u.ID,
		Pos:   Vec2{X: float64(u.X) / e.posScale, Y: float64(u.Y) / e.posScale},
		Dir:   decodeDir(u.Dir),
		Speed: float64(u.Speed) / e.speedScale,
	}
}

// Encode 与上一帧已发送值比较,阈值内不返回更新。
func (e *Encoder) Encode(tick uint64, ent Entity) (WireUpdate, bool) {
	cur := e.Quantize(ent)
	cur.Tick = tick
	prev, ok := e.last[ent.ID]
	if !ok {
		e.last[ent.ID] = cur
		return cur, true
	}
	if !e.changed(prev, cur) {
		return WireUpdate{}, false
	}
	e.last[ent.ID] = cur
	return cur, true
}

// EncodeFrame 对一批实体按 ID 排序后编码,保证同输入产出稳定。
func (e *Encoder) EncodeFrame(tick uint64, entities []Entity) []WireUpdate {
	ordered := append([]Entity(nil), entities...)
	sort.Slice(ordered, func(i, j int) bool { return ordered[i].ID < ordered[j].ID })
	out := make([]WireUpdate, 0, len(ordered))
	for _, ent := range ordered {
		if u, ok := e.Encode(tick, ent); ok {
			out = append(out, u)
		}
	}
	return out
}

func (e *Encoder) Forget(id EntityID) {
	delete(e.last, id)
}

func (e *Encoder) changed(prev, cur WireUpdate) bool {
	if math.Abs(float64(cur.X-prev.X))/e.posScale >= e.moveEps {
		return true
	}
	if math.Abs(float64(cur.Y-prev.Y))/e.posScale >= e.moveEps {
		return true
	}
	if dirDelta(prev.Dir, cur.Dir) >= e.dirEps {
		return true
	}
	ds := math.Abs(float64(int32(cur.Speed)-int32(prev.Speed))) / e.speedScale
	return ds >= e.speedEps
}

func quantize(v, scale float64) int32 {
	if math.IsNaN(v) || math.IsInf(v, 0) {
		return 0
	}
	q := math.Round(v * scale)
	if q > math.MaxInt32 {
		return math.MaxInt32
	}
	if q < math.MinInt32 {
		return math.MinInt32
	}
	return int32(q)
}

func quantizeSpeed(speed, scale float64) uint16 {
	if math.IsNaN(speed) || math.IsInf(speed, 0) || speed < 0 {
		speed = 0
	}
	q := math.Round(speed * scale)
	if q > math.MaxUint16 {
		return math.MaxUint16
	}
	return uint16(q)
}

func normalizeDir(dir float64) float64 {
	if math.IsNaN(dir) || math.IsInf(dir, 0) {
		return 0
	}
	dir = math.Mod(dir, 2*math.Pi)
	if dir < 0 {
		dir += 2 * math.Pi
	}
	return dir
}

func quantizeDir(dir float64) uint16 {
	return uint16(math.Round(normalizeDir(dir) / (2 * math.Pi) * 65535))
}

func decodeDir(q uint16) float64 {
	return float64(q) / 65535 * 2 * math.Pi
}

func dirDelta(a, b uint16) float64 {
	d := int32(a) - int32(b)
	if d < 0 {
		d = -d
	}
	if d > 32767 {
		d = 65535 - d
	}
	return float64(d) / 65535 * 2 * math.Pi
}

测试在 sync_test.go 中:坐标、朝向、速度的量化往返误差不超过半个刻度;首次更新必发;0.01 的小位移被抑制;0.2 的位移会发送;朝向变化超过 2 度会发送;速度变化会发送;NaN/Inf 被归零而不是产生未定义的整数;同一批实体按 ID 稳定排序;Forget 后重新发送完整值。

七、运行测试

cd .local/checks/gs-practice-lab && gofmt -l ch07/map && go vet ./ch07/map/ && go test ./ch07/map/ -count=1 -run 'TestQuantize|TestEncoder|TestEncodeFrame'
ok  	gspractice/ch07/map	0.347s

小结

位置同步的关键是固定 tick、字段稳定、定点量化、与上次发送值做差分。服务器推进权威位置,客户端负责插值表现;阈值只抑制连续量,enter/leave 与关键战斗事件不能被降频。把量化误差、时间戳和作弊边界写进协议与测试,弱网和改包才不会把同步层变成猜谜。

常见错误与边界

  • 用 float64 全精度传输坐标,包体大且跨平台舍入不一致。
  • 位置包没有 tick 或时间戳,旧包晚到导致角色回拉。
  • 直接采信客户端坐标,瞬移和加速无法从根上挡住。
  • 差分对象选成上一帧服务器状态,微小移动永远触发发送。
  • 只发位置不发朝向和速度,客户端插值时角色朝向突变。
  • 实体离开视野后不清理差分基线,重回视野时漏发完整状态。
  • 量化溢出不做截断,极端坐标回绕成相反方向。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「game」更多文章

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