From 35aab9e8d5a2470e2731fee67318bb9774bc45bb Mon Sep 17 00:00:00 2001 From: core Date: Mon, 18 May 2026 22:51:35 -0400 Subject: [PATCH] feat(netcode-rewrite): atttempt prediction --- crates/unified/src/client/interpolation.rs | 284 ++++++++++++++++++--- crates/unified/src/shared/gravity.rs | 18 +- 2 files changed, 252 insertions(+), 50 deletions(-) diff --git a/crates/unified/src/client/interpolation.rs b/crates/unified/src/client/interpolation.rs index c11e4cfdcf3ff29c3864b79b8082c521c7108970..e8cba98a136c0f17d4fbfb7ece647d6b5a8aab00 100644 --- a/crates/unified/src/client/interpolation.rs +++ b/crates/unified/src/client/interpolation.rs @@ -1,13 +1,14 @@ -use std::f64::consts::PI; +use std::collections::HashSet; use bevy::math::DVec2; +use bevy::transform::TransformSystems; use bevy_replicon::client::confirm_history::ConfirmHistory; use bevy_replicon::prelude::Replicated; use bevy_replicon::shared::replicon_tick::RepliconTick; use crate::prelude::*; -use crate::shared::config::planet::PlanetSpring; +use crate::shared::config::planet::{Planet, PlanetSpring}; +use crate::shared::world_config::WorldConfigResource; -const CORRECTION_FACTOR: f64 = 0.1; -const SNAP_THRESHOLD: f64 = 500.0; +const SKIP_THRESHOLD: f64 = 10.0; #[derive(Component, Default)] struct LocalState { @@ -20,6 +21,7 @@ struct LocalState { #[derive(Component, Default)] struct CorrectionTarget { + needs_correction: bool, position: DVec2, linvel: DVec2, angvel: f64, @@ -28,21 +30,107 @@ struct CorrectionTarget { last_tick: Option, } +#[derive(Resource, Default)] +struct LocalTick(u64); + +#[derive(Resource)] +struct LagEstimate { + horizon: f64, + last_local_tick: u64, +} + +impl Default for LagEstimate { + fn default() -> Self { + Self { horizon: 4.0, last_local_tick: 0 } + } +} + +#[derive(Resource, Default)] +struct PlanetPositions(Vec<(DVec2, f64)>); + +/// Public so future thrust-prediction systems can replay buffered inputs at the correct tick. +#[derive(Resource, Default)] +pub struct Resimulating { + pub active: bool, + /// The absolute `LocalTick` value corresponding to the current resimulation step. + pub resim_tick: u64, +} + +struct PhysicsSnapshot { + position: DVec2, + rotation_cos: f64, + rotation_sin: f64, + linvel: DVec2, + angvel: f64, +} + +impl PhysicsSnapshot { + fn from_target(t: &CorrectionTarget) -> Self { + Self { + position: t.position, + rotation_cos: t.rotation_cos, + rotation_sin: t.rotation_sin, + linvel: t.linvel, + angvel: t.angvel, + } + } + + fn from_components( + p: &Position, + r: &Rotation, + lv: &LinearVelocity, + av: &AngularVelocity, + ) -> Self { + Self { + position: p.0, + rotation_cos: r.cos, + rotation_sin: r.sin, + linvel: lv.0, + angvel: av.0, + } + } + + fn apply(&self, world: &mut World, entity: Entity) { + let mut e = world.entity_mut(entity); + if let Some(mut p) = e.get_mut::() { p.0 = self.position; } + if let Some(mut r) = e.get_mut::() { + r.cos = self.rotation_cos; + r.sin = self.rotation_sin; + } + if let Some(mut lv) = e.get_mut::() { lv.0 = self.linvel; } + if let Some(mut av) = e.get_mut::() { av.0 = self.angvel; } + } +} + pub fn prediction_plugin(app: &mut App) { app + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() .add_observer(init_prediction) + .add_systems(FixedFirst, tick_local_clock) .add_systems(PreUpdate, ( - save_local_state.before(bevy_replicon::client::ClientSystems::Receive), + ( + save_local_state, + collect_planet_positions, + ).before(bevy_replicon::client::ClientSystems::Receive), record_server_correction.after(bevy_replicon::client::ClientSystems::Receive), )) - .add_systems(FixedUpdate, apply_correction - .after(PhysicsSystems::Prepare) - .before(PhysicsSystems::StepSimulation)); + .add_systems(PostUpdate, + perform_resimulation.before(TransformSystems::Propagate)); +} + +fn tick_local_clock(mut tick: ResMut) { + tick.0 += 1; } fn init_prediction( trigger: On, - query: Query<(&Position, &Rotation, &LinearVelocity, Option<&AngularVelocity>), Without>, + query: Query< + (&Position, &Rotation, &LinearVelocity, Option<&AngularVelocity>), + (Without, Without), + >, mut commands: Commands, ) { let entity = trigger.event_target(); @@ -59,6 +147,13 @@ fn init_prediction( )); } +fn collect_planet_positions( + mut resource: ResMut, + planets: Query<(&Position, &Mass), (With, Without)>, +) { + resource.0 = planets.iter().map(|(p, m)| (p.0, m.0 as f64)).collect(); +} + fn save_local_state( mut query: Query<(&Position, &Rotation, &LinearVelocity, &AngularVelocity, &mut LocalState)>, ) { @@ -74,21 +169,70 @@ fn save_local_state( fn record_server_correction( mut query: Query<( - &mut Position, &mut Rotation, &mut LinearVelocity, &mut AngularVelocity, + &mut Position, + &mut Rotation, + &mut LinearVelocity, + &mut AngularVelocity, &ConfirmHistory, - &mut CorrectionTarget, &LocalState, + &mut CorrectionTarget, + &LocalState, )>, + planet_positions: Res, + world_config: Res, + local_tick: Res, + mut lag: ResMut, ) { + let Some(cfg) = &world_config.config else { + for (mut pos, mut rot, mut linvel, mut angvel, _, _, local) in &mut query { + pos.0 = local.position; + rot.cos = local.rotation_cos; + rot.sin = local.rotation_sin; + linvel.0 = local.linvel; + angvel.0 = local.angvel; + } + return; + }; + + let planet_snapshot = &planet_positions.0; + for (mut pos, mut rot, mut linvel, mut angvel, history, mut target, local) in &mut query { let tick = history.last_tick(); - if target.last_tick != Some(tick) { - target.last_tick = Some(tick); - target.position = pos.0; - target.linvel = linvel.0; + + if target.last_tick == Some(tick) { + pos.0 = local.position; + rot.cos = local.rotation_cos; + rot.sin = local.rotation_sin; + linvel.0 = local.linvel; + angvel.0 = local.angvel; + continue; + } + + let elapsed = (local_tick.0.saturating_sub(lag.last_local_tick)).clamp(1, 20) as f64; + lag.horizon = lag.horizon * 0.9 + elapsed * 0.1; + lag.last_local_tick = local_tick.0; + target.last_tick = Some(tick); + + let server_pos = pos.0; + let server_vel = linvel.0; + + let n = lag.horizon.round() as u64; + let dt = 1.0 / crate::shared::plugins::TICK_RATE; + let (ext_pos, _) = extrapolate( + server_pos, server_vel, &planet_snapshot, n, dt, cfg.world.gravity, + ); + + let error = (ext_pos - local.position).length(); + if error < SKIP_THRESHOLD { + target.needs_correction = false; + } else { + target.needs_correction = true; + target.position = server_pos; + target.linvel = server_vel; target.angvel = angvel.0; target.rotation_cos = rot.cos; target.rotation_sin = rot.sin; } + pos.0 = local.position; rot.cos = local.rotation_cos; rot.sin = local.rotation_sin; @@ -97,32 +241,90 @@ fn record_server_correction( } } -fn apply_correction( - mut query: Query<(&mut Position, &mut Rotation, &mut LinearVelocity, &mut AngularVelocity, &CorrectionTarget)>, -) { - for (mut pos, mut rot, mut linvel, mut angvel, target) in &mut query { - let Some(_) = target.last_tick else { continue }; - - let pos_err = target.position - pos.0; - if pos_err.length() > SNAP_THRESHOLD { - pos.0 = target.position; - linvel.0 = target.linvel; - angvel.0 = target.angvel; - rot.cos = target.rotation_cos; - rot.sin = target.rotation_sin; - } else { - pos.0 += pos_err * CORRECTION_FACTOR; - let cur_linvel = linvel.0; - linvel.0 += (target.linvel - cur_linvel) * CORRECTION_FACTOR; - let cur_angvel = angvel.0; - angvel.0 += (target.angvel - cur_angvel) * CORRECTION_FACTOR; - - let angle_local = rot.sin.atan2(rot.cos); - let angle_target = target.rotation_sin.atan2(target.rotation_cos); - let diff = ((angle_target - angle_local + PI).rem_euclid(2.0 * PI)) - PI; - let new_angle = angle_local + diff * CORRECTION_FACTOR; - rot.cos = new_angle.cos(); - rot.sin = new_angle.sin(); +fn extrapolate( + mut pos: DVec2, + mut vel: DVec2, + planets: &[(DVec2, f64)], + n: u64, + dt: f64, + gravity: f64, +) -> (DVec2, DVec2) { + for _ in 0..n { + let mut accel = DVec2::ZERO; + for &(planet_pos, planet_mass) in planets { + let diff = planet_pos - pos; + let dist_sq = diff.length_squared().max(1.0); + accel += diff.normalize() * gravity * planet_mass / dist_sq; + } + vel += accel * dt; + pos += vel * dt; + } + (pos, vel) +} + +fn perform_resimulation(world: &mut World) { + let corrections: Vec<(Entity, PhysicsSnapshot)> = { + let mut q = world.query::<(Entity, &CorrectionTarget)>(); + q.iter(world) + .filter(|(_, t)| t.needs_correction) + .map(|(e, t)| (e, PhysicsSnapshot::from_target(t))) + .collect() + }; + if corrections.is_empty() { return; } + + let saved: Vec<(Entity, PhysicsSnapshot)> = { + let mut q = world.query::<( + Entity, &Position, &Rotation, &LinearVelocity, &AngularVelocity, + )>(); + q.iter(world) + .map(|(e, p, r, lv, av)| (e, PhysicsSnapshot::from_components(p, r, lv, av))) + .collect() + }; + + // Save Transforms separately; avian2d's Writeback overwrites Transform in each FixedUpdate + // step, so non-corrected entities would visually jump if we don't restore it. + let saved_transforms: Vec<(Entity, Vec3, Quat)> = { + let mut q = world.query::<(Entity, &Transform)>(); + q.iter(world) + .map(|(e, t)| (e, t.translation, t.rotation)) + .collect() + }; + + let corrected_set: HashSet = corrections.iter().map(|(e, _)| *e).collect(); + + for (entity, snapshot) in &corrections { + snapshot.apply(world, *entity); + } + + let n = world.resource::().horizon.round() as u64; + let base_tick = world.resource::().0; + world.resource_mut::().active = true; + for step in 0..n { + world.resource_mut::().resim_tick = base_tick + step; + world.run_schedule(FixedUpdate); + } + world.resource_mut::().active = false; + + for (entity, snapshot) in &saved { + if !corrected_set.contains(entity) { + snapshot.apply(world, *entity); + } + } + + // Restore transforms for non-corrected entities so the visual doesn't jump. + for (entity, translation, rotation) in &saved_transforms { + if !corrected_set.contains(entity) { + let mut e = world.entity_mut(*entity); + if let Some(mut t) = e.get_mut::() { + t.translation = *translation; + t.rotation = *rotation; + } + } + } + + for (entity, _) in &corrections { + if let Some(mut t) = world.entity_mut(*entity).get_mut::() { + t.needs_correction = false; } } } diff --git a/crates/unified/src/shared/gravity.rs b/crates/unified/src/shared/gravity.rs index 1cc2cfc12ca1a4e40249fcf2772b2b583cc56aad..40557478c8ee6bbfa1d0f66658e48c38f4186f08 100644 --- a/crates/unified/src/shared/gravity.rs +++ b/crates/unified/src/shared/gravity.rs @@ -4,8 +4,8 @@ use crate::prelude::*; use crate::shared::world_config::WorldConfigResource; pub fn update_gravity( - mut part_query: Query<(&Transform, &LinearVelocity, &Mass, &mut ConstantForce), With>, - planet_query: Query<(&Transform, &Mass), With>, + mut part_query: Query<(&Position, &LinearVelocity, &Mass, &mut ConstantForce), With>, + planet_query: Query<(&Position, &Mass), With>, world_config: Res, time: Res