Skip to content

Commit dfc3311

Browse files
authored
Merge pull request #13 from NOVIIC/feat/highping
fix: 优化高延迟下游玩体验
2 parents 3f62d20 + 687e92d commit dfc3311

14 files changed

Lines changed: 448 additions & 123 deletions

File tree

crates/client/src/app.rs

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -305,11 +305,17 @@ pub struct Game {
305305
pub interp: PlayerInterp,
306306
/// Chunk 快照接收组装器(Remote 端用,Host/Local 闲置)。
307307
pub chunk_assembler: ChunkAssembler,
308+
/// 本地生成的 PlayerInput 序号。Remote 模式也独立递增,不能借用本地 dummy server tick。
309+
pub local_input_tick: u32,
308310
/// 本地位置预测的输入历史(60Hz 推入,PlayerTick reconcile 时修剪)。
309311
pub input_history: InputHistory,
310-
/// Host 时钟与本地时钟的瞬态偏移(ms):server_time_ms - local_now_ms。
311-
/// PlayerTick 每帧覆盖;远端的 rendering target 用。
312-
pub server_clock_offset_ms: i64,
312+
/// 中等位置误差的剩余软修正量。每个渲染帧应用一小段,避免高 RTT 下画面回弹。
313+
pub pending_position_correction: Vec3,
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,
313319
/// Phase 8:Local/Host 的 OPFS 存储句柄。Remote 不写存档。
314320
pub storage: Option<OpfsStorage>,
315321
pub known_persisted: HashSet<voxweb_core::ChunkPos>,
@@ -448,8 +454,11 @@ impl Game {
448454
remote_players: HashMap::new(),
449455
interp,
450456
chunk_assembler: ChunkAssembler::new(),
457+
local_input_tick: 0,
451458
input_history: InputHistory::new(120),
452-
server_clock_offset_ms: 0,
459+
pending_position_correction: Vec3::ZERO,
460+
server_clock_offset_ms: 0.0,
461+
server_clock_synced: false,
453462
storage: None,
454463
known_persisted: HashSet::new(),
455464
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: 94 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,9 @@ use crate::camera::CameraMode;
4242
use crate::chunk_loader::affected_chunks;
4343
use crate::input::InputState;
4444
use crate::mesh_jobs::{MeshPriority, MeshRunStats};
45-
use crate::prediction::{PendingAction, PendingKind, reconcile_self};
45+
use crate::prediction::{
46+
PendingAction, PendingKind, ReconcileResult, apply_pending_position_correction, reconcile_self,
47+
};
4648
use crate::raycast::raycast;
4749
use crate::storage::{OpfsStorage, WorldStorage};
4850
use crate::ui::lobby::{
@@ -56,6 +58,11 @@ const MAX_REACH: f32 = 6.0;
5658
/// Ping 间隔(毫秒)。
5759
const PING_INTERVAL_MS: f64 = 5000.0;
5860

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

@@ -1844,7 +1851,10 @@ fn render_game_frame(
18441851
return Ok(());
18451852
};
18461853
let now = now_ms();
1847-
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+
{
18481858
let client_time_ms = now as u64;
18491859
game.pending_pings.insert(client_time_ms, now);
18501860
// 上限 16 个待办,避免长期不通时无限增长
@@ -1934,30 +1944,34 @@ fn render_game_frame(
19341944
game.physics.step(&getter, &game.camera, &neutral, dt);
19351945
}
19361946
}
1947+
apply_pending_position_correction(
1948+
&mut game.physics,
1949+
&mut game.pending_position_correction,
1950+
dt,
1951+
);
19371952
game.camera.position = game.physics.eye_position();
19381953

19391954
// 60Hz 逻辑帧
19401955
game.frame_clock.accumulate(dt);
19411956
let mut steps_consumed: u32 = 0;
19421957
let server_tick_allowed = matches!(game.mode, GameMode::Local | GameMode::Host);
1958+
let mut last_input_tick_to_send = None;
19431959
while game.frame_clock.consume_logic_step() {
19441960
if server_tick_allowed {
19451961
game.server.borrow_mut().tick();
19461962
}
1947-
// 每个逻辑步推一条 input history(Host reconcile 用;Remote 也可靠它追踪本地步数)
1963+
// 每个逻辑步分配本地输入序号。Remote 不能借 dummy server tick,
1964+
// 否则 Host 回播时无法把权威位置和同一条预测记录对齐。
1965+
game.local_input_tick = game.local_input_tick.wrapping_add(1).max(1);
19481966
game.input_history
1949-
.push(game.server.borrow().tick, game.physics.feet_position);
1967+
.push(game.local_input_tick, game.physics.feet_position);
1968+
last_input_tick_to_send = Some(game.local_input_tick);
19501969
steps_consumed += 1;
19511970
}
19521971

1953-
// 每个 logic step 上报一条 PlayerInput
1972+
// 若本帧消费了一个或多个逻辑步,只上报最新位置;tick 仍对应最后一个本地输入序号。
19541973
if steps_consumed > 0 && game.entity_id != 0 {
1955-
let tick = if server_tick_allowed {
1956-
game.server.borrow().tick
1957-
} else {
1958-
// Remote:用自己的 input history 计数(从 physics 最后一次 reconcile 后的步数推导)
1959-
0 // Phase 5 简化:Remote 的 PlayerInput.tick 用 0;Host Server 不依赖 Remote 的 tick 做排序
1960-
};
1974+
let tick = last_input_tick_to_send.unwrap_or(game.local_input_tick);
19611975
game.net.send_client_message(ClientMessage::PlayerInput {
19621976
tick,
19631977
position: game.physics.feet_position,
@@ -2108,7 +2122,7 @@ fn render_game_frame(
21082122
let mut view_proj_for_np = glam::Mat4::IDENTITY;
21092123
if let Some(g) = a.game.as_mut() {
21102124
view_proj_for_np = g.camera.vp_matrix();
2111-
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;
21122126
let cam_pos = g.camera.position;
21132127
let eids: Vec<EntityId> = g.interp.ids().collect();
21142128
for eid in eids {
@@ -2316,8 +2330,7 @@ fn render_game_frame(
23162330
let now = now_ms();
23172331
let mut instances: Vec<voxweb_render::passes::player::PlayerInstance> = Vec::new();
23182332
if let Some(ref mut game) = a.game {
2319-
let render_server_time =
2320-
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;
23212334
let eids: Vec<voxweb_core::protocol::EntityId> = game.interp.ids().collect();
23222335
for eid in eids {
23232336
if let Some((pos, _yaw, _pitch)) = game.interp.advance(eid, render_server_time)
@@ -2503,9 +2516,16 @@ fn dispatch_actions(game: &mut Game, input: &InputState) {
25032516
let server = game.server.borrow();
25042517
server.world.get_block(pos)
25052518
};
2506-
// Local 模式 client 和 server 共享同一份 world,乐观更新会干扰 server 校验
2507-
// (server 读回 AIR 误判 BlockNotEmpty 拒绝)。因此跳过乐观 set_block;
2508-
// 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+
}
25092529
let request_id = game.pending.next_request_id();
25102530
game.pending.insert(
25112531
request_id,
@@ -2515,8 +2535,12 @@ fn dispatch_actions(game: &mut Game, input: &InputState) {
25152535
backup,
25162536
},
25172537
);
2518-
game.net
2519-
.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+
});
25202544
game.last_break_at_ms = now;
25212545
}
25222546

@@ -2545,6 +2569,8 @@ fn dispatch_actions(game: &mut Game, input: &InputState) {
25452569
return;
25462570
}
25472571
let request_id = game.pending.next_request_id();
2572+
let input_tick = game.local_input_tick;
2573+
let player_position = game.physics.feet_position;
25482574
game.pending.insert(
25492575
request_id,
25502576
PendingAction {
@@ -2553,10 +2579,18 @@ fn dispatch_actions(game: &mut Game, input: &InputState) {
25532579
backup,
25542580
},
25552581
);
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+
}
25562588
game.net.send_client_message(ClientMessage::Place {
25572589
pos: neighbor,
25582590
block,
25592591
request_id,
2592+
input_tick,
2593+
player_position,
25602594
});
25612595
}
25622596
}
@@ -2575,6 +2609,21 @@ fn send_chat(game: &mut Game, content: String) {
25752609
.send_client_message(ClientMessage::Chat { content });
25762610
}
25772611

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+
25782627
fn apply_server_message(game: &mut Game, msg: ServerMessage) {
25792628
match msg {
25802629
ServerMessage::Welcome {
@@ -2672,18 +2721,31 @@ fn apply_server_message(game: &mut Game, msg: ServerMessage) {
26722721
players,
26732722
server_time_ms,
26742723
} => {
2675-
let now = (now_ms()) as i64;
2676-
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+
}
26772730

26782731
for snap in &players {
26792732
if snap.entity_id == game.entity_id {
26802733
// 自己的权威位置 → reconcile
2681-
let _r = reconcile_self(
2734+
let result = reconcile_self(
26822735
snap.position,
2683-
server_tick,
2736+
snap.last_input_tick,
26842737
&mut game.physics,
26852738
&mut game.input_history,
26862739
);
2740+
match result {
2741+
ReconcileResult::SoftCorrection(delta) => {
2742+
game.pending_position_correction += delta;
2743+
}
2744+
ReconcileResult::HardCorrection(_) => {
2745+
game.pending_position_correction = glam::Vec3::ZERO;
2746+
}
2747+
ReconcileResult::Ok | ReconcileResult::MissingHistory => {}
2748+
}
26872749
} else {
26882750
// 远端玩家 → 喂入插值缓冲
26892751
game.interp.ingest_tick(
@@ -2735,11 +2797,17 @@ fn apply_server_message(game: &mut Game, msg: ServerMessage) {
27352797
}
27362798
ServerMessage::Pong {
27372799
client_time_ms,
2738-
server_time_ms: _,
2800+
server_time_ms,
27392801
} => {
27402802
if let Some(sent_ms) = game.pending_pings.remove(&client_time_ms) {
2741-
let rtt = (now_ms() - sent_ms) as f32;
2742-
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);
27432811
}
27442812
}
27452813
}

0 commit comments

Comments
 (0)