《MMO 服务器开发》13.3 插值与平滑

逻辑层拿到权威状态了,渲染层还没完:远端玩家的位置是离散快照,直接画会一顿一顿地跳。本节给 aegis 的 predict 包补齐渲染侧的三件工具——用位置插值把快照之间补平、用速度外推兜住「快照还没到」的空档、用误差平滑把 13.2 的校正摊到多帧上,并给出各自的取舍与真跑测试。

13.3 插值与平滑

13.2 的和解把逻辑层的位置修正到了权威状态,但玩家的眼睛看的不是逻辑层,是渲染层。这一节处理的是最后一公里:怎么把离散、带延迟、偶尔被校正的权威状态,画成连续流畅的运动。

这里有三个独立的问题,容易混在一起:远端实体收到的是「每 50ms 一个位置点」,点与点之间怎么补?快照晚到时画面往哪走?服务端校正回来了,位置突然变了,怎么不跳帧地挪过去?这三个问题对应三种工具:插值、外推、平滑。

为什么远端实体要「活在过去」

先分清两类实体。本机玩家用 13.1 的预测,永远画在「当前 + 一步」的位置,因为它能立刻响应输入。远端玩家没有本地输入,它只有服务端周期性下发的快照。如果直接把最新快照的位置画出来,你会看到它每隔一个同步周期「瞬移」一小段——因为位置是阶梯式更新的。

标准解法是让远端实体慢半拍。渲染时不是画「最新快照」,而是画「当前时间减去一个插值延迟」那个时刻的位置。只要这个延迟大于一个同步周期,渲染时刻就一定落在两个已知快照之间,于是可以在这两点之间做线性插值,得到连续的轨迹。

用一张时间轴说明。设同步周期 50ms,插值延迟取 100ms:

服务端快照:      S0@t=0     S1@t=50    S2@t=100   S3@t=150
渲染时刻 t=120:  └──────────┴──────────┴────┐
                                    ┌───────┘ 落在 S2(100) 与 S3(150) 之间
                                    │
                          插值 = S2 + (S3 - S2) * (120-100)/50

渲染时刻 t=120 时,最新快照可能只到 S2(S3 还没到),但因为渲染用的是 100ms 前的时刻,它需要的数据(S2 和 S3)在本地都已经有了。延迟换来的是「永远有下一个点可插」。

代价是远端实体的位置比真实情况滞后一个插值延迟。对 MMO 来说这个代价可以接受:你看队友的位置本来就慢了一拍,多慢几十毫秒没人察觉。真正不能慢的是你自己的输入响应,那由预测解决。这两条线不要混。

插值延迟怎么定

插值延迟不是越大越安全,也不是越小越跟手,它要在两个失败模式之间取平衡:

  • 太小:最新快照还没到,渲染时刻越过了缓冲区里最后一个点,只能走外推,抖动明显。
  • 太大:远端实体明显滞后,近战里你砍到的是「它 200ms 前的位置」,判定和画面脱节。

经验取法是「一个同步周期 + 一点余量」,再按丢包率上调。同步周期 50ms 的 MMO,插值延迟通常落在 80~150ms。如果链路丢包严重,快照到达时间会忽早忽晚,缓冲区深度波动,这时应该按到达抖动自适应:测量最近 N 个快照的到达间隔方差,方差大就多加一点延迟,方差小就收回来。

一个常见误区是把插值延迟和「服务端 tick 间隔」混为一谈。两者独立:tick 间隔决定服务端多久算一次,插值延迟决定客户端画多久以前的状态。你可以 10Hz 的 tick 配 120ms 的插值延迟,也可以 30Hz 配 80ms。

位置插值 vs 速度外推

插值和外推是两种补点方式,方向相反:

位置插值速度外推
用到的数据两个已知快照一个快照 + 它的速度
渲染时刻落在两个快照之间落在最新快照之后
结果平滑且不会过冲平滑但会冲过头
何时用默认路径快照晚到、需要救急

插值永远不会错,因为它的两端都是已知事实;代价是要等——渲染时刻必须落后于最新快照。外推能补上「最新快照还没到」的空档,但它是在赌实体保持当前速度,一旦实体转向或停下,外推出来的位置就是错的,接着就要靠平滑把它拽回来。

所以工程上的取舍很明确:默认走插值,外推只作为少数几帧的兜底,并且要有时间上限。让外推跑太久,画面会明显「飘」。一个可操作的规则:外推最多跑 2~3 个 tick,超过就直接停在最后已知位置(宁可顿一下,也不要飘出去)。

从零实现:插值、外推与平滑

下面这段是 L1,go test 可以直接跑。三个函数分别对应上面三种工具:

