《MMO 服务器开发》14.2 延迟补偿裁决

「我屏幕上打中了,为什么没伤害」和「我躲进掩体了,为什么还被击中」是同一枚硬币的两面。本节实现延迟补偿裁决:为每个实体保存历史位置,按「服务端当前时间 − 半程 RTT − 插值延迟」把世界回退到射手当时看到的那一刻再做命中判定,并用确定性测试复现这两种不公。

14.2 延迟补偿裁决

上一节把「现在几点」和「客户端落后服务端多少」这两件事解决掉了,这一节把它们用在 MMO 里最经典、也最招骂的一个问题上:打得到底算不算打中。

先看两个玩家都会遇到的抱怨。射手说:「我屏幕上准星明明压在对面头上,开枪也生效了,为什么对面一点血没掉?」被射的人说:「我早就缩回掩体后面了,为什么还是被爆头?」这两句话听起来互相矛盾,其实描述的是同一件事的两面——双方看到的从来不是同一个世界。射手看到的,是被网络延迟和插值缓冲推迟过的、若干百毫秒之前的世界;服务端收到的开枪请求,也是在同一份延迟之后才到达的。

延迟补偿要做的,就是让服务端别按它自己的「现在」判,而按射手当时看到的「那一刻」判。听起来简单,难在三个地方:回退多久才公平、历史位置要留多长、以及补偿本身会制造一种反向不公。这一节先把判定写对,下一节再处理公平性的上限。

还有一个容易忽略的前提:延迟补偿只对**「射手视角的瞬时命中」**有意义。持续引导、范围伤害的判定中心、需要精确时序的技能,补偿的处理方式各不相同——有的按回退时刻取样,有的干脆用「宽容判定」直接放行。本节只处理最基础的单次瞬时命中,把机制写对;具体技能怎么套用,属于玩法卷的范围。

打得到底算不算打中

没有补偿的朴素做法是:服务端收到 CmdMoveReq 之外的射击命令时,直接用当前帧的目标位置做命中判定。它的失败方式很好推演。假设射手 RTT 100ms、客户端插值缓冲 50ms,那么射手屏幕上的一切都滞后服务端约 100/2 + 50 = 100ms。当他在自己屏幕上瞄准目标 A 并开枪,这一枪到达服务端时,目标 A 已经又往前跑了 100ms 的位置。如果 A 在横向移动,服务端用「现在」的位置判定,就会把这一枪算成打偏——射手看到的「命中」被判成「未命中」。这就是第一句抱怨的来源。

反过来,一个被瞄准的玩家 B,在自己屏幕上「已经躲进掩体」的动作,同样要滞后 100ms 才在服务端生效;而在服务端看到 B 进掩体之前,射手的枪已经到了。如果服务端回退到射手开枪那一刻,那时 B 确实还在掩体外面,于是这一枪算命中——这就是第二句抱怨的来源。

把这个错位量化一下。射手屏幕滞后 RTT/2 + 插值,服务端在收到射击时又额外过去了 RTT/2 的上行时间,于是「射手瞄准的位置」和「服务端当前目标位置」之间差了整整一个 RTT + 插值。以 100ms RTT、100ms 插值为例,错位是 200ms;一个 6 米/秒横向跑动的目标,200ms 就移动了 1.2 米——远超一个身位的命中半径。这不是可以忽略的抖动,而是能直接决定胜负的系统性偏差。

注意这里的 RTT 必须是服务端持续估计出来的,而不是客户端上报的。原因在 14.1 已经讲过:偏移估计器给出的 rtt 是「扣掉服务端处理时间后的纯网络往返」,而客户端自报的 RTT 既可以造假,也可能因为它自己测不准而偏小。用服务端侧的估计值,射手就无法通过谎报延迟来悄悄扩大自己的补偿窗口。

所以两条抱怨不是矛盾,而是同一个补偿决定的两端。服务端能选的只有「回退多少」,以及「怎么让两端的偏差都可接受」。业界主流选择是站在射手这一边:让「我打中了」尽量兑现,因为射手的瞄准动作是主动的、可感知的,而「我躲开了」的错觉可以由客户端侧的反馈层去缓解。回退量由三部分构成:

   回退时刻 = 服务端当前逻辑时间 − 射手RTT/2 − 客户端插值延迟

   ↑ 半程 RTT:射手看到的画面,是 RTT/2 之前从服务端发出的
   ↑ 插值延迟:客户端为了平滑,还会再往后拖一段渲染

回退窗口:从历史位置到命中判定

要回退,就得有历史位置。每个实体维护一个按逻辑时间索引的位置环,服务端每帧把当前位置推入环中。收到射击时,算出回退时刻,从目标的历史环里插值出那一刻的位置,再拿它和弹道做命中判定。

下面这段是 L1,go test 可以直接跑。它实现了位置历史环、回退时刻折算,以及一次命中判定。

// file: internal/clock/rewind/rewind.go
// tier: L1
// verify: go test ./internal/clock/rewind/

package rewind

import "time"

// Pos is a world position sampled on the server timeline.
type Pos struct {
	T    time.Duration
	X, Y float64
}

// History is a bounded ring of past positions for one entity, oldest first.
type History struct {
	buf []Pos
}

// NewHistory builds a history keeping at most capacity samples.
func NewHistory(capacity int) *History {
	if capacity < 1 {
		capacity = 1
	}
	return &History{buf: make([]Pos, 0, capacity)}
}

// Push appends a sample, evicting the oldest when full. Samples must be pushed
// in non-decreasing logical time.
func (h *History) Push(p Pos) {
	if len(h.buf) == cap(h.buf) {
		copy(h.buf, h.buf[1:])
		h.buf = h.buf[:len(h.buf)-1]
	}
	h.buf = append(h.buf, p)
}

// Len reports the number of retained samples.
func (h *History) Len() int { return len(h.buf) }

// At returns the position interpolated at logical time t. Times before the
// oldest or after the newest sample are clamped to the nearest sample.
func (h *History) At(t time.Duration) (Pos, bool) {
	n := len(h.buf)
	if n == 0 {
		return Pos{}, false
	}
	if t <= h.buf[0].T {
		return h.buf[0], true
	}
	if t >= h.buf[n-1].T {
		return h.buf[n-1], true
	}
	for i := 1; i < n; i++ {
		if h.buf[i].T >= t {
			a, b := h.buf[i-1], h.buf[i]
			span := b.T - a.T
			if span <= 0 {
				return b, true
			}
			f := float64(t-a.T) / float64(span)
			return Pos{T: t, X: a.X + (b.X-a.X)*f, Y: a.Y + (b.Y-a.Y)*f}, true
		}
	}
	return h.buf[n-1], true
}

// RewindTo computes the logical time the shooter's screen showed when it fired:
// the server now, minus half the shooter's round-trip (the uplink half of the
// view delay), minus the client's interpolation buffer.
func RewindTo(serverNow, rtt, interp time.Duration) time.Duration {
	t := serverNow - rtt/2 - interp
	if t < 0 {
		return 0
	}
	return t
}

// HitTest reports whether a shot aimed at (ax,ay) with radius r connects with
// the target sampled at rewound logical time t. It returns the sampled position
// so callers can log the adjudication.
func HitTest(h *History, t time.Duration, ax, ay, r float64) (bool, Pos) {
	p, ok := h.At(t)
	if !ok {
		return false, Pos{}
	}
	dx, dy := p.X-ax, p.Y-ay
	return dx*dx+dy*dy <= r*r, p
}

三个设计点值得单独说。第一,历史环是定长的:容量乘以帧间隔就是能回退的最大时长,超出部分会被 Push 挤掉,At 只会夹到最老样本上——这既是内存上限,也顺带成了天然的补偿上限(下一节会用到)。第二,At 在两端夹取而不是返回错误,是为了让「回退到比历史还早」这种越界不至于直接判空、把枪吞掉。第三,RewindTo 里 rtt/2 取的是半程:射手看到的画面是 RTT/2 之前从服务端发出的,而不是一整个 RTT 之前。

