From 7b2acb9201520142539c4d5e0ad40a8578630c7e Mon Sep 17 00:00:00 2001 From: stainlu Date: Thu, 9 Apr 2026 01:42:02 +0800 Subject: [PATCH] Add ExternalForce component and force/torque accumulation pipeline Introduces a general-purpose force/torque system for the physics engine. External code (gameplay, wind, thrusters) can now apply forces through ExternalForce components instead of directly mutating Velocity. - Add ExternalForce component with force, torque, and persistent flag - Add apply_external_forces step in physics_step_single between gravity and integration - One-shot forces auto-clear; persistent forces remain until manually zeroed - Forces on sleeping bodies wake them automatically - 7 new unit tests covering all force/torque behaviors Co-Authored-By: Claude Sonnet 4.6 --- crates/euca-physics/src/components.rs | 16 ++ crates/euca-physics/src/lib.rs | 4 +- crates/euca-physics/src/systems.rs | 271 ++++++++++++++++++++++++++ 3 files changed, 289 insertions(+), 2 deletions(-) diff --git a/crates/euca-physics/src/components.rs b/crates/euca-physics/src/components.rs index 75f7d3a..6a42a91 100644 --- a/crates/euca-physics/src/components.rs +++ b/crates/euca-physics/src/components.rs @@ -100,6 +100,22 @@ pub struct Velocity { pub angular: Vec3, } +/// Accumulated external force and torque applied to a physics body. +/// +/// Gameplay code, wind, thrusters, and other systems write force/torque here; +/// the physics step reads it, applies `F = m*a` integration, then optionally +/// clears it (one-shot mode) so the caller does not need to reset it each frame. +#[derive(Clone, Copy, Debug, Default, Reflect)] +pub struct ExternalForce { + /// World-space force in Newtons. + pub force: Vec3, + /// World-space torque in Newton-meters. + pub torque: Vec3, + /// If `true`, force and torque persist across physics steps (caller clears + /// manually). If `false`, they are zeroed after each step (one-shot impulse). + pub persistent: bool, +} + /// Gravity component (overrides global gravity for this entity). #[derive(Clone, Copy, Debug)] pub struct Gravity(pub Vec3); diff --git a/crates/euca-physics/src/lib.rs b/crates/euca-physics/src/lib.rs index 70314d7..fcfcbdf 100644 --- a/crates/euca-physics/src/lib.rs +++ b/crates/euca-physics/src/lib.rs @@ -16,8 +16,8 @@ mod world; pub use character::{CharacterController, character_controller_system}; pub use collision::{CollisionPair, intersect_aabb}; pub use components::{ - CachedColliderShape, Collider, ColliderShape, CollisionEvent, Gravity, Mass, PhysicsBody, - RigidBodyType, SLEEP_THRESHOLD, Sleeping, Velocity, layers_interact, + CachedColliderShape, Collider, ColliderShape, CollisionEvent, ExternalForce, Gravity, Mass, + PhysicsBody, RigidBodyType, SLEEP_THRESHOLD, Sleeping, Velocity, layers_interact, }; pub use joints::{Joint, JointKind}; pub use raycast::{ diff --git a/crates/euca-physics/src/systems.rs b/crates/euca-physics/src/systems.rs index 7fa57cb..42618af 100644 --- a/crates/euca-physics/src/systems.rs +++ b/crates/euca-physics/src/systems.rs @@ -48,6 +48,7 @@ pub fn physics_step_system(world: &mut World) { fn physics_step_single(world: &mut World, dt: f32, gravity: Vec3) { sync_cached_shapes(world); apply_gravity(world, gravity, dt); + apply_external_forces(world, dt); integrate_positions(world, dt); resolve_collisions_and_joints(world); update_sleep_states(world); @@ -102,6 +103,61 @@ fn apply_gravity(world: &mut World, gravity: Vec3, dt: f32) { } } +/// Apply external forces and torques to dynamic bodies. +/// +/// For each entity with `ExternalForce`, `Mass`, and `Velocity`: +/// - Static/kinematic bodies are skipped (their inverse mass is zero anyway). +/// - Sleeping bodies are skipped unless the force or torque is nonzero, in +/// which case the body is woken first. +/// - Linear: `v += F * inverse_mass * dt` +/// - Angular: `w += T * inverse_inertia * dt` +/// - One-shot forces (`persistent == false`) are zeroed after application. +fn apply_external_forces(world: &mut World, dt: f32) { + let entities: Vec = { + let query = Query::<(Entity, &PhysicsBody, &ExternalForce)>::new(world); + query + .iter() + .filter(|(_, body, _)| body.body_type == RigidBodyType::Dynamic) + .map(|(e, _, _)| e) + .collect() + }; + + for entity in entities { + // Single immutable read to extract all needed fields. + let (force, torque, persistent) = match world.get::(entity) { + Some(ef) => (ef.force, ef.torque, ef.persistent), + None => continue, + }; + + let has_force = force.length_squared() > 0.0 || torque.length_squared() > 0.0; + + // Skip sleeping bodies unless external force/torque is nonzero. + if world.get::(entity).is_some() { + if !has_force { + continue; + } + world.remove::(entity); + } + + let (inv_mass, inv_inertia) = match world.get::(entity) { + Some(m) => (m.inverse_mass, m.inverse_inertia), + None => continue, + }; + + if let Some(vel) = world.get_mut::(entity) { + vel.linear = vel.linear + force * inv_mass * dt; + vel.angular = vel.angular + torque * inv_inertia * dt; + } + + if !persistent { + if let Some(ef) = world.get_mut::(entity) { + ef.force = Vec3::ZERO; + ef.torque = Vec3::ZERO; + } + } + } +} + /// Put slow bodies to sleep, wake bodies involved in collisions. fn update_sleep_states(world: &mut World) { let candidates: Vec<(Entity, f32)> = { @@ -1362,4 +1418,219 @@ mod tests { ); assert!(events[0].penetration > 0.0); } + + // ---- ExternalForce tests ------------------------------------------------ + + /// Helper: create a zero-gravity world with a single dynamic body that has + /// the given mass/inertia and an `ExternalForce` component. Returns the + /// entity. + fn spawn_forced_body(world: &mut World, mass: f32, inertia: f32) -> Entity { + world.insert_resource(PhysicsConfig { + gravity: Vec3::ZERO, + fixed_dt: 1.0 / 60.0, + max_substeps: 1, + }); + let entity = world.spawn(LocalTransform(Transform::from_translation(Vec3::ZERO))); + world.insert(entity, GlobalTransform::default()); + world.insert(entity, PhysicsBody::dynamic()); + world.insert(entity, Mass::new(mass, inertia)); + world.insert(entity, Velocity::default()); + world.insert(entity, ExternalForce::default()); + entity + } + + #[test] + fn constant_force_linear_velocity_grows_linearly() { + let mut world = World::new(); + let entity = spawn_forced_body(&mut world, 1.0, 1.0); + + // Persistent force of 10 N in +X. + world.insert( + entity, + ExternalForce { + force: Vec3::new(10.0, 0.0, 0.0), + torque: Vec3::ZERO, + persistent: true, + }, + ); + + let dt = 1.0 / 60.0; + let n = 10; + for _ in 0..n { + physics_step_system(&mut world); + } + + let vel = world.get::(entity).unwrap(); + let expected = 10.0 * dt * n as f32; // F * inv_mass(=1) * dt * steps + assert!( + (vel.linear.x - expected).abs() < 1e-4, + "Expected vx ~ {expected}, got {}", + vel.linear.x + ); + } + + #[test] + fn constant_torque_angular_velocity_grows_linearly() { + let mut world = World::new(); + let entity = spawn_forced_body(&mut world, 1.0, 2.0); + + world.insert( + entity, + ExternalForce { + force: Vec3::ZERO, + torque: Vec3::new(0.0, 6.0, 0.0), + persistent: true, + }, + ); + + let dt = 1.0 / 60.0; + let inv_inertia = 1.0 / 2.0; + let n = 10; + for _ in 0..n { + physics_step_system(&mut world); + } + + let vel = world.get::(entity).unwrap(); + let expected = 6.0 * inv_inertia * dt * n as f32; + assert!( + (vel.angular.y - expected).abs() < 1e-4, + "Expected wy ~ {expected}, got {}", + vel.angular.y + ); + } + + #[test] + fn oneshot_force_cleared_after_one_step() { + let mut world = World::new(); + let entity = spawn_forced_body(&mut world, 1.0, 1.0); + + world.insert( + entity, + ExternalForce { + force: Vec3::new(60.0, 0.0, 0.0), + torque: Vec3::ZERO, + persistent: false, + }, + ); + + // First step applies the force. + physics_step_system(&mut world); + let vel_after_1 = world.get::(entity).unwrap().linear.x; + assert!(vel_after_1 > 0.0, "Force should have been applied"); + + // Second step: force was cleared, so velocity should not increase. + physics_step_system(&mut world); + let vel_after_2 = world.get::(entity).unwrap().linear.x; + assert!( + (vel_after_2 - vel_after_1).abs() < 1e-6, + "One-shot force should not re-apply: v1={vel_after_1}, v2={vel_after_2}" + ); + } + + #[test] + fn persistent_force_persists_across_steps() { + let mut world = World::new(); + let entity = spawn_forced_body(&mut world, 1.0, 1.0); + + world.insert( + entity, + ExternalForce { + force: Vec3::new(10.0, 0.0, 0.0), + torque: Vec3::ZERO, + persistent: true, + }, + ); + + physics_step_system(&mut world); + let v1 = world.get::(entity).unwrap().linear.x; + + physics_step_system(&mut world); + let v2 = world.get::(entity).unwrap().linear.x; + + // Velocity should keep growing each step. + assert!( + v2 > v1, + "Persistent force should keep accelerating: v1={v1}, v2={v2}" + ); + // And force component should still be set. + let ef = world.get::(entity).unwrap(); + assert!( + ef.force.length_squared() > 0.0, + "Persistent force should not be cleared" + ); + } + + #[test] + fn force_on_sleeping_body_wakes_it() { + let mut world = World::new(); + let entity = spawn_forced_body(&mut world, 1.0, 1.0); + world.insert(entity, Sleeping); + + world.insert( + entity, + ExternalForce { + force: Vec3::new(5.0, 0.0, 0.0), + torque: Vec3::ZERO, + persistent: false, + }, + ); + + physics_step_system(&mut world); + + assert!( + world.get::(entity).is_none(), + "Body should have been woken by external force" + ); + let vel = world.get::(entity).unwrap(); + assert!(vel.linear.x > 0.0, "Force should have been applied"); + } + + #[test] + fn force_on_static_body_is_ignored() { + let mut world = World::new(); + world.insert_resource(PhysicsConfig { + gravity: Vec3::ZERO, + fixed_dt: 1.0 / 60.0, + max_substeps: 1, + }); + + let entity = world.spawn(LocalTransform(Transform::from_translation(Vec3::ZERO))); + world.insert(entity, GlobalTransform::default()); + world.insert(entity, PhysicsBody::fixed()); + world.insert(entity, Mass::infinite()); + world.insert(entity, Velocity::default()); + world.insert( + entity, + ExternalForce { + force: Vec3::new(100.0, 0.0, 0.0), + torque: Vec3::ZERO, + persistent: true, + }, + ); + + physics_step_system(&mut world); + + let vel = world.get::(entity).unwrap(); + assert!( + vel.linear.x.abs() < 1e-8, + "Static body should not be affected by external force, vx={}", + vel.linear.x + ); + } + + #[test] + fn zero_force_on_sleeping_body_does_not_wake_it() { + let mut world = World::new(); + let entity = spawn_forced_body(&mut world, 1.0, 1.0); + world.insert(entity, Sleeping); + // ExternalForce is default (zero), so the body should stay asleep. + + physics_step_system(&mut world); + + assert!( + world.get::(entity).is_some(), + "Zero external force should not wake a sleeping body" + ); + } + }