服务器高发送频率下Entity interpolation平滑性问题排查
我使用Unity和Riptide开发多人游戏,Riptide用于客户端/服务器间的数据包传输。当前逻辑是客户端发送自身位置到服务器,服务器转发给其他已连接客户端,目标是实现其他玩家位置状态的平滑过渡,暂未实现服务器权威机制。
客户端以固定频率向服务器发送位置,服务器以稍低频率向所有客户端同步玩家位置。目前发现:服务器使用低发送频率(如0.5秒)时,插值效果平滑;但测试高频率(如0.1秒)时,其他玩家会出现延迟、抖动、弹跳现象,移动时没有恒定加速度,不符合预期。
我参考了实体插值相关资料后,在客户端实现了如下插值代码:
// 收到服务器发来的位置数据包时调用 void UpdatePlayerPositionBuffer(int id, Vector2 newPosition) { Player player = null; ClientManager.Instance.OtherPlayers.TryGetValue(id, out player); if (player != null) { player.PositionBuffer.Add((Time.time, newPosition)); } } // Unity每帧调用,Time.time为总运行时间 void Update() { // 按照Valve推荐,插值延迟设为服务器发送频率的两倍 float renderingTime = Time.time - interpolationDelay; foreach (Player p in ClientManager.Instance.OtherPlayers.Values) { while (p.PositionBuffer.Count >= 2 && p.PositionBuffer[1].time <= renderingTime) { p.PositionBuffer.RemoveAt(0); } if (p.PositionBuffer.Count >= 2) { float t0 = p.PositionBuffer[0].time; float t1 = p.PositionBuffer[1].time; bool inbetween = t0 < renderingTime && t1 > renderingTime; if (!inbetween) { continue; } Vector2 p0 = p.PositionBuffer[0].position; Vector2 p1 = p.PositionBuffer[1].position; p.Position = Vector2.Lerp(p0, p1, (renderingTime - t0) / (t1 - t0)); spawner.GetPlayerObject(p.Id).transform.position = p.Position; } } } public class Player { public int Id; public Vector2 Position; public List<(float time, Vector2 position)> PositionBuffer = new(); public Player(int id, Vector2 position) { Id = id; Position = position; } }
演示视频中,红色方块为玩家,带白点的是本地玩家,可以看到本地玩家移动平滑,但其他玩家存在明显的延迟抖动。
问题原因及修复方案
1. 时间戳使用错误
当前代码用客户端本地的Time.time作为位置数据包的时间戳,但这是客户端接收数据包的时间,不是服务器发送该位置的时间,也不是原始客户端发送位置的时间。高频率下,网络波动导致的接收时间偏差会被放大,直接破坏插值的时间基准,引发抖动。
修复:服务器转发位置时,带上服务器发送该数据包的时间戳(或原始客户端发送位置时的时间戳,由服务器同步后转发),客户端用这个统一的时间戳存入PositionBuffer,而非本地接收时间。
2. 插值延迟设置不合理
Valve推荐的插值延迟是基于服务器tick rate的,并非简单的“双倍服务器发送频率”。当服务器发送频率提升到0.1秒(10Hz),双倍延迟为0.2秒,若网络延迟波动超过这个值,会导致renderingTime跑到缓冲队列的最新数据之后,无法触发插值,直接出现位置跳变。
修复:
- 动态调整插值延迟:基于最近几个数据包的网络延迟平均值,再额外添加50ms左右的缓冲
- 当缓冲队列中没有足够的插值点(比如
renderingTime超过最新的t1),不要直接跳过,而是用外推法预测位置:else if (p.PositionBuffer.Count == 1) { var latest = p.PositionBuffer[0]; float timeElapsed = renderingTime - latest.time; // 若能从服务器同步速度数据,这里用真实速度更准确 Vector2 velocity = (latest.position - p.Position) / Time.deltaTime; p.Position = latest.position + velocity * timeElapsed; spawner.GetPlayerObject(p.Id).transform.position = p.Position; }
3. 位置缓冲未做去重过滤
高频率发送时,服务器可能转发重复位置(比如玩家未移动),或因网络丢包重传导致重复数据。这些数据会让缓冲队列中出现时间相近但位置相同的点,插值时t1-t0趋近于0,导致Lerp权重异常,引发跳变。
修复:
- 添加过滤逻辑:新位置与缓冲队列最后一个位置的距离小于阈值(如0.01)时,不加入队列
- 检查时间戳有效性:确保新时间戳大于缓冲队列最后一个时间戳,避免添加旧数据:
if (player != null) { bool shouldAdd = player.PositionBuffer.Count == 0 || player.PositionBuffer.Last().time < serverTimestamp || Vector2.Distance(newPosition, player.PositionBuffer.Last().position) > 0.01f; if (shouldAdd) { player.PositionBuffer.Add((serverTimestamp, newPosition)); } }
4. 时间基准受帧率/时间缩放影响
Time.time会受Unity的Time.timeScale影响,且客户端帧率波动时,插值时间计算会出现偏差。改用Time.unscaledTime或Time.realtimeSinceStartup作为时间基准更可靠。
5. 线程安全问题
Riptide的数据包接收回调可能在主线程外执行,直接在回调中操作PositionBuffer会导致Update遍历队列时数据不一致,引发抖动。
修复:将接收的位置数据先存入线程安全队列,在Update中再从队列取出并更新PositionBuffer。
内容的提问来源于stack exchange,提问作者lomstfer

