#[cfg(all(feature = "2d", not(feature = "3d")))]
use avian2d::{math::*, physics_transform::*, prelude::*};
#[cfg(all(feature = "3d", not(feature = "2d")))]
use avian3d::{math::*, physics_transform::*, prelude::*};
use bevy_app::prelude::*;
use bevy_ecs::prelude::*;
use bevy_ecs::schedule::{IntoScheduleConfigs, ScheduleLabel};
use bevy_transform::components::GlobalTransform;
use bevy_transform::systems::{
mark_dirty_trees, propagate_parent_transforms, sync_simple_transforms,
};
use bevy_transform::{TransformSystems, components::Transform};
#[allow(unused_imports)]
use tracing::info;
use tracing::trace;
use lightyear_frame_interpolation::FrameInterpolationSystems;
use lightyear_interpolation::prelude::{
AppInterpolationExt, Interpolated, InterpolationFns, InterpolationRegistrationExt,
};
use lightyear_prediction::plugin::PredictionSystems;
use lightyear_prediction::prelude::{PredictionBuilderExt, RollbackSystems};
use lightyear_replication::prelude::{
AppComponentExt, ComponentRegistry, TransformLinearInterpolation,
};
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum AvianReplicationMode {
Position {
sync_to_transform: bool,
},
Transform,
}
impl Default for AvianReplicationMode {
fn default() -> Self {
Self::Position {
sync_to_transform: false,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct LightyearAvianPlugin {
pub replication_mode: AvianReplicationMode,
pub update_syncs_manually: bool,
pub register_physics_components: bool,
pub rollback_resources: bool,
}
impl Default for LightyearAvianPlugin {
fn default() -> Self {
Self {
replication_mode: AvianReplicationMode::default(),
update_syncs_manually: false,
register_physics_components: true,
rollback_resources: false,
}
}
}
const DEFAULT_ROLLBACK_TOLERANCE: f32 = 0.01;
fn position_should_rollback(confirmed: &Position, predicted: &Position) -> bool {
(confirmed.0 - predicted.0).length() >= Scalar::from(DEFAULT_ROLLBACK_TOLERANCE)
}
fn rotation_should_rollback(confirmed: &Rotation, predicted: &Rotation) -> bool {
confirmed.angle_between(*predicted) >= Scalar::from(DEFAULT_ROLLBACK_TOLERANCE)
}
fn linear_velocity_should_rollback(confirmed: &LinearVelocity, predicted: &LinearVelocity) -> bool {
(confirmed.0 - predicted.0).length() >= Scalar::from(DEFAULT_ROLLBACK_TOLERANCE)
}
#[cfg(all(feature = "2d", not(feature = "3d")))]
fn angular_velocity_should_rollback(
confirmed: &AngularVelocity,
predicted: &AngularVelocity,
) -> bool {
(confirmed.0 - predicted.0).abs() >= Scalar::from(DEFAULT_ROLLBACK_TOLERANCE)
}
#[cfg(all(feature = "3d", not(feature = "2d")))]
fn angular_velocity_should_rollback(
confirmed: &AngularVelocity,
predicted: &AngularVelocity,
) -> bool {
(confirmed.0 - predicted.0).length() >= Scalar::from(DEFAULT_ROLLBACK_TOLERANCE)
}
impl Plugin for LightyearAvianPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<PhysicsTransformConfig>();
Self::install_position_to_transform_markers(app);
match self.replication_mode {
AvianReplicationMode::Position { sync_to_transform } => {
if self.register_physics_components
&& app.world().contains_resource::<ComponentRegistry>()
{
Self::register_position_mode_components(app);
}
Self::add_position_rotation_hermite_rule(app);
if !self.update_syncs_manually {
let mut config = app.world_mut().resource_mut::<PhysicsTransformConfig>();
config.position_to_transform = true;
config.transform_to_position = sync_to_transform;
if sync_to_transform {
LightyearAvianPlugin::sync_position_to_transform(app, RunFixedMainLoop);
LightyearAvianPlugin::propagate_transform(app, RunFixedMainLoop);
app.configure_sets(
RunFixedMainLoop,
(
FrameInterpolationSystems::Restore,
PhysicsTransformSystems::PositionToTransform,
PhysicsTransformSystems::Propagate,
)
.chain()
.in_set(RunFixedMainLoopSystems::BeforeFixedMainLoop),
);
LightyearAvianPlugin::sync_transform_to_position_authoritative(
app,
FixedPostUpdate,
);
LightyearAvianPlugin::sync_position_to_transform(app, FixedPostUpdate);
}
LightyearAvianPlugin::sync_position_to_transform(app, PostUpdate);
}
app.add_systems(
RunFixedMainLoop,
LightyearAvianPlugin::update_child_collider_position
.in_set(RunFixedMainLoopSystems::AfterFixedMainLoop),
);
app.configure_sets(
FixedPostUpdate,
(
PhysicsSystems::StepSimulation,
PhysicsSystems::Writeback,
(
PredictionSystems::UpdateHistory,
FrameInterpolationSystems::Update,
),
)
.chain(),
);
app.configure_sets(
PostUpdate,
(
FrameInterpolationSystems::Interpolate,
RollbackSystems::VisualCorrection,
PhysicsSystems::Writeback,
TransformSystems::Propagate,
)
.chain(),
);
}
AvianReplicationMode::Transform => {
Self::add_transform_frame_interpolation_rule(app);
if !self.update_syncs_manually {
LightyearAvianPlugin::install_transform_to_position_sync(app, FixedPostUpdate);
LightyearAvianPlugin::sync_position_to_transform(app, FixedPostUpdate);
app.add_systems(
FixedPostUpdate,
LightyearAvianPlugin::update_child_collider_position
.in_set(PhysicsTransformSystems::PositionToTransform)
.before(position_to_transform),
);
}
app.configure_sets(
FixedPostUpdate,
(
PhysicsSystems::Prepare,
PhysicsSystems::StepSimulation,
PhysicsSystems::Writeback,
(
PredictionSystems::UpdateHistory,
FrameInterpolationSystems::Update,
),
)
.chain(),
);
app.configure_sets(
PostUpdate,
(
FrameInterpolationSystems::Interpolate,
RollbackSystems::VisualCorrection,
TransformSystems::Propagate,
)
.chain(),
);
}
}
#[cfg(all(feature = "3d", not(feature = "2d")))]
app.try_register_required_components::<avian3d::prelude::ColliderMarker, Transform>()
.ok();
#[cfg(all(feature = "2d", not(feature = "3d")))]
app.try_register_required_components::<avian2d::prelude::ColliderMarker, Transform>()
.ok();
if self.rollback_resources {
crate::rollback::register_rollback(app);
}
}
fn finish(&self, app: &mut App) {
if self.rollback_resources && app.is_plugin_added::<IslandPlugin>() {
let rollback_sleeping = app.is_plugin_added::<IslandSleepingPlugin>();
crate::rollback::register_island_rollback(app, rollback_sleeping);
}
}
}
impl LightyearAvianPlugin {
fn register_position_mode_components(app: &mut App) {
app.component::<Position>()
.replicate_filtered::<With<RigidBody>>()
.predict()
.with_rollback_condition(position_should_rollback)
.add_linear_interpolation()
.add_correction();
app.component::<Rotation>()
.replicate_filtered::<With<RigidBody>>()
.predict()
.with_rollback_condition(rotation_should_rollback)
.add_linear_interpolation()
.add_correction();
app.component::<LinearVelocity>()
.replicate_filtered::<With<RigidBody>>()
.predict()
.with_rollback_condition(linear_velocity_should_rollback);
app.component::<AngularVelocity>()
.replicate_filtered::<With<RigidBody>>()
.predict()
.with_rollback_condition(angular_velocity_should_rollback);
}
fn install_position_to_transform_markers(app: &mut App) {
app.add_observer(Self::add_apply_pos_to_transform);
app.add_observer(Self::remove_apply_pos_to_transform_from_child_collider);
}
fn add_apply_pos_to_transform(
trigger: On<Add, (Position, Rotation, Interpolated)>,
query: Query<
Option<&ColliderOf>,
(
With<Position>,
With<Rotation>,
With<Interpolated>,
Without<ApplyPosToTransform>,
Without<RigidBody>,
),
>,
mut commands: Commands,
) {
if query.get(trigger.entity).is_ok_and(|collider_of| {
collider_of.is_none_or(|collider_of| collider_of.body == trigger.entity)
}) {
commands.entity(trigger.entity).insert(ApplyPosToTransform);
}
}
fn remove_apply_pos_to_transform_from_child_collider(
trigger: On<Insert, (ColliderOf, ApplyPosToTransform)>,
query: Query<
&ColliderOf,
(
With<ColliderOf>,
With<ApplyPosToTransform>,
Without<RigidBody>,
),
>,
mut commands: Commands,
) {
if query
.get(trigger.entity)
.is_ok_and(|collider_of| collider_of.body != trigger.entity)
{
commands
.entity(trigger.entity)
.remove::<ApplyPosToTransform>();
}
}
fn add_position_rotation_hermite_rule(app: &mut App) {
app.interpolate_bundle_with::<(Position, Rotation, LinearVelocity, AngularVelocity)>(
InterpolationFns::interpolate_with_context(crate::types::position_rotation::hermite),
);
}
fn add_transform_frame_interpolation_rule(app: &mut App) {
app.interpolate_with_priority::<Transform>(
0,
InterpolationFns::no_history(TransformLinearInterpolation::lerp),
);
}
fn propagate_transform(app: &mut App, schedule: impl ScheduleLabel) {
let schedule = schedule.intern();
app.configure_sets(
schedule,
PhysicsTransformSystems::Propagate.in_set(PhysicsSystems::Prepare),
);
app.add_systems(
schedule,
(
mark_dirty_trees,
propagate_parent_transforms,
sync_simple_transforms,
)
.chain()
.in_set(PhysicsTransformSystems::Propagate)
.run_if(|config: Res<PhysicsTransformConfig>| config.propagate_before_physics),
);
}
fn install_transform_to_position_sync(app: &mut App, schedule: impl ScheduleLabel) {
let schedule = schedule.intern();
app.configure_sets(
FixedPostUpdate,
(
PhysicsTransformSystems::Propagate,
PhysicsTransformSystems::TransformToPosition,
)
.chain()
.in_set(PhysicsSystems::Prepare),
);
app.configure_sets(
schedule,
PhysicsTransformSystems::TransformToPosition
.in_set(PhysicsSystems::Prepare)
.after(PhysicsTransformSystems::Propagate),
);
Self::propagate_transform(app, schedule);
app.add_systems(
schedule,
transform_to_position
.in_set(PhysicsTransformSystems::TransformToPosition)
.run_if(|config: Res<PhysicsTransformConfig>| config.transform_to_position),
);
}
fn sync_transform_to_position_authoritative(app: &mut App, schedule: impl ScheduleLabel) {
let schedule = schedule.intern();
app.configure_sets(
schedule,
PhysicsTransformSystems::TransformToPosition
.in_set(PhysicsSystems::Prepare)
.after(PhysicsTransformSystems::Propagate),
);
Self::propagate_transform(app, schedule);
app.add_systems(
schedule,
Self::transform_to_position_authoritative
.in_set(PhysicsTransformSystems::TransformToPosition)
.run_if(|config: Res<PhysicsTransformConfig>| config.transform_to_position),
);
}
fn transform_to_position_authoritative(
mut query: Query<
(&GlobalTransform, &mut Position, &mut Rotation),
(
With<RigidBody>,
Or<(Changed<Transform>, Changed<GlobalTransform>)>,
),
>,
) {
for (global_transform, mut position, mut rotation) in &mut query {
let (_, global_rotation, global_translation) =
global_transform.to_scale_rotation_translation();
#[cfg(feature = "2d")]
{
let new_position = global_translation.truncate().adjust_precision();
let new_rotation = Rotation::from(global_rotation.adjust_precision());
if position.0 != new_position {
position.0 = new_position;
}
if *rotation != new_rotation {
*rotation = new_rotation;
}
}
#[cfg(feature = "3d")]
{
let new_position = global_translation.adjust_precision();
let new_rotation = Rotation(global_rotation.adjust_precision());
if position.0 != new_position {
position.0 = new_position;
}
if *rotation != new_rotation {
*rotation = new_rotation;
}
}
}
}
fn sync_position_to_transform(app: &mut App, schedule: impl ScheduleLabel) {
let schedule = schedule.intern();
app.configure_sets(
FixedPostUpdate,
PhysicsTransformSystems::PositionToTransform.in_set(PhysicsSystems::Writeback),
);
app.configure_sets(
schedule,
PhysicsTransformSystems::PositionToTransform.in_set(PhysicsSystems::Writeback),
);
app.add_systems(
schedule,
(position_to_transform, Self::add_transform)
.in_set(PhysicsTransformSystems::PositionToTransform)
.run_if(|config: Res<PhysicsTransformConfig>| config.position_to_transform),
);
}
fn add_transform(
query: Query<(Entity, Ref<Position>, Ref<Rotation>, Option<&ChildOf>), Without<Transform>>,
parents: Query<(
Option<&GlobalTransform>,
Option<&Position>,
Option<&Rotation>,
)>,
mut commands: Commands,
) {
query.iter().for_each(|(entity, pos, rot, parent)| {
if !(pos.is_added() || rot.is_added()) {
return;
}
let mut transform = Transform::default();
#[cfg(feature = "2d")]
if let Some(&ChildOf(parent)) = parent {
if let Ok((parent_global_transform, parent_pos, parent_rot)) = parents.get(parent) {
let parent_transform = parent_global_transform
.unwrap_or(&GlobalTransform::IDENTITY)
.compute_transform();
let parent_pos = parent_pos.map_or(parent_transform.translation, |pos| {
pos.f32().extend(parent_transform.translation.z)
});
let parent_rot = parent_rot.map_or(parent_transform.rotation, |rot| {
Quaternion::from(*rot).f32()
});
let parent_scale = parent_transform.scale;
let parent_transform = Transform::from_translation(parent_pos)
.with_rotation(parent_rot)
.with_scale(parent_scale);
let new_transform = GlobalTransform::from(
Transform::from_translation(
pos.f32().extend(parent_transform.translation.z),
)
.with_rotation(Quaternion::from(*rot).f32()),
)
.reparented_to(&GlobalTransform::from(parent_transform));
transform.translation = new_transform.translation;
transform.rotation = new_transform.rotation;
}
} else {
transform.translation = pos.f32().extend(transform.translation.z);
transform.rotation = Quaternion::from(*rot).f32();
}
#[cfg(feature = "3d")]
if let Some(&ChildOf(parent)) = parent {
if let Ok((parent_global_transform, parent_pos, parent_rot)) = parents.get(parent) {
let parent_transform = parent_global_transform
.unwrap_or(&GlobalTransform::IDENTITY)
.compute_transform();
let parent_pos =
parent_pos.map_or(parent_transform.translation, |pos| pos.f32());
let parent_rot = parent_rot.map_or(parent_transform.rotation, |rot| rot.f32());
let parent_scale = parent_transform.scale;
let parent_transform = Transform::from_translation(parent_pos)
.with_rotation(parent_rot)
.with_scale(parent_scale);
let new_transform = GlobalTransform::from(
Transform::from_translation(pos.f32()).with_rotation(rot.f32()),
)
.reparented_to(&GlobalTransform::from(parent_transform));
transform.translation = new_transform.translation;
transform.rotation = new_transform.rotation;
}
} else {
transform.translation = pos.f32();
transform.rotation = rot.f32();
}
trace!(
?transform,
"Adding transform because Position/Rotation were added for {entity:?}"
);
commands.entity(entity).insert(transform);
});
}
#[allow(clippy::type_complexity)]
pub fn update_child_collider_position(
mut collider_query: Query<
(
&ColliderTransform,
&mut Position,
&mut Rotation,
&ColliderOf,
),
Without<RigidBody>,
>,
rb_query: Query<(&Position, &Rotation), (With<RigidBody>, With<Children>)>,
) {
for (collider_transform, mut position, mut rotation, collider_of) in &mut collider_query {
let Ok((rb_pos, rb_rot)) = rb_query.get(collider_of.body) else {
continue;
};
position.0 = rb_pos.0 + rb_rot * collider_transform.translation;
#[cfg(feature = "2d")]
{
*rotation = *rb_rot * collider_transform.rotation;
}
#[cfg(feature = "3d")]
{
*rotation = (rb_rot.0 * collider_transform.rotation.0)
.normalize()
.into();
}
}
}
}
#[cfg(test)]
mod mode_tests {
use super::*;
use bevy_state::app::StatesPlugin;
use bevy_transform::TransformPlugin;
use lightyear_core::prelude::ConfirmedHistory;
use lightyear_interpolation::registry::InterpolationRegistry;
use lightyear_prediction::prelude::PredictionRegistry;
use lightyear_replication::LightyearRepliconBackend;
#[test]
fn position_mode_defaults_to_sync_to_transform_disabled() {
assert_eq!(
AvianReplicationMode::default(),
AvianReplicationMode::Position {
sync_to_transform: false
}
);
}
#[test]
fn interpolated_collider_root_keeps_position_to_transform_marker() {
let mut app = App::new();
app.add_plugins(LightyearAvianPlugin {
update_syncs_manually: true,
..Default::default()
});
let root = app
.world_mut()
.spawn((
Position::default(),
Rotation::default(),
Interpolated,
Transform::default(),
GlobalTransform::default(),
))
.id();
app.world_mut().flush();
assert!(app.world().get::<ApplyPosToTransform>(root).is_some());
app.world_mut()
.entity_mut(root)
.insert(ColliderOf { body: root });
app.world_mut().flush();
assert!(app.world().get::<ApplyPosToTransform>(root).is_some());
let child = app
.world_mut()
.spawn((
Position::default(),
Rotation::default(),
Interpolated,
Transform::default(),
GlobalTransform::default(),
))
.id();
app.world_mut().flush();
assert!(app.world().get::<ApplyPosToTransform>(child).is_some());
app.world_mut()
.entity_mut(child)
.insert(ColliderOf { body: root });
app.world_mut().flush();
assert!(app.world().get::<ApplyPosToTransform>(child).is_none());
}
#[test]
fn interpolated_pose_propagates_to_fixed_offset_child_collider() {
let mut app = App::new();
app.add_plugins(TransformPlugin);
app.add_plugins(LightyearAvianPlugin {
register_physics_components: false,
..Default::default()
});
let root = app
.world_mut()
.spawn((
Position::default(),
Rotation::default(),
Interpolated,
Transform::default(),
GlobalTransform::default(),
))
.id();
let child_local = Transform::from_xyz(0.75, 0.25, 0.0);
let child = app
.world_mut()
.spawn((
ChildOf(root),
ColliderOf { body: root },
Position::default(),
Rotation::default(),
Interpolated,
child_local,
GlobalTransform::default(),
))
.id();
app.update();
let sampled_position =
Position(Vector::X * Scalar::from(3.0_f32) + Vector::Y * Scalar::from(2.0_f32));
#[cfg(all(feature = "2d", not(feature = "3d")))]
let sampled_rotation = Rotation::radians(Scalar::from(0.4_f32));
#[cfg(all(feature = "3d", not(feature = "2d")))]
let sampled_rotation = Rotation(Quaternion::from_rotation_z(Scalar::from(0.4_f32)));
*app.world_mut().get_mut::<Position>(root).unwrap() = sampled_position;
*app.world_mut().get_mut::<Rotation>(root).unwrap() = sampled_rotation;
app.update();
assert!(app.world().get::<ApplyPosToTransform>(root).is_some());
assert!(app.world().get::<ApplyPosToTransform>(child).is_none());
assert_eq!(*app.world().get::<Transform>(child).unwrap(), child_local);
let root_transform = app.world().get::<Transform>(root).unwrap();
#[cfg(all(feature = "2d", not(feature = "3d")))]
let expected_root_translation = sampled_position.0.f32().extend(0.0);
#[cfg(all(feature = "3d", not(feature = "2d")))]
let expected_root_translation = sampled_position.0.f32();
let expected_root_rotation = Quaternion::from(sampled_rotation).f32();
assert!(
root_transform
.translation
.distance(expected_root_translation)
< 1e-5
);
assert!(
root_transform
.rotation
.angle_between(expected_root_rotation)
< 1e-5
);
let child_global = app
.world()
.get::<GlobalTransform>(child)
.unwrap()
.compute_transform();
let expected_child_translation = root_transform.transform_point(child_local.translation);
assert!(
child_global
.translation
.distance(expected_child_translation)
< 1e-5
);
assert!(
(child_global.rotation * bevy_math::Vec3::X)
.distance(root_transform.rotation * bevy_math::Vec3::X)
< 1e-5
);
}
#[test]
fn position_mode_registers_pose_velocity_hermite_rule() {
let mut app = App::new();
app.add_plugins(LightyearAvianPlugin {
replication_mode: AvianReplicationMode::Position {
sync_to_transform: false,
},
update_syncs_manually: true,
..Default::default()
});
let registry = app.world().resource::<InterpolationRegistry>();
assert!(registry.interpolated::<Position>());
assert!(registry.interpolated::<Rotation>());
assert!(registry.interpolated::<LinearVelocity>());
assert!(registry.interpolated::<AngularVelocity>());
assert!(
app.world()
.components()
.component_id::<ConfirmedHistory<Position>>()
.is_some(),
"the automatic rule must own delayed-interpolation history"
);
}
#[test]
fn position_mode_registers_the_default_physics_protocol() {
let mut app = App::new();
app.add_plugins(StatesPlugin);
app.add_plugins(LightyearRepliconBackend);
app.init_resource::<PredictionRegistry>();
app.add_plugins(LightyearAvianPlugin {
replication_mode: AvianReplicationMode::Position {
sync_to_transform: false,
},
update_syncs_manually: true,
..Default::default()
});
let components = app.world().resource::<ComponentRegistry>();
assert!(components.is_registered::<Position>());
assert!(components.is_registered::<Rotation>());
assert!(components.is_registered::<LinearVelocity>());
assert!(components.is_registered::<AngularVelocity>());
let prediction = app.world().resource::<PredictionRegistry>();
assert!(!prediction.should_rollback(&Position::default(), &Position::default()));
assert!(prediction.should_rollback(
&Position::default(),
&Position(Vector::X * Scalar::from(0.02_f32))
));
#[cfg(all(feature = "2d", not(feature = "3d")))]
{
assert!(!prediction.should_rollback(
&Rotation::default(),
&Rotation::radians(Scalar::from(0.005_f32))
));
assert!(prediction.should_rollback(
&Rotation::default(),
&Rotation::radians(Scalar::from(0.02_f32))
));
}
#[cfg(all(feature = "3d", not(feature = "2d")))]
{
assert!(!prediction.should_rollback(
&Rotation::default(),
&Rotation(Quaternion::from_rotation_x(Scalar::from(0.005_f32)))
));
assert!(prediction.should_rollback(
&Rotation::default(),
&Rotation(Quaternion::from_rotation_x(Scalar::from(0.02_f32)))
));
}
assert!(!prediction.should_rollback(
&LinearVelocity::default(),
&LinearVelocity(Vector::X * Scalar::from(0.005_f32))
));
assert!(prediction.should_rollback(
&LinearVelocity::default(),
&LinearVelocity(Vector::X * Scalar::from(0.02_f32))
));
#[cfg(all(feature = "2d", not(feature = "3d")))]
{
assert!(!prediction.should_rollback(
&AngularVelocity::default(),
&AngularVelocity(Scalar::from(0.005_f32))
));
assert!(prediction.should_rollback(
&AngularVelocity::default(),
&AngularVelocity(Scalar::from(0.02_f32))
));
}
#[cfg(all(feature = "3d", not(feature = "2d")))]
{
assert!(!prediction.should_rollback(
&AngularVelocity::default(),
&AngularVelocity(Vector::X * Scalar::from(0.005_f32))
));
assert!(prediction.should_rollback(
&AngularVelocity::default(),
&AngularVelocity(Vector::X * Scalar::from(0.02_f32))
));
}
}
#[test]
fn custom_physics_protocol_uses_normal_rollback_builder() {
fn never_rollback(_confirmed: &Position, _predicted: &Position) -> bool {
false
}
let mut app = App::new();
app.add_plugins(StatesPlugin);
app.add_plugins(LightyearRepliconBackend);
app.init_resource::<PredictionRegistry>();
app.add_plugins(LightyearAvianPlugin {
register_physics_components: false,
update_syncs_manually: true,
..Default::default()
});
app.component::<Position>()
.replicate_filtered::<With<RigidBody>>()
.predict()
.with_rollback_condition(never_rollback);
let prediction = app.world().resource::<PredictionRegistry>();
assert!(!prediction.should_rollback(
&Position::default(),
&Position(Vector::X * Scalar::from(100.0_f32))
));
}
}
#[cfg(all(test, feature = "2d", not(feature = "3d")))]
mod tests {
use super::*;
use bevy_ecs::system::RunSystemOnce;
use bevy_time::{Fixed, Time};
use core::time::Duration;
use lightyear_frame_interpolation::{
FrameInterpolate, FrameInterpolationHistory, FrameInterpolationPlugin,
};
fn seed_stationary_hermite_histories(app: &mut App, entity: Entity, include_previous: bool) {
let mut entity = app.world_mut().entity_mut(entity);
let mut rotation = entity
.get_mut::<FrameInterpolationHistory<Rotation>>()
.unwrap();
rotation.current_value = Some(Rotation::default());
rotation.previous_value = include_previous.then(Rotation::default);
let mut linear = entity
.get_mut::<FrameInterpolationHistory<LinearVelocity>>()
.unwrap();
linear.current_value = Some(LinearVelocity::default());
linear.previous_value = include_previous.then(LinearVelocity::default);
let mut angular = entity
.get_mut::<FrameInterpolationHistory<AngularVelocity>>()
.unwrap();
angular.current_value = Some(AngularVelocity::default());
angular.previous_value = include_previous.then(AngularVelocity::default);
}
#[test]
fn position_mode_does_not_copy_stale_transform_into_physics() {
let mut app = App::new();
app.init_resource::<bevy_transform::systems::StaticTransformOptimizations>();
app.add_plugins((
PhysicsSchedulePlugin::default(),
LightyearAvianPlugin {
replication_mode: AvianReplicationMode::Position {
sync_to_transform: false,
},
..Default::default()
},
));
app.finish();
assert!(
!app.world()
.resource::<PhysicsTransformConfig>()
.transform_to_position,
"Position mode must keep physics authoritative without relying on frame restore"
);
let canonical_position = Position::from_xy(10.0, 20.0);
let entity = app
.world_mut()
.spawn((
RigidBody::Kinematic,
canonical_position,
Rotation::default(),
Transform::from_xyz(-5.0, -6.0, 0.0),
GlobalTransform::default(),
))
.id();
app.world_mut().run_schedule(RunFixedMainLoop);
app.world_mut()
.entity_mut(entity)
.get_mut::<Transform>()
.unwrap()
.translation = bevy_math::Vec3::new(-50.0, -60.0, 0.0);
app.world_mut().run_schedule(RunFixedMainLoop);
assert_eq!(
app.world().get::<Position>(entity),
Some(&canonical_position)
);
}
#[test]
fn position_mode_writes_frame_interpolated_pose_to_transform() {
let mut app = App::new();
app.insert_resource(Time::<Fixed>::from_duration(Duration::from_secs(1)));
app.add_plugins((
PhysicsSchedulePlugin::default(),
FrameInterpolationPlugin,
LightyearAvianPlugin {
replication_mode: AvianReplicationMode::Position {
sync_to_transform: false,
},
..Default::default()
},
));
app.interpolate_with::<Position>(InterpolationFns::no_history(|_, end, _| end));
app.finish();
let entity = app
.world_mut()
.spawn((
RigidBody::Kinematic,
Position::default(),
Rotation::default(),
Transform::default(),
GlobalTransform::default(),
FrameInterpolate,
))
.id();
app.world_mut().run_schedule(PostUpdate);
let visual_position = Position::from_xy(4.0, 6.0);
app.world_mut()
.entity_mut(entity)
.get_mut::<FrameInterpolationHistory<Position>>()
.unwrap()
.current_value = Some(visual_position);
seed_stationary_hermite_histories(&mut app, entity, false);
app.world_mut().run_schedule(PostUpdate);
let transform = app.world().get::<Transform>(entity).unwrap();
assert_eq!(transform.translation.truncate(), visual_position.f32());
}
#[test]
fn position_mode_can_import_transform_authored_during_fixed_update() {
let mut app = App::new();
app.init_resource::<bevy_transform::systems::StaticTransformOptimizations>();
app.init_resource::<Time>();
app.insert_resource(Time::<Fixed>::from_duration(Duration::from_secs(1)));
app.world_mut()
.resource_mut::<Time<Fixed>>()
.accumulate_overstep(Duration::from_millis(500));
app.add_plugins((
PhysicsSchedulePlugin::default(),
FrameInterpolationPlugin,
LightyearAvianPlugin {
replication_mode: AvianReplicationMode::Position {
sync_to_transform: true,
},
..Default::default()
},
));
app.interpolate_with::<Position>(InterpolationFns::no_history(|start, end, t| {
Position(start.0.lerp(end.0, t as Scalar))
}));
app.finish();
assert!(
app.world()
.resource::<PhysicsTransformConfig>()
.transform_to_position
);
let canonical_position = Position::from_xy(10.0, 20.0);
let entity = app
.world_mut()
.spawn((
RigidBody::Kinematic,
canonical_position,
Rotation::default(),
Transform::from_xyz(10.0, 20.0, 0.0),
GlobalTransform::default(),
FrameInterpolate,
FrameInterpolationHistory::<Position> {
previous_value: Some(Position::default()),
current_value: Some(canonical_position),
},
))
.id();
seed_stationary_hermite_histories(&mut app, entity, true);
app.world_mut().run_schedule(PostUpdate);
assert_eq!(
app.world().get::<Position>(entity),
Some(&Position::from_xy(5.0, 10.0))
);
app.world_mut().run_schedule(RunFixedMainLoop);
assert_eq!(
app.world().get::<Position>(entity),
Some(&canonical_position)
);
assert_eq!(
app.world()
.get::<Transform>(entity)
.unwrap()
.translation
.truncate(),
canonical_position.f32()
);
let authored_position = Position::from_xy(30.0, 40.0);
app.world_mut()
.entity_mut(entity)
.get_mut::<Transform>()
.unwrap()
.translation = authored_position.f32().extend(0.0);
app.world_mut().run_schedule(FixedPostUpdate);
assert_eq!(
app.world().get::<Position>(entity),
Some(&authored_position)
);
}
#[test]
fn child_position_tracks_local_transform_and_reparenting() {
let mut app = App::new();
let body1_position = Position(Vector::new(10.0, 0.0));
let body1_rotation = Rotation::radians(core::f32::consts::FRAC_PI_2);
let body1 = app
.world_mut()
.spawn((
RigidBody::Dynamic,
body1_position,
body1_rotation,
GlobalTransform::IDENTITY,
))
.id();
let body2_position = Position(Vector::new(-5.0, 3.0));
let body2_rotation = Rotation::radians(-0.5);
let body2 = app
.world_mut()
.spawn((
RigidBody::Dynamic,
body2_position,
body2_rotation,
GlobalTransform::IDENTITY,
))
.id();
let local1 = ColliderTransform {
translation: Vector::new(2.0, 0.0),
rotation: Rotation::radians(0.25),
scale: Vector::ONE,
};
let child = app
.world_mut()
.spawn((
ChildOf(body1),
GlobalTransform::IDENTITY,
Position::default(),
Rotation::default(),
ColliderOf { body: body1 },
))
.id();
app.world_mut().entity_mut(child).insert(local1);
app.world_mut()
.run_system_once(LightyearAvianPlugin::update_child_collider_position)
.unwrap();
assert_eq!(
*app.world().get::<Position>(child).unwrap(),
Position(body1_position.0 + body1_rotation * local1.translation)
);
assert_eq!(
*app.world().get::<Rotation>(child).unwrap(),
body1_rotation * local1.rotation
);
let local2 = ColliderTransform {
translation: Vector::new(-1.0, 4.0),
rotation: Rotation::radians(-0.3),
scale: Vector::ONE,
};
app.world_mut()
.entity_mut(child)
.insert((ChildOf(body2), ColliderOf { body: body2 }));
app.world_mut().entity_mut(child).insert(local2);
app.world_mut()
.run_system_once(LightyearAvianPlugin::update_child_collider_position)
.unwrap();
assert_eq!(
*app.world().get::<Position>(child).unwrap(),
Position(body2_position.0 + body2_rotation * local2.translation)
);
assert_eq!(
*app.world().get::<Rotation>(child).unwrap(),
body2_rotation * local2.rotation
);
}
}
#[cfg(all(test, feature = "3d", not(feature = "2d")))]
mod tests_3d {
use super::*;
use bevy_time::{Fixed, Time};
use core::time::Duration;
use lightyear_frame_interpolation::{
FrameInterpolate, FrameInterpolationHistory, FrameInterpolationPlugin,
};
fn seed_stationary_hermite_histories(app: &mut App, entity: Entity, include_previous: bool) {
let mut entity = app.world_mut().entity_mut(entity);
let mut rotation = entity
.get_mut::<FrameInterpolationHistory<Rotation>>()
.unwrap();
rotation.current_value = Some(Rotation::default());
rotation.previous_value = include_previous.then(Rotation::default);
let mut linear = entity
.get_mut::<FrameInterpolationHistory<LinearVelocity>>()
.unwrap();
linear.current_value = Some(LinearVelocity::default());
linear.previous_value = include_previous.then(LinearVelocity::default);
let mut angular = entity
.get_mut::<FrameInterpolationHistory<AngularVelocity>>()
.unwrap();
angular.current_value = Some(AngularVelocity::default());
angular.previous_value = include_previous.then(AngularVelocity::default);
}
#[test]
fn position_mode_does_not_copy_stale_transform_into_physics() {
let mut app = App::new();
app.init_resource::<bevy_transform::systems::StaticTransformOptimizations>();
app.add_plugins((
PhysicsSchedulePlugin::default(),
LightyearAvianPlugin {
replication_mode: AvianReplicationMode::Position {
sync_to_transform: false,
},
..Default::default()
},
));
app.finish();
assert!(
!app.world()
.resource::<PhysicsTransformConfig>()
.transform_to_position,
"Position mode must keep physics authoritative without relying on frame restore"
);
let canonical_position = Position::new(Vector::new(10.0, 20.0, 30.0));
let entity = app
.world_mut()
.spawn((
RigidBody::Kinematic,
canonical_position,
Rotation::default(),
Transform::from_xyz(-5.0, -6.0, -7.0),
GlobalTransform::default(),
))
.id();
app.world_mut().run_schedule(RunFixedMainLoop);
app.world_mut()
.entity_mut(entity)
.get_mut::<Transform>()
.unwrap()
.translation = bevy_math::Vec3::new(-50.0, -60.0, -70.0);
app.world_mut().run_schedule(RunFixedMainLoop);
assert_eq!(
app.world().get::<Position>(entity),
Some(&canonical_position)
);
}
#[test]
fn position_mode_writes_frame_interpolated_pose_to_transform() {
let mut app = App::new();
app.insert_resource(Time::<Fixed>::from_duration(Duration::from_secs(1)));
app.add_plugins((
PhysicsSchedulePlugin::default(),
FrameInterpolationPlugin,
LightyearAvianPlugin {
replication_mode: AvianReplicationMode::Position {
sync_to_transform: false,
},
..Default::default()
},
));
app.interpolate_with::<Position>(InterpolationFns::no_history(|_, end, _| end));
app.finish();
let entity = app
.world_mut()
.spawn((
RigidBody::Kinematic,
Position::default(),
Rotation::default(),
Transform::default(),
GlobalTransform::default(),
FrameInterpolate,
))
.id();
app.world_mut().run_schedule(PostUpdate);
let visual_position = Position::new(Vector::new(4.0, 6.0, 8.0));
app.world_mut()
.entity_mut(entity)
.get_mut::<FrameInterpolationHistory<Position>>()
.unwrap()
.current_value = Some(visual_position);
seed_stationary_hermite_histories(&mut app, entity, false);
app.world_mut().run_schedule(PostUpdate);
let transform = app.world().get::<Transform>(entity).unwrap();
assert_eq!(transform.translation, visual_position.f32());
}
#[test]
fn position_mode_can_import_transform_authored_during_fixed_update() {
let mut app = App::new();
app.init_resource::<bevy_transform::systems::StaticTransformOptimizations>();
app.init_resource::<Time>();
app.insert_resource(Time::<Fixed>::from_duration(Duration::from_secs(1)));
app.world_mut()
.resource_mut::<Time<Fixed>>()
.accumulate_overstep(Duration::from_millis(500));
app.add_plugins((
PhysicsSchedulePlugin::default(),
FrameInterpolationPlugin,
LightyearAvianPlugin {
replication_mode: AvianReplicationMode::Position {
sync_to_transform: true,
},
..Default::default()
},
));
app.interpolate_with::<Position>(InterpolationFns::no_history(|start, end, t| {
Position(start.0.lerp(end.0, t as Scalar))
}));
app.finish();
assert!(
app.world()
.resource::<PhysicsTransformConfig>()
.transform_to_position
);
let canonical_position = Position::new(Vector::new(10.0, 20.0, 30.0));
let entity = app
.world_mut()
.spawn((
RigidBody::Kinematic,
canonical_position,
Rotation::default(),
Transform::from_xyz(10.0, 20.0, 30.0),
GlobalTransform::default(),
FrameInterpolate,
FrameInterpolationHistory::<Position> {
previous_value: Some(Position::default()),
current_value: Some(canonical_position),
},
))
.id();
seed_stationary_hermite_histories(&mut app, entity, true);
app.world_mut().run_schedule(PostUpdate);
assert_eq!(
app.world().get::<Position>(entity),
Some(&Position::new(Vector::new(5.0, 10.0, 15.0)))
);
app.world_mut().run_schedule(RunFixedMainLoop);
assert_eq!(
app.world().get::<Position>(entity),
Some(&canonical_position)
);
assert_eq!(
app.world().get::<Transform>(entity).unwrap().translation,
canonical_position.f32()
);
let authored_position = Position::new(Vector::new(30.0, 40.0, 50.0));
app.world_mut()
.entity_mut(entity)
.get_mut::<Transform>()
.unwrap()
.translation = authored_position.f32();
app.world_mut().run_schedule(FixedPostUpdate);
assert_eq!(
app.world().get::<Position>(entity),
Some(&authored_position)
);
}
}