一、同步频率:服务器 tick 与客户端插值
位置同步的第一条规则是“服务器按固定 tick 发,客户端按到达时间插值”。服务器不能跟着渲染帧走,也不能每收到一次输入就发一次位置;频率不稳定会让客户端插值忽快忽慢。
| 服务器 tick | 间隔 | 常见用途 | 取舍 |
|---|---|---|---|
| 10Hz | 100ms | 大世界、SLG、低对抗 | 带宽低,快速转向明显滞后 |
| 20Hz | 50ms | MMORPG、MOBA | 通用折中,插值平滑 |
| 30Hz | 33ms | 射击、动作、强同步 | 包量高,弱网更容易抖动 |
客户端收到位置后不要直接瞬移,而是保留一小段缓冲,在旧位置和新位置之间插值;缓冲长度至少覆盖两个 tick 间隔。服务器侧则用固定步长推进,即使某一帧耗时变长,也要按固定 dt 补算,避免不同机器上速度不一致。
“实时”不等于“每一帧都发”。同格内的小幅移动可以按阈值降频,真正需要低延迟的是 enter/leave、技能和命中判定;位置更新只需要让客户端有足够的数据做平滑表现。
示例值:20Hz 下每个客户端每秒最多收到 20 次位置更新,周边 20 个实体就是 400 条实体更新每秒;如果每个实体每帧都发,包量还会乘以字段大小。先合并再差分,通常能把大部分静止实体过滤掉。
插值缓冲不是越大越好:缓冲越长,画面越平滑,但输入到表现的延迟越高。20Hz 下常见做法是保留 100ms 左右缓冲,并允许短时间外推;外推超过阈值就停下,避免角色穿墙后才被拉回。
二、位置消息的字段
一条位置更新至少需要实体 ID、坐标、朝向、速度和时间基准。缺少时间戳时,客户端无法判断包是否过期,弱网下旧包晚到会把角色拉回过去。
| 字段 | 类型 | 作用 | 量化建议 |
|---|---|---|---|
ID | EntityID | 定位实体 | 不做量化 |
X / Y | int32 | 世界坐标 | 1/16 单位定点 |
Dir | uint16 | 朝向弧度 | 65535 等分一圈 |
Speed | uint16 | 当前速度 | 1/100 单位每秒 |
Tick | uint64 | 服务器帧号 | 单调递增 |
字段顺序固定、整数对齐,解析端就不需要反射或按名字查找。示例值: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 或时间戳,旧包晚到导致角色回拉。
- 直接采信客户端坐标,瞬移和加速无法从根上挡住。
- 差分对象选成上一帧服务器状态,微小移动永远触发发送。
- 只发位置不发朝向和速度,客户端插值时角色朝向突变。
- 实体离开视野后不清理差分基线,重回视野时漏发完整状态。
- 量化溢出不做截断,极端坐标回绕成相反方向。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。