一句话:网络包到达的时间忽快忽慢(这叫"抖动"),抖动缓冲区先把包攒一小会儿,再按固定节奏取出来播放或显示,用一点点额外延迟换来画面和声音的平稳。
一、生活里的例子:蓄水池
自来水管的水压忽大忽小,但你家洗澡的花洒需要稳定的水流。
办法:在中间放一个水桶。
进水(忽大忽小) → [ 水桶 ] → 出水(稳定)| 水桶的大小 | 效果 |
|---|---|
| 太小 | 进水一断,桶马上空了,花洒断流 |
| 太大 | 要等很久才装满,打开开关半天才出水 |
| 刚好 | 偶尔断水也能撑住,开水也不用等太久 |
| 蓄水池 | 网络 |
|---|---|
| 忽大忽小的进水 | 时快时慢到达的网络包 |
| 水桶 | 抖动缓冲区 |
| 稳定的出水 | 按固定节奏播放的声音 / 显示的画面 |
| 桶空了断流 | 缓冲区欠载:卡顿、声音断续 |
| 等桶装满 | 额外的延迟 |
二、什么是"抖动"?
先分清三个经常混在一起的概念:
| 概念 | 意思 | 例子 |
|---|---|---|
| 延迟(Latency) | 一个包从发出到收到要多久 | 平均 40ms |
| 抖动(Jitter) | 延迟变化有多大 | 这个包 30ms,下个包 80ms |
| 丢包(Packet Loss) | 包根本没到 | 100 个包丢了 2 个 |
延迟稳定在 100ms,比延迟在 20~150ms 之间乱跳更容易处理。前者只是"慢",后者是"乱"。
看一个具体的例子
服务器每20ms发一个包:
| 包编号 | 发送时间 | 路上花了 | 到达时间 |
|---|---|---|---|
| 0 | 0 | 30ms | 30 |
| 1 | 20 | 50ms | 70 |
| 2 | 40 | 25ms | 65(比 1 号先到!) |
| 3 | 60 | 45ms | 105 |
| 4 | 80 | 30ms | 110 |
发的时候是整齐的 20ms 一个,收的时候间隔变成了:35、-5、40、5……还乱了序。
如果收到一个就马上用一个:
- 声音:一会儿挤在一起、一会儿断掉,咔咔响
- 游戏画面:敌人一顿一顿地瞬移
三、抖动缓冲区怎么解决
核心思路:不按"到达时间"播放,而按"发送时间 + 固定延迟"播放。
上面的例子,设缓冲延迟 =60ms,第 n 个包在发送时间 + 60播放:
| 包编号 | 到达时间 | 计划播放时间 | 来得及吗? |
|---|---|---|---|
| 0 | 30 | 60 | ✅ 等 30ms |
| 1 | 70 | 80 | ✅ 等 10ms |
| 2 | 65 | 100 | ✅ 等 35ms(乱序也不怕,按编号排好) |
| 3 | 105 | 120 | ✅ 等 15ms |
| 4 | 110 | 140 | ✅ 等 30ms |
播放时间变成了 60、80、100、120、140 ——又恢复了整齐的 20ms 间隔。
到达: ──●────────●●─────────●──●──→ 乱 ↓ 进入缓冲区,按编号排序 播放: ──────●───●───●───●───●──→ 齐 └ 60ms ┘ 代价:所有东西晚 60ms 出现抖动缓冲区做的三件事:
| 事情 | 说明 |
|---|---|
| 排序 | 按编号/时间戳重新排好,解决乱序 |
| 等待 | 攒够一定的量再开始取 |
| 匀速取出 | 按固定节奏交给播放器或渲染 |
四、最关键的取舍:缓冲多大?
| 缓冲太小 | 缓冲太大 |
|---|---|
| 包晚到一点就来不及 →欠载(Underrun) | 所有东西都晚很久才出现 |
| 声音断续、角色卡顿、只能靠猜 | 语音对话时两人抢话,射击游戏手感"肉" |
一个简单的经验公式:
缓冲延迟 ≈ 发送间隔 + k × 抖动 (k 通常取 2~4)k 越大,越能扛住偶尔的大波动,但延迟越高。
| 应用 | 能接受的缓冲 | 原因 |
|---|---|---|
| 在线看视频 | 几秒到几十秒 | 不需要互动,晚点播没关系 |
| 语音通话 | 20~200ms | 太大就会互相抢话 |
| 竞技射击游戏 | 尽量小,通常 2~3 个快照间隔 | 每一毫秒都影响手感 |
看视频时的"缓冲中……",就是抖动缓冲区欠载了,正在重新蓄水。
五、固定缓冲 vs 自适应缓冲
固定缓冲
永远等同样长的时间。简单,但网络好时浪费延迟,网络差时又不够用。
自适应缓冲(实际产品几乎都用这种)
一边收包,一边估算当前抖动多大,动态调整缓冲长度。
抖动的估算方法来自 RTP 协议标准(RFC 3550),非常简单:
D = 这次包的路上时间 − 上次包的路上时间 J = J + (|D| − J) / 16每来一个包更新一次,/16让结果平滑,不会被单个异常值带偏。
这里有个巧妙之处:发送时间是对方的时钟,接收时间是自己的时钟,两台机器的时钟不一样。但算的是两次的差值,时钟偏差会被互相抵消,所以不需要两边对时。
调整时不能"跳"
缓冲区要从 60ms 缩到 40ms,不能一下子丢掉 20ms 的内容(声音会"咔"一下,角色会瞬移)。常用做法是悄悄改变播放速度:
| 缓冲区里的量 | 播放速度 | 效果 |
|---|---|---|
| 比目标多很多 | 1.05 倍 | 慢慢追上,减少延迟 |
| 接近目标 | 1.0 倍 | 正常 |
| 比目标少很多 | 0.95 倍 | 慢慢放,攒一点缓冲 |
语音里对应的技术叫变速不变调(如 WebRTC 的 NetEQ 会对语音做轻微拉伸或压缩),玩家几乎听不出来。
六、包丢了、或者晚到太久怎么办?
到了播放时间,包还没来:
| 场景 | 处理方式 |
|---|---|
| 语音 | 丢包隐藏(PLC):根据前一段声音"编"一小段相似的声音补上 |
| 视频 | 重复上一帧,或请求关键帧 |
| 游戏位置同步 | 外推(Extrapolation):按最后的速度继续往前猜一小段 |
之后那个包才姗姗来迟 →直接丢掉。已经过了它的时间,放了反而更乱。
七、在射击游戏里的两种抖动缓冲区
1. 客户端:快照插值缓冲(最常见)
服务器每秒发 20 次快照(每 50ms 一次),告诉你"敌人在哪"。客户端故意显示过去一点的时间,在两个快照之间平滑插值:
服务器快照: S1 S2 S3 S4 时间轴: ───────────┼─────────┼─────────┼─────────┼───→ 0ms 50ms 100ms 150ms ▲ ▲ 渲染的时刻 现在收到的最新时刻 (落后 100ms) 在 S2 和 S3 之间插值显示敌人位置为什么要落后2 个快照间隔(100ms)?这样丢一个快照也还有下一个可用,依然能插值,不必去猜。
经典的 Source 引擎(CS 系列)默认插值延迟就是 0.1 秒左右,原理正是这个。
2. 服务器端:输入缓冲
客户端每帧把"按了什么键"发给服务器。如果输入到晚了,服务器这一 tick 就没有输入可用,只能猜(比如沿用上一帧的输入),结果就是"我明明按了,角色却没反应"。
所以服务器会为每个玩家攒 1~2 个 tick 的输入再执行。网络好的玩家缓冲小,网络抖的玩家缓冲大,有的游戏还会让客户端稍微加快或放慢发送节奏来调整这个缓冲,原理和第五节的"变速"一样。
八、动手:Unity 里的简易快照缓冲
第 1 步:估算抖动
usingSystem;publicclassJitterEstimator{doublelastTransit;boolhasLast;publicdoubleJitter{get;privateset;}// 单位:秒// sendTime:包里带的服务器时间;recvTime:本地收到的时间publicvoidOnPacket(doublesendTime,doublerecvTime){doubletransit=recvTime-sendTime;// 两台机器时钟不同也没关系if(hasLast){doubled=Math.Abs(transit-lastTransit);Jitter+=(d-Jitter)/16.0;// RFC 3550 的平滑公式}lastTransit=transit;hasLast=true;}}第 2 步:缓冲 + 插值
usingSystem.Collections.Generic;usingUnityEngine;publicstructSnapshot{publicdoubletime;// 服务器时间publicVector3position;}publicclassSnapshotBuffer{readonlyList<Snapshot>buffer=newList<Snapshot>();constintMaxSize=32;publicvoidAdd(Snapshots){// 太旧的(早于已有最早的)直接丢;按时间插入,自动处理乱序if(buffer.Count>0&&s.time<=buffer[0].time)return;inti=buffer.Count;while(i>0&&buffer[i-1].time>s.time)i--;if(i>0&&buffer[i-1].time==s.time)return;// 重复包buffer.Insert(i,s);if(buffer.Count>MaxSize)buffer.RemoveAt(0);}// renderTime = 估算的服务器时间 − 缓冲延迟publicboolSample(doublerenderTime,outVector3pos){pos=default;if(buffer.Count==0)returnfalse;// 找到 renderTime 两侧的快照for(inti=0;i<buffer.Count-1;i++){Snapshota=buffer[i],b=buffer[i+1];if(renderTime>=a.time&&renderTime<=b.time){floatt=(float)((renderTime-a.time)/(b.time-a.time));pos=Vector3.Lerp(a.position,b.position,t);// 已经用不到的旧快照可以清掉(保留 a)if(i>0)buffer.RemoveRange(0,i);returntrue;}}// 走到这里:要么太早(缓冲还没攒够),要么欠载(新包没来)pos=renderTime<buffer[0].time?buffer[0].position:buffer[buffer.Count-1].position;// 简化:停在最后位置,实际项目可做有限外推returnfalse;}}第 3 步:自适应地决定缓冲延迟
publicclassRemotePlayer:MonoBehaviour{publicfloatsendInterval=0.05f;// 服务器 20HzreadonlySnapshotBufferbuffer=newSnapshotBuffer();readonlyJitterEstimatorjitter=newJitterEstimator();doubleServerTimeNow;// 由时钟同步模块提供(另一个话题)publicvoidOnSnapshotReceived(Snapshots){jitter.OnPacket(s.time,Time.timeAsDouble);buffer.Add(s);}voidUpdate(){// 缓冲延迟 = 发送间隔 × 2 + 3 倍抖动,并限制在合理范围doubledelay=sendInterval*2+3*jitter.Jitter;delay=System.Math.Clamp(delay,sendInterval,0.3);if(buffer.Sample(ServerTimeNow-delay,outVector3pos))transform.position=pos;}}这只是讲原理的最小版本。实际项目中Mirror、Netcode for GameObjects、Fish-Net等网络库都内置了类似的插值缓冲,通常只需要调几个参数;但懂原理之后,才知道"敌人一顿一顿"时该调哪个。
九、和前面几篇的联系
| 前面讲过的 | 关系 |
|---|---|
| 网关 / Wi-Fi | 抖动主要产生在这里:Wi-Fi 干扰、路由器排队、运营商拥塞 |
| 缓冲膨胀(Bufferbloat) | ⚠️ 名字像但不一样:那是路由器里缓冲太大造成延迟,是"病";抖动缓冲区是应用里主动设的,是"药" |
| 系统调用 | 每收一个包都要recvfrom进一次内核;包来得乱,并不影响你按固定节奏从缓冲区里取 |
| 对象池 | 快照结构体频繁创建,可用固定大小的环形数组代替List,避免 GC |
| 架构原则 | 缓冲、时钟同步、插值拆成独立模块,好测试、好调参 |
十、常见的坑
| 坑 | 现象 | 解决 |
|---|---|---|
| 收到就直接用,不缓冲 | 远程玩家一顿一顿地瞬移 | 加插值缓冲,落后 2 个快照间隔左右 |
| 缓冲设得太大 | 被打中时自己已经躲到墙后很久了 | 用自适应缓冲,网络好就缩小 |
| 用"到达时间"排序 | 乱序时角色往回跳 | 用包里的序号或服务器时间戳排序 |
| 晚到的旧包也拿去用 | 角色闪回过去的位置 | 早于当前渲染时间的包直接丢 |
| 调整缓冲时直接跳 | 声音"咔"一下、角色瞬移 | 用 0.95~1.05 倍速慢慢调 |
| 抖动估算没有平滑 | 缓冲大小来回乱跳 | 用/16这类平滑公式,并加上下限 |
| 外推太久 | 网络断一下,敌人穿墙跑出去很远 | 外推设上限(比如 200ms),超过就停住 |
| 把抖动当成延迟问题 | 优化了服务器位置,体验还是差 | 先测抖动;建议用网线、开启路由器 QoS |
| 只在本机测试 | 本地零抖动,一上线全是问题 | 用网络模拟工具加延迟、抖动、丢包(如 Unity Transport 的模拟器、clumsy) |
一句话总结
抖动 = 延迟的变化;抖动缓冲区 = 先攒一点,再按发送时的节奏匀速取出。
它做三件事:排序、等待、匀速取出;核心取舍是缓冲越大越平稳,但延迟越高。实际系统用自适应缓冲:持续估算抖动、动态调整缓冲长度,并通过轻微变速平滑过渡。
在射击游戏里,它的样子就是客户端快照插值和服务器输入缓冲。