HitTest 用的是最简化的圆-点判定:目标是一个半径 r 的圆,弹道是一个点。真实项目里更常见的是射线 vs 胶囊 / 包围盒,还要考虑爆头区、护甲、穿透。但这些都属于几何细节,和延迟补偿要解决的时间维度正交——无论命中形状怎么换,回退时刻的算法完全一样,把判定函数替换成自己的即可。

回退窗口该留多大

历史环的长度决定了「最多能回退多久」,这个值要同时满足两个相反的要求:够长,才能覆盖正常玩家的 RTT/2 + 插值延迟;够短,才不会让一个 500ms 的高延迟玩家把整个世界拖回半秒前,也不会让内存随玩家数线性膨胀。

先算下界。回退量 = RTT/2 + 插值延迟,插值延迟通常等于两个快照间隔(客户端要等下一个快照才能插值),在 20Hz 快照下约 100ms:

玩家 RTTRTT/2插值延迟需要回退
40ms(同城)20ms100ms120ms
100ms(同区)50ms100ms150ms
200ms(跨区)100ms100ms200ms

按 30Hz 服务端 tick(约 33ms 一帧)算,覆盖 200ms 需要 7 帧、约 8 个样本。留一点余量取 12–16 个样本,对应 400–530ms 的可回退范围。前面代码里 NewHistory(64) 给了 64 的容量,不是每个样本都要用满,而是给「夹取」留足余量,避免一次网络抖动把历史环挤空、让本该命中的枪直接落空。

历史环的采样频率也是一个选择。按服务端 tick(30Hz)每帧推一次,插值最平滑,代价是内存和写入翻倍;按快照频率(20Hz)推,和客户端看到的一致,两帧之间全靠插值补。aegis 取前者——按 tick 推、按需插值:采样密一点,At 的插值误差就小一点,代价只是每帧多一次定长数组的写入。

但上界不能由环容量决定,得由策略决定。一个 400ms RTT 的玩家如果被完整补偿,意味着他每开一枪都要把世界拖回 200ms 前,受害者的体感极差。工程做法是把两件事分开:历史环留够(保证「有历史可查」),补偿量另设上限(保证「查得不越界」)。环容量负责前者,上限负责后者——这条界线就是下一节 14.3 的主线。

内存也要一起算。一个 Pos 是 8 字节时间 + 16 字节坐标 = 24 字节,64 个样本约 1.5KB/实体;一万个实体约 15MB,可以接受。但把环开到 1024 就是 240MB,直接出局。历史环长度是内存预算,不是精度旋钮。

为什么不干脆「放大判定盒」

既然回退会带来反向不公,另一个思路是不回退,只把判定放宽:服务端按当前时刻判,但把命中半径放大到足以覆盖 200ms 的错位。这样实现更简单,也不用存历史。

它的代价是判定失去精度。放大判定盒意味着瞄准边缘、擦身而过的枪也会算中,近距离对枪变成「谁先开枪谁赢」,精确瞄准的价值被抹平。更要命的是这个放大对所有人生效:低延迟玩家本来瞄得很准,却要为高延迟玩家的错位买单,等于把不公平从高延迟一侧转移到了低延迟一侧。

所以主流选择仍然是回退 + 上限:回退保住低延迟玩家的瞄准精度,上限把高延迟玩家的优势压在一个可控范围内。这个「上限」具体设多少、怎么设,就是下一节的全部内容。

双向不公:确定性测试

补偿的代价是它把不公平从射手身上转移到了目标身上。下面这张 ASCII 把两种情形叠在同一条时间线上:

   服务端逻辑时间 →  400        500        600 (now)
   靶子位置 X =      40         50         60
                     ↑          ↑
              回退到这一刻    服务端当前时刻
             (射手看到的)   (不补偿时的判定点)

   射手瞄准 X=50:
     回退到 500ms 判 → 命中(靶子在 50)     ← 补偿兑现「我打中了」
     按 600ms 判     → 落空(靶子已到 60)   ← 不补偿的「打中不算」

   受害者视角反过来:
     600ms 躲进掩体 → 回退到 500ms 时它还在外面 → 仍被判中

下面这段 L1 测试用一个匀速移动的靶子,把两种不公都钉在确定的数字上:

// snippet: internal/clock/rewind/rewind_test.go(节选)

// buildTarget is a deterministic moving target: it walks +10 units per 100ms
// along X. Same input, same numbers, every run.
func buildTarget() *History {
	h := NewHistory(64)
	for i := 0; i <= 10; i++ {
		t := time.Duration(i) * 100 * time.Millisecond
		h.Push(Pos{T: t, X: float64(i) * 10})
	}
	return h
}

func TestHitAtRewoundTimeButMissAtNow(t *testing.T) {
	h := buildTarget()
	const r = 6.0

	// The shooter's screen lagged 100ms behind the server; it fired at X=50,
	// which is where the target stood 100ms ago (t=500ms).
	rewound := RewindTo(600*time.Millisecond, 100*time.Millisecond, 50*time.Millisecond)
	hit, p := HitTest(h, rewound, 50, 0, r)
	if !hit {
		t.Fatalf("rewound shot missed; target at %v", p)
	}

	// Judged at server-now the target has already walked on, so the same shot
	// misses. This is the "I hit but got no damage" complaint, reproduced.
	nowHit, np := HitTest(h, 600*time.Millisecond, 50, 0, r)
	if nowHit {
		t.Fatalf("shot judged at server-now should miss; target at %v", np)
	}
}

靶子每 100ms 沿 X 走 10 个单位,射手 RTT 100ms、插值 50ms,于是 RewindTo(600ms, 100ms, 50ms) = 500ms——回退到靶子还站在 X=50 的时刻,这一枪命中;而按服务端当前时刻 600ms 判,靶子已经走到 X=60,同一枪落空。同一个输入、同一次开枪,只因「按哪个时刻判」就得到相反的结论。

把测试反过来读,就是「掩体」那句抱怨:如果一个玩家在 t=600ms 才躲到安全位置,而射手的回退时刻是 500ms,那么服务端按 500ms 判定,仍会判中——在受害者眼里,他「明明已经躲开了」。这就是补偿的反向不公,它不是 bug,而是「站在射手一边」这个选择的必然结果。要缓解它,只有两条路:给补偿设上限(下一节),或者用客户端反馈层让受害者「感觉」这一枪来得合理。

服务端怎么知道射手的 rtt 和 interp?rtt 来自 14.1 的偏移估计器持续维护的样本(取一个平滑后的值,避免单次抖动带偏);interp 是客户端上报的插值缓冲时长,服务端只做范围校验后采信。注意 interp 是唯一一个「由客户端说了算」的延迟参数,所以下一节会专门防它被滥用——一个恶意客户端只要谎报一个很大的 interp,就能把自己的回退窗口拉得很长。

工程上还会给每次裁决留一条回放日志:射击序号、回退时刻、命中的目标、以及「按当前时刻判定会得到什么结果」。这条日志有两个用处——客诉时能解释「为什么这一枪算中」,调参时能统计「补偿把多少原本落空的枪变成了命中」。如果这个比例高得离谱,说明回退窗口设得太宽,正是 14.3 要收紧的信号。

本节小结

  • 两条经典抱怨是同一个补偿决定的两端:不补偿则射手「打中不算」,补偿则目标「躲开还被击中」。
  • 回退时刻 = 服务端当前时间 − 射手RTT/2 − 插值延迟;RTT/2 是因为画面滞后半个往返。
  • 历史位置环是定长的:容量 × 帧间隔 = 可回退的最长时间,越界由 At 夹取兜底。
  • 回退窗口的下界由 RTT/2 + 插值 决定(跨区约 200ms),上界必须由策略另设,不能交给环容量。
  • 命中形状(圆 / 胶囊 / 射线)与回退算法正交,换判定函数不影响时间维度。
  • 补偿会把不公平从射手转移到目标,这不是缺陷,而是选边的代价,只能靠上限与反馈缓解。

下一节把「上限」写清楚:补偿给多少才算够,以及怎么识别「平时很快、开枪瞬间掉包」的延迟作弊。

阅读导航:上一节:14.1 服务器时间线与时钟同步 · 下一节:14.3 高延迟玩家的公平性 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「mmo」更多文章

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