use std::time::Duration;
use bevy_ecs::prelude::{Commands, Query, Res, ResMut, Resource, With};
use crate::Transform;
use super::*;
#[derive(Resource, Default)]
struct Counts {
startup: u32,
fixed: u32,
update: u32,
extracted: u32,
events_seen: usize,
}
fn startup(mut counts: ResMut<Counts>) {
counts.startup += 1;
}
fn fixed(mut counts: ResMut<Counts>) {
counts.fixed += 1;
}
fn update(mut counts: ResMut<Counts>) {
counts.update += 1;
}
fn extract(mut counts: ResMut<Counts>) {
counts.extracted += 1;
}
#[test]
fn schedule_types_are_exact_core_reexports() {
fn core_stage_to_runtime(
stage: rusting_core::schedule::ScheduleStage,
) -> ScheduleStage {
stage
}
fn runtime_stage_to_core(
stage: ScheduleStage,
) -> rusting_core::schedule::ScheduleStage {
stage
}
fn core_report_to_runtime(
report: rusting_core::schedule::FrameReport,
) -> FrameReport {
report
}
fn runtime_report_to_core(
report: FrameReport,
) -> rusting_core::schedule::FrameReport {
report
}
let _stage = runtime_stage_to_core(core_stage_to_runtime(
rusting_core::schedule::ScheduleStage::Update,
));
let _report = runtime_report_to_core(core_report_to_runtime(
rusting_core::schedule::FrameReport::default(),
));
}
#[test]
fn time_types_are_exact_core_reexports() {
fn core_frame_to_runtime(time: rusting_core::time::FrameTime) -> FrameTime {
time
}
fn runtime_frame_to_core(time: FrameTime) -> rusting_core::time::FrameTime {
time
}
fn core_control_to_runtime(
control: rusting_core::time::TimeControl,
) -> TimeControl {
control
}
fn runtime_control_to_core(
control: TimeControl,
) -> rusting_core::time::TimeControl {
control
}
let _time =
runtime_frame_to_core(core_frame_to_runtime(FrameTime::default()));
let _control = runtime_control_to_core(core_control_to_runtime(
TimeControl::default(),
));
}
#[test]
fn zero_fixed_delta_after_build_maps_to_app_error_without_advancing_time() {
let mut app = EngineBuilder::new().build().unwrap();
app.world_mut().resource_mut::<TimeControl>().fixed_delta = Duration::ZERO;
assert_eq!(
app.update(Duration::from_secs(1)),
Err(AppError::InvalidFixedDelta)
);
let time = app.world().resource::<FrameTime>();
assert_eq!(time.frame, 0);
assert_eq!(time.real_delta, Duration::ZERO);
assert_eq!(time.delta, Duration::ZERO);
assert_eq!(time.elapsed, Duration::ZERO);
assert_eq!(time.fixed_tick, 0);
}
#[test]
fn schedules_and_fixed_catch_up_are_deterministic() {
let mut app = EngineBuilder::new()
.fixed_delta(Duration::from_millis(10))
.max_fixed_steps(3)
.build()
.unwrap();
app.insert_resource(Counts::default())
.add_systems(ScheduleStage::Startup, startup)
.add_systems(ScheduleStage::FixedUpdate, fixed)
.add_systems(ScheduleStage::Update, update)
.add_systems(ScheduleStage::RenderExtract, extract);
let first = app.update(Duration::from_millis(35)).unwrap();
let second = app.update(Duration::from_millis(5)).unwrap();
let counts = app.world().resource::<Counts>();
assert_eq!(first.fixed_steps, 3);
assert_eq!(second.fixed_steps, 1);
assert_eq!(counts.startup, 1);
assert_eq!(counts.fixed, 4);
assert_eq!(counts.update, 2);
assert_eq!(counts.extracted, 2);
}
fn slow(_: ResMut<Counts>) {
std::thread::sleep(Duration::from_millis(2));
}
#[test]
fn update_times_physics_and_extraction() {
let mut app = EngineBuilder::new()
.fixed_delta(Duration::from_millis(10))
.build()
.unwrap();
app.insert_resource(Counts::default())
.add_systems(ScheduleStage::FixedUpdate, slow)
.add_systems(ScheduleStage::RenderExtract, slow);
app.update(Duration::from_millis(10)).unwrap();
let timings = *app.world().resource::<CpuFrameTimings>();
assert!(timings.physics >= Duration::from_millis(2), "{timings:?}");
assert!(
timings.extraction >= Duration::from_millis(2),
"{timings:?}"
);
app.update(Duration::ZERO).unwrap();
let timings = *app.world().resource::<CpuFrameTimings>();
assert!(timings.physics < Duration::from_millis(2), "{timings:?}");
}
#[test]
fn pause_and_single_step_keep_variable_systems_alive() {
let mut app = EngineBuilder::new()
.fixed_delta(Duration::from_millis(10))
.build()
.unwrap();
app.insert_resource(Counts::default())
.add_systems(ScheduleStage::FixedUpdate, fixed)
.add_systems(ScheduleStage::Update, update);
app.world_mut().resource_mut::<TimeControl>().pause();
assert_eq!(app.update(Duration::from_secs(1)).unwrap().fixed_steps, 0);
app.world_mut().resource_mut::<TimeControl>().step();
assert_eq!(app.update(Duration::from_secs(1)).unwrap().fixed_steps, 1);
let counts = app.world().resource::<Counts>();
assert_eq!(counts.fixed, 1);
assert_eq!(counts.update, 2);
let time = app.world().resource::<FrameTime>();
assert_eq!(time.delta, Duration::ZERO);
assert_eq!(time.elapsed, Duration::from_millis(10));
}
#[derive(Clone, Copy)]
struct TestEvent;
fn count_events(
events: Res<EventQueue<TestEvent>>,
mut counts: ResMut<Counts>,
) {
counts.events_seen += events.len();
}
#[test]
fn typed_events_are_visible_for_one_frame() {
let mut app = App::new();
app.insert_resource(Counts::default())
.add_event::<TestEvent>()
.add_systems(ScheduleStage::Update, count_events);
app.send_event(TestEvent);
app.update(Duration::ZERO).unwrap();
app.update(Duration::ZERO).unwrap();
assert_eq!(app.world().resource::<Counts>().events_seen, 1);
}
#[test]
fn adding_an_event_type_twice_does_not_duplicate_maintenance() {
let mut app = App::new();
app.add_event::<TestEvent>().add_event::<TestEvent>();
app.send_event(TestEvent);
app.update(Duration::ZERO).unwrap();
assert_eq!(app.world().resource::<EventQueue<TestEvent>>().len(), 1);
app.update(Duration::ZERO).unwrap();
assert!(app.world().resource::<EventQueue<TestEvent>>().is_empty());
}
#[derive(bevy_ecs::component::Component)]
struct DeferredSpawn;
fn queue_spawn(mut commands: Commands) {
commands.spawn(DeferredSpawn);
}
fn count_spawned(
query: Query<(), With<DeferredSpawn>>,
mut counts: ResMut<Counts>,
) {
counts.extracted = query.iter().count() as u32;
}
#[test]
fn deferred_commands_are_applied_between_ordered_schedules() {
let mut app = App::new();
app.insert_resource(Counts::default())
.add_systems(ScheduleStage::Update, queue_spawn)
.add_systems(ScheduleStage::PostUpdate, count_spawned);
app.update(Duration::ZERO).unwrap();
assert_eq!(app.world().resource::<Counts>().extracted, 1);
}
#[test]
fn hierarchy_propagates_and_rejects_cycles() {
let mut app = App::new();
let root = app.spawn(Transform::new([1.0, 0.0, 0.0]));
let child = app.spawn(Transform::new([2.0, 0.0, 0.0]));
let grandchild = app.spawn(Transform::new([4.0, 0.0, 0.0]));
app.set_parent(child, root).unwrap();
app.set_parent(grandchild, child).unwrap();
app.update(Duration::ZERO).unwrap();
let global = app.world().get::<GlobalTransform>(grandchild).unwrap();
assert!((global.matrix[3][0] - 7.0).abs() < f32::EPSILON);
assert!(matches!(
app.set_parent(root, grandchild),
Err(AppError::HierarchyCycle { .. })
));
}
struct CountingPlugin;
impl Plugin for CountingPlugin {
fn build(&self, app: &mut App) -> Result<(), AppError> {
app.insert_resource(Counts::default())
.add_systems(ScheduleStage::Startup, startup);
Ok(())
}
}
#[test]
fn plugins_configure_apps_and_cannot_be_added_twice() {
let mut app = App::new();
app.add_plugin(CountingPlugin).unwrap();
assert!(matches!(
app.add_plugin(CountingPlugin),
Err(AppError::DuplicatePlugin(_))
));
app.update(Duration::ZERO).unwrap();
assert_eq!(app.world().resource::<Counts>().startup, 1);
}
#[test]
fn rendering_is_uncapped_by_default() {
let settings = RenderSettings::default();
assert!(!settings.vsync);
assert!(!settings.limit_fps);
assert!(settings.max_fps > 0);
}
#[test]
fn physics_ids_reject_delayed_events_after_slot_reuse() {
let first = Entity::from_raw_u32(10).unwrap();
let second = Entity::from_raw_u32(11).unwrap();
let mut ids = PhysicsIdRegistry::default();
let old_id = ids.assign(first);
assert_eq!(ids.resolve(old_id), Some(first));
ids.release(first);
let new_id = ids.assign(second);
assert_eq!(old_id.slot, new_id.slot);
assert_ne!(old_id.generation, new_id.generation);
assert_eq!(ids.resolve(old_id), None);
assert_eq!(ids.resolve(new_id), Some(second));
}
#[test]
fn rust_condition_builder_compiles_to_postfix_gpu_instructions() {
let condition = GpuCondition::position_y()
.less_than(-100.0)
.and(GpuCondition::velocity_y().less_than(0.0))
.or(!GpuCondition::sleeping());
let instructions = condition.compile().unwrap();
assert_eq!(instructions.len(), 6);
assert_eq!(instructions[0].values[0], -100.0);
assert_eq!(instructions[1].values[0], 0.0);
assert_ne!(instructions[2].opcode, instructions[5].opcode);
}
#[test]
fn one_class_rule_is_prepared_for_ten_thousand_matching_gpu_bodies() {
const BODY_COUNT: usize = 10_000;
let mut app = App::new();
app.add_plugin(HybridPhysicsPlugin).unwrap();
let rule = GpuPhysicsRule::new(
"body_fell",
GpuCondition::position_y().less_than(-100.0),
);
{
let mut watches =
app.world_mut().resource_mut::<GpuPhysicsClassWatches>();
watches.add("falling_cubes", rule.clone());
watches.add("gravity", rule);
}
for index in 0..BODY_COUNT {
app.spawn((
Transform {
position: [index as f32, 0.0, 0.0],
..Transform::default()
},
PhysicsBody {
simulation: SimulationClass::Gpu,
..PhysicsBody::default()
},
RigidBody::default(),
ObjectClasses::new(["falling_cubes", "gravity"]),
));
}
app.spawn((
Transform::default(),
PhysicsBody {
simulation: SimulationClass::Gpu,
..PhysicsBody::default()
},
RigidBody::default(),
ObjectClasses::new(["unrelated"]),
));
app.update(Duration::ZERO).unwrap();
let extracted =
super::hybrid_physics::extract_gpu_physics_bodies(app.world_mut());
assert_eq!(
app.world().resource::<GpuPhysicsClassWatches>().classes
["falling_cubes"]
.len(),
1
);
assert_eq!(extracted.len(), BODY_COUNT + 1);
assert_eq!(
extracted
.iter()
.filter(|body| body.rules.len() == 1)
.count(),
BODY_COUNT
);
assert_eq!(
extracted
.iter()
.filter(|body| body.rules.is_empty())
.count(),
1
);
assert!(extracted
.windows(2)
.all(|bodies| bodies[0].physics_id != bodies[1].physics_id));
}
#[test]
fn watched_gpu_bodies_keep_their_revision_while_unchanged() {
let mut app = App::new();
app.add_plugin(HybridPhysicsPlugin).unwrap();
app.add_plugin(RenderExtractPlugin).unwrap();
app.world_mut()
.resource_mut::<GpuPhysicsClassWatches>()
.add(
"falling",
GpuPhysicsRule::new(
"fell",
GpuCondition::position_y().less_than(-100.0),
),
);
app.spawn((
Transform::default(),
PhysicsBody {
simulation: SimulationClass::Gpu,
..PhysicsBody::default()
},
RigidBody::default(),
ObjectClasses::new(["falling"]),
));
app.update(Duration::ZERO).unwrap();
app.update(Duration::ZERO).unwrap();
let revision = app.world().resource::<RenderWorld>().gpu_physics_revision;
assert_eq!(app.world().resource::<RenderWorld>().gpu_physics.len(), 1);
app.update(Duration::ZERO).unwrap();
app.update(Duration::ZERO).unwrap();
assert_eq!(
app.world().resource::<RenderWorld>().gpu_physics_revision,
revision
);
}
#[test]
fn watched_gpu_bodies_skip_extraction_until_a_watch_changes() {
let mut app = App::new();
app.add_plugin(HybridPhysicsPlugin).unwrap();
app.add_plugin(RenderExtractPlugin).unwrap();
let body = app.spawn((
Transform::default(),
PhysicsBody {
simulation: SimulationClass::Gpu,
..PhysicsBody::default()
},
RigidBody::default(),
GpuPhysicsWatch {
rules: vec![GpuPhysicsRule::new(
"fell",
GpuCondition::position_y().less_than(-100.0),
)],
},
));
app.update(Duration::ZERO).unwrap();
let signature =
super::hybrid_physics::simple_gpu_physics_signature(app.world_mut());
app.update(Duration::ZERO).unwrap();
assert_eq!(
super::hybrid_physics::simple_gpu_physics_signature(app.world_mut()),
signature
);
let revision = app.world().resource::<RenderWorld>().gpu_physics_revision;
app.world_mut()
.get_mut::<GpuPhysicsWatch>(body)
.unwrap()
.rules[0]
.cooldown_seconds = 1.0;
app.update(Duration::ZERO).unwrap();
let render_world = app.world().resource::<RenderWorld>();
assert_eq!(render_world.gpu_physics_revision, revision.wrapping_add(1));
assert_eq!(render_world.gpu_physics[0].rules.len(), 1);
}
#[test]
fn gpu_event_and_instruction_layouts_are_stable() {
use std::mem::{offset_of, size_of};
assert_eq!(size_of::<PhysicsId>(), 8);
assert_eq!(size_of::<GpuConditionInstruction>(), 32);
assert_eq!(offset_of!(GpuConditionInstruction, values), 16);
assert_eq!(size_of::<RawGpuPhysicsEvent>(), 48);
assert_eq!(offset_of!(RawGpuPhysicsEvent, tick_low), 16);
assert_eq!(offset_of!(RawGpuPhysicsEvent, payload), 32);
}
#[test]
fn raw_gpu_events_reach_the_live_ecs_entity() {
let mut app = App::new();
app.add_plugin(HybridPhysicsPlugin).unwrap();
let entity = app.spawn(PhysicsBody {
simulation: SimulationClass::Gpu,
..PhysicsBody::default()
});
app.update(Duration::from_secs_f64(1.0 / 60.0)).unwrap();
let physics_id = *app.world().get::<PhysicsId>(entity).unwrap();
let event_id = app
.world_mut()
.resource_mut::<GpuEventRegistry>()
.register("cube_fell");
let tick = u64::from(u32::MAX) + 25;
let raw = RawGpuPhysicsEvent {
body_slot: physics_id.slot,
body_generation: physics_id.generation,
event_id: event_id.0,
tick_low: tick as u32,
tick_high: (tick >> 32) as u32,
payload_kind: GpuEventPayload::Position as u32,
payload: [1.0, -101.0, 2.0, 1.0],
..Default::default()
};
let report = route_gpu_physics_events(app.world_mut(), &[raw]);
assert_eq!(report.delivered, 1);
app.update(Duration::ZERO).unwrap();
let events = app.world().resource::<EventQueue<GpuPhysicsEvent>>();
let event = events.iter().next().unwrap();
assert_eq!(event.entity, entity);
assert_eq!(event.tick, tick);
assert_eq!(event.payload[1], -101.0);
}
#[test]
fn gpu_events_are_delivered_in_tick_and_body_order() {
let mut app = App::new();
app.add_plugin(HybridPhysicsPlugin).unwrap();
let bodies = [
app.spawn(PhysicsBody::default()),
app.spawn(PhysicsBody::default()),
]
.map(|entity| {
app.world_mut()
.resource_mut::<PhysicsIdRegistry>()
.assign(entity)
});
let event_id = app
.world_mut()
.resource_mut::<GpuEventRegistry>()
.register("hit");
let raw = |body: PhysicsId, tick: u32| RawGpuPhysicsEvent {
body_slot: body.slot,
body_generation: body.generation,
event_id: event_id.0,
tick_low: tick,
..Default::default()
};
route_gpu_physics_events(
app.world_mut(),
&[raw(bodies[1], 2), raw(bodies[1], 1), raw(bodies[0], 2)],
);
app.update(Duration::ZERO).unwrap();
let order: Vec<_> = app
.world()
.resource::<EventQueue<GpuPhysicsEvent>>()
.iter()
.map(|event| (event.tick, event.physics_id))
.collect();
assert_eq!(order, [(1, bodies[1]), (2, bodies[0]), (2, bodies[1])]);
}
#[test]
fn sync_mode_controls_rules_and_state_mirror() {
let mut app = App::new();
app.add_plugin(HybridPhysicsPlugin).unwrap();
app.world_mut()
.resource_mut::<GpuPhysicsClassWatches>()
.add(
"watched",
GpuPhysicsRule::new(
"fell",
GpuCondition::position_y().less_than(0.0),
),
);
let spawn = |app: &mut App, sync| {
app.spawn((
Transform::default(),
PhysicsBody {
simulation: SimulationClass::Gpu,
..PhysicsBody::default()
},
RigidBody::default(),
ObjectClasses::new(["watched"]),
sync,
))
};
let silent = spawn(&mut app, PhysicsSyncMode::None);
let mirrored = spawn(&mut app, PhysicsSyncMode::SelectedState);
app.update(Duration::ZERO).unwrap();
let extracted =
super::hybrid_physics::extract_gpu_physics_bodies(app.world_mut());
let sync_and_rules: Vec<_> = extracted
.iter()
.map(|body| (body.sync, body.rules.len()))
.collect();
assert_eq!(
sync_and_rules,
[
(PhysicsSyncMode::None, 0),
(PhysicsSyncMode::SelectedState, 1)
]
);
let id = app.world().get::<PhysicsId>(mirrored).copied().unwrap();
let sample = |tick, y| GpuStateSample {
physics_id: id,
tick,
transform: Transform::default().with_position(0.0, y, 0.0),
linear_velocity: [0.0, -1.0, 0.0],
angular_velocity: [0.0; 3],
custom_values: None,
};
let removed = PhysicsId {
generation: id.generation + 1,
..id
};
let report = apply_gpu_state_samples(
app.world_mut(),
&[
sample(5, -1.0),
sample(4, 3.0),
GpuStateSample {
physics_id: removed,
..sample(6, 0.0)
},
],
);
assert_eq!((report.delivered, report.stale), (1, 1));
let mirror = app.world().get::<GpuStateMirror>(mirrored).unwrap();
assert_eq!(mirror.transform.position[1], -1.0);
assert_eq!(mirror.age_ticks(8), 3);
assert_eq!(
app.world().get::<Transform>(mirrored).unwrap().position[1],
0.0
);
assert!(app.world().get::<GpuStateMirror>(silent).is_none());
}
#[test]
fn gpu_commands_reach_the_render_world_once_per_batch() {
let mut app = App::new();
app.add_plugin(HybridPhysicsPlugin).unwrap();
app.add_plugin(RenderExtractPlugin).unwrap();
let body = PhysicsId {
slot: 4,
generation: 2,
};
app.world_mut()
.resource_mut::<GpuPhysicsCommands>()
.push(body, GpuBodyCommand::Impulse([0.0, 5.0, 0.0]));
app.update(Duration::ZERO).unwrap();
let render_world = app.world().resource::<RenderWorld>();
assert_eq!(
render_world.gpu_physics_commands,
[(body, GpuBodyCommand::Impulse([0.0, 5.0, 0.0]))]
);
assert_eq!(render_world.gpu_physics_commands_serial, 1);
assert!(app
.world()
.resource::<GpuPhysicsCommands>()
.commands
.is_empty());
app.update(Duration::ZERO).unwrap();
assert_eq!(
app.world()
.resource::<RenderWorld>()
.gpu_physics_commands_serial,
1
);
app.world_mut()
.resource_mut::<GpuPhysicsCommands>()
.reset_to_authored = true;
app.update(Duration::ZERO).unwrap();
let render_world = app.world().resource::<RenderWorld>();
assert!(render_world.gpu_physics_reset);
assert!(render_world.gpu_physics_commands.is_empty());
assert_eq!(render_world.gpu_physics_commands_serial, 2);
}
#[test]
fn class_snapshots_request_one_read_per_member() {
let mut app = App::new();
app.add_plugin(HybridPhysicsPlugin).unwrap();
let world = app.world_mut();
let id = |slot| PhysicsId {
slot,
generation: 1,
};
world.spawn((id(0), ObjectClasses::new(["debris"])));
world.spawn((id(1), ObjectClasses::new(["debris", "hot"])));
world.spawn((id(2), ObjectClasses::new(["enemy"])));
world.spawn(ObjectClasses::new(["debris"]));
assert_eq!(request_gpu_class_snapshot(world, "debris"), 2);
let mut commands = world.resource::<GpuPhysicsCommands>().commands.clone();
commands.sort_by_key(|(id, _)| id.slot);
assert_eq!(
commands,
[
(id(0), GpuBodyCommand::ReadState),
(id(1), GpuBodyCommand::ReadState)
]
);
}
#[test]
fn condition_shaders_reach_the_render_world_with_registered_events() {
let mut app = App::new();
app.add_plugin(HybridPhysicsPlugin).unwrap();
app.add_plugin(RenderExtractPlugin).unwrap();
let existing = app
.world_mut()
.resource_mut::<GpuEventRegistry>()
.register("landed");
app.world_mut().resource_mut::<GpuConditionShaders>().0 = vec![
GpuConditionShader {
events: vec!["fell".into(), "landed".into()],
glsl: "void condition(inout PhysicsState body) {}".into(),
},
GpuConditionShader {
events: Vec::new(),
glsl: "void condition(inout PhysicsState body) {}".into(),
},
];
app.update(Duration::ZERO).unwrap();
let fell = app
.world()
.resource::<GpuEventRegistry>()
.id("fell")
.unwrap();
let sources = &app.world().resource::<RenderWorld>().gpu_condition_shaders;
assert_eq!(
sources[0],
format!(
"const uint EVENTS[2] = uint[]({}u, {}u);\n#line 1\n\
void condition(inout PhysicsState body) {{}}",
fell.0, existing.0
)
);
assert_eq!(
sources[1],
"#line 1\nvoid condition(inout PhysicsState body) {}"
);
}
#[test]
fn clicking_a_rendered_cube_fires_a_click_event() {
let mut app = App::new();
app.add_plugin(crate::assets::AssetPlugin).unwrap();
app.add_plugin(RenderExtractPlugin).unwrap();
let (mesh, material) = {
let assets = app.world().resource::<crate::assets::AssetServer>();
(assets.fallback_mesh, assets.fallback_material)
};
app.spawn((
Transform::default().with_position(0.0, 0.0, 5.0),
Camera {
projection: Projection::Perspective {
vertical_fov_radians: 1.0,
near: 0.1,
far: 100.0,
},
active: true,
priority: 0,
},
));
let cube = app.spawn((
Transform::default(),
MeshRenderer {
mesh,
material,
cast_shadows: true,
receive_shadows: true,
},
));
app.update(Duration::ZERO).unwrap();
{
let mut input = app.world_mut().resource_mut::<RuntimeInput>();
input.record_viewport_size([800.0, 600.0]);
input.record_cursor_position([400.0, 300.0]);
input.record_mouse_button(MouseButton::Left, true);
}
app.update(Duration::ZERO).unwrap();
app.update(Duration::ZERO).unwrap();
let events = app.world().resource::<EventQueue<ClickEvent>>();
let event = events.iter().next().expect("a click event was fired");
assert_eq!(event.entity, cube);
assert_eq!(event.button, MouseButton::Left);
}
fn cpu_body(
world: &mut bevy_ecs::world::World,
position: [f32; 3],
shape: ColliderShape,
kind: RigidBodyKind,
) -> bevy_ecs::entity::Entity {
world
.spawn((
Transform::new(position),
PhysicsBody::default(),
RigidBody {
kind,
..RigidBody::default()
},
Collider {
shape,
..Collider::default()
},
))
.id()
}
fn run_fixed_steps(app: &mut App, steps: u32) {
for _ in 0..steps {
app.update(Duration::from_secs_f64(1.0 / 60.0)).unwrap();
}
}
const UNIT_BOX: ColliderShape = ColliderShape::Box {
half_extents: [0.5; 3],
};
fn cpu_ground(world: &mut bevy_ecs::world::World) {
cpu_body(
world,
[0.0, -0.5, 0.0],
ColliderShape::Box {
half_extents: [5.0, 0.5, 5.0],
},
RigidBodyKind::Fixed,
);
}
#[test]
fn cpu_tilted_box_tips_over_onto_a_face() {
let mut app = App::new();
let world = app.world_mut();
cpu_ground(world);
let tilted =
cpu_body(world, [0.0, 1.5, 0.0], UNIT_BOX, RigidBodyKind::Dynamic);
world.get_mut::<Transform>(tilted).unwrap().rotation = [0.3, 0.0, 0.2];
run_fixed_steps(&mut app, 300);
let world = app.world();
let position = world.get::<Transform>(tilted).unwrap().position;
assert!(
(position[1] - 0.5).abs() < 0.03,
"box rests at {position:?}"
);
let spin = world.get::<RigidBody>(tilted).unwrap().angular_velocity;
assert!(
spin.iter().all(|w| w.abs() < 0.1),
"box still spins {spin:?}"
);
}
#[test]
fn cpu_sphere_sliding_on_the_ground_starts_rolling() {
let mut app = App::new();
let world = app.world_mut();
cpu_ground(world);
let ball = cpu_body(
world,
[0.0, 0.5, 0.0],
ColliderShape::Sphere { radius: 0.5 },
RigidBodyKind::Dynamic,
);
world.get_mut::<RigidBody>(ball).unwrap().linear_velocity = [3.0, 0.0, 0.0];
run_fixed_steps(&mut app, 60);
let rigid = app.world().get::<RigidBody>(ball).unwrap();
let speed = rigid.linear_velocity[0];
assert!(speed > 1.0 && speed < 3.0, "ball moves {speed}");
let rolling = -speed / 0.5;
assert!(
(rigid.angular_velocity[2] - rolling).abs() < 0.1 * rolling.abs(),
"ball spins {:?} at speed {speed}",
rigid.angular_velocity
);
}
#[test]
fn cpu_convex_mesh_lands_flat_on_a_triangle_mesh_floor() {
use crate::assets::{MeshAsset, MeshVertex, PrimitiveShape};
let mut app = App::new();
app.add_plugin(crate::assets::AssetPlugin).unwrap();
let (cube, floor, material) = {
let mut assets =
app.world_mut().resource_mut::<crate::assets::AssetServer>();
let floor = assets.meshes.insert(MeshAsset {
vertices: [
[-10.0, -10.0],
[10.0, -10.0],
[10.0, 10.0],
[-10.0, 10.0],
]
.map(|[x, z]| MeshVertex {
position: [x, 0.0, z],
..MeshVertex::default()
})
.to_vec(),
indices: vec![0, 2, 1, 0, 3, 2],
});
(
assets.builtin_primitives[&PrimitiveShape::Cube],
floor,
assets.fallback_material,
)
};
let world = app.world_mut();
let mut spawn = |mesh, position, shape, kind| {
let entity = cpu_body(world, position, shape, kind);
world.entity_mut(entity).insert(MeshRenderer {
mesh,
material,
cast_shadows: true,
receive_shadows: true,
});
entity
};
let ground = spawn(
floor,
[0.0; 3],
ColliderShape::TriangleMesh,
RigidBodyKind::Dynamic,
);
let falling = spawn(
cube,
[0.0, 1.5, 0.0],
ColliderShape::ConvexMesh,
RigidBodyKind::Dynamic,
);
world.get_mut::<Transform>(falling).unwrap().rotation = [0.3, 0.0, 0.2];
run_fixed_steps(&mut app, 300);
let world = app.world();
assert_eq!(world.get::<Transform>(ground).unwrap().position, [0.0; 3]);
let position = world.get::<Transform>(falling).unwrap().position;
assert!(
(position[1] - 0.5).abs() < 0.03,
"cube rests at {position:?}"
);
let hit = world
.resource::<PhysicsWorld>()
.raycast([4.0, 5.0, 4.0], [0.0, -1.0, 0.0], 20.0, u32::MAX)
.unwrap();
assert_eq!(hit.entity, ground);
assert!((hit.distance - 5.0).abs() < 1e-4);
}
#[test]
fn cpu_boxes_fall_and_rest_in_a_stack_on_static_ground() {
let mut app = App::new();
let world = app.world_mut();
let ground = cpu_body(
world,
[0.0, -0.5, 0.0],
ColliderShape::Box {
half_extents: [5.0, 0.5, 5.0],
},
RigidBodyKind::Fixed,
);
let boxes = [0.5, 1.6, 2.7].map(|y| {
cpu_body(world, [0.0, y, 0.0], UNIT_BOX, RigidBodyKind::Dynamic)
});
run_fixed_steps(&mut app, 180);
let world = app.world();
assert_eq!(world.get::<Transform>(ground).unwrap().position[1], -0.5);
for (level, entity) in boxes.into_iter().enumerate() {
let position = world.get::<Transform>(entity).unwrap().position;
let expected = 0.5 + level as f32;
assert!(
(position[1] - expected).abs() < 0.03,
"box {level} rests at {position:?}"
);
assert!(
position[0].abs() < 5e-3 && position[2].abs() < 5e-3,
"box {level} drifts to {position:?}"
);
let rotation = world.get::<Transform>(entity).unwrap().rotation;
assert!(
rotation.iter().all(|angle| angle.abs() < 0.01),
"box {level} tilts to {rotation:?}"
);
let velocity = world.get::<RigidBody>(entity).unwrap().linear_velocity;
assert!(velocity[1].abs() < 0.2, "box {level} moves {velocity:?}");
}
let hit = world
.resource::<PhysicsWorld>()
.raycast([0.0, 10.0, 0.0], [0.0, -1.0, 0.0], 20.0, u32::MAX)
.unwrap();
assert_eq!(hit.entity, boxes[2]);
assert!((hit.point[1] - 3.0).abs() < 0.03);
}
#[test]
fn cpu_restitution_bounces_and_sensors_only_report() {
let mut app = App::new();
let world = app.world_mut();
cpu_body(
world,
[0.0, -0.5, 0.0],
ColliderShape::Box {
half_extents: [5.0, 0.5, 5.0],
},
RigidBodyKind::Fixed,
);
let ball = cpu_body(
world,
[0.0, 3.0, 0.0],
ColliderShape::Sphere { radius: 0.5 },
RigidBodyKind::Dynamic,
);
world.get_mut::<Collider>(ball).unwrap().restitution = 0.8;
let sensor =
cpu_body(world, [4.0, 0.5, 0.0], UNIT_BOX, RigidBodyKind::Fixed);
world.get_mut::<Collider>(sensor).unwrap().sensor = true;
let walker = cpu_body(
world,
[2.0, 0.5, 0.0],
ColliderShape::Sphere { radius: 0.4 },
RigidBodyKind::Kinematic,
);
world.get_mut::<RigidBody>(walker).unwrap().linear_velocity =
[3.0, 0.0, 0.0];
let mut peak_after_bounce = f32::MIN;
let mut bounced = false;
let mut sensor_events = 0;
for _ in 0..90 {
run_fixed_steps(&mut app, 1);
let world = app.world();
let ball_state = world.get::<RigidBody>(ball).unwrap();
bounced |= ball_state.linear_velocity[1] > 1.0;
if bounced {
peak_after_bounce = peak_after_bounce
.max(world.get::<Transform>(ball).unwrap().position[1]);
}
sensor_events += world
.resource::<EventQueue<CollisionEvent>>()
.iter()
.filter(|event| event.sensor)
.count();
}
assert!(bounced, "the ball never bounced");
assert!(
(1.5..3.0).contains(&peak_after_bounce),
"bounce peak {peak_after_bounce}"
);
assert!(sensor_events > 0);
let walker_x = app.world().get::<Transform>(walker).unwrap().position[0];
assert!((walker_x - (2.0 + 3.0 * 1.5)).abs() < 1e-3, "{walker_x}");
}
#[test]
fn cpu_physics_is_deterministic_and_ignores_gpu_bodies() {
let run = || {
let mut app = App::new();
let world = app.world_mut();
cpu_body(
world,
[0.0, -0.5, 0.0],
ColliderShape::Box {
half_extents: [5.0, 0.5, 5.0],
},
RigidBodyKind::Fixed,
);
let bodies = (0..6)
.map(|index| {
let shape = if index % 2 == 0 {
UNIT_BOX
} else {
ColliderShape::Capsule {
half_height: 0.3,
radius: 0.3,
}
};
cpu_body(
world,
[index as f32 * 0.3, 1.0 + index as f32, 0.1],
shape,
RigidBodyKind::Dynamic,
)
})
.collect::<Vec<_>>();
let gpu =
cpu_body(world, [0.0, 5.0, 0.0], UNIT_BOX, RigidBodyKind::Dynamic);
world.get_mut::<PhysicsBody>(gpu).unwrap().simulation =
SimulationClass::Gpu;
run_fixed_steps(&mut app, 120);
let world = app.world();
assert_eq!(world.get::<Transform>(gpu).unwrap().position[1], 5.0);
bodies
.iter()
.map(|entity| world.get::<Transform>(*entity).unwrap().position)
.collect::<Vec<_>>()
};
assert_eq!(run(), run());
}
#[test]
fn cpu_collision_layers_filter_pairs_and_queries() {
let mut app = App::new();
let world = app.world_mut();
let ground = cpu_body(
world,
[0.0, -0.5, 0.0],
ColliderShape::Box {
half_extents: [5.0, 0.5, 5.0],
},
RigidBodyKind::Fixed,
);
world.entity_mut(ground).insert(CollisionLayers {
memberships: 0b01,
filters: 0b01,
});
let solid =
cpu_body(world, [-2.0, 0.5, 0.0], UNIT_BOX, RigidBodyKind::Dynamic);
let ghost =
cpu_body(world, [2.0, 0.5, 0.0], UNIT_BOX, RigidBodyKind::Dynamic);
world.entity_mut(ghost).insert(CollisionLayers {
memberships: 0b10,
filters: u32::MAX,
});
run_fixed_steps(&mut app, 60);
let world = app.world();
assert!(
(world.get::<Transform>(solid).unwrap().position[1] - 0.5).abs() < 0.03
);
assert!(world.get::<Transform>(ghost).unwrap().position[1] < -2.0);
let physics = world.resource::<PhysicsWorld>();
let down = [0.0, -1.0, 0.0];
let hit = physics
.raycast([-2.0, 5.0, 0.0], down, 20.0, u32::MAX)
.unwrap();
assert_eq!(hit.entity, solid);
let hit = physics.raycast([-2.0, 5.0, 0.0], down, 20.0, 0b01).unwrap();
assert_eq!(hit.entity, solid, "the solid box has no layers: all bits");
let ground_only = physics.overlap_sphere([4.0, 0.0, 0.0], 0.5, 0b01);
assert_eq!(ground_only, vec![ground]);
assert!(physics
.overlap_sphere([4.0, 0.0, 0.0], 0.5, 0b100)
.is_empty());
}
#[test]
fn cpu_bodies_sleep_when_still_and_wake_on_touch_or_edit() {
let mut app = App::new();
let world = app.world_mut();
cpu_body(
world,
[0.0, -0.5, 0.0],
ColliderShape::Box {
half_extents: [5.0, 0.5, 5.0],
},
RigidBodyKind::Fixed,
);
let resting =
cpu_body(world, [0.0, 0.5, 0.0], UNIT_BOX, RigidBodyKind::Dynamic);
let pushed =
cpu_body(world, [3.0, 0.5, 0.0], UNIT_BOX, RigidBodyKind::Dynamic);
run_fixed_steps(&mut app, SLEEP_STEPS + 10);
assert!(app.world().get::<Sleeping>(resting).is_some());
assert!(app.world().get::<Sleeping>(pushed).is_some());
let before = *app.world().get::<Transform>(resting).unwrap();
run_fixed_steps(&mut app, 10);
assert_eq!(*app.world().get::<Transform>(resting).unwrap(), before);
app.world_mut()
.get_mut::<RigidBody>(pushed)
.unwrap()
.linear_velocity = [0.0, 4.0, 0.0];
run_fixed_steps(&mut app, 1);
assert!(app.world().get::<Sleeping>(pushed).is_none());
assert!(app.world().get::<Transform>(pushed).unwrap().position[1] > 0.5);
let dropped = cpu_body(
app.world_mut(),
[0.0, 3.0, 0.0],
UNIT_BOX,
RigidBodyKind::Dynamic,
);
let mut woke = false;
for _ in 0..60 {
run_fixed_steps(&mut app, 1);
woke |= app.world().get::<Sleeping>(resting).is_none();
}
assert!(woke, "the landing box must wake the resting one");
let top = app.world().get::<Transform>(dropped).unwrap().position[1];
assert!((top - 1.5).abs() < 0.05, "dropped box rests on top: {top}");
}
#[test]
fn fast_cpu_bodies_do_not_tunnel_through_thin_walls() {
let mut app = App::new();
let world = app.world_mut();
world.resource_mut::<PhysicsSettings>().gravity = [0.0; 3];
cpu_body(
world,
[0.0, 0.0, -5.0],
ColliderShape::Box {
half_extents: [2.0, 2.0, 0.05],
},
RigidBodyKind::Fixed,
);
let bullet = cpu_body(
world,
[0.0; 3],
ColliderShape::Sphere { radius: 0.1 },
RigidBodyKind::Dynamic,
);
world.get_mut::<RigidBody>(bullet).unwrap().linear_velocity =
[0.0, 0.0, -270.0];
run_fixed_steps(&mut app, 5);
let z = app.world().get::<Transform>(bullet).unwrap().position[2];
assert!(z > -5.0 && z < -4.8, "bullet stops at the wall: {z}");
}
#[test]
fn gpu_bodies_extract_their_collider_and_see_cpu_colliders() {
let mut app = App::new();
app.add_plugin(HybridPhysicsPlugin).unwrap();
app.add_plugin(RenderExtractPlugin).unwrap();
let world = app.world_mut();
cpu_body(
world,
[0.0, -0.5, 0.0],
ColliderShape::Box {
half_extents: [5.0, 0.5, 5.0],
},
RigidBodyKind::Fixed,
);
let sensor =
cpu_body(world, [3.0, 0.0, 0.0], UNIT_BOX, RigidBodyKind::Fixed);
world.get_mut::<Collider>(sensor).unwrap().sensor = true;
let gpu =
cpu_body(world, [0.0, 5.0, 0.0], UNIT_BOX, RigidBodyKind::Dynamic);
world.get_mut::<PhysicsBody>(gpu).unwrap().simulation =
SimulationClass::Gpu;
run_fixed_steps(&mut app, 2);
let render_world = app.world().resource::<RenderWorld>();
assert_eq!(render_world.gpu_physics.len(), 1);
let (collider, _) = render_world.gpu_physics[0].collider.unwrap();
assert_eq!(collider.shape, UNIT_BOX);
assert_eq!(render_world.gpu_colliders.len(), 1);
let ground = render_world.gpu_colliders[0];
assert_eq!(ground.shape, [0.0, 5.0, 0.5, 5.0]);
assert_eq!(ground.model[3], [0.0, -0.5, 0.0, 1.0]);
let revision = render_world.gpu_physics_revision;
app.world_mut().get_mut::<Collider>(gpu).unwrap().friction = 0.9;
run_fixed_steps(&mut app, 1);
assert_ne!(
app.world().resource::<RenderWorld>().gpu_physics_revision,
revision
);
}
#[test]
fn custom_solver_files_reach_the_render_world_once_per_path() {
let dir = std::env::temp_dir()
.join(format!("rusting-custom-solver-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("rise.glsl").to_string_lossy().into_owned();
std::fs::write(&path, "void solve(inout PhysicsState body) {}").unwrap();
let missing = dir.join("missing.glsl").to_string_lossy().into_owned();
let mut app = App::new();
app.add_plugin(HybridPhysicsPlugin).unwrap();
app.add_plugin(RenderExtractPlugin).unwrap();
let world = app.world_mut();
for (solver, shader) in [
(PhysicsSolver::Custom, Some(&path)),
(PhysicsSolver::Custom, Some(&path)),
(PhysicsSolver::Custom, Some(&missing)),
(PhysicsSolver::Full, Some(&path)),
] {
let body = cpu_body(world, [0.0; 3], UNIT_BOX, RigidBodyKind::Dynamic);
let mut physics = world.get_mut::<PhysicsBody>(body).unwrap();
physics.simulation = SimulationClass::Gpu;
physics.solver = solver;
physics.custom_shader = shader.cloned();
}
run_fixed_steps(&mut app, 1);
let render_world = app.world().resource::<RenderWorld>();
let shaders = render_world
.gpu_physics
.iter()
.map(|body| body.custom_shader.as_deref())
.collect::<Vec<_>>();
assert_eq!(shaders.iter().filter(|shader| shader.is_some()).count(), 3);
let solvers = &render_world.gpu_solver_shaders;
assert_eq!(solvers.len(), 2, "{solvers:?}");
let rise = format!("SOLVER_ID = {}u", custom_solver_id(&path));
assert!(solvers.iter().any(|source| source.contains(&rise)
&& source.contains("void solve(inout PhysicsState body) {}")));
assert!(solvers
.iter()
.any(|source| source.contains("#error cannot read custom solver")));
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn cpu_shape_casts_stop_before_colliders_and_characters_slide() {
let mut app = App::new();
let world = app.world_mut();
let ground = cpu_body(
world,
[0.0, -0.5, 0.0],
ColliderShape::Box {
half_extents: [10.0, 0.5, 10.0],
},
RigidBodyKind::Fixed,
);
let wall = cpu_body(
world,
[3.0, 1.0, 0.0],
ColliderShape::Box {
half_extents: [0.5, 1.0, 5.0],
},
RigidBodyKind::Fixed,
);
run_fixed_steps(&mut app, 1);
let physics = app.world().resource::<PhysicsWorld>();
let ball = ColliderShape::Sphere { radius: 0.3 };
let capsule = ColliderShape::Capsule {
half_height: 0.5,
radius: 0.3,
};
let down = physics
.shape_cast(
ball,
[0.0, 1.0, 0.0],
[0.0, -1.0, 0.0],
5.0,
u32::MAX,
None,
)
.unwrap();
assert_eq!(down.entity, ground);
assert!((down.distance - 0.7).abs() < 1e-3, "{down:?}");
assert!(down.normal[1] > 0.99, "{down:?}");
let side = physics
.shape_cast(
capsule,
[0.0, 1.0, 0.0],
[1.0, 0.0, 0.0],
5.0,
u32::MAX,
None,
)
.unwrap();
assert_eq!(side.entity, wall);
assert!((side.distance - 2.2).abs() < 1e-3, "{side:?}");
assert!(side.normal[0] < -0.99, "{side:?}");
let cast = |exclude, mask, max| {
physics.shape_cast(
capsule,
[0.0, 1.0, 0.0],
[1.0, 0.0, 0.0],
max,
mask,
exclude,
)
};
assert_eq!(cast(Some(wall), u32::MAX, 5.0), None);
assert_eq!(cast(None, 0, 5.0), None);
assert_eq!(cast(None, u32::MAX, 2.0), None);
let resting = [0.0, 0.29, 0.0];
assert_eq!(
physics.shape_cast(ball, resting, [0.0, 1.0, 0.0], 1.0, u32::MAX, None),
None
);
let sink = physics
.shape_cast(ball, resting, [0.0, -1.0, 0.0], 1.0, u32::MAX, None)
.unwrap();
assert_eq!((sink.entity, sink.distance), (ground, 0.0));
let moved = physics.move_character(
capsule,
[0.0, 1.0, 0.0],
[5.0, -2.0, 1.0],
u32::MAX,
None,
);
assert!(moved.grounded);
assert!((moved.position[0] - 2.19).abs() < 0.02, "{moved:?}");
assert!((moved.position[1] - 0.81).abs() < 0.02, "{moved:?}");
assert!((moved.position[2] - 1.0).abs() < 0.02, "{moved:?}");
}
#[test]
fn gpu_events_place_move_and_remove_a_cpu_query_proxy() {
let mut app = App::new();
app.add_plugin(HybridPhysicsPlugin).unwrap();
let (place, remove) = {
let mut registry = app.world_mut().resource_mut::<GpuEventRegistry>();
(registry.register("seen"), registry.register("gone"))
};
let body = app.spawn((
Transform::default(),
PhysicsBody {
simulation: SimulationClass::Gpu,
..PhysicsBody::default()
},
GpuQueryProxy {
collider: Collider {
shape: ColliderShape::Sphere { radius: 0.5 },
..Collider::default()
},
layers: CollisionLayers::default(),
place_on: place,
remove_on: Some(remove),
},
));
app.update(Duration::ZERO).unwrap();
let id = *app.world().get::<PhysicsId>(body).unwrap();
let send = |app: &mut App, event: GpuEventId, x: f32| {
let raw = RawGpuPhysicsEvent {
body_slot: id.slot,
body_generation: id.generation,
event_id: event.0,
payload_kind: GpuEventPayload::Position as u32,
payload: [x, 0.0, 0.0, 0.0],
..Default::default()
};
route_gpu_physics_events(app.world_mut(), &[raw]);
app.update(Duration::ZERO).unwrap();
run_fixed_steps(app, 1);
};
let down = [0.0, -1.0, 0.0];
let hit_x = |app: &App, x: f32| {
app.world()
.resource::<PhysicsWorld>()
.raycast([x, 5.0, 0.0], down, 20.0, u32::MAX)
.map(|hit| hit.entity)
};
send(&mut app, place, 3.0);
let proxy = hit_x(&app, 3.0).expect("proxy is placed");
assert_eq!(
app.world().get::<GpuProxyOf>(proxy),
Some(&GpuProxyOf(body))
);
send(&mut app, place, -3.0);
assert_eq!(hit_x(&app, 3.0), None);
assert_eq!(hit_x(&app, -3.0), Some(proxy), "the same proxy moves");
send(&mut app, remove, 0.0);
assert_eq!(hit_x(&app, -3.0), None);
assert!(app.world().get_entity(proxy).is_err());
}
#[test]
fn auto_simulation_picks_cpu_or_gpu_and_stays_overridable() {
let mut app = App::new();
app.add_plugin(HybridPhysicsPlugin).unwrap();
app.insert_resource(PhysicsBackendStatus {
gameplay_available: true,
gpu_dynamic_available: true,
});
app.insert_resource(AutoAllocationPolicy {
gpu_min_bodies: 3,
..AutoAllocationPolicy::default()
});
let auto = |app: &mut App, extra: PhysicsBody| {
app.spawn((Transform::default(), extra, AutoSimulation::default()))
};
let decision = |app: &App, entity| {
let decision = app.world().get::<AutoSimulation>(entity).unwrap();
let class = app.world().get::<PhysicsBody>(entity).unwrap().simulation;
let decision = decision.decision.unwrap();
assert_eq!(decision.class, class);
(class, decision.reason)
};
let first = auto(&mut app, PhysicsBody::default());
let second = auto(&mut app, PhysicsBody::default());
app.update(Duration::ZERO).unwrap();
for entity in [first, second] {
assert_eq!(
decision(&app, entity),
(SimulationClass::Cpu, AllocationReason::FewBodies)
);
}
let third = auto(&mut app, PhysicsBody::default());
app.update(Duration::ZERO).unwrap();
assert_eq!(
decision(&app, third),
(SimulationClass::Gpu, AllocationReason::ManyBodies)
);
assert!(app.world().get::<PhysicsId>(third).is_some());
assert_eq!(decision(&app, first).0, SimulationClass::Cpu);
let kinematic = auto(&mut app, PhysicsBody::default());
app.world_mut().entity_mut(kinematic).insert(RigidBody {
kind: RigidBodyKind::Kinematic,
..RigidBody::default()
});
let sensor = auto(&mut app, PhysicsBody::default());
app.world_mut().entity_mut(sensor).insert(Collider {
sensor: true,
..Collider::default()
});
let watched = auto(&mut app, PhysicsBody::default());
app.world_mut()
.entity_mut(watched)
.insert(PhysicsSyncMode::SelectedState);
let custom = auto(
&mut app,
PhysicsBody {
solver: PhysicsSolver::Custom,
..PhysicsBody::default()
},
);
let wall = auto(
&mut app,
PhysicsBody {
simulation: SimulationClass::Static,
..PhysicsBody::default()
},
);
app.update(Duration::ZERO).unwrap();
assert_eq!(decision(&app, kinematic).1, AllocationReason::Kinematic);
assert_eq!(decision(&app, sensor).1, AllocationReason::CpuOnlyCollider);
assert_eq!(
decision(&app, watched).1,
AllocationReason::ReadsStateEveryTick
);
assert_eq!(
decision(&app, custom),
(SimulationClass::Gpu, AllocationReason::CustomSolver)
);
assert_eq!(
decision(&app, wall),
(SimulationClass::Static, AllocationReason::NotDynamic)
);
let manual = app.spawn((
Transform::default(),
PhysicsBody {
simulation: SimulationClass::Gpu,
..PhysicsBody::default()
},
));
app.world_mut().entity_mut(first).remove::<AutoSimulation>();
app.world_mut()
.get_mut::<PhysicsBody>(first)
.unwrap()
.simulation = SimulationClass::Gpu;
app.world_mut()
.resource_mut::<AutoAllocationPolicy>()
.gpu_min_bodies = 2;
app.world_mut()
.get_mut::<AutoSimulation>(second)
.unwrap()
.decision = None;
app.update(Duration::ZERO).unwrap();
let world = app.world();
assert_eq!(
world.get::<PhysicsBody>(manual).unwrap().simulation,
SimulationClass::Gpu
);
assert_eq!(
world.get::<PhysicsBody>(first).unwrap().simulation,
SimulationClass::Gpu
);
assert_eq!(
decision(&app, second),
(SimulationClass::Gpu, AllocationReason::ManyBodies)
);
app.insert_resource(PhysicsBackendStatus::default());
let offline = auto(
&mut app,
PhysicsBody {
solver: PhysicsSolver::Custom,
..PhysicsBody::default()
},
);
app.update(Duration::ZERO).unwrap();
assert_eq!(
decision(&app, offline),
(SimulationClass::Cpu, AllocationReason::NoGpuBackend)
);
app.insert_resource(PhysicsBackendStatus {
gameplay_available: true,
gpu_dynamic_available: true,
});
app.world_mut()
.resource_mut::<AutoAllocationPolicy>()
.gpu_min_bodies = 1_000;
app.world_mut().resource_mut::<CpuFrameTimings>().physics =
Duration::from_millis(10);
let late = auto(&mut app, PhysicsBody::default());
app.update(Duration::ZERO).unwrap();
assert_eq!(
decision(&app, late),
(SimulationClass::Gpu, AllocationReason::CpuOverBudget)
);
}
#[test]
fn player_controller_falls_walks_jumps_and_stops_at_walls() {
let mut app = App::new();
cpu_ground(app.world_mut());
let wall = cpu_body(
app.world_mut(),
[0.0, 1.0, -2.0],
ColliderShape::Box {
half_extents: [5.0, 1.0, 0.5],
},
RigidBodyKind::Fixed,
);
let trigger = cpu_body(
app.world_mut(),
[0.0, 0.5, -1.0],
UNIT_BOX,
RigidBodyKind::Fixed,
);
app.world_mut().get_mut::<Collider>(trigger).unwrap().sensor = true;
let player = cpu_body(
app.world_mut(),
[0.0, 3.0, 0.0],
DEFAULT_PLAYER_SHAPE,
RigidBodyKind::Kinematic,
);
app.world_mut()
.entity_mut(player)
.insert(PlayerController::default());
let body =
|app: &App| app.world().get::<Transform>(player).unwrap().position;
let state =
|app: &App| *app.world().get::<PlayerController>(player).unwrap();
let run = |app: &mut App, steps| {
let mut triggered = false;
for _ in 0..steps {
run_fixed_steps(app, 1);
triggered |= app
.world()
.resource::<EventQueue<CollisionEvent>>()
.iter()
.any(|event| {
let pair = [event.a, event.b];
event.sensor
&& pair.contains(&trigger)
&& pair.contains(&player)
});
}
triggered
};
assert!(!run(&mut app, 90));
assert!(state(&app).grounded);
assert!((body(&app)[1] - 0.91).abs() < 0.02, "{:?}", body(&app));
app.world_mut()
.resource_mut::<RuntimeInput>()
.record_key(KeyCode::KeyW, true);
let triggered = run(&mut app, 60);
let stopped = body(&app);
assert!((stopped[2] + 1.19).abs() < 0.02, "{stopped:?}");
assert_eq!(stopped[0], 0.0);
assert!(triggered, "walking through the sensor reported nothing");
let ahead = app
.world()
.resource::<PhysicsWorld>()
.raycast(stopped, [0.0, 0.0, -1.0], 1.0, 0b1)
.unwrap();
assert_eq!(ahead.entity, wall);
let mut input = app.world_mut().resource_mut::<RuntimeInput>();
input.record_key(KeyCode::KeyW, false);
input.record_key(KeyCode::Space, true);
run(&mut app, 10);
app.world_mut()
.resource_mut::<RuntimeInput>()
.clear_frame_edges();
assert!(!state(&app).grounded);
assert!(body(&app)[1] > 1.3, "{:?}", body(&app));
run(&mut app, 90);
assert!(state(&app).grounded);
assert!((body(&app)[1] - 0.91).abs() < 0.02, "{:?}", body(&app));
}
#[test]
fn player_look_needs_captured_cursor_and_clamps_pitch() {
let mut app = App::new();
let player = app.spawn((Transform::default(), PlayerController::default()));
let camera =
app.spawn((Transform::new([0.0, 0.7, 0.0]), Camera::default()));
app.set_parent(camera, player).unwrap();
let look = |app: &mut App, motion: [f32; 2]| {
app.world_mut()
.resource_mut::<RuntimeInput>()
.record_mouse_motion(motion);
app.update(Duration::ZERO).unwrap();
app.world_mut()
.resource_mut::<RuntimeInput>()
.clear_frame_edges();
let player = *app.world().get::<PlayerController>(player).unwrap();
(player.yaw, player.pitch)
};
assert_eq!(look(&mut app, [100.0, 0.0]), (0.0, 0.0));
app.world_mut()
.resource_mut::<RuntimeInput>()
.record_mouse_button(MouseButton::Left, true);
let (yaw, pitch) = look(&mut app, [100.0, 0.0]);
assert!(app.world().resource::<RuntimeInput>().cursor_captured());
assert!((yaw + 0.2).abs() < 1e-6 && pitch == 0.0);
let (_, pitch) = look(&mut app, [0.0, -10_000.0]);
assert!((pitch - 89.0_f32.to_radians()).abs() < 1e-6);
let world = app.world();
assert_eq!(
world.get::<Transform>(player).unwrap().rotation,
[0.0, yaw, 0.0]
);
assert_eq!(
world.get::<Transform>(camera).unwrap().rotation,
[pitch, 0.0, 0.0]
);
app.world_mut()
.resource_mut::<RuntimeInput>()
.record_key(KeyCode::Escape, true);
look(&mut app, [0.0; 2]);
assert!(!app.world().resource::<RuntimeInput>().cursor_captured());
}
#[test]
fn player_controller_settings_round_trip_through_scene_registry() {
let mut app = App::new();
let player = app.spawn((
Transform::default(),
PlayerController {
walk_speed: 7.0,
yaw: 1.0,
vertical_speed: -3.0,
..PlayerController::default()
},
));
let values = registered_component_values(app.world(), player).unwrap();
let (_, json) = values
.iter()
.find(|(name, _)| name == PLAYER_CONTROLLER_COMPONENT)
.unwrap();
let loaded: PlayerController = serde_json::from_str(json).unwrap();
assert_eq!(loaded.walk_speed, 7.0);
assert_eq!(loaded.yaw, 1.0);
assert_eq!(loaded.vertical_speed, 0.0);
}
#[cfg(feature = "ui")]
#[test]
fn runtime_ui_systems_draw_and_receive_clicks_each_update() {
#[derive(Resource, Default)]
struct Hud {
button: Option<egui::Rect>,
clicks: u32,
}
fn hud(ui: Res<RuntimeUi>, mut hud: ResMut<Hud>) {
egui::Area::new("hud".into()).show(ui.context(), |ui| {
ui.label("Score 3");
let response = ui.button("Restart");
hud.button = Some(response.rect);
hud.clicks += u32::from(response.clicked());
});
}
let mut app = App::new();
app.world_mut().init_resource::<Hud>();
app.add_system(ScheduleStage::Update, hud);
let frame = Duration::from_millis(16);
app.update(frame).unwrap();
app.update(frame).unwrap();
let output = app.world_mut().resource_mut::<RuntimeUi>().take_output();
let output = output.expect("every update finishes a UI pass");
assert!(!output.shapes.is_empty());
assert!(!output.textures_delta.set.is_empty(), "font atlas upload");
let context = app.world().resource::<RuntimeUi>().context().clone();
assert!(!context
.tessellate(output.shapes, output.pixels_per_point)
.is_empty());
let center = app.world().resource::<Hud>().button.unwrap().center();
let button = |pressed| egui::Event::PointerButton {
pos: center,
button: egui::PointerButton::Primary,
pressed,
modifiers: egui::Modifiers::NONE,
};
app.world_mut()
.resource_mut::<RuntimeUi>()
.set_input(egui::RawInput {
events: vec![egui::Event::PointerMoved(center), button(true)],
..Default::default()
});
app.update(frame).unwrap();
app.world_mut()
.resource_mut::<RuntimeUi>()
.set_input(egui::RawInput {
events: vec![button(false)],
..Default::default()
});
app.update(frame).unwrap();
assert_eq!(app.world().resource::<Hud>().clicks, 1);
app.update(frame).unwrap();
assert_eq!(app.world().resource::<Hud>().clicks, 1);
}
#[test]
fn easing_curves_start_at_zero_and_end_at_one() {
for easing in [
Easing::Linear,
Easing::QuadIn,
Easing::QuadOut,
Easing::QuadInOut,
Easing::CubicOut,
Easing::SineInOut,
Easing::BackOut,
Easing::BounceOut,
] {
assert!(easing.apply(0.0).abs() < 1e-5, "{easing:?}");
assert!((easing.apply(1.0) - 1.0).abs() < 1e-5, "{easing:?}");
}
assert!(Easing::QuadIn.apply(0.5) < 0.5);
assert!(Easing::QuadOut.apply(0.5) > 0.5);
assert!(Easing::BackOut.apply(0.8) > 1.0);
}
#[test]
fn tweens_play_once_loop_and_ping_pong_on_the_fixed_step() {
let mut app = App::new();
let tween = |repeat| Tween {
from: [0.0; 3],
to: [2.0, 0.0, 0.0],
duration: 1.0,
delay: 0.5,
easing: Easing::Linear,
repeat,
..Tween::default()
};
let once = app.spawn((Transform::default(), tween(TweenRepeat::Once)));
let looped = app.spawn((Transform::default(), tween(TweenRepeat::Loop)));
let ping = app.spawn((Transform::default(), tween(TweenRepeat::PingPong)));
let scaled = app.spawn((
Transform::default(),
Tween {
property: TweenProperty::Scale,
from: [1.0; 3],
to: [3.0; 3],
repeat: TweenRepeat::Once,
..tween(TweenRepeat::Once)
},
));
let x = |app: &App, entity| {
app.world().get::<Transform>(entity).unwrap().position[0]
};
run_fixed_steps(&mut app, 30);
assert!(x(&app, once).abs() < 1e-4);
run_fixed_steps(&mut app, 30);
assert!((x(&app, once) - 1.0).abs() < 1e-3, "{}", x(&app, once));
run_fixed_steps(&mut app, 45);
assert!((x(&app, once) - 2.0).abs() < 1e-4);
assert!(app.world().get::<Tween>(once).unwrap().finished());
assert!((x(&app, looped) - 0.5).abs() < 1e-3, "{}", x(&app, looped));
assert!((x(&app, ping) - 1.5).abs() < 1e-3, "{}", x(&app, ping));
let scale = app.world().get::<Transform>(scaled).unwrap().scale;
assert!((scale[1] - 3.0).abs() < 1e-4, "{scale:?}");
}
#[test]
fn landing_fires_one_sound_and_a_seeded_burst_that_expires() {
let run = || {
let mut app = App::new();
cpu_ground(app.world_mut());
let crate_body = cpu_body(
app.world_mut(),
[0.0, 1.0, 0.0],
UNIT_BOX,
RigidBodyKind::Dynamic,
);
app.world_mut().entity_mut(crate_body).insert((
SceneId(uuid::Uuid::from_u128(7)),
SoundCue {
clip: "sounds/land.ogg".into(),
..SoundCue::default()
},
BurstEmitter {
count: 5,
lifetime: 0.25,
..BurstEmitter::default()
},
));
let mut sounds = Vec::new();
let mut first_burst = None;
for _ in 0..90 {
run_fixed_steps(&mut app, 1);
sounds.extend(
app.world()
.resource::<EventQueue<SoundEvent>>()
.iter()
.cloned(),
);
let world = app.world_mut();
let mut particles = world.query::<(&BurstParticle, &Transform)>();
if first_burst.is_none() && particles.iter(world).len() > 0 {
let mut positions: Vec<_> = particles
.iter(world)
.map(|(particle, _)| particle.velocity)
.collect();
positions.sort_by(|a, b| a.partial_cmp(b).unwrap());
first_burst = Some(positions);
}
}
let left = app
.world_mut()
.query::<&BurstParticle>()
.iter(app.world())
.count();
(app, crate_body, sounds, first_burst.unwrap(), left)
};
let (mut app, crate_body, sounds, burst, left) = run();
assert_eq!(sounds.len(), 1, "{sounds:?}");
assert_eq!(sounds[0].entity, crate_body);
assert_eq!(sounds[0].clip, "sounds/land.ogg");
assert_eq!(burst.len(), 5);
for velocity in &burst {
let speed = velocity.iter().map(|axis| axis * axis).sum::<f32>();
assert!(speed.sqrt() <= 3.0 + 1e-4, "{velocity:?}");
}
assert_eq!(left, 0, "particles outlive their lifetime");
assert_eq!(run().3, burst);
app.world_mut()
.get_mut::<SoundCue>(crate_body)
.unwrap()
.trigger();
app.world_mut()
.get_mut::<BurstEmitter>(crate_body)
.unwrap()
.trigger();
run_fixed_steps(&mut app, 2);
assert_eq!(app.world().resource::<EventQueue<SoundEvent>>().len(), 1);
assert_eq!(
app.world_mut()
.query::<&BurstParticle>()
.iter(app.world())
.count(),
5
);
}
#[cfg(feature = "ui")]
#[test]
fn hud_draws_scene_text_and_reports_button_clicks() {
let mut app = App::new();
app.spawn(HudElement {
text: "Score: 3".into(),
..HudElement::default()
});
let button = app.spawn(HudElement {
text: "Restart".into(),
anchor: HudAnchor::TopLeft,
offset: [100.0, 100.0],
button: true,
..HudElement::default()
});
let screen =
egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(800.0, 600.0));
let frame = |app: &mut App, events: Vec<egui::Event>| {
app.world_mut()
.resource_mut::<RuntimeUi>()
.set_input(egui::RawInput {
screen_rect: Some(screen),
events,
..egui::RawInput::default()
});
app.update(Duration::from_millis(16)).unwrap();
};
frame(&mut app, Vec::new());
frame(&mut app, Vec::new());
let output = app
.world_mut()
.resource_mut::<RuntimeUi>()
.take_output()
.unwrap();
assert!(!output.shapes.is_empty());
let at = egui::pos2(110.0, 110.0);
let press = |pressed| egui::Event::PointerButton {
pos: at,
button: egui::PointerButton::Primary,
pressed,
modifiers: egui::Modifiers::default(),
};
frame(&mut app, vec![egui::Event::PointerMoved(at), press(true)]);
frame(&mut app, vec![press(false)]);
frame(&mut app, Vec::new());
let pressed: Vec<_> = app
.world()
.resource::<EventQueue<HudButtonPressed>>()
.iter()
.copied()
.collect();
assert_eq!(pressed, vec![HudButtonPressed { entity: button }]);
}
#[test]
fn tile_maps_spawn_merged_colliders_and_rebuild_or_clean_up() {
let mut app = App::new();
app.add_plugin(crate::assets::AssetPlugin).unwrap();
let map = app.spawn((
Transform::new([-2.0, 1.0, 0.0]),
TileMap {
tile_size: 0.5,
rows: vec!["#..#".into(), "##x#".into()],
tiles: std::collections::BTreeMap::from([
("#".into(), TileKind::default()),
(
"x".into(),
TileKind {
solid: false,
..TileKind::default()
},
),
]),
},
));
let parts = |app: &mut App| {
let world = app.world_mut();
let mut boxes: Vec<_> = world
.query::<(&TileOf, &Collider, &Transform)>()
.iter(world)
.map(|(_, collider, transform)| {
let ColliderShape::Box { half_extents } = collider.shape else {
panic!("tile colliders are boxes");
};
(transform.position, half_extents)
})
.collect();
boxes.sort_by(|a, b| a.partial_cmp(b).unwrap());
let drawn = world
.query::<(&TileOf, &MeshRenderer)>()
.iter(world)
.count();
(drawn, boxes)
};
app.update(Duration::ZERO).unwrap();
let (drawn, boxes) = parts(&mut app);
assert_eq!(drawn, 6);
assert_eq!(
boxes,
vec![
([-1.75, 0.75, 0.0], [0.25, 0.25, 0.25]),
([-1.5, 0.25, 0.0], [0.5, 0.25, 0.25]),
([-0.25, 0.25, 0.0], [0.25, 0.25, 0.25]),
([-0.25, 0.75, 0.0], [0.25, 0.25, 0.25]),
]
);
app.world_mut().get_mut::<TileMap>(map).unwrap().rows = vec!["#".into()];
app.update(Duration::ZERO).unwrap();
assert_eq!(parts(&mut app).0, 1);
app.despawn(map).unwrap();
app.update(Duration::ZERO).unwrap();
let world = app.world_mut();
assert_eq!(world.query::<&TileOf>().iter(world).count(), 0);
}
#[test]
fn platformer_runs_jumps_and_lands_on_tiles() {
let mut app = App::new();
app.add_plugin(crate::assets::AssetPlugin).unwrap();
app.spawn((
Transform::new([-5.0, 0.0, 0.0]),
TileMap {
rows: vec!["##########".into()],
..TileMap::default()
},
));
let player = app.spawn((
Transform::new([-2.5, 1.0, 0.0]),
PlatformerController::default(),
));
let state = |app: &App| {
(
*app.world().get::<PlatformerController>(player).unwrap(),
app.world().get::<Transform>(player).unwrap().position,
)
};
run_fixed_steps(&mut app, 60);
let (landed, start) = state(&app);
assert!(landed.grounded);
assert!((start[1] - 0.5).abs() < 0.02, "{start:?}");
let mut input = app.world_mut().resource_mut::<RuntimeInput>();
input.record_key(KeyCode::ArrowRight, true);
input.record_key(KeyCode::Space, true);
run_fixed_steps(&mut app, 1);
app.world_mut()
.resource_mut::<RuntimeInput>()
.clear_frame_edges();
run_fixed_steps(&mut app, 10);
let (airborne, high) = state(&app);
assert!(!airborne.grounded);
assert!(high[1] > start[1] + 0.8, "{high:?}");
assert!(high[0] > start[0] + 0.5, "{high:?}");
assert_eq!(high[2], 0.0);
run_fixed_steps(&mut app, 60);
let (back, end) = state(&app);
assert!(back.grounded);
assert!((end[1] - 0.5).abs() < 0.02, "{end:?}");
}
#[test]
fn pickups_count_hide_and_unlock_in_scene_id_order() {
let mut app = App::new();
let world = app.world_mut();
let player = cpu_body(world, [0.0; 3], UNIT_BOX, RigidBodyKind::Kinematic);
world.entity_mut(player).insert(PlatformerController {
gravity: 0.0,
..PlatformerController::default()
});
let pickup = |world: &mut bevy_ecs::world::World, id, pickup| {
let entity =
cpu_body(world, [0.5, 0.0, 0.0], UNIT_BOX, RigidBodyKind::Fixed);
world.get_mut::<Collider>(entity).unwrap().sensor = true;
world
.entity_mut(entity)
.insert((SceneId(uuid::Uuid::from_u128(id)), pickup));
entity
};
let goal = pickup(
world,
1,
Pickup {
counter: "won".into(),
requires: Some("coins".into()),
..Pickup::default()
},
);
let coin = pickup(
world,
2,
Pickup {
counter: "coins".into(),
..Pickup::default()
},
);
world.entity_mut(coin).insert(BurstEmitter {
count: 3,
on_collision: false,
..BurstEmitter::default()
});
pickup(
world,
3,
Pickup {
counter: "coins".into(),
value: 2,
..Pickup::default()
},
);
let counter = |world: &mut bevy_ecs::world::World, name: &str, target| {
world
.spawn(Counter {
name: name.into(),
value: 0,
target: Some(target),
})
.id()
};
let coins = counter(world, "coins", 3);
let won = counter(world, "won", 1);
let mut ticks = 0;
while !app.world().get::<Pickup>(coin).unwrap().collected {
assert!(ticks < 10, "the coin was never collected");
run_fixed_steps(&mut app, 1);
ticks += 1;
}
let world = app.world();
assert_eq!(world.get::<Counter>(coins).unwrap().value, 3);
assert!(!world.get::<Pickup>(goal).unwrap().collected);
assert!(!world.get::<Visibility>(coin).unwrap().visible);
run_fixed_steps(&mut app, 2);
let world = app.world_mut();
assert!(world.get::<Pickup>(goal).unwrap().collected);
assert_eq!(world.get::<Counter>(won).unwrap().value, 1);
assert_eq!(world.get::<Counter>(coins).unwrap().value, 3);
assert_eq!(world.query::<&BurstParticle>().iter(world).count(), 3);
}
#[test]
fn hud_text_fills_counter_placeholders() {
let coins = Counter {
name: "coins".into(),
value: 4,
target: Some(5),
};
let counters = [(&coins, None)];
assert_eq!(
hud_text("Coins {coins}/5 {missing} {", counters.iter().copied()),
"Coins 4/5 {missing} {"
);
}
#[test]
fn shader_pragmas_declare_determinism() {
let source =
"#version 450\n // rusting: determinism = Local\nvoid main() {}";
assert_eq!(shader_determinism(source), Some(DeterminismMode::Local));
assert_eq!(
shader_determinism("// rusting: determinism = cross-platform"),
Some(DeterminismMode::CrossPlatform)
);
assert_eq!(shader_determinism("void main() {}"), None);
assert_eq!(shader_determinism("// rusting: determinism = fast"), None);
}
#[test]
fn determinism_check_names_every_part_below_the_project_mode() {
use bevy_ecs::world::World;
let mut world = World::new();
world.spawn(PhysicsBody {
simulation: SimulationClass::Cpu,
..PhysicsBody::default()
});
world.spawn(PhysicsBody {
simulation: SimulationClass::Gpu,
solver: PhysicsSolver::Custom,
custom_shader: Some("missing/shader.comp".into()),
});
let mut support = DeterminismSupport::default();
support.declare("game_ai", DeterminismMode::CrossPlatform);
support.declare("game_ai", DeterminismMode::Local);
world.insert_resource(support);
world.insert_resource(DeterminismMode::Off);
assert_eq!(check_determinism(&mut world), Ok(()));
world.insert_resource(DeterminismMode::Local);
let error = check_determinism(&mut world).unwrap_err();
assert_eq!(
error.offenders,
vec![DeterminismOffender {
part: "gpu_shader:missing/shader.comp".into(),
supports: DeterminismMode::Off,
}],
"a custom shader without a pragma supports only Off"
);
world.insert_resource(DeterminismMode::CrossPlatform);
let error = check_determinism(&mut world).unwrap_err();
let parts: Vec<_> = error
.offenders
.iter()
.map(|offender| offender.part.as_str())
.collect();
assert_eq!(
parts,
["cpu_physics", "game_ai", "gpu_shader:missing/shader.comp"],
"offenders are sorted, and a second declaration keeps the weaker mode"
);
assert!(error.to_string().contains("cpu_physics (supports Local)"));
}
#[test]
fn engine_fixed_update_systems_have_one_order() {
use bevy_ecs::schedule::{LogLevel, ScheduleBuildSettings};
let mut app = EngineBuilder::new().build().unwrap();
app.fixed_update.set_build_settings(ScheduleBuildSettings {
ambiguity_detection: LogLevel::Error,
..Default::default()
});
let App {
world,
fixed_update,
..
} = &mut app;
fixed_update.initialize(world).unwrap();
}
#[test]
fn simulation_bits_depend_on_ticks_not_frame_pacing() {
let run = |frames: &[Duration]| {
let mut app = App::new();
cpu_ground(app.world_mut());
for (index, height) in [1.0, 2.1, 3.2].into_iter().enumerate() {
let body = cpu_body(
app.world_mut(),
[0.1 * index as f32, height, 0.0],
UNIT_BOX,
RigidBodyKind::Dynamic,
);
let mut rigid = app.world_mut().get_mut::<RigidBody>(body).unwrap();
rigid.angular_velocity = [0.3, 0.0, 0.7];
}
let mut frame = 0;
while app.world().resource::<FrameTime>().fixed_tick < 120 {
app.update(frames[frame % frames.len()]).unwrap();
frame += 1;
}
assert_eq!(app.world().resource::<FrameTime>().fixed_tick, 120);
let world = app.world_mut();
let mut bodies = world
.query::<(bevy_ecs::entity::Entity, &Transform, &RigidBody)>()
.iter(world)
.map(|(entity, transform, rigid)| (entity, *transform, *rigid))
.collect::<Vec<_>>();
bodies.sort_by_key(|(entity, ..)| *entity);
format!("{bodies:?}")
};
let tick = Duration::from_secs_f64(1.0 / 60.0);
let half = tick / 2;
let steady = run(&[tick]);
let uneven = run(&[tick * 3, half, tick - half, Duration::ZERO, tick * 5]);
assert_eq!(steady, uneven);
}
#[test]
fn fixed_steps_see_their_own_tick_and_stamp_gpu_commands_for_the_next() {
#[derive(Resource, Default)]
struct Seen(Vec<u64>);
let id = PhysicsId {
slot: 0,
generation: 0,
};
let mut app = App::new();
app.world_mut().init_resource::<Seen>();
app.world_mut().init_resource::<GpuPhysicsCommands>();
app.add_system(
ScheduleStage::FixedUpdate,
move |time: Res<FrameTime>,
mut seen: ResMut<Seen>,
mut commands: ResMut<GpuPhysicsCommands>| {
seen.0.push(time.fixed_tick);
commands.push(id, GpuBodyCommand::Impulse([1.0; 3]));
},
);
app.add_system(
ScheduleStage::Update,
move |mut commands: ResMut<GpuPhysicsCommands>| {
commands.push(id, GpuBodyCommand::ReadState);
},
);
let tick = Duration::from_secs_f64(1.0 / 60.0);
app.update(tick * 3).unwrap();
assert_eq!(app.world().resource::<Seen>().0, [0, 1, 2]);
assert_eq!(app.world().resource::<FrameTime>().fixed_tick, 3);
super::hybrid_physics::stamp_gpu_commands(app.world_mut());
assert_eq!(
app.world().resource::<GpuPhysicsCommands>().apply_ticks,
[1, 2, 3, 4]
);
}
#[test]
fn emitters_without_scene_ids_draw_their_own_bursts() {
let mut app = App::new();
for x in [0.0, 5.0] {
let mut emitter = BurstEmitter {
count: 3,
..BurstEmitter::default()
};
emitter.trigger();
app.world_mut()
.spawn((Transform::new([x, 0.0, 0.0]), emitter));
}
run_fixed_steps(&mut app, 1);
let world = app.world_mut();
let mut velocities = world
.query::<&BurstParticle>()
.iter(world)
.map(|particle| format!("{:?}", particle.velocity))
.collect::<Vec<_>>();
assert_eq!(velocities.len(), 6);
velocities.sort();
velocities.dedup();
assert_eq!(velocities.len(), 6, "two emitters shared a stream");
}
#[test]
fn state_hashes_cover_every_tick_and_only_simulation_state() {
#[derive(Resource)]
struct Nudge(Option<u64>);
let run = |frames: &[Duration], nudge: Option<u64>| {
let mut app = App::new();
cpu_ground(app.world_mut());
for (index, height) in [1.0, 2.1, 3.2].into_iter().enumerate() {
cpu_body(
app.world_mut(),
[0.1 * index as f32, height, 0.0],
UNIT_BOX,
RigidBodyKind::Dynamic,
);
}
app.world_mut().spawn((
Transform::default(),
PhysicsBody {
simulation: SimulationClass::Gpu,
..PhysicsBody::default()
},
));
app.world_mut().insert_resource(Nudge(nudge));
app.add_system(
ScheduleStage::FixedUpdate,
|time: Res<FrameTime>,
nudge: Res<Nudge>,
mut bodies: Query<&mut RigidBody>| {
if nudge.0 == Some(time.fixed_tick) {
for mut rigid in &mut bodies {
if rigid.kind == RigidBodyKind::Dynamic {
let speed = rigid.linear_velocity[0];
rigid.linear_velocity[0] =
f32::from_bits(speed.to_bits() + 1);
break;
}
}
}
},
);
app.add_system(
ScheduleStage::Update,
|time: Res<FrameTime>,
mut bodies: Query<(&mut Transform, &PhysicsBody)>| {
for (mut transform, body) in &mut bodies {
if body.uses_gpu() {
transform.position[0] = time.frame as f32;
}
}
},
);
let mut frame = 0;
while app.world().resource::<FrameTime>().fixed_tick < 120 {
app.update(frames[frame % frames.len()]).unwrap();
frame += 1;
}
let hashes = &app.world().resource::<StateHashes>().recent;
hashes.iter().copied().take(120).collect::<Vec<_>>()
};
let tick = Duration::from_secs_f64(1.0 / 60.0);
let steady = run(&[tick], None);
assert_eq!(
steady.iter().map(|(tick, _)| *tick).collect::<Vec<_>>(),
(1..=120).collect::<Vec<_>>()
);
let uneven = run(&[tick * 3, tick / 2, tick / 2, Duration::ZERO], None);
assert_eq!(steady, uneven);
let nudged = run(&[tick], Some(40));
for ((tick, before), (_, after)) in steady.iter().zip(&nudged) {
assert_eq!(before == after, *tick <= 40, "tick {tick}");
}
}
#[test]
fn compare_runs_reports_the_first_divergent_tick_and_body() {
#[derive(Resource)]
struct Nudge(u64);
let scene = |nudge: Option<u64>, seed: u64| {
let mut app = App::new();
app.world_mut().insert_resource(RandomSeed(seed));
cpu_ground(app.world_mut());
for (name, height) in [("Low", 1.0), ("Middle", 2.1), ("High", 3.2)] {
let body = cpu_body(
app.world_mut(),
[0.0, height, 0.0],
UNIT_BOX,
RigidBodyKind::Dynamic,
);
app.world_mut().entity_mut(body).insert(Name(name.into()));
}
if let Some(tick) = nudge {
app.world_mut().insert_resource(Nudge(tick));
app.add_system(
ScheduleStage::FixedUpdate,
|time: Res<FrameTime>,
nudge: Res<Nudge>,
mut bodies: Query<(&Name, &mut RigidBody)>| {
for (name, mut rigid) in &mut bodies {
if time.fixed_tick == nudge.0 && name.0 == "Middle" {
rigid.linear_velocity[2] = 1e-6;
}
}
},
);
}
app
};
assert_eq!(compare_runs(|| scene(None, 7), 90).unwrap(), None);
let mut runs = 0;
let divergence = compare_runs(
|| {
runs += 1;
scene((runs == 2).then_some(5), 7)
},
90,
)
.unwrap()
.unwrap();
assert_eq!(divergence.tick, 6);
let entity = divergence.entity.unwrap();
assert_eq!(entity.name.as_deref(), Some("Middle"));
let mut runs = 0;
let divergence = compare_runs(
|| {
runs += 1;
scene(None, runs)
},
90,
)
.unwrap()
.unwrap();
assert_eq!((divergence.tick, divergence.entity), (1, None));
let ticks = [(1, 10), (2, 20), (3, 30)];
assert_eq!(first_divergent_tick(&ticks, &ticks), None);
assert_eq!(first_divergent_tick(&ticks, &[(1, 10), (2, 21)]), Some(2));
assert_eq!(first_divergent_tick(&ticks[..1], &ticks), Some(2));
}
#[test]
fn replays_reproduce_recorded_hashes_and_find_changed_input() {
let scene = || {
let mut app = App::new();
app.add_plugin(crate::assets::AssetPlugin).unwrap();
app.world_mut().insert_resource(RandomSeed(11));
app.spawn((
Transform::new([-5.0, 0.0, 0.0]),
TileMap {
rows: vec!["##########".into()],
..TileMap::default()
},
));
app.spawn((
Transform::new([-2.5, 1.0, 0.0]),
PlatformerController::default(),
));
app
};
let mut app = scene();
app.start_recording();
for frame in 0..90u64 {
let mut input = app.world_mut().resource_mut::<RuntimeInput>();
match frame {
20 => input.record_key(KeyCode::ArrowRight, true),
30 | 55 => input.record_key(KeyCode::Space, true),
31 | 56 => input.record_key(KeyCode::Space, false),
70 => input.record_key(KeyCode::ArrowRight, false),
_ => {}
}
let millis = [16, 33, 5, 50][frame as usize % 4];
app.update(Duration::from_millis(millis)).unwrap();
app.world_mut()
.resource_mut::<RuntimeInput>()
.clear_frame_edges();
}
let replay = app.finish_recording().unwrap();
assert_eq!(replay.format_version, REPLAY_FORMAT_VERSION);
assert_eq!((replay.seed, replay.start_tick), (11, 0));
assert_eq!(replay.frames.len(), 90);
let last_tick = app.world().resource::<FrameTime>().fixed_tick;
assert_eq!(replay.hashes.len() as u64, last_tick);
let stored = replay.frames.iter().filter(|f| f.input.is_some()).count();
assert!(stored < 20, "{stored}");
let text = serde_json::to_string(&replay).unwrap();
let replay: Replay = serde_json::from_str(&text).unwrap();
assert_eq!(play_replay(&mut scene(), &replay).unwrap(), None);
let mut edited = replay.clone();
let input = edited.frames[55].input.as_mut().unwrap();
input.record_key(KeyCode::Space, false);
input.clear_frame_edges();
let expected = edited.frames[56].tick + 1;
assert!(expected > edited.frames[55].tick + 1);
assert_eq!(play_replay(&mut scene(), &edited).unwrap(), Some(expected));
let mut old = replay;
old.format_version = 0;
assert!(matches!(
play_replay(&mut scene(), &old),
Err(ReplayError::UnsupportedVersion(0))
));
}
fn churning_scene() -> (App, Entity) {
let mut app = App::new();
app.add_plugin(crate::assets::AssetPlugin).unwrap();
app.world_mut().insert_resource(RandomSeed(5));
app.spawn((
Transform::new([-5.0, 0.0, 0.0]),
TileMap {
rows: vec!["##########".into()],
..TileMap::default()
},
));
app.spawn((
Transform::new([-2.5, 1.0, 0.0]),
PlatformerController::default(),
));
let emitter = app.spawn((
Transform::new([0.0, 3.0, 0.0]),
BurstEmitter {
count: 30,
lifetime: 0.1,
..BurstEmitter::default()
},
));
(app, emitter)
}
fn churn_frame(app: &mut App, emitter: Entity, frame: u64) {
if frame.is_multiple_of(5) {
app.world_mut()
.get_mut::<BurstEmitter>(emitter)
.unwrap()
.triggered = true;
}
let mut input = app.world_mut().resource_mut::<RuntimeInput>();
match frame {
10 => input.record_key(KeyCode::ArrowRight, true),
25 | 70 => input.record_key(KeyCode::Space, true),
26 | 71 => input.record_key(KeyCode::Space, false),
_ => {}
}
let millis = [16, 33, 5, 50][frame as usize % 4];
app.update(Duration::from_millis(millis)).unwrap();
app.world_mut()
.resource_mut::<RuntimeInput>()
.clear_frame_edges();
}
fn entity_ids(snapshot: &WorldSnapshot) -> Vec<Entity> {
snapshot.entities.iter().map(|saved| saved.entity).collect()
}
#[test]
fn snapshots_restore_entity_ids_and_state_into_a_new_app() {
let (mut original, emitter) = churning_scene();
for frame in 0..38 {
churn_frame(&mut original, emitter, frame);
}
let snapshot = original.snapshot().unwrap();
assert!(
entity_ids(&snapshot)
.iter()
.any(|entity| entity.generation().to_bits() > 0),
"{:?} {:?}",
entity_ids(&snapshot),
snapshot.allocator
);
assert!(
!snapshot.allocator.local.is_empty(),
"{:?}",
snapshot.allocator
);
let (mut restored, _) = churning_scene();
restored.restore(&snapshot).unwrap();
let again = restored.snapshot().unwrap();
assert_eq!(entity_ids(&again), entity_ids(&snapshot));
assert_eq!(again.allocator, snapshot.allocator);
for frame in 38..90 {
churn_frame(&mut original, emitter, frame);
churn_frame(&mut restored, emitter, frame);
}
let hashes =
|app: &App| app.world().resource::<StateHashes>().recent.clone();
assert!(hashes(&original).len() > 50);
assert_eq!(hashes(&restored), hashes(&original));
let (original, restored) =
(original.snapshot().unwrap(), restored.snapshot().unwrap());
assert_eq!(entity_ids(&restored), entity_ids(&original));
assert_eq!(restored.allocator, original.allocator);
}
#[test]
fn snapshots_name_unregistered_types() {
#[derive(bevy_ecs::component::Component, Clone)]
struct Unlisted;
let (mut app, _) = churning_scene();
app.spawn(Unlisted);
let Err(SnapshotError::Unregistered(types)) = app.snapshot() else {
panic!("snapshot took an unregistered component");
};
assert_eq!(types.len(), 1);
assert!(types[0].ends_with("Unlisted"), "{types:?}");
app.register_snapshot_component::<Unlisted>();
assert!(app.snapshot().is_ok());
}