Skip to content

Commit 687e92d

Browse files
committed
feat(net): 优化高延迟下的多人交互体验
- Break/Place 协议升级至 v4,携带 input_tick 和点击时玩家位置 - Remote 挖放增加本地乐观更新,拒绝时沿用 rollback 还原 - 服务端按点击时位置校验挖放,减少高 RTT 下的误拒 - 平滑 Host 时钟同步,并为远端玩家插值增加短外推 - 更新协议、预测和模块文档
1 parent 864f2eb commit 687e92d

13 files changed

Lines changed: 283 additions & 67 deletions

File tree

crates/client/src/app.rs

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -311,9 +311,11 @@ pub struct Game {
311311
pub input_history: InputHistory,
312312
/// 中等位置误差的剩余软修正量。每个渲染帧应用一小段,避免高 RTT 下画面回弹。
313313
pub pending_position_correction: Vec3,
314-
/// Host 时钟与本地时钟的瞬态偏移(ms):server_time_ms - local_now_ms。
315-
/// PlayerTick 每帧覆盖;远端的 rendering target 用。
316-
pub server_clock_offset_ms: i64,
314+
/// Host 时钟与本地时钟的平滑偏移(ms):server_time_ms - local_now_ms。
315+
/// Pong 按半 RTT 估算,PlayerTick 提供低权重补充样本;远端插值 target 使用它。
316+
pub server_clock_offset_ms: f64,
317+
/// 是否已经吃过至少一条 Host 时钟样本。第一条样本直接采用,后续再指数平滑。
318+
pub server_clock_synced: bool,
317319
/// Phase 8:Local/Host 的 OPFS 存储句柄。Remote 不写存档。
318320
pub storage: Option<OpfsStorage>,
319321
pub known_persisted: HashSet<voxweb_core::ChunkPos>,
@@ -455,7 +457,8 @@ impl Game {
455457
local_input_tick: 0,
456458
input_history: InputHistory::new(120),
457459
pending_position_correction: Vec3::ZERO,
458-
server_clock_offset_ms: 0,
460+
server_clock_offset_ms: 0.0,
461+
server_clock_synced: false,
459462
storage: None,
460463
known_persisted: HashSet::new(),
461464
last_persist_ms: 0.0,

crates/client/src/interp.rs

Lines changed: 49 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,20 @@
44
//! 每渲染帧以 `server_time_ms - interp_delay` 作为渲染 target,
55
//! 找出 bracket `[a.time, b.time]` 后 lerp position / yaw / pitch。
66
//!
7-
//! Phase 5 不做外推(若 render_time 超出最新快照直接返回最新);
8-
//! Phase 7 可加 50ms 外推窗口。
7+
//! Phase 8 起在缺少未来快照时允许最多 50ms 短外推,降低丢一两帧时的冻结感。
98
109
use std::collections::HashMap;
1110

1211
use glam::Vec3;
1312

1413
use voxweb_core::protocol::EntityId;
1514

15+
/// 没有未来快照时允许的短外推窗口。超过该窗口仍停在外推 50ms 的位置,
16+
/// 避免丢包时远端玩家立刻冻结,同时避免长时间猜测导致穿墙。
17+
const MAX_EXTRAPOLATE_MS: f64 = 50.0;
18+
/// 相邻快照距离超过该阈值视为传送或大纠偏,不做插值/外推。
19+
const TELEPORT_SNAP_DISTANCE_M: f32 = 10.0;
20+
1621
/// 单条远端玩家快照。由 `apply_server_message::PlayerTick` 推入。
1722
#[derive(Copy, Clone, Debug)]
1823
struct Sample {
@@ -54,7 +59,7 @@ impl RemoteBuffer {
5459

5560
/// 给定渲染 target(server 时间 ms),返回插值后的 (position, yaw, pitch)。
5661
/// - target 早于最早样本 → 返回最早
57-
/// - target 晚于最新样本 → 返回最新(不外推
62+
/// - target 晚于最新样本 → 按最近速度短外推(最多 50ms
5863
/// - 在 [a, b] 之间 → lerp
5964
fn get(&self, render_server_time_ms: f64) -> Option<(Vec3, f32, f32)> {
6065
if self.buf.is_empty() {
@@ -74,11 +79,13 @@ impl RemoteBuffer {
7479
let s = &self.buf[0];
7580
Some((s.position, s.yaw, s.pitch))
7681
} else if idx >= self.buf.len() {
77-
let s = &self.buf[self.buf.len() - 1];
78-
Some((s.position, s.yaw, s.pitch))
82+
Some(self.latest_or_extrapolated(render_server_time_ms))
7983
} else {
8084
let a = &self.buf[idx - 1];
8185
let b = &self.buf[idx];
86+
if a.position.distance(b.position) > TELEPORT_SNAP_DISTANCE_M {
87+
return Some((b.position, b.yaw, b.pitch));
88+
}
8289
let denom = (b.server_time_ms - a.server_time_ms) as f64;
8390
if denom <= 0.0 {
8491
return Some((a.position, a.yaw, a.pitch));
@@ -92,6 +99,29 @@ impl RemoteBuffer {
9299
Some((position, yaw, pitch))
93100
}
94101
}
102+
103+
fn latest_or_extrapolated(&self, render_server_time_ms: f64) -> (Vec3, f32, f32) {
104+
let latest = &self.buf[self.buf.len() - 1];
105+
if self.buf.len() < 2 {
106+
return (latest.position, latest.yaw, latest.pitch);
107+
}
108+
let prev = &self.buf[self.buf.len() - 2];
109+
if prev.server_time_ms >= latest.server_time_ms
110+
|| prev.position.distance(latest.position) > TELEPORT_SNAP_DISTANCE_M
111+
{
112+
return (latest.position, latest.yaw, latest.pitch);
113+
}
114+
115+
let sample_dt_s = (latest.server_time_ms - prev.server_time_ms) as f32 * 0.001;
116+
if sample_dt_s <= f32::EPSILON {
117+
return (latest.position, latest.yaw, latest.pitch);
118+
}
119+
let extrapolate_ms =
120+
(render_server_time_ms - latest.server_time_ms as f64).clamp(0.0, MAX_EXTRAPOLATE_MS);
121+
let velocity = (latest.position - prev.position) / sample_dt_s;
122+
let position = latest.position + velocity * (extrapolate_ms as f32 * 0.001);
123+
(position, latest.yaw, latest.pitch)
124+
}
95125
}
96126

97127
/// 整个房间的远端玩家插值状态。
@@ -238,14 +268,25 @@ mod tests {
238268
}
239269

240270
#[test]
241-
fn interp_clamps_to_latest_when_render_time_after_all() {
271+
fn interp_short_extrapolates_when_render_time_after_latest() {
242272
let mut interp = PlayerInterp::new();
243273
interp.delay_ms = 0.0;
244274
interp.ingest_tick(1, 1000, Vec3::new(0.0, 64.0, 0.0), 0.5, 0.0);
245275
interp.ingest_tick(1, 2000, Vec3::new(10.0, 64.0, 10.0), 1.0, 0.0);
246-
// render 3000 > 最新 2000 → 返回最新
276+
// render 3000 > 最新 2000 → 只短外推 50ms,速度为 10m/s
247277
let (pos, yaw, _) = interp.advance(1, 3000.0).expect("should return latest");
248-
assert!((pos - Vec3::new(10.0, 64.0, 10.0)).length() < 0.01);
278+
assert!((pos - Vec3::new(10.5, 64.0, 10.5)).length() < 0.01);
249279
assert!((yaw - 1.0).abs() < 0.01);
250280
}
281+
282+
#[test]
283+
fn interp_does_not_extrapolate_teleports() {
284+
let mut interp = PlayerInterp::new();
285+
interp.delay_ms = 0.0;
286+
interp.ingest_tick(1, 1000, Vec3::ZERO, 0.0, 0.0);
287+
interp.ingest_tick(1, 2000, Vec3::new(20.0, 64.0, 0.0), 0.0, 0.0);
288+
289+
let (pos, _, _) = interp.advance(1, 2050.0).expect("should return latest");
290+
assert!((pos - Vec3::new(20.0, 64.0, 0.0)).length() < 0.01);
291+
}
251292
}

crates/client/src/lib.rs

Lines changed: 67 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,11 @@ const MAX_REACH: f32 = 6.0;
5858
/// Ping 间隔(毫秒)。
5959
const PING_INTERVAL_MS: f64 = 5000.0;
6060

61+
/// Pong 校时样本权重。Pong 带 RTT 信息,可信度高于裸 PlayerTick。
62+
const CLOCK_PONG_ALPHA: f64 = 0.2;
63+
/// PlayerTick 校时样本权重。它可能受单向排队延迟影响,只作为低权重补充。
64+
const CLOCK_TICK_ALPHA: f64 = 0.05;
65+
6166
/// 信令服务 URL meta tag 名称。
6267
const SIGNALING_META_NAME: &str = "signaling-url";
6368

@@ -1846,7 +1851,10 @@ fn render_game_frame(
18461851
return Ok(());
18471852
};
18481853
let now = now_ms();
1849-
if game.entity_id != 0 && now - game.last_ping_sent_ms >= PING_INTERVAL_MS {
1854+
if game.mode != GameMode::Local
1855+
&& game.entity_id != 0
1856+
&& now - game.last_ping_sent_ms >= PING_INTERVAL_MS
1857+
{
18501858
let client_time_ms = now as u64;
18511859
game.pending_pings.insert(client_time_ms, now);
18521860
// 上限 16 个待办,避免长期不通时无限增长
@@ -2114,7 +2122,7 @@ fn render_game_frame(
21142122
let mut view_proj_for_np = glam::Mat4::IDENTITY;
21152123
if let Some(g) = a.game.as_mut() {
21162124
view_proj_for_np = g.camera.vp_matrix();
2117-
let render_target = now_local + g.server_clock_offset_ms as f64 - g.interp.delay_ms;
2125+
let render_target = now_local + g.server_clock_offset_ms - g.interp.delay_ms;
21182126
let cam_pos = g.camera.position;
21192127
let eids: Vec<EntityId> = g.interp.ids().collect();
21202128
for eid in eids {
@@ -2322,8 +2330,7 @@ fn render_game_frame(
23222330
let now = now_ms();
23232331
let mut instances: Vec<voxweb_render::passes::player::PlayerInstance> = Vec::new();
23242332
if let Some(ref mut game) = a.game {
2325-
let render_server_time =
2326-
now + game.server_clock_offset_ms as f64 - game.interp.delay_ms;
2333+
let render_server_time = now + game.server_clock_offset_ms - game.interp.delay_ms;
23272334
let eids: Vec<voxweb_core::protocol::EntityId> = game.interp.ids().collect();
23282335
for eid in eids {
23292336
if let Some((pos, _yaw, _pitch)) = game.interp.advance(eid, render_server_time)
@@ -2509,9 +2516,16 @@ fn dispatch_actions(game: &mut Game, input: &InputState) {
25092516
let server = game.server.borrow();
25102517
server.world.get_block(pos)
25112518
};
2512-
// Local 模式 client 和 server 共享同一份 world,乐观更新会干扰 server 校验
2513-
// (server 读回 AIR 误判 BlockNotEmpty 拒绝)。因此跳过乐观 set_block;
2514-
// BlockUpdate 返回后再重 mesh。Phase 5 Remote 端加独立 WorldView 时再加乐观路径。
2519+
let input_tick = game.local_input_tick;
2520+
let player_position = game.physics.feet_position;
2521+
// Remote 的 server.world 只是本地世界视图,可以安全乐观修改;
2522+
// Local/Host 与权威 server 共享同一份 world,仍等 BlockUpdate,避免提前改世界干扰校验。
2523+
if game.mode == GameMode::Remote {
2524+
game.server.borrow_mut().world.set_block(pos, BlockID::AIR);
2525+
for cp in affected_chunks(pos) {
2526+
game.mesh_jobs.enqueue(cp, MeshPriority::High);
2527+
}
2528+
}
25152529
let request_id = game.pending.next_request_id();
25162530
game.pending.insert(
25172531
request_id,
@@ -2521,8 +2535,12 @@ fn dispatch_actions(game: &mut Game, input: &InputState) {
25212535
backup,
25222536
},
25232537
);
2524-
game.net
2525-
.send_client_message(ClientMessage::Break { pos, request_id });
2538+
game.net.send_client_message(ClientMessage::Break {
2539+
pos,
2540+
request_id,
2541+
input_tick,
2542+
player_position,
2543+
});
25262544
game.last_break_at_ms = now;
25272545
}
25282546

@@ -2551,6 +2569,8 @@ fn dispatch_actions(game: &mut Game, input: &InputState) {
25512569
return;
25522570
}
25532571
let request_id = game.pending.next_request_id();
2572+
let input_tick = game.local_input_tick;
2573+
let player_position = game.physics.feet_position;
25542574
game.pending.insert(
25552575
request_id,
25562576
PendingAction {
@@ -2559,10 +2579,18 @@ fn dispatch_actions(game: &mut Game, input: &InputState) {
25592579
backup,
25602580
},
25612581
);
2582+
if game.mode == GameMode::Remote {
2583+
game.server.borrow_mut().world.set_block(neighbor, block);
2584+
for cp in affected_chunks(neighbor) {
2585+
game.mesh_jobs.enqueue(cp, MeshPriority::High);
2586+
}
2587+
}
25622588
game.net.send_client_message(ClientMessage::Place {
25632589
pos: neighbor,
25642590
block,
25652591
request_id,
2592+
input_tick,
2593+
player_position,
25662594
});
25672595
}
25682596
}
@@ -2581,6 +2609,21 @@ fn send_chat(game: &mut Game, content: String) {
25812609
.send_client_message(ClientMessage::Chat { content });
25822610
}
25832611

2612+
/// 摄入一条 Host 时钟偏移样本。
2613+
///
2614+
/// 高延迟网络下,直接用最新 `server_time_ms - now` 覆盖偏移会让远端玩家插值 target
2615+
/// 来回抖动。这里第一条样本直接采用,后续做指数平滑;Pong 样本权重较高,
2616+
/// PlayerTick 样本只做低权重微调。
2617+
fn ingest_server_clock_sample(game: &mut Game, estimated_offset_ms: f64, alpha: f64) {
2618+
if !game.server_clock_synced {
2619+
game.server_clock_offset_ms = estimated_offset_ms;
2620+
game.server_clock_synced = true;
2621+
return;
2622+
}
2623+
let a = alpha.clamp(0.0, 1.0);
2624+
game.server_clock_offset_ms = game.server_clock_offset_ms * (1.0 - a) + estimated_offset_ms * a;
2625+
}
2626+
25842627
fn apply_server_message(game: &mut Game, msg: ServerMessage) {
25852628
match msg {
25862629
ServerMessage::Welcome {
@@ -2678,8 +2721,12 @@ fn apply_server_message(game: &mut Game, msg: ServerMessage) {
26782721
players,
26792722
server_time_ms,
26802723
} => {
2681-
let now = (now_ms()) as i64;
2682-
game.server_clock_offset_ms = server_time_ms as i64 - now;
2724+
let now = now_ms();
2725+
if game.mode != GameMode::Local {
2726+
let one_way_ms = game.rtt_ms.map(|rtt| rtt as f64 * 0.5).unwrap_or(0.0);
2727+
let estimated_offset = server_time_ms as f64 + one_way_ms - now;
2728+
ingest_server_clock_sample(game, estimated_offset, CLOCK_TICK_ALPHA);
2729+
}
26832730

26842731
for snap in &players {
26852732
if snap.entity_id == game.entity_id {
@@ -2750,11 +2797,17 @@ fn apply_server_message(game: &mut Game, msg: ServerMessage) {
27502797
}
27512798
ServerMessage::Pong {
27522799
client_time_ms,
2753-
server_time_ms: _,
2800+
server_time_ms,
27542801
} => {
27552802
if let Some(sent_ms) = game.pending_pings.remove(&client_time_ms) {
2756-
let rtt = (now_ms() - sent_ms) as f32;
2757-
game.rtt_ms = Some(rtt);
2803+
let now = now_ms();
2804+
let rtt = (now - sent_ms) as f32;
2805+
game.rtt_ms = Some(match game.rtt_ms {
2806+
Some(prev) => prev * 0.8 + rtt * 0.2,
2807+
None => rtt,
2808+
});
2809+
let estimated_offset = server_time_ms as f64 + (rtt as f64 * 0.5) - now;
2810+
ingest_server_clock_sample(game, estimated_offset, CLOCK_PONG_ALPHA);
27582811
}
27592812
}
27602813
}

crates/core/src/protocol.rs

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,9 @@ use crate::chunk::{ChunkPos, Position};
1212
/// 协议版本号。Hello.version 与之不一致时 Host 拒绝接入。
1313
/// 任何破坏性消息字段变更必须递增此版本。
1414
///
15-
/// v3(Phase 8):PlayerSnapshot 携带 last_input_tick
16-
/// 让客户端按同一条本地输入记录做预测协调,避免高延迟时被旧位置回声拉回
17-
pub const PROTOCOL_VERSION: u32 = 3;
15+
/// v4(Phase 8):Break/Place 携带点击时 input_tick 与 player_position
16+
/// 让 Host 在高延迟下按玩家实际点击时的位置做挖放范围校验
17+
pub const PROTOCOL_VERSION: u32 = 4;
1818

1919
/// ChunkSnapshot 单片 payload 上限(字节)。
2020
/// 浏览器 SCTP 用户消息上限约 16 KB;保守留 14 KB,剩余给 frag_index/frag_total/bincode header。
@@ -65,12 +65,24 @@ pub enum ClientMessage {
6565
pitch: f32,
6666
},
6767
/// 挖掘方块请求
68-
Break { pos: Position, request_id: u32 },
68+
Break {
69+
pos: Position,
70+
request_id: u32,
71+
/// 玩家点击时的本地输入序号,用于让服务端把操作与预测历史对齐。
72+
input_tick: u32,
73+
/// 玩家点击时的脚底位置。可靠操作包可能先于最新 PlayerInput 到达,
74+
/// 服务端用它做范围校验,避免高 RTT 下误判 OutOfRange。
75+
player_position: Vec3,
76+
},
6977
/// 放置方块请求
7078
Place {
7179
pos: Position,
7280
block: BlockID,
7381
request_id: u32,
82+
/// 玩家点击时的本地输入序号。
83+
input_tick: u32,
84+
/// 玩家点击时的脚底位置,用于范围与玩家 AABB 重叠校验。
85+
player_position: Vec3,
7486
},
7587
/// 文本聊天
7688
Chat { content: String },
@@ -238,6 +250,8 @@ mod tests {
238250
roundtrip(&ClientMessage::Break {
239251
pos: Position::new(10, 64, -5),
240252
request_id: 42,
253+
input_tick: 9,
254+
player_position: Vec3::new(8.0, 65.0, 8.0),
241255
});
242256
}
243257

@@ -247,6 +261,8 @@ mod tests {
247261
pos: Position::new(10, 65, -5),
248262
block: BlockID::STONE,
249263
request_id: 43,
264+
input_tick: 10,
265+
player_position: Vec3::new(8.0, 65.0, 8.0),
250266
});
251267
}
252268

@@ -287,9 +303,9 @@ mod tests {
287303
}
288304

289305
#[test]
290-
fn protocol_version_is_three() {
291-
// Phase 8 起为 3;任何破坏性变更应同步更新此测试与版本号。
292-
assert_eq!(PROTOCOL_VERSION, 3);
306+
fn protocol_version_is_four() {
307+
// Phase 8 高延迟挖放优化为 v4;任何破坏性变更应同步更新此测试与版本号。
308+
assert_eq!(PROTOCOL_VERSION, 4);
293309
}
294310

295311
#[test]

crates/net/src/transport.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,8 @@ mod tests {
9696
channel_for_client_message(&ClientMessage::Break {
9797
pos: Position::new(0, 0, 0),
9898
request_id: 0,
99+
input_tick: 0,
100+
player_position: Vec3::ZERO,
99101
}),
100102
ChannelKind::Reliable
101103
);
@@ -150,6 +152,8 @@ mod tests {
150152
pos: Position::new(1, 2, 3),
151153
block: BlockID::STONE,
152154
request_id: 7,
155+
input_tick: 8,
156+
player_position: Vec3::new(1.0, 64.0, 1.0),
153157
};
154158
let bytes = encode_client_message(&msg).unwrap();
155159
let back = decode_client_message(&bytes).unwrap();

0 commit comments

Comments
 (0)