// file: internal/predict/interpolate.go
// tier: L1
// verify: go test ./internal/predict/ -run 'TestInterpolate|TestExtrapolate|TestSmoothCorrection' -v

package predict

// Snapshot is one authoritative state sample tagged with the server tick it
// belongs to. Remote entities are rendered from a short buffer of these.
type Snapshot struct {
	Tick uint32
	Pos  Vec2
	Vel  Vec2
}

// Interpolate renders a remote entity at renderTick by lerping between the two
// snapshots that bracket it. Rendering in the past -- behind the newest sample,
// by the interpolation delay -- means there is always a "next" sample to move
// toward, so the entity glides instead of stepping.
func Interpolate(buf []Snapshot, renderTick float64) Vec2 {
	if len(buf) == 0 {
		return Vec2{}
	}
	if renderTick <= float64(buf[0].Tick) {
		return buf[0].Pos
	}
	last := buf[len(buf)-1]
	if renderTick >= float64(last.Tick) {
		return Extrapolate(last, renderTick-float64(last.Tick))
	}
	for i := 1; i < len(buf); i++ {
		a, b := buf[i-1], buf[i]
		if renderTick <= float64(b.Tick) {
			t := (renderTick - float64(a.Tick)) / float64(b.Tick-a.Tick)
			return lerp(a.Pos, b.Pos, t)
		}
	}
	return last.Pos
}

// Extrapolate projects a snapshot forward by dt ticks using its velocity. It is
// the fallback when the render clock has run past the newest sample: cheaper
// than a stall, but wrong whenever the entity changes direction, so it should
// be limited to a few ticks of lookahead.
func Extrapolate(s Snapshot, dt float64) Vec2 {
	return Vec2{X: s.Pos.X + s.Vel.X*dt, Y: s.Pos.Y + s.Vel.Y*dt}
}

// SmoothCorrection moves a rendered position toward the corrected one by at most
// alpha of the remaining gap per tick. Instead of snapping to the server's
// answer, the error is spread over several frames and decays geometrically.
func SmoothCorrection(current, target Vec2, alpha float64) Vec2 {
	if alpha <= 0 {
		return current
	}
	if alpha >= 1 {
		return target
	}
	return lerp(current, target, alpha)
}

func lerp(a, b Vec2, t float64) Vec2 {
	return Vec2{X: a.X + (b.X-a.X)*t, Y: a.Y + (b.Y-a.Y)*t}
}

Interpolate 里有三处 clamp,对应三种边界情况:缓冲区为空返回原点;渲染时刻早于最旧快照,返回最旧位置(不再往过去外推);渲染时刻晚于最新快照,交给 Extrapolate。中间的正常路径才是插值。把边界写全很重要——真实网络下丢包会让快照缓冲区忽长忽短,这三条分支每一条都会被触发。

SmoothCorrection 的 alpha 是每帧吃掉误差的比例。0.25 意味着每帧消掉剩余误差的四分之一:10 个单位的偏差,第一帧走 2.5、第二帧走 1.875……大约 20 帧后残差降到 0.01 以下。这个「几何衰减」比线性逼近自然,因为它开头快、结尾慢,视觉上像减速靠拢而不是匀速滑行。

真跑:插值、外推与平滑

三条断言各测一件事:插值取中点、外推接在最新快照之后、平滑能把误差衰减到接近零。

// snippet: internal/predict/interpolate_test.go(节选)

// TestInterpolateBrackets checks that a render clock inside the buffer lerps
// between the bracketing snapshots, and that a clock outside the buffer clamps
// to the nearest sample instead of inventing motion.
func TestInterpolateBrackets(t *testing.T) {
	buf := []Snapshot{
		{Tick: 100, Pos: Vec2{X: 0}},
		{Tick: 102, Pos: Vec2{X: 10}},
	}
	if got := Interpolate(buf, 101); got.X != 5 {
		t.Fatalf("midpoint = %v, want 5", got.X)
	}
	if got := Interpolate(buf, 100); got.X != 0 {
		t.Fatalf("on first sample = %v, want 0", got.X)
	}
	if got := Interpolate(buf, 99); got.X != 0 {
		t.Fatalf("before buffer = %v, want 0 (clamped)", got.X)
	}
}

// TestExtrapolatePastNewest checks the fallback path: once the render clock is
// ahead of the newest sample, velocity carries the entity forward.
func TestExtrapolatePastNewest(t *testing.T) {
	buf := []Snapshot{
		{Tick: 100, Pos: Vec2{X: 0}, Vel: Vec2{X: 5}},
		{Tick: 101, Pos: Vec2{X: 5}, Vel: Vec2{X: 5}},
	}
	if got := Interpolate(buf, 102); got.X != 10 {
		t.Fatalf("extrapolated = %v, want 10", got.X)
	}
}

// TestSmoothCorrectionFadesError checks that a correction is spread over frames
// and decays to (near) zero rather than snapping in one tick.
func TestSmoothCorrectionFadesError(t *testing.T) {
	cur := Vec2{X: 0}
	target := Vec2{X: 10}
	cur = SmoothCorrection(cur, target, 0.25)
	if cur.X != 2.5 {
		t.Fatalf("after one step = %v, want 2.5", cur.X)
	}
	for i := 0; i < 40; i++ {
		cur = SmoothCorrection(cur, target, 0.25)
	}
	if diff := target.X - cur.X; diff > 0.01 {
		t.Fatalf("residual = %v, want ~0", diff)
	}
}

本机实测:tick 101 落在 100 与 102 之间,插值返回 X=5,正好是中点;渲染时刻早于缓冲区时钳到最旧样本,不会凭空外推。外推部分,从 tick 101(位置 5、速度 5)外推到 tick 102,返回 X=10,是纯速度投影。平滑部分,alpha=0.25 时首帧把 10 个单位的误差压到 7.5(即前进 2.5),40 帧后残差小于 0.01。

一个实操建议:平滑只作用于渲染层,绝不回写逻辑层。逻辑层的位置必须是精确的权威状态,否则下一次和解会把你平滑了一半的位置当成「预测起点」,误差会滚雪球。渲染层和逻辑层要各存各的位置,中间用一条单行道连接:逻辑层的结果流向渲染层,反之不行。

渲染管线里的编排

三个工具在一个渲染帧里怎么配合,顺序很重要。下面是一条可以照抄的管线:

每渲染帧(本地 60Hz):
  1. 计算 renderTick = now - interpolationDelay      // 活在过去
  2. 远端实体: pos = Interpolate(snapshotBuf, renderTick)
  3. 本机玩家: pos = p.Pos                            // 预测位置,不过插值
  4. 若本帧收到和解: err = dist(rendered, p.Pos)
       err 小  -> rendered = SmoothCorrection(rendered, p.Pos, alpha)
       err 大  -> rendered = p.Pos                    // 瞬移,不平滑
  5. 用 rendered 画到屏幕

两条不变量值得写进注释:其一,本机玩家不插值——它已经有预测,再插值就是延迟叠加延迟;其二,平滑发生在渲染位置 rendered 上,不回写 p.Pos。p.Pos 永远是逻辑真相,rendered 只是它的「视觉化身」,允许短暂滞后。把这两者混成一个变量,是插值-预测实现里最常见的返工点。

还有一个容易忽略的细节:远端实体的插值缓冲区要有淘汰。快照不断到达,缓冲区不能无限增长。保留最近约「2 × 插值延迟」的量就够——更旧的快照渲染时刻永远用不到。淘汰按 tick 做,别按到达顺序,否则乱序到达的包会让旧快照被误删。

与第 14 章的接口

本节所有函数都用 Tick(服务端世界帧号)做时间基准,而不是墙钟。这是有意为后面留的接口:第 14 章要做的延迟补偿裁决,本质上是「把别人的历史状态回放到某个过去时刻再判定命中」,它需要的正是「按 tick 索引的快照缓冲」。本节写好的 []Snapshot + Interpolate 就是它的地基——延迟补偿在服务端用同一套插值,只是渲染时刻换成了「开枪那一刻的 tick」。

所以这里的设计不是巧合:渲染插值和延迟补偿共用一份快照缓冲区、一套插值函数,区别只在查询的 tick 由谁来定。客户端按自己的渲染时钟查,服务端按攻击者的延迟补偿 tick 查。把时间统一成 tick,两个场景就变成同一个函数的两次调用。

关键数字速查

量值来源
插值延迟(建议)一个同步周期 + 余量本节经验值
平滑 alpha0.2540 帧后残差 < 0.01
外推上限2~3 tick本节经验值
快照缓冲保留量约 2 × 插值延迟按 tick 淘汰

本节小结

  • 远端实体渲染要「活在过去」:用插值延迟换平滑,插值时刻永远落在两个已知快照之间。
  • 插值延迟取「一个同步周期 + 余量」,按到达抖动自适应;它和服务端 tick 间隔是两回事。
  • 插值不过冲、外推会过冲;默认插值,外推只作几帧的兜底并设上限。
  • 校正用几何衰减的平滑(alpha 每帧吃掉一部分误差),比线性逼近更自然。
  • 平滑只碰渲染层;逻辑层的位置必须保持权威精确,两者用单行道相连。
  • 快照缓冲区按 tick 淘汰,保留约「2 × 插值延迟」的量;按到达顺序淘汰会被乱序包坑到。

阅读导航:上一节:13.2 服务器回滚与和解 · 下一节:14.1 服务器时间线与时钟同步 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「mmo」更多文章

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