diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 36e2fe88..fd32c3e0 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -22,7 +22,7 @@ repos: name: Linting for lambda-rs & tools. types: [rust] - id: clippy - entry: rustup run nightly cargo clippy --workspace --all-targets -- -D warnings + entry: cargo clippy --workspace --all-targets -- -D warnings language: system name: Clippy for lambda-rs & tools. pass_filenames: false diff --git a/crates/lambda-rs-platform/src/physics/mod.rs b/crates/lambda-rs-platform/src/physics/mod.rs index 1d7e1724..1c33dcc4 100644 --- a/crates/lambda-rs-platform/src/physics/mod.rs +++ b/crates/lambda-rs-platform/src/physics/mod.rs @@ -6,4 +6,8 @@ pub mod rapier2d; -pub use rapier2d::PhysicsBackend2D; +pub use rapier2d::{ + PhysicsBackend2D, + RigidBody2DBackendError, + RigidBodyType2D, +}; diff --git a/crates/lambda-rs-platform/src/physics/rapier2d.rs b/crates/lambda-rs-platform/src/physics/rapier2d.rs index 85c77d6a..2df5c1d0 100644 --- a/crates/lambda-rs-platform/src/physics/rapier2d.rs +++ b/crates/lambda-rs-platform/src/physics/rapier2d.rs @@ -4,8 +4,92 @@ //! higher-level `lambda-rs` physics APIs without exposing vendor types outside //! of the platform layer. +use std::{ + error::Error, + fmt, +}; + use rapier2d::prelude::*; +/// The rigid body integration mode. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RigidBodyType2D { + /// A body that does not move under simulation. + Static, + /// A body affected by gravity and forces. + Dynamic, + /// A body integrated only by user-provided motion. + Kinematic, +} + +/// Backend errors for 2D rigid body operations. +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum RigidBody2DBackendError { + /// The referenced rigid body was not found. + BodyNotFound, + /// The provided position is invalid. + InvalidPosition { x: f32, y: f32 }, + /// The provided rotation is invalid. + InvalidRotation { radians: f32 }, + /// The provided linear velocity is invalid. + InvalidVelocity { x: f32, y: f32 }, + /// The provided force is invalid. + InvalidForce { x: f32, y: f32 }, + /// The provided impulse is invalid. + InvalidImpulse { x: f32, y: f32 }, + /// The provided dynamic mass is invalid. + InvalidMassKg { mass_kg: f32 }, + /// The requested operation is unsupported for the body type. + UnsupportedOperation { body_type: RigidBodyType2D }, +} + +impl fmt::Display for RigidBody2DBackendError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::BodyNotFound => { + return write!(formatter, "rigid body not found"); + } + Self::InvalidPosition { x, y } => { + return write!(formatter, "invalid position: ({x}, {y})"); + } + Self::InvalidRotation { radians } => { + return write!(formatter, "invalid rotation: {radians}"); + } + Self::InvalidVelocity { x, y } => { + return write!(formatter, "invalid velocity: ({x}, {y})"); + } + Self::InvalidForce { x, y } => { + return write!(formatter, "invalid force: ({x}, {y})"); + } + Self::InvalidImpulse { x, y } => { + return write!(formatter, "invalid impulse: ({x}, {y})"); + } + Self::InvalidMassKg { mass_kg } => { + return write!(formatter, "invalid mass_kg: {mass_kg}"); + } + Self::UnsupportedOperation { body_type } => { + return write!( + formatter, + "unsupported operation for body_type: {body_type:?}" + ); + } + } + } +} + +impl Error for RigidBody2DBackendError {} + +#[derive(Debug, Clone, Copy)] +struct RigidBodySlot2D { + body_type: RigidBodyType2D, + position: [f32; 2], + rotation: f32, + velocity: [f32; 2], + force_accumulator: [f32; 2], + dynamic_mass_kg: f32, + generation: u32, +} + /// A 2D physics backend powered by `rapier2d`. /// /// This type is an internal implementation detail used by `lambda-rs`. @@ -21,6 +105,7 @@ pub struct PhysicsBackend2D { multibody_joints: MultibodyJointSet, ccd_solver: CCDSolver, pipeline: PhysicsPipeline, + rigid_body_slots_2d: Vec, } impl PhysicsBackend2D { @@ -52,9 +137,302 @@ impl PhysicsBackend2D { multibody_joints: MultibodyJointSet::new(), ccd_solver: CCDSolver::new(), pipeline: PhysicsPipeline::new(), + rigid_body_slots_2d: Vec::new(), }; } + /// Creates and stores a new 2D rigid body without colliders. + /// + /// # Arguments + /// - `body_type`: The integration mode for the rigid body. + /// - `position`: The initial position in meters. + /// - `rotation`: The initial rotation in radians. + /// - `velocity`: The initial linear velocity in meters per second. + /// - `dynamic_mass_kg`: The mass in kilograms for dynamic bodies. + /// + /// # Returns + /// Returns a `(slot_index, slot_generation)` pair for the created body. + /// + /// # Errors + /// Returns `RigidBody2DBackendError` if any input is invalid or unsupported. + pub fn create_rigid_body_2d( + &mut self, + body_type: RigidBodyType2D, + position: [f32; 2], + rotation: f32, + velocity: [f32; 2], + dynamic_mass_kg: Option, + ) -> Result<(u32, u32), RigidBody2DBackendError> { + validate_position(position[0], position[1])?; + validate_rotation(rotation)?; + validate_velocity(velocity[0], velocity[1])?; + + let mass_kg = resolve_dynamic_mass_kg(body_type, dynamic_mass_kg)?; + + let slot_index = self.rigid_body_slots_2d.len() as u32; + let slot_generation = 1; + + self.rigid_body_slots_2d.push(RigidBodySlot2D { + body_type, + position, + rotation, + velocity, + force_accumulator: [0.0, 0.0], + dynamic_mass_kg: mass_kg, + generation: slot_generation, + }); + + return Ok((slot_index, slot_generation)); + } + + /// Returns the rigid body type for the referenced body. + /// + /// # Arguments + /// - `slot_index`: The body slot index. + /// - `slot_generation`: The slot generation counter. + /// + /// # Returns + /// Returns the rigid body type. + /// + /// # Errors + /// Returns `RigidBody2DBackendError` if the referenced body does not exist. + pub fn rigid_body_type_2d( + &self, + slot_index: u32, + slot_generation: u32, + ) -> Result { + let body = self.rigid_body_2d(slot_index, slot_generation)?; + return Ok(body.body_type); + } + + /// Returns the current position for the referenced body. + /// + /// # Arguments + /// - `slot_index`: The body slot index. + /// - `slot_generation`: The slot generation counter. + /// + /// # Returns + /// Returns the position in meters. + /// + /// # Errors + /// Returns `RigidBody2DBackendError` if the referenced body does not exist. + pub fn rigid_body_position_2d( + &self, + slot_index: u32, + slot_generation: u32, + ) -> Result<[f32; 2], RigidBody2DBackendError> { + let body = self.rigid_body_2d(slot_index, slot_generation)?; + return Ok(body.position); + } + + /// Returns the current rotation for the referenced body. + /// + /// # Arguments + /// - `slot_index`: The body slot index. + /// - `slot_generation`: The slot generation counter. + /// + /// # Returns + /// Returns the rotation in radians. + /// + /// # Errors + /// Returns `RigidBody2DBackendError` if the referenced body does not exist. + pub fn rigid_body_rotation_2d( + &self, + slot_index: u32, + slot_generation: u32, + ) -> Result { + let body = self.rigid_body_2d(slot_index, slot_generation)?; + return Ok(body.rotation); + } + + /// Returns the current linear velocity for the referenced body. + /// + /// # Arguments + /// - `slot_index`: The body slot index. + /// - `slot_generation`: The slot generation counter. + /// + /// # Returns + /// Returns the linear velocity in meters per second. + /// + /// # Errors + /// Returns `RigidBody2DBackendError` if the referenced body does not exist. + pub fn rigid_body_velocity_2d( + &self, + slot_index: u32, + slot_generation: u32, + ) -> Result<[f32; 2], RigidBody2DBackendError> { + let body = self.rigid_body_2d(slot_index, slot_generation)?; + return Ok(body.velocity); + } + + /// Sets the current position for the referenced body. + /// + /// # Arguments + /// - `slot_index`: The body slot index. + /// - `slot_generation`: The slot generation counter. + /// - `position`: The new position in meters. + /// + /// # Returns + /// Returns `()` after applying the mutation. + /// + /// # Errors + /// Returns `RigidBody2DBackendError` if the input is invalid or the + /// referenced body does not exist. + pub fn rigid_body_set_position_2d( + &mut self, + slot_index: u32, + slot_generation: u32, + position: [f32; 2], + ) -> Result<(), RigidBody2DBackendError> { + validate_position(position[0], position[1])?; + let body = self.rigid_body_2d_mut(slot_index, slot_generation)?; + body.position = position; + return Ok(()); + } + + /// Sets the current rotation for the referenced body. + /// + /// # Arguments + /// - `slot_index`: The body slot index. + /// - `slot_generation`: The slot generation counter. + /// - `rotation`: The new rotation in radians. + /// + /// # Returns + /// Returns `()` after applying the mutation. + /// + /// # Errors + /// Returns `RigidBody2DBackendError` if the input is invalid or the + /// referenced body does not exist. + pub fn rigid_body_set_rotation_2d( + &mut self, + slot_index: u32, + slot_generation: u32, + rotation: f32, + ) -> Result<(), RigidBody2DBackendError> { + validate_rotation(rotation)?; + let body = self.rigid_body_2d_mut(slot_index, slot_generation)?; + body.rotation = rotation; + return Ok(()); + } + + /// Sets the current linear velocity for the referenced body. + /// + /// # Arguments + /// - `slot_index`: The body slot index. + /// - `slot_generation`: The slot generation counter. + /// - `velocity`: The new linear velocity in meters per second. + /// + /// # Returns + /// Returns `()` after applying the mutation. + /// + /// # Errors + /// Returns `RigidBody2DBackendError` if the input is invalid, if the + /// operation is unsupported for the body type, or if the referenced body + /// does not exist. + pub fn rigid_body_set_velocity_2d( + &mut self, + slot_index: u32, + slot_generation: u32, + velocity: [f32; 2], + ) -> Result<(), RigidBody2DBackendError> { + validate_velocity(velocity[0], velocity[1])?; + let body = self.rigid_body_2d_mut(slot_index, slot_generation)?; + + match body.body_type { + RigidBodyType2D::Static => { + return Err(RigidBody2DBackendError::UnsupportedOperation { + body_type: body.body_type, + }); + } + RigidBodyType2D::Dynamic | RigidBodyType2D::Kinematic => { + body.velocity = velocity; + return Ok(()); + } + } + } + + /// Applies a force, in Newtons, at the center of mass. + /// + /// # Arguments + /// - `slot_index`: The body slot index. + /// - `slot_generation`: The slot generation counter. + /// - `force`: The force in Newtons. + /// + /// # Returns + /// Returns `()` after accumulating the force. + /// + /// # Errors + /// Returns `RigidBody2DBackendError` if the input is invalid, if the + /// operation is unsupported for the body type, or if the referenced body + /// does not exist. + pub fn rigid_body_apply_force_2d( + &mut self, + slot_index: u32, + slot_generation: u32, + force: [f32; 2], + ) -> Result<(), RigidBody2DBackendError> { + validate_force(force[0], force[1])?; + let body = self.rigid_body_2d_mut(slot_index, slot_generation)?; + + if body.body_type != RigidBodyType2D::Dynamic { + return Err(RigidBody2DBackendError::UnsupportedOperation { + body_type: body.body_type, + }); + } + + body.force_accumulator[0] += force[0]; + body.force_accumulator[1] += force[1]; + + return Ok(()); + } + + /// Applies an impulse, in Newton-seconds, at the center of mass. + /// + /// # Arguments + /// - `slot_index`: The body slot index. + /// - `slot_generation`: The slot generation counter. + /// - `impulse`: The impulse in Newton-seconds. + /// + /// # Returns + /// Returns `()` after applying the impulse. + /// + /// # Errors + /// Returns `RigidBody2DBackendError` if the input is invalid, if the + /// operation is unsupported for the body type, or if the referenced body + /// does not exist. + pub fn rigid_body_apply_impulse_2d( + &mut self, + slot_index: u32, + slot_generation: u32, + impulse: [f32; 2], + ) -> Result<(), RigidBody2DBackendError> { + validate_impulse(impulse[0], impulse[1])?; + let body = self.rigid_body_2d_mut(slot_index, slot_generation)?; + + if body.body_type != RigidBodyType2D::Dynamic { + return Err(RigidBody2DBackendError::UnsupportedOperation { + body_type: body.body_type, + }); + } + + body.velocity[0] += impulse[0] / body.dynamic_mass_kg; + body.velocity[1] += impulse[1] / body.dynamic_mass_kg; + + return Ok(()); + } + + /// Clears accumulated forces for all stored bodies. + /// + /// # Returns + /// Returns `()` after clearing force accumulators. + pub fn clear_rigid_body_forces_2d(&mut self) { + for body in &mut self.rigid_body_slots_2d { + body.force_accumulator = [0.0, 0.0]; + } + + return; + } + /// Returns the gravity vector used by this backend. /// /// # Returns @@ -90,6 +468,8 @@ impl PhysicsBackend2D { pub fn step_with_timestep_seconds(&mut self, timestep_seconds: f32) { self.integration_parameters.dt = timestep_seconds; + self.step_rigid_bodies_2d(timestep_seconds); + self.pipeline.step( self.gravity, &self.integration_parameters, @@ -107,4 +487,311 @@ impl PhysicsBackend2D { return; } + + fn rigid_body_2d( + &self, + slot_index: u32, + slot_generation: u32, + ) -> Result<&RigidBodySlot2D, RigidBody2DBackendError> { + let Some(body) = self.rigid_body_slots_2d.get(slot_index as usize) else { + return Err(RigidBody2DBackendError::BodyNotFound); + }; + + if body.generation != slot_generation { + return Err(RigidBody2DBackendError::BodyNotFound); + } + + return Ok(body); + } + + fn rigid_body_2d_mut( + &mut self, + slot_index: u32, + slot_generation: u32, + ) -> Result<&mut RigidBodySlot2D, RigidBody2DBackendError> { + let Some(body) = self.rigid_body_slots_2d.get_mut(slot_index as usize) + else { + return Err(RigidBody2DBackendError::BodyNotFound); + }; + + if body.generation != slot_generation { + return Err(RigidBody2DBackendError::BodyNotFound); + } + + return Ok(body); + } + + fn step_rigid_bodies_2d(&mut self, timestep_seconds: f32) { + let gravity = [self.gravity.x, self.gravity.y]; + + for body in &mut self.rigid_body_slots_2d { + match body.body_type { + RigidBodyType2D::Static => {} + RigidBodyType2D::Kinematic => { + body.position[0] += body.velocity[0] * timestep_seconds; + body.position[1] += body.velocity[1] * timestep_seconds; + } + RigidBodyType2D::Dynamic => { + let acceleration_x = + gravity[0] + (body.force_accumulator[0] / body.dynamic_mass_kg); + let acceleration_y = + gravity[1] + (body.force_accumulator[1] / body.dynamic_mass_kg); + + body.velocity[0] += acceleration_x * timestep_seconds; + body.velocity[1] += acceleration_y * timestep_seconds; + + body.position[0] += body.velocity[0] * timestep_seconds; + body.position[1] += body.velocity[1] * timestep_seconds; + } + } + } + + return; + } +} + +fn validate_position(x: f32, y: f32) -> Result<(), RigidBody2DBackendError> { + if !x.is_finite() || !y.is_finite() { + return Err(RigidBody2DBackendError::InvalidPosition { x, y }); + } + + return Ok(()); +} + +fn validate_rotation(radians: f32) -> Result<(), RigidBody2DBackendError> { + if !radians.is_finite() { + return Err(RigidBody2DBackendError::InvalidRotation { radians }); + } + + return Ok(()); +} + +fn validate_velocity(x: f32, y: f32) -> Result<(), RigidBody2DBackendError> { + if !x.is_finite() || !y.is_finite() { + return Err(RigidBody2DBackendError::InvalidVelocity { x, y }); + } + + return Ok(()); +} + +fn validate_force(x: f32, y: f32) -> Result<(), RigidBody2DBackendError> { + if !x.is_finite() || !y.is_finite() { + return Err(RigidBody2DBackendError::InvalidForce { x, y }); + } + + return Ok(()); +} + +fn validate_impulse(x: f32, y: f32) -> Result<(), RigidBody2DBackendError> { + if !x.is_finite() || !y.is_finite() { + return Err(RigidBody2DBackendError::InvalidImpulse { x, y }); + } + + return Ok(()); +} + +fn resolve_dynamic_mass_kg( + body_type: RigidBodyType2D, + dynamic_mass_kg: Option, +) -> Result { + let Some(mass_kg) = dynamic_mass_kg else { + if body_type == RigidBodyType2D::Dynamic { + return Ok(1.0); + } + + return Ok(0.0); + }; + + if body_type != RigidBodyType2D::Dynamic { + return Err(RigidBody2DBackendError::UnsupportedOperation { body_type }); + } + + if !mass_kg.is_finite() || mass_kg <= 0.0 { + return Err(RigidBody2DBackendError::InvalidMassKg { mass_kg }); + } + + return Ok(mass_kg); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn dynamic_body_integrates_with_symplectic_euler() { + let mut backend = PhysicsBackend2D::new([0.0, -10.0], 1.0); + + let (slot_index, slot_generation) = backend + .create_rigid_body_2d( + RigidBodyType2D::Dynamic, + [0.0, 0.0], + 0.0, + [0.0, 0.0], + Some(1.0), + ) + .unwrap(); + + backend.step_with_timestep_seconds(1.0); + + assert_eq!( + backend + .rigid_body_velocity_2d(slot_index, slot_generation) + .unwrap(), + [0.0, -10.0] + ); + assert_eq!( + backend + .rigid_body_position_2d(slot_index, slot_generation) + .unwrap(), + [0.0, -10.0] + ); + + backend.step_with_timestep_seconds(1.0); + + assert_eq!( + backend + .rigid_body_velocity_2d(slot_index, slot_generation) + .unwrap(), + [0.0, -20.0] + ); + assert_eq!( + backend + .rigid_body_position_2d(slot_index, slot_generation) + .unwrap(), + [0.0, -30.0] + ); + + return; + } + + #[test] + fn kinematic_body_integrates_using_velocity() { + let mut backend = PhysicsBackend2D::new([0.0, -10.0], 1.0); + + let (slot_index, slot_generation) = backend + .create_rigid_body_2d( + RigidBodyType2D::Kinematic, + [0.0, 0.0], + 0.0, + [2.0, 0.0], + None, + ) + .unwrap(); + + backend.step_with_timestep_seconds(1.0); + + assert_eq!( + backend + .rigid_body_position_2d(slot_index, slot_generation) + .unwrap(), + [2.0, 0.0] + ); + + return; + } + + #[test] + fn static_body_does_not_move_during_step() { + let mut backend = PhysicsBackend2D::new([0.0, -10.0], 1.0); + + let (slot_index, slot_generation) = backend + .create_rigid_body_2d( + RigidBodyType2D::Static, + [1.0, 2.0], + 0.0, + [3.0, 4.0], + None, + ) + .unwrap(); + + backend.step_with_timestep_seconds(1.0); + + assert_eq!( + backend + .rigid_body_position_2d(slot_index, slot_generation) + .unwrap(), + [1.0, 2.0] + ); + + return; + } + + #[test] + fn force_accumulates_until_cleared() { + let mut backend = PhysicsBackend2D::new([0.0, 0.0], 1.0); + + let (slot_index, slot_generation) = backend + .create_rigid_body_2d( + RigidBodyType2D::Dynamic, + [0.0, 0.0], + 0.0, + [0.0, 0.0], + Some(2.0), + ) + .unwrap(); + + backend + .rigid_body_apply_force_2d(slot_index, slot_generation, [10.0, 0.0]) + .unwrap(); + + backend.step_with_timestep_seconds(1.0); + assert_eq!( + backend + .rigid_body_velocity_2d(slot_index, slot_generation) + .unwrap(), + [5.0, 0.0] + ); + assert_eq!( + backend + .rigid_body_position_2d(slot_index, slot_generation) + .unwrap(), + [5.0, 0.0] + ); + + backend.clear_rigid_body_forces_2d(); + backend.step_with_timestep_seconds(1.0); + + assert_eq!( + backend + .rigid_body_velocity_2d(slot_index, slot_generation) + .unwrap(), + [5.0, 0.0] + ); + assert_eq!( + backend + .rigid_body_position_2d(slot_index, slot_generation) + .unwrap(), + [10.0, 0.0] + ); + + return; + } + + #[test] + fn impulse_updates_velocity_immediately() { + let mut backend = PhysicsBackend2D::new([0.0, 0.0], 1.0); + + let (slot_index, slot_generation) = backend + .create_rigid_body_2d( + RigidBodyType2D::Dynamic, + [0.0, 0.0], + 0.0, + [0.0, 0.0], + Some(2.0), + ) + .unwrap(); + + backend + .rigid_body_apply_impulse_2d(slot_index, slot_generation, [2.0, 0.0]) + .unwrap(); + + assert_eq!( + backend + .rigid_body_velocity_2d(slot_index, slot_generation) + .unwrap(), + [1.0, 0.0] + ); + + return; + } } diff --git a/crates/lambda-rs/src/physics/mod.rs b/crates/lambda-rs/src/physics/mod.rs index 7f2431ad..e2b5aec4 100644 --- a/crates/lambda-rs/src/physics/mod.rs +++ b/crates/lambda-rs/src/physics/mod.rs @@ -6,17 +6,33 @@ use std::{ error::Error, fmt, + sync::atomic::{ + AtomicU64, + Ordering, + }, }; use lambda_platform::physics::PhysicsBackend2D; +mod rigid_body_2d; + +pub use rigid_body_2d::{ + RigidBody2D, + RigidBody2DBuilder, + RigidBody2DError, + RigidBodyType, +}; + const DEFAULT_GRAVITY_X: f32 = 0.0; const DEFAULT_GRAVITY_Y: f32 = -9.81; const DEFAULT_TIMESTEP_SECONDS: f32 = 1.0 / 60.0; const DEFAULT_SUBSTEPS: u32 = 1; +static NEXT_WORLD_ID: AtomicU64 = AtomicU64::new(1); + /// A 2D physics simulation world. pub struct PhysicsWorld2D { + world_id: u64, gravity: [f32; 2], timestep_seconds: f32, substeps: u32, @@ -37,6 +53,8 @@ impl PhysicsWorld2D { .step_with_timestep_seconds(substep_timestep_seconds); } + self.backend.clear_rigid_body_forces_2d(); + return; } @@ -136,8 +154,10 @@ impl PhysicsWorld2DBuilder { validate_timestep_seconds(substep_timestep_seconds)?; let backend = PhysicsBackend2D::new(self.gravity, substep_timestep_seconds); + let world_id = allocate_world_id(); return Ok(PhysicsWorld2D { + world_id, gravity: self.gravity, timestep_seconds: self.timestep_seconds, substeps: self.substeps, @@ -196,6 +216,7 @@ fn validate_timestep_seconds( return Ok(()); } +/// Validates that the configured substep count is non-zero. fn validate_substeps(substeps: u32) -> Result<(), PhysicsWorld2DError> { if substeps < 1 { return Err(PhysicsWorld2DError::InvalidSubsteps { substeps }); @@ -204,6 +225,7 @@ fn validate_substeps(substeps: u32) -> Result<(), PhysicsWorld2DError> { return Ok(()); } +/// Validates that the configured gravity vector is finite. fn validate_gravity(gravity: [f32; 2]) -> Result<(), PhysicsWorld2DError> { let x = gravity[0]; let y = gravity[1]; @@ -215,10 +237,21 @@ fn validate_gravity(gravity: [f32; 2]) -> Result<(), PhysicsWorld2DError> { return Ok(()); } +/// Allocates a non-zero unique world identifier. +fn allocate_world_id() -> u64 { + loop { + let id = NEXT_WORLD_ID.fetch_add(1, Ordering::Relaxed); + if id != 0 { + return id; + } + } +} + #[cfg(test)] mod tests { use super::*; + /// Builds a world using the default builder configuration. #[test] fn world_builds_with_defaults() { let world = PhysicsWorld2DBuilder::new().build().unwrap(); @@ -232,6 +265,7 @@ mod tests { return; } + /// Builds a world with custom gravity, timestep, and substeps. #[test] fn world_builds_with_custom_config() { let world = PhysicsWorld2DBuilder::new() @@ -251,6 +285,7 @@ mod tests { return; } + /// Rejects timestep values that are positive but invalid for integration. #[test] fn build_rejects_non_positive_timestep_seconds() { let error = match PhysicsWorld2DBuilder::new() @@ -273,6 +308,7 @@ mod tests { return; } + /// Rejects non-finite timestep values. #[test] fn build_rejects_non_finite_timestep_seconds() { let error = match PhysicsWorld2DBuilder::new() @@ -297,6 +333,7 @@ mod tests { return; } + /// Rejects zero substeps to avoid divide-by-zero in derived substep timestep. #[test] fn build_rejects_zero_substeps() { let error = match PhysicsWorld2DBuilder::new().with_substeps(0).build() { @@ -311,6 +348,7 @@ mod tests { return; } + /// Rejects gravity vectors containing non-finite components. #[test] fn build_rejects_non_finite_gravity() { let error = match PhysicsWorld2DBuilder::new() @@ -334,6 +372,7 @@ mod tests { return; } + /// Ensures stepping an empty world succeeds without panicking. #[test] fn step_does_not_panic_for_empty_world() { let mut world = PhysicsWorld2DBuilder::new().build().unwrap(); @@ -342,6 +381,7 @@ mod tests { return; } + /// Ensures `step()` uses the derived substep timestep when substeps are set. #[test] fn step_uses_substep_timestep_seconds() { let mut world = PhysicsWorld2DBuilder::new() diff --git a/crates/lambda-rs/src/physics/rigid_body_2d.rs b/crates/lambda-rs/src/physics/rigid_body_2d.rs new file mode 100644 index 00000000..b922ceed --- /dev/null +++ b/crates/lambda-rs/src/physics/rigid_body_2d.rs @@ -0,0 +1,893 @@ +//! 2D rigid body support. +//! +//! This module provides backend-agnostic rigid body handles and builders for +//! `PhysicsWorld2D`. + +use std::{ + error::Error, + fmt, +}; + +use lambda_platform::physics::{ + RigidBody2DBackendError, + RigidBodyType2D, +}; + +use super::PhysicsWorld2D; + +const DEFAULT_POSITION_X: f32 = 0.0; +const DEFAULT_POSITION_Y: f32 = 0.0; +const DEFAULT_ROTATION_RADIANS: f32 = 0.0; +const DEFAULT_VELOCITY_X: f32 = 0.0; +const DEFAULT_VELOCITY_Y: f32 = 0.0; + +/// The rigid body integration mode. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RigidBodyType { + /// A body that does not move under simulation. + Static, + /// A body affected by gravity and forces. + Dynamic, + /// A body integrated only by user-provided motion. + Kinematic, +} + +/// An opaque handle to a rigid body stored in a `PhysicsWorld2D`. +/// +/// This handle is world-scoped. Operations validate that the handle belongs to +/// the provided world and that the referenced body exists. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct RigidBody2D { + world_id: u64, + slot_index: u32, + slot_generation: u32, +} + +impl RigidBody2D { + /// Returns the body type. + /// + /// # Arguments + /// - `world`: The physics world that owns the body. + /// + /// # Returns + /// Returns the rigid body type. + /// + /// # Errors + /// Returns `RigidBody2DError` if the handle is invalid, belongs to a + /// different world, or does not reference a live body. + pub fn body_type( + self, + world: &PhysicsWorld2D, + ) -> Result { + self.validate_handle(world)?; + let body_type = world + .backend + .rigid_body_type_2d(self.slot_index, self.slot_generation) + .map_err(map_backend_error)?; + + return Ok(map_body_type_from_backend(body_type)); + } + + /// Returns the current position, in meters. + /// + /// # Arguments + /// - `world`: The physics world that owns the body. + /// + /// # Returns + /// Returns the translation as `(x, y)` in meters. + /// + /// # Errors + /// Returns `RigidBody2DError` if the handle is invalid, belongs to a + /// different world, or does not reference a live body. + pub fn position( + self, + world: &PhysicsWorld2D, + ) -> Result<[f32; 2], RigidBody2DError> { + self.validate_handle(world)?; + return world + .backend + .rigid_body_position_2d(self.slot_index, self.slot_generation) + .map_err(map_backend_error); + } + + /// Returns the current rotation, in radians. + /// + /// # Arguments + /// - `world`: The physics world that owns the body. + /// + /// # Returns + /// Returns the rotation around the 2D Z axis in radians. + /// + /// # Errors + /// Returns `RigidBody2DError` if the handle is invalid, belongs to a + /// different world, or does not reference a live body. + pub fn rotation( + self, + world: &PhysicsWorld2D, + ) -> Result { + self.validate_handle(world)?; + return world + .backend + .rigid_body_rotation_2d(self.slot_index, self.slot_generation) + .map_err(map_backend_error); + } + + /// Returns the current linear velocity, in meters per second. + /// + /// # Arguments + /// - `world`: The physics world that owns the body. + /// + /// # Returns + /// Returns the linear velocity as `(x, y)` in meters per second. + /// + /// # Errors + /// Returns `RigidBody2DError` if the handle is invalid, belongs to a + /// different world, or does not reference a live body. + pub fn velocity( + self, + world: &PhysicsWorld2D, + ) -> Result<[f32; 2], RigidBody2DError> { + self.validate_handle(world)?; + return world + .backend + .rigid_body_velocity_2d(self.slot_index, self.slot_generation) + .map_err(map_backend_error); + } + + /// Sets the position, in meters. + /// + /// # Arguments + /// - `world`: The physics world that owns the body. + /// - `x`: The new X translation in meters. + /// - `y`: The new Y translation in meters. + /// + /// # Returns + /// Returns `()` after applying the mutation. + /// + /// # Errors + /// Returns `RigidBody2DError` if the input is invalid, the handle is + /// invalid, belongs to a different world, or does not reference a live body. + pub fn set_position( + self, + world: &mut PhysicsWorld2D, + x: f32, + y: f32, + ) -> Result<(), RigidBody2DError> { + validate_position(x, y)?; + self.validate_handle(world)?; + return world + .backend + .rigid_body_set_position_2d(self.slot_index, self.slot_generation, [x, y]) + .map_err(map_backend_error); + } + + /// Sets the rotation, in radians. + /// + /// # Arguments + /// - `world`: The physics world that owns the body. + /// - `radians`: The new rotation in radians. + /// + /// # Returns + /// Returns `()` after applying the mutation. + /// + /// # Errors + /// Returns `RigidBody2DError` if the input is invalid, the handle is + /// invalid, belongs to a different world, or does not reference a live body. + pub fn set_rotation( + self, + world: &mut PhysicsWorld2D, + radians: f32, + ) -> Result<(), RigidBody2DError> { + validate_rotation(radians)?; + self.validate_handle(world)?; + return world + .backend + .rigid_body_set_rotation_2d( + self.slot_index, + self.slot_generation, + radians, + ) + .map_err(map_backend_error); + } + + /// Sets the linear velocity, in meters per second. + /// + /// # Arguments + /// - `world`: The physics world that owns the body. + /// - `vx`: The new X velocity component in meters per second. + /// - `vy`: The new Y velocity component in meters per second. + /// + /// # Returns + /// Returns `()` after applying the mutation. + /// + /// # Errors + /// Returns `RigidBody2DError` if the input is invalid, the handle is + /// invalid, belongs to a different world, the operation is unsupported for + /// the body type, or does not reference a live body. + pub fn set_velocity( + self, + world: &mut PhysicsWorld2D, + vx: f32, + vy: f32, + ) -> Result<(), RigidBody2DError> { + validate_velocity(vx, vy)?; + self.validate_handle(world)?; + return world + .backend + .rigid_body_set_velocity_2d( + self.slot_index, + self.slot_generation, + [vx, vy], + ) + .map_err(map_backend_error); + } + + /// Applies a force, in Newtons, at the center of mass. + /// + /// # Arguments + /// - `world`: The physics world that owns the body. + /// - `fx`: The force X component in Newtons. + /// - `fy`: The force Y component in Newtons. + /// + /// # Returns + /// Returns `()` after applying the force. + /// + /// # Errors + /// Returns `RigidBody2DError` if the input is invalid, the handle is + /// invalid, belongs to a different world, the operation is unsupported for + /// the body type, or does not reference a live body. + pub fn apply_force( + self, + world: &mut PhysicsWorld2D, + fx: f32, + fy: f32, + ) -> Result<(), RigidBody2DError> { + validate_force(fx, fy)?; + self.validate_handle(world)?; + return world + .backend + .rigid_body_apply_force_2d( + self.slot_index, + self.slot_generation, + [fx, fy], + ) + .map_err(map_backend_error); + } + + /// Applies an impulse, in Newton-seconds, at the center of mass. + /// + /// # Arguments + /// - `world`: The physics world that owns the body. + /// - `ix`: The impulse X component in Newton-seconds. + /// - `iy`: The impulse Y component in Newton-seconds. + /// + /// # Returns + /// Returns `()` after applying the impulse. + /// + /// # Errors + /// Returns `RigidBody2DError` if the input is invalid, the handle is + /// invalid, belongs to a different world, the operation is unsupported for + /// the body type, or does not reference a live body. + pub fn apply_impulse( + self, + world: &mut PhysicsWorld2D, + ix: f32, + iy: f32, + ) -> Result<(), RigidBody2DError> { + validate_impulse(ix, iy)?; + self.validate_handle(world)?; + return world + .backend + .rigid_body_apply_impulse_2d( + self.slot_index, + self.slot_generation, + [ix, iy], + ) + .map_err(map_backend_error); + } + + /// Validates that this handle is non-zero and belongs to the given world. + fn validate_handle( + self, + world: &PhysicsWorld2D, + ) -> Result<(), RigidBody2DError> { + if self.world_id == 0 { + return Err(RigidBody2DError::InvalidHandle); + } + + if self.world_id != world.world_id { + return Err(RigidBody2DError::WorldMismatch); + } + + return Ok(()); + } +} + +/// Builder for `RigidBody2D`. +#[derive(Debug, Clone, Copy)] +pub struct RigidBody2DBuilder { + body_type: RigidBodyType, + position: [f32; 2], + rotation: f32, + velocity: [f32; 2], + dynamic_mass_kg: Option, +} + +impl RigidBody2DBuilder { + /// Creates a builder for the given body type with stable defaults. + /// + /// Defaults + /// - Position: `(0.0, 0.0)` + /// - Rotation: `0.0` + /// - Velocity: `(0.0, 0.0)` + /// - Dynamic mass: unset (defaults to `1.0` on dynamic bodies) + /// + /// # Arguments + /// - `body_type`: The rigid body integration mode. + /// + /// # Returns + /// Returns a new `RigidBody2DBuilder`. + pub fn new(body_type: RigidBodyType) -> Self { + return Self { + body_type, + position: [DEFAULT_POSITION_X, DEFAULT_POSITION_Y], + rotation: DEFAULT_ROTATION_RADIANS, + velocity: [DEFAULT_VELOCITY_X, DEFAULT_VELOCITY_Y], + dynamic_mass_kg: None, + }; + } + + /// Sets the initial position, in meters. + /// + /// # Arguments + /// - `x`: The initial X translation in meters. + /// - `y`: The initial Y translation in meters. + /// + /// # Returns + /// Returns the updated builder. + pub fn with_position(mut self, x: f32, y: f32) -> Self { + self.position = [x, y]; + return self; + } + + /// Sets the initial rotation, in radians. + /// + /// # Arguments + /// - `radians`: The initial rotation in radians. + /// + /// # Returns + /// Returns the updated builder. + pub fn with_rotation(mut self, radians: f32) -> Self { + self.rotation = radians; + return self; + } + + /// Sets the initial linear velocity, in meters per second. + /// + /// # Arguments + /// - `vx`: The initial X velocity component in meters per second. + /// - `vy`: The initial Y velocity component in meters per second. + /// + /// # Returns + /// Returns the updated builder. + pub fn with_velocity(mut self, vx: f32, vy: f32) -> Self { + self.velocity = [vx, vy]; + return self; + } + + /// Sets the mass, in kilograms, for dynamic bodies. + /// + /// # Arguments + /// - `mass_kg`: The mass in kilograms. + /// + /// # Returns + /// Returns the updated builder. + pub fn with_dynamic_mass_kg(mut self, mass_kg: f32) -> Self { + self.dynamic_mass_kg = Some(mass_kg); + return self; + } + + /// Inserts the body into the given world. + /// + /// # Arguments + /// - `world`: The physics world that will own the body. + /// + /// # Returns + /// Returns a world-scoped `RigidBody2D` handle. + /// + /// # Errors + /// Returns `RigidBody2DError` if any configuration value is invalid or if + /// the configuration is unsupported for the body type. + pub fn build( + self, + world: &mut PhysicsWorld2D, + ) -> Result { + validate_position(self.position[0], self.position[1])?; + validate_rotation(self.rotation)?; + validate_velocity(self.velocity[0], self.velocity[1])?; + + validate_dynamic_mass_for_body_type(self.body_type, self.dynamic_mass_kg)?; + + let backend_body_type = map_body_type_to_backend(self.body_type); + let (slot_index, slot_generation) = world + .backend + .create_rigid_body_2d( + backend_body_type, + self.position, + self.rotation, + self.velocity, + self.dynamic_mass_kg, + ) + .map_err(map_backend_error)?; + + return Ok(RigidBody2D { + world_id: world.world_id, + slot_index, + slot_generation, + }); + } +} + +/// Errors for rigid body construction and operations. +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum RigidBody2DError { + /// The rigid body handle encoding is invalid. + InvalidHandle, + /// The rigid body handle does not belong to the provided world. + WorldMismatch, + /// The rigid body referenced by the handle was not found. + BodyNotFound, + /// The provided position is invalid. + InvalidPosition { x: f32, y: f32 }, + /// The provided rotation is invalid. + InvalidRotation { radians: f32 }, + /// The provided linear velocity is invalid. + InvalidVelocity { x: f32, y: f32 }, + /// The provided force is invalid. + InvalidForce { x: f32, y: f32 }, + /// The provided impulse is invalid. + InvalidImpulse { x: f32, y: f32 }, + /// The provided mass is invalid. + InvalidMassKg { mass_kg: f32 }, + /// The requested operation is unsupported for the body type. + UnsupportedOperation { body_type: RigidBodyType }, +} + +impl fmt::Display for RigidBody2DError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidHandle => { + return write!(formatter, "invalid rigid body handle"); + } + Self::WorldMismatch => { + return write!(formatter, "rigid body handle does not match the world"); + } + Self::BodyNotFound => { + return write!(formatter, "rigid body not found"); + } + Self::InvalidPosition { x, y } => { + return write!(formatter, "invalid position: ({x}, {y})"); + } + Self::InvalidRotation { radians } => { + return write!(formatter, "invalid rotation: {radians}"); + } + Self::InvalidVelocity { x, y } => { + return write!(formatter, "invalid velocity: ({x}, {y})"); + } + Self::InvalidForce { x, y } => { + return write!(formatter, "invalid force: ({x}, {y})"); + } + Self::InvalidImpulse { x, y } => { + return write!(formatter, "invalid impulse: ({x}, {y})"); + } + Self::InvalidMassKg { mass_kg } => { + return write!(formatter, "invalid mass_kg: {mass_kg}"); + } + Self::UnsupportedOperation { body_type } => { + return write!( + formatter, + "unsupported operation for body_type: {body_type:?}" + ); + } + } + } +} + +impl Error for RigidBody2DError {} + +/// Validates that the provided translation is finite. +fn validate_position(x: f32, y: f32) -> Result<(), RigidBody2DError> { + if !x.is_finite() || !y.is_finite() { + return Err(RigidBody2DError::InvalidPosition { x, y }); + } + + return Ok(()); +} + +/// Validates that the provided rotation is finite. +fn validate_rotation(radians: f32) -> Result<(), RigidBody2DError> { + if !radians.is_finite() { + return Err(RigidBody2DError::InvalidRotation { radians }); + } + + return Ok(()); +} + +/// Validates that the provided linear velocity is finite. +fn validate_velocity(x: f32, y: f32) -> Result<(), RigidBody2DError> { + if !x.is_finite() || !y.is_finite() { + return Err(RigidBody2DError::InvalidVelocity { x, y }); + } + + return Ok(()); +} + +/// Validates that the provided force vector is finite. +fn validate_force(x: f32, y: f32) -> Result<(), RigidBody2DError> { + if !x.is_finite() || !y.is_finite() { + return Err(RigidBody2DError::InvalidForce { x, y }); + } + + return Ok(()); +} + +/// Validates that the provided impulse vector is finite. +fn validate_impulse(x: f32, y: f32) -> Result<(), RigidBody2DError> { + if !x.is_finite() || !y.is_finite() { + return Err(RigidBody2DError::InvalidImpulse { x, y }); + } + + return Ok(()); +} + +/// Validates that `dynamic_mass_kg` is only used on dynamic bodies and is valid. +fn validate_dynamic_mass_for_body_type( + body_type: RigidBodyType, + dynamic_mass_kg: Option, +) -> Result<(), RigidBody2DError> { + let Some(mass_kg) = dynamic_mass_kg else { + return Ok(()); + }; + + match body_type { + RigidBodyType::Dynamic => { + if !mass_kg.is_finite() || mass_kg <= 0.0 { + return Err(RigidBody2DError::InvalidMassKg { mass_kg }); + } + + return Ok(()); + } + RigidBodyType::Static | RigidBodyType::Kinematic => { + return Err(RigidBody2DError::UnsupportedOperation { body_type }); + } + } +} + +/// Maps a public `RigidBodyType` to the backend representation. +fn map_body_type_to_backend(body_type: RigidBodyType) -> RigidBodyType2D { + match body_type { + RigidBodyType::Static => { + return RigidBodyType2D::Static; + } + RigidBodyType::Dynamic => { + return RigidBodyType2D::Dynamic; + } + RigidBodyType::Kinematic => { + return RigidBodyType2D::Kinematic; + } + } +} + +/// Maps a backend `RigidBodyType2D` to the public representation. +fn map_body_type_from_backend(body_type: RigidBodyType2D) -> RigidBodyType { + match body_type { + RigidBodyType2D::Static => { + return RigidBodyType::Static; + } + RigidBodyType2D::Dynamic => { + return RigidBodyType::Dynamic; + } + RigidBodyType2D::Kinematic => { + return RigidBodyType::Kinematic; + } + } +} + +/// Maps backend rigid body errors into the public `RigidBody2DError` surface. +fn map_backend_error(error: RigidBody2DBackendError) -> RigidBody2DError { + match error { + RigidBody2DBackendError::BodyNotFound => { + return RigidBody2DError::BodyNotFound; + } + RigidBody2DBackendError::InvalidPosition { x, y } => { + return RigidBody2DError::InvalidPosition { x, y }; + } + RigidBody2DBackendError::InvalidRotation { radians } => { + return RigidBody2DError::InvalidRotation { radians }; + } + RigidBody2DBackendError::InvalidVelocity { x, y } => { + return RigidBody2DError::InvalidVelocity { x, y }; + } + RigidBody2DBackendError::InvalidForce { x, y } => { + return RigidBody2DError::InvalidForce { x, y }; + } + RigidBody2DBackendError::InvalidImpulse { x, y } => { + return RigidBody2DError::InvalidImpulse { x, y }; + } + RigidBody2DBackendError::InvalidMassKg { mass_kg } => { + return RigidBody2DError::InvalidMassKg { mass_kg }; + } + RigidBody2DBackendError::UnsupportedOperation { body_type } => { + return RigidBody2DError::UnsupportedOperation { + body_type: map_body_type_from_backend(body_type), + }; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::physics::PhysicsWorld2DBuilder; + + /// Ensures static bodies are queryable and remain fixed after stepping. + #[test] + fn static_body_is_queryable_and_does_not_move_on_step() { + let mut world = PhysicsWorld2DBuilder::new().build().unwrap(); + + let body = RigidBody2DBuilder::new(RigidBodyType::Static) + .with_position(1.0, 2.0) + .with_rotation(0.5) + .with_velocity(100.0, -200.0) + .build(&mut world) + .unwrap(); + + assert_eq!(body.body_type(&world).unwrap(), RigidBodyType::Static); + assert_eq!(body.position(&world).unwrap(), [1.0, 2.0]); + assert_eq!(body.rotation(&world).unwrap(), 0.5); + assert_eq!(body.velocity(&world).unwrap(), [100.0, -200.0]); + + world.step(); + + assert_eq!(body.position(&world).unwrap(), [1.0, 2.0]); + + return; + } + + /// Ensures dynamic bodies move under gravity after stepping. + #[test] + fn dynamic_body_moves_under_gravity_after_step() { + let mut world = PhysicsWorld2DBuilder::new() + .with_gravity(0.0, -1.0) + .with_timestep_seconds(1.0) + .build() + .unwrap(); + + let body = RigidBody2DBuilder::new(RigidBodyType::Dynamic) + .with_position(0.0, 0.0) + .with_velocity(0.0, 0.0) + .build(&mut world) + .unwrap(); + + world.step(); + + assert_eq!(body.position(&world).unwrap(), [0.0, -1.0]); + + return; + } + + /// Ensures setters mutate state immediately and are reflected during stepping. + #[test] + fn setters_are_immediately_observable() { + let mut world = PhysicsWorld2DBuilder::new() + .with_gravity(0.0, 0.0) + .with_timestep_seconds(1.0) + .build() + .unwrap(); + + let body = RigidBody2DBuilder::new(RigidBodyType::Kinematic) + .with_position(0.0, 0.0) + .build(&mut world) + .unwrap(); + + body.set_position(&mut world, 10.0, 20.0).unwrap(); + body.set_rotation(&mut world, 0.25).unwrap(); + body.set_velocity(&mut world, 1.0, 2.0).unwrap(); + + assert_eq!(body.position(&world).unwrap(), [10.0, 20.0]); + assert_eq!(body.rotation(&world).unwrap(), 0.25); + assert_eq!(body.velocity(&world).unwrap(), [1.0, 2.0]); + + world.step(); + assert_eq!(body.position(&world).unwrap(), [11.0, 22.0]); + + return; + } + + /// Ensures a body handle cannot be used with a different world. + #[test] + fn handle_world_mismatch_is_reported() { + let mut world_a = PhysicsWorld2DBuilder::new().build().unwrap(); + let world_b = PhysicsWorld2DBuilder::new().build().unwrap(); + + let body = RigidBody2DBuilder::new(RigidBodyType::Static) + .build(&mut world_a) + .unwrap(); + + assert_eq!( + body.position(&world_b), + Err(RigidBody2DError::WorldMismatch) + ); + + return; + } + + /// Ensures invalid slot indices are reported as `BodyNotFound`. + #[test] + fn handle_body_not_found_is_reported() { + let world = PhysicsWorld2DBuilder::new().build().unwrap(); + + let body = RigidBody2D { + world_id: world.world_id, + slot_index: 999, + slot_generation: 1, + }; + + assert_eq!(body.position(&world), Err(RigidBody2DError::BodyNotFound)); + + return; + } + + /// Ensures velocity mutation is rejected on static bodies. + #[test] + fn static_set_velocity_returns_unsupported_operation() { + let mut world = PhysicsWorld2DBuilder::new().build().unwrap(); + + let body = RigidBody2DBuilder::new(RigidBodyType::Static) + .build(&mut world) + .unwrap(); + + assert_eq!( + body.set_velocity(&mut world, 1.0, 2.0), + Err(RigidBody2DError::UnsupportedOperation { + body_type: RigidBodyType::Static, + }) + ); + + return; + } + + /// Ensures accumulated forces are applied for all substeps then cleared. + #[test] + fn step_clears_accumulated_forces_once_per_outer_step() { + let mut world = PhysicsWorld2DBuilder::new() + .with_gravity(0.0, 0.0) + .with_timestep_seconds(1.0) + .with_substeps(2) + .build() + .unwrap(); + + let body = RigidBody2DBuilder::new(RigidBodyType::Dynamic) + .build(&mut world) + .unwrap(); + + body.apply_force(&mut world, 1.0, 0.0).unwrap(); + + world.step(); + assert_eq!(body.position(&world).unwrap(), [0.75, 0.0]); + + world.step(); + assert_eq!(body.position(&world).unwrap(), [1.75, 0.0]); + + return; + } + + /// Ensures applied forces accumulate and produce less acceleration for higher + /// dynamic mass. + #[test] + fn apply_force_accumulates_and_respects_dynamic_mass() { + let mut world = PhysicsWorld2DBuilder::new() + .with_gravity(0.0, 0.0) + .with_timestep_seconds(1.0) + .build() + .unwrap(); + + let body = RigidBody2DBuilder::new(RigidBodyType::Dynamic) + .with_dynamic_mass_kg(2.0) + .build(&mut world) + .unwrap(); + + body.apply_force(&mut world, 2.0, 0.0).unwrap(); + body.apply_force(&mut world, 2.0, 0.0).unwrap(); + + world.step(); + + assert_eq!(body.position(&world).unwrap(), [2.0, 0.0]); + assert_eq!(body.velocity(&world).unwrap(), [2.0, 0.0]); + + return; + } + + /// Ensures impulses affect velocity immediately and position after stepping. + #[test] + fn apply_impulse_updates_velocity_immediately_for_dynamic_bodies() { + let mut world = PhysicsWorld2DBuilder::new() + .with_gravity(0.0, 0.0) + .with_timestep_seconds(1.0) + .build() + .unwrap(); + + let body = RigidBody2DBuilder::new(RigidBodyType::Dynamic) + .with_dynamic_mass_kg(2.0) + .build(&mut world) + .unwrap(); + + body.apply_impulse(&mut world, 2.0, 0.0).unwrap(); + assert_eq!(body.velocity(&world).unwrap(), [1.0, 0.0]); + + world.step(); + assert_eq!(body.position(&world).unwrap(), [1.0, 0.0]); + + return; + } + + /// Ensures applying forces to non-dynamic bodies is rejected. + #[test] + fn non_dynamic_apply_force_returns_unsupported_operation() { + let mut world = PhysicsWorld2DBuilder::new().build().unwrap(); + + let static_body = RigidBody2DBuilder::new(RigidBodyType::Static) + .build(&mut world) + .unwrap(); + + assert_eq!( + static_body.apply_force(&mut world, 1.0, 2.0), + Err(RigidBody2DError::UnsupportedOperation { + body_type: RigidBodyType::Static, + }) + ); + + let kinematic_body = RigidBody2DBuilder::new(RigidBodyType::Kinematic) + .build(&mut world) + .unwrap(); + + assert_eq!( + kinematic_body.apply_force(&mut world, 1.0, 2.0), + Err(RigidBody2DError::UnsupportedOperation { + body_type: RigidBodyType::Kinematic, + }) + ); + + return; + } + + /// Ensures applying impulses to non-dynamic bodies is rejected. + #[test] + fn non_dynamic_apply_impulse_returns_unsupported_operation() { + let mut world = PhysicsWorld2DBuilder::new().build().unwrap(); + + let static_body = RigidBody2DBuilder::new(RigidBodyType::Static) + .build(&mut world) + .unwrap(); + + assert_eq!( + static_body.apply_impulse(&mut world, 1.0, 2.0), + Err(RigidBody2DError::UnsupportedOperation { + body_type: RigidBodyType::Static, + }) + ); + + let kinematic_body = RigidBody2DBuilder::new(RigidBodyType::Kinematic) + .build(&mut world) + .unwrap(); + + assert_eq!( + kinematic_body.apply_impulse(&mut world, 1.0, 2.0), + Err(RigidBody2DError::UnsupportedOperation { + body_type: RigidBodyType::Kinematic, + }) + ); + + return; + } +} diff --git a/demos/physics/Cargo.toml b/demos/physics/Cargo.toml index 9b043f32..19482ddb 100644 --- a/demos/physics/Cargo.toml +++ b/demos/physics/Cargo.toml @@ -19,3 +19,8 @@ validation-all = ["lambda-rs/render-validation-all"] name = "physics_falling_quad" path = "src/bin/physics_falling_quad.rs" required-features = ["physics-2d"] + +[[bin]] +name = "physics_rigid_bodies_2d" +path = "src/bin/physics_rigid_bodies_2d.rs" +required-features = ["physics-2d"] diff --git a/demos/physics/src/bin/physics_rigid_bodies_2d.rs b/demos/physics/src/bin/physics_rigid_bodies_2d.rs new file mode 100644 index 00000000..335f959d --- /dev/null +++ b/demos/physics/src/bin/physics_rigid_bodies_2d.rs @@ -0,0 +1,706 @@ +#![allow(clippy::needless_return)] + +//! Demo: Render several 2D rigid bodies and step the simulation. +//! +//! This demo showcases `RigidBody2D` usage without collision shapes: +//! - Two dynamic bodies fall under gravity and respond to forces/impulses. +//! - One kinematic body is moved by user-provided velocity and rotation. +//! - One static body stays fixed (visual reference). +//! +//! Controls: +//! - Space: apply an upward impulse to both dynamic bodies. + +use lambda::{ + component::Component, + events::{ + EventMask, + Key, + VirtualKey, + WindowEvent, + }, + physics::{ + PhysicsWorld2D, + PhysicsWorld2DBuilder, + RigidBody2D, + RigidBody2DBuilder, + RigidBodyType, + }, + render::{ + bind::{ + BindGroupBuilder, + BindGroupLayoutBuilder, + BindingVisibility, + }, + buffer::{ + Buffer, + BufferBuilder, + Properties, + Usage, + }, + command::RenderCommand, + mesh::{ + Mesh, + MeshBuilder, + }, + pipeline::{ + CullingMode, + RenderPipelineBuilder, + }, + render_pass::RenderPassBuilder, + shader::{ + Shader, + ShaderBuilder, + ShaderKind, + VirtualShader, + }, + vertex::{ + ColorFormat, + VertexAttribute, + VertexBuilder, + VertexElement, + }, + viewport::ViewportBuilder, + ResourceId, + }, + runtime::start_runtime, + runtimes::{ + application::ComponentResult, + ApplicationRuntimeBuilder, + }, +}; + +// ------------------------------ SHADER SOURCE -------------------------------- + +const VERTEX_SHADER_SOURCE: &str = r#" +#version 450 + +layout (location = 0) in vec3 vertex_position; +layout (location = 1) in vec3 vertex_normal; +layout (location = 2) in vec3 vertex_color; + +layout (location = 0) out vec3 frag_color; + +layout (set = 0, binding = 0) uniform QuadGlobals { + vec4 offset_rotation; + vec4 tint; +} globals; + +void main() { + float radians = globals.offset_rotation.z; + float cosine = cos(radians); + float sine = sin(radians); + + mat2 rotation = mat2( + cosine, -sine, + sine, cosine + ); + + vec2 rotated = rotation * vertex_position.xy; + vec2 translated = rotated + globals.offset_rotation.xy; + + gl_Position = vec4(translated, vertex_position.z, 1.0); + frag_color = vertex_color * globals.tint.xyz; +} + +"#; + +const FRAGMENT_SHADER_SOURCE: &str = r#" +#version 450 + +layout (location = 0) in vec3 frag_color; +layout (location = 0) out vec4 fragment_color; + +void main() { + fragment_color = vec4(frag_color, 1.0); +} + +"#; + +// ---------------------------- UNIFORM STRUCTURE ------------------------------ + +#[repr(C)] +#[derive(Debug, Clone, Copy)] +pub struct QuadGlobalsUniform { + pub offset_rotation: [f32; 4], + pub tint: [f32; 4], +} + +unsafe impl lambda::pod::PlainOldData for QuadGlobalsUniform {} + +// -------------------------------- DEMO TYPES --------------------------------- + +struct RenderBody { + body: RigidBody2D, + tint: [f32; 4], + uniform_buffer: Buffer, + bind_group_id: ResourceId, +} + +pub struct RigidBodies2DDemo { + physics_world: PhysicsWorld2D, + physics_accumulator_seconds: f32, + pending_impulse: bool, + + dynamic_light_body: RigidBody2D, + dynamic_heavy_body: RigidBody2D, + kinematic_body: RigidBody2D, + static_body: RigidBody2D, + + floor_y: f32, + ceiling_y: f32, + left_wall_x: f32, + right_wall_x: f32, + restitution: f32, + wind_force_x_newtons: f32, + kinematic_rotation_radians: f32, + + vertex_shader: Shader, + fragment_shader: Shader, + mesh: Option, + render_pipeline_id: Option, + render_pass_id: Option, + bodies: Vec, + + width: u32, + height: u32, +} + +impl RigidBodies2DDemo { + fn apply_wall_bounce(&mut self, body: RigidBody2D) -> Result<(), String> { + let position = body + .position(&self.physics_world) + .map_err(|error| error.to_string())?; + + if position[0] <= self.right_wall_x && position[0] >= self.left_wall_x { + return Ok(()); + } + + let velocity = body + .velocity(&self.physics_world) + .map_err(|error| error.to_string())?; + + if position[0] > self.right_wall_x { + body + .set_position(&mut self.physics_world, self.right_wall_x, position[1]) + .map_err(|error| error.to_string())?; + body + .set_velocity( + &mut self.physics_world, + -velocity[0].abs() * self.restitution, + velocity[1], + ) + .map_err(|error| error.to_string())?; + } + + if position[0] < self.left_wall_x { + body + .set_position(&mut self.physics_world, self.left_wall_x, position[1]) + .map_err(|error| error.to_string())?; + body + .set_velocity( + &mut self.physics_world, + velocity[0].abs() * self.restitution, + velocity[1], + ) + .map_err(|error| error.to_string())?; + } + + return Ok(()); + } + + fn apply_floor_bounce(&mut self, body: RigidBody2D) -> Result<(), String> { + let position = body + .position(&self.physics_world) + .map_err(|error| error.to_string())?; + + if position[1] >= self.floor_y { + return Ok(()); + } + + let velocity = body + .velocity(&self.physics_world) + .map_err(|error| error.to_string())?; + + body + .set_position(&mut self.physics_world, position[0], self.floor_y) + .map_err(|error| error.to_string())?; + + body + .set_velocity( + &mut self.physics_world, + velocity[0], + -velocity[1] * self.restitution, + ) + .map_err(|error| error.to_string())?; + + return Ok(()); + } + + fn apply_ceiling_bounce(&mut self, body: RigidBody2D) -> Result<(), String> { + let position = body + .position(&self.physics_world) + .map_err(|error| error.to_string())?; + + if position[1] <= self.ceiling_y { + return Ok(()); + } + + let velocity = body + .velocity(&self.physics_world) + .map_err(|error| error.to_string())?; + + body + .set_position(&mut self.physics_world, position[0], self.ceiling_y) + .map_err(|error| error.to_string())?; + + body + .set_velocity( + &mut self.physics_world, + velocity[0], + -velocity[1].abs() * self.restitution, + ) + .map_err(|error| error.to_string())?; + + return Ok(()); + } + + fn clamp_kinematic_within_walls(&mut self) -> Result<(), String> { + let position = self + .kinematic_body + .position(&self.physics_world) + .map_err(|error| error.to_string())?; + + let velocity = self + .kinematic_body + .velocity(&self.physics_world) + .map_err(|error| error.to_string())?; + + if position[0] > self.right_wall_x { + self + .kinematic_body + .set_position(&mut self.physics_world, self.right_wall_x, position[1]) + .map_err(|error| error.to_string())?; + self + .kinematic_body + .set_velocity(&mut self.physics_world, -velocity[0].abs(), velocity[1]) + .map_err(|error| error.to_string())?; + } + + if position[0] < self.left_wall_x { + self + .kinematic_body + .set_position(&mut self.physics_world, self.left_wall_x, position[1]) + .map_err(|error| error.to_string())?; + self + .kinematic_body + .set_velocity(&mut self.physics_world, velocity[0].abs(), velocity[1]) + .map_err(|error| error.to_string())?; + } + + return Ok(()); + } +} + +// --------------------------------- COMPONENT --------------------------------- + +impl Component for RigidBodies2DDemo { + fn on_attach( + &mut self, + render_context: &mut lambda::render::RenderContext, + ) -> Result { + let render_pass = RenderPassBuilder::new() + .with_label("physics-rigid-bodies-2d-pass") + .build( + render_context.gpu(), + render_context.surface_format(), + render_context.depth_format(), + ); + + let quad_half_size = 0.07_f32; + let vertices = [ + VertexBuilder::new() + .with_position([-quad_half_size, -quad_half_size, 0.0]) + .with_normal([0.0, 0.0, 1.0]) + .with_color([1.0, 0.3, 0.2]) + .build(), + VertexBuilder::new() + .with_position([quad_half_size, -quad_half_size, 0.0]) + .with_normal([0.0, 0.0, 1.0]) + .with_color([0.2, 1.0, 0.3]) + .build(), + VertexBuilder::new() + .with_position([quad_half_size, quad_half_size, 0.0]) + .with_normal([0.0, 0.0, 1.0]) + .with_color([0.2, 0.3, 1.0]) + .build(), + VertexBuilder::new() + .with_position([-quad_half_size, -quad_half_size, 0.0]) + .with_normal([0.0, 0.0, 1.0]) + .with_color([1.0, 0.3, 0.2]) + .build(), + VertexBuilder::new() + .with_position([quad_half_size, quad_half_size, 0.0]) + .with_normal([0.0, 0.0, 1.0]) + .with_color([0.2, 0.3, 1.0]) + .build(), + VertexBuilder::new() + .with_position([-quad_half_size, quad_half_size, 0.0]) + .with_normal([0.0, 0.0, 1.0]) + .with_color([1.0, 1.0, 0.2]) + .build(), + ]; + + let mut mesh_builder = MeshBuilder::new(); + vertices.iter().for_each(|vertex| { + mesh_builder.with_vertex(*vertex); + }); + + let mesh = mesh_builder + .with_attributes(vec![ + VertexAttribute { + location: 0, + offset: 0, + element: VertexElement { + format: ColorFormat::Rgb32Sfloat, + offset: 0, + }, + }, + VertexAttribute { + location: 1, + offset: 0, + element: VertexElement { + format: ColorFormat::Rgb32Sfloat, + offset: 12, + }, + }, + VertexAttribute { + location: 2, + offset: 0, + element: VertexElement { + format: ColorFormat::Rgb32Sfloat, + offset: 24, + }, + }, + ]) + .build(); + + let layout = BindGroupLayoutBuilder::new() + .with_uniform(0, BindingVisibility::Vertex) + .build(render_context.gpu()); + + let mut bodies = Vec::new(); + let demo_bodies = [ + (self.dynamic_light_body, [0.9, 0.35, 0.25, 1.0]), + (self.dynamic_heavy_body, [0.25, 0.6, 0.95, 1.0]), + (self.kinematic_body, [0.25, 0.95, 0.45, 1.0]), + (self.static_body, [0.7, 0.7, 0.7, 1.0]), + ]; + + for (body, tint) in demo_bodies { + let position = body + .position(&self.physics_world) + .map_err(|error| error.to_string())?; + let rotation = body + .rotation(&self.physics_world) + .map_err(|error| error.to_string())?; + + let initial_uniform = QuadGlobalsUniform { + offset_rotation: [position[0], position[1], rotation, 0.0], + tint, + }; + + let uniform_buffer = BufferBuilder::new() + .with_length(std::mem::size_of::()) + .with_usage(Usage::UNIFORM) + .with_properties(Properties::CPU_VISIBLE) + .with_label("quad-globals") + .build(render_context.gpu(), vec![initial_uniform]) + .map_err(|error| error.to_string())?; + + let bind_group = BindGroupBuilder::new() + .with_layout(&layout) + .with_uniform(0, &uniform_buffer, 0, None) + .build(render_context.gpu()); + + bodies.push(RenderBody { + body, + tint, + uniform_buffer, + bind_group_id: render_context.attach_bind_group(bind_group), + }); + } + + let pipeline = RenderPipelineBuilder::new() + .with_label("physics-rigid-bodies-2d-pipeline") + .with_culling(CullingMode::None) + .with_layouts(&[&layout]) + .with_buffer( + BufferBuilder::build_from_mesh(&mesh, render_context.gpu()) + .map_err(|error| error.to_string())?, + mesh.attributes().to_vec(), + ) + .build( + render_context.gpu(), + render_context.surface_format(), + render_context.depth_format(), + &render_pass, + &self.vertex_shader, + Some(&self.fragment_shader), + ); + + self.render_pass_id = Some(render_context.attach_render_pass(render_pass)); + self.render_pipeline_id = Some(render_context.attach_pipeline(pipeline)); + self.mesh = Some(mesh); + self.bodies = bodies; + + return Ok(ComponentResult::Success); + } + + fn on_detach( + &mut self, + _render_context: &mut lambda::render::RenderContext, + ) -> Result { + return Ok(ComponentResult::Success); + } + + fn event_mask(&self) -> EventMask { + return EventMask::WINDOW | EventMask::KEYBOARD; + } + + fn on_window_event(&mut self, event: &WindowEvent) -> Result<(), String> { + match event { + WindowEvent::Resize { width, height } => { + self.width = *width; + self.height = *height; + } + WindowEvent::Close => {} + } + + return Ok(()); + } + + fn on_keyboard_event(&mut self, event: &Key) -> Result<(), String> { + if let Key::Pressed { virtual_key, .. } = event { + if virtual_key == &Some(VirtualKey::Space) { + self.pending_impulse = true; + } + } + + return Ok(()); + } + + fn on_update( + &mut self, + last_frame: &std::time::Duration, + ) -> Result { + self.physics_accumulator_seconds += last_frame.as_secs_f32(); + + let timestep_seconds = self.physics_world.timestep_seconds(); + + while self.physics_accumulator_seconds >= timestep_seconds { + self + .dynamic_light_body + .apply_force(&mut self.physics_world, self.wind_force_x_newtons, 0.0) + .map_err(|error| error.to_string())?; + self + .dynamic_heavy_body + .apply_force(&mut self.physics_world, self.wind_force_x_newtons, 0.0) + .map_err(|error| error.to_string())?; + + if self.pending_impulse { + let impulse_y_newton_seconds = 1.6; + self + .dynamic_light_body + .apply_impulse(&mut self.physics_world, 0.0, impulse_y_newton_seconds) + .map_err(|error| error.to_string())?; + self + .dynamic_heavy_body + .apply_impulse(&mut self.physics_world, 0.0, impulse_y_newton_seconds) + .map_err(|error| error.to_string())?; + self.pending_impulse = false; + } + + self.kinematic_rotation_radians += timestep_seconds * 1.1; + self + .kinematic_body + .set_rotation(&mut self.physics_world, self.kinematic_rotation_radians) + .map_err(|error| error.to_string())?; + + self.physics_world.step(); + + self.apply_floor_bounce(self.dynamic_light_body)?; + self.apply_floor_bounce(self.dynamic_heavy_body)?; + self.apply_ceiling_bounce(self.dynamic_light_body)?; + self.apply_ceiling_bounce(self.dynamic_heavy_body)?; + self.apply_wall_bounce(self.dynamic_light_body)?; + self.apply_wall_bounce(self.dynamic_heavy_body)?; + self.clamp_kinematic_within_walls()?; + + self.physics_accumulator_seconds -= timestep_seconds; + } + + return Ok(ComponentResult::Success); + } + + fn on_render( + &mut self, + render_context: &mut lambda::render::RenderContext, + ) -> Vec { + let viewport = ViewportBuilder::new().build(self.width, self.height); + + for body in self.bodies.iter() { + let position = body + .body + .position(&self.physics_world) + .expect("RigidBody2D position failed"); + let rotation = body + .body + .rotation(&self.physics_world) + .expect("RigidBody2D rotation failed"); + + let value = QuadGlobalsUniform { + offset_rotation: [position[0], position[1], rotation, 0.0], + tint: body.tint, + }; + + body + .uniform_buffer + .write_value(render_context.gpu(), 0, &value); + } + + let render_pass = self.render_pass_id.expect("render pass missing"); + let render_pipeline = + self.render_pipeline_id.expect("render pipeline missing"); + + let mut commands = vec![ + RenderCommand::BeginRenderPass { + render_pass, + viewport: viewport.clone(), + }, + RenderCommand::SetPipeline { + pipeline: render_pipeline, + }, + RenderCommand::SetViewports { + start_at: 0, + viewports: vec![viewport.clone()], + }, + RenderCommand::SetScissors { + start_at: 0, + viewports: vec![viewport.clone()], + }, + RenderCommand::BindVertexBuffer { + pipeline: render_pipeline, + buffer: 0, + }, + ]; + + for body in self.bodies.iter() { + commands.push(RenderCommand::SetBindGroup { + set: 0, + group: body.bind_group_id, + dynamic_offsets: Vec::new(), + }); + commands.push(RenderCommand::Draw { + vertices: 0..self.mesh.as_ref().unwrap().vertices().len() as u32, + instances: 0..1, + }); + } + + commands.push(RenderCommand::EndRenderPass); + + return commands; + } +} + +impl Default for RigidBodies2DDemo { + fn default() -> Self { + let mut physics_world = PhysicsWorld2DBuilder::new() + .with_gravity(0.0, -1.6) + .build() + .expect("Failed to create PhysicsWorld2D"); + + let dynamic_light_body = RigidBody2DBuilder::new(RigidBodyType::Dynamic) + .with_position(-0.35, 0.75) + .with_dynamic_mass_kg(0.5) + .build(&mut physics_world) + .expect("Failed to create dynamic body (light)"); + + let dynamic_heavy_body = RigidBody2DBuilder::new(RigidBodyType::Dynamic) + .with_position(0.35, 0.75) + .with_dynamic_mass_kg(2.0) + .build(&mut physics_world) + .expect("Failed to create dynamic body (heavy)"); + + let kinematic_body = RigidBody2DBuilder::new(RigidBodyType::Kinematic) + .with_position(0.0, 0.15) + .with_velocity(0.6, 0.0) + .build(&mut physics_world) + .expect("Failed to create kinematic body"); + + let static_body = RigidBody2DBuilder::new(RigidBodyType::Static) + .with_position(0.0, -0.75) + .build(&mut physics_world) + .expect("Failed to create static body"); + + let mut shader_builder = ShaderBuilder::new(); + let vertex_shader = shader_builder.build(VirtualShader::Source { + source: VERTEX_SHADER_SOURCE.to_string(), + kind: ShaderKind::Vertex, + entry_point: "main".to_string(), + name: "physics-rigid-bodies-2d".to_string(), + }); + let fragment_shader = shader_builder.build(VirtualShader::Source { + source: FRAGMENT_SHADER_SOURCE.to_string(), + kind: ShaderKind::Fragment, + entry_point: "main".to_string(), + name: "physics-rigid-bodies-2d".to_string(), + }); + + return Self { + physics_world, + physics_accumulator_seconds: 0.0, + pending_impulse: false, + + dynamic_light_body, + dynamic_heavy_body, + kinematic_body, + static_body, + + floor_y: -0.75, + ceiling_y: 0.9, + left_wall_x: -0.85, + right_wall_x: 0.85, + restitution: 0.7, + wind_force_x_newtons: 0.9, + kinematic_rotation_radians: 0.0, + + vertex_shader, + fragment_shader, + mesh: None, + render_pipeline_id: None, + render_pass_id: None, + bodies: Vec::new(), + + width: 1200, + height: 600, + }; + } +} + +fn main() { + let runtime = ApplicationRuntimeBuilder::new("Physics: 2D Rigid Bodies") + .with_renderer_configured_as(move |render_context_builder| { + return render_context_builder.with_render_timeout(1_000_000_000); + }) + .with_window_configured_as(move |window_builder| { + return window_builder + .with_dimensions(1200, 600) + .with_name("Physics: 2D Rigid Bodies"); + }) + .with_component(move |runtime, demo: RigidBodies2DDemo| { + return (runtime, demo); + }) + .build(); + + start_runtime(runtime); +} diff --git a/docs/features.md b/docs/features.md index 05293d1b..94cc458a 100644 --- a/docs/features.md +++ b/docs/features.md @@ -3,13 +3,13 @@ title: "Cargo Features Overview" document_id: "features-2025-11-17" status: "living" created: "2025-11-17T23:59:00Z" -last_updated: "2026-02-10T00:00:00Z" -version: "0.1.15" +last_updated: "2026-02-13T20:47:25Z" +version: "0.1.16" engine_workspace_version: "2023.1.30" wgpu_version: "26.0.1" shader_backend_default: "naga" winit_version: "0.29.10" -repo_commit: "fa36b760348f7b4a924220885fa88684bded03f6" +repo_commit: "c66de56f04d6d3f9866eb8d7e48847f3882aad28" owners: ["lambda-sh"] reviewers: ["engine", "rendering"] tags: ["guide", "features", "validation", "cargo", "audio", "physics"] @@ -89,11 +89,11 @@ Audio Physics - `physics-2d` (umbrella, disabled by default): enables the 2D physics world - APIs (for example, `lambda::physics::PhysicsWorld2D`). This feature enables - the platform physics backend via `lambda-rs-platform/physics-2d` - (currently backed by `rapier2d =0.32.0`). Expected runtime cost depends on - simulation workload; no runtime cost is incurred unless a physics world is - constructed and stepped. + APIs (for example, `lambda::physics::PhysicsWorld2D` and + `lambda::physics::RigidBody2D`). This feature enables the platform physics + backend via `lambda-rs-platform/physics-2d` (currently backed by `rapier2d`). + Expected runtime cost depends on simulation workload; no runtime cost is + incurred unless a physics world is constructed, populated, and stepped. Render validation @@ -169,10 +169,12 @@ Audio Physics - `physics-2d` (umbrella, disabled by default): enables the internal 2D physics - backend (currently backed by `rapier2d =0.32.0`). Applications MUST NOT - depend on `rapier2d` directly via this crate. + backend (currently backed by `rapier2d`). Applications MUST NOT depend on + `rapier2d` directly via this crate. ## Changelog +- 0.1.16 (2026-02-13): Document 2D rigid bodies under `physics-2d` and update + metadata. - 0.1.15 (2026-02-10): Document `audio-playback` in `lambda-rs` and update metadata. - 0.1.14 (2026-02-06): Document 2D physics feature flags in `lambda-rs` and diff --git a/docs/specs/README.md b/docs/specs/README.md index 2e512431..c943a24c 100644 --- a/docs/specs/README.md +++ b/docs/specs/README.md @@ -3,8 +3,9 @@ title: "Specifications Index" document_id: "specs-index-2026-02-07" status: "living" created: "2026-02-07T00:00:00Z" -last_updated: "2026-02-09T00:00:00Z" -version: "0.1.2" +last_updated: "2026-02-12T23:03:52Z" +version: "0.1.3" +repo_commit: "6f96052fae896a095b658f29af1eff96e5aaa348" owners: ["lambda-sh"] reviewers: ["engine"] tags: ["index", "specs", "docs"] @@ -32,6 +33,7 @@ tags: ["index", "specs", "docs"] ## Physics - 2D Physics World — [physics/physics-world-2d.md](physics/physics-world-2d.md) +- 2D Rigid Bodies — [physics/rigid-bodies-2d.md](physics/rigid-bodies-2d.md) ## Templates diff --git a/docs/specs/physics/rigid-bodies-2d.md b/docs/specs/physics/rigid-bodies-2d.md new file mode 100644 index 00000000..dd491a22 --- /dev/null +++ b/docs/specs/physics/rigid-bodies-2d.md @@ -0,0 +1,514 @@ +--- +title: "2D Rigid Bodies" +document_id: "rigid-bodies-2d-2026-02-12" +status: "draft" +created: "2026-02-12T23:03:52Z" +last_updated: "2026-02-12T23:33:50Z" +version: "0.2.0" +engine_workspace_version: "2023.1.30" +wgpu_version: "26.0.1" +shader_backend_default: "naga" +winit_version: "0.29.10" +repo_commit: "6f96052fae896a095b658f29af1eff96e5aaa348" +owners: ["lambda-sh"] +reviewers: ["engine"] +tags: ["spec", "physics", "2d", "lambda-rs", "platform"] +--- + +# 2D Rigid Bodies + +## Table of Contents + +- [Summary](#summary) +- [Scope](#scope) +- [Terminology](#terminology) +- [Architecture Overview](#architecture-overview) +- [Design](#design) + - [API Surface](#api-surface) + - [lambda-rs Public API](#lambda-rs-public-api) + - [Behavior](#behavior) + - [Validation and Errors](#validation-and-errors) + - [Cargo Features](#cargo-features) +- [Constraints and Rules](#constraints-and-rules) +- [Performance Considerations](#performance-considerations) +- [Requirements Checklist](#requirements-checklist) +- [Verification and Testing](#verification-and-testing) +- [Compatibility and Migration](#compatibility-and-migration) +- [Changelog](#changelog) + +## Summary + +- Introduce a `RigidBody2D` API for adding 2D rigid bodies to `PhysicsWorld2D`. +- Support `Static`, `Dynamic`, and `Kinematic` body types with predictable, + backend-agnostic behavior. +- Provide configuration and mutation APIs for position, rotation, velocity, + mass (dynamic only), forces, and impulses. +- Define a production-safe handle model (world-scoped, generational) to prevent + cross-world misuse and stale-handle access. + +Rationale +- A world stepping API without application-facing bodies cannot express common + 2D gameplay simulation (gravity, movement, and externally-applied forces). +- Rigid bodies establish the minimal entity layer required before adding + colliders, collision response, and constraints. + +## Scope + +### Goals + +- Support inserting rigid bodies into `PhysicsWorld2D` via a builder pattern. +- Support body types: + - `Static`: immovable; infinite mass semantics. + - `Dynamic`: affected by gravity and forces. + - `Kinematic`: user-controlled; not affected by gravity or forces. +- Support querying and setting position and rotation. +- Support querying and setting linear velocity. +- Support applying forces and impulses to dynamic bodies. +- Specify integration and mutation timing rules that are stable across physics + backend implementations. +- Support unit tests validating each body type and the force/impulse behavior. + +### Non-Goals + +- Collision shapes (colliders) and broad-phase collision queries. +- Collision response and contact events. +- Joints/constraints. +- Sleeping, deactivation, and other advanced solver behavior. +- Angular dynamics (angular velocity, torque) beyond explicitly setting and + reading `rotation`. +- Body destruction/removal APIs. +- Bitwise deterministic simulation across platforms and CPU architectures. + +## Terminology + +- Rigid body: an entity with a transform and velocity that participates in the + physics simulation. +- Static body: a body that is not integrated by the simulation. +- Dynamic body: a body integrated by the simulation and influenced by gravity + and forces. +- Kinematic body: a body integrated only by user-provided motion (for example, + explicit velocity) and not influenced by gravity or forces. +- Force: a continuous influence applied over time (units: N = kg·m/s²). +- Impulse: an instantaneous change to momentum (units: N·s = kg·m/s). +- Step: a single fixed-timestep advancement of `PhysicsWorld2D`. +- Symplectic Euler: an integration scheme that updates velocity before + position (`v += a * dt`, then `x += v * dt`). +- Teleport: a direct transform assignment (for example, `set_position`) that + bypasses integration for that update. + +## Architecture Overview + +Dependencies +- This work item depends on `PhysicsWorld2D` and its fixed-timestep stepping + contract as specified by `docs/specs/physics/physics-world-2d.md`. + +Crate boundaries +- Crate `lambda` (package: `lambda-rs`) + - Public module `physics` MUST expose backend-agnostic rigid body APIs. + - Public types MUST NOT expose vendor types (for example, `rapier2d`). +- Crate `lambda_platform` (package: `lambda-rs-platform`) + - MUST contain the internal backend wrapper that owns vendor rigid body + storage and provides body mutation/query entry points to `lambda-rs`. + +Data flow + +``` +application + └── lambda::physics::{PhysicsWorld2D, RigidBody2D} + └── lambda_platform::physics::PhysicsBackend2D (internal) + └── vendor crate (for example, rapier2d) +``` + +## Design + +### API Surface + +Module layout (new) +- `crates/lambda-rs/src/physics/rigid_body_2d.rs` + - Public `RigidBody2D`, `RigidBody2DBuilder`, `RigidBodyType`, and error + types. +- `crates/lambda-rs/src/physics/mod.rs` + - Re-export `RigidBody2D` APIs as part of `lambda::physics`. + - Provide `PhysicsWorld2D` entry points required by the handle-based API. +- `crates/lambda-rs-platform/src/physics/rapier2d.rs` + - Add internal backend entry points for body creation, mutation, and query. + +Handle model +- `RigidBody2D` MUST be a compact, copyable, opaque handle. +- Handles MUST be world-scoped and generational. + - Rationale: Prevents use-after-free and cross-world access when bodies are + later made removable. +- A valid `RigidBody2D` MUST encode: + - A stable body slot index. + - A generation counter for the slot. + - A world identifier. +- The handle encoding MUST be treated as an implementation detail and MUST NOT + be relied on by applications. +- `PhysicsWorld2D` MUST assign a stable, non-zero world identifier at + construction time, and this identifier MUST be stored in each `RigidBody2D` + created by the world. + +### lambda-rs Public API + +Public entry points (draft) + +```rust +/// The rigid body integration mode. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RigidBodyType { + Static, + Dynamic, + Kinematic, +} + +/// An opaque handle to a rigid body stored in a `PhysicsWorld2D`. +/// +/// This handle is world-scoped. Operations MUST validate that the handle +/// belongs to the provided world. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct RigidBody2D { + // World identifier used for cross-world validation (implementation detail). + world_id: u64, + // Body slot index (implementation detail). + slot_index: u32, + // Slot generation used for stale-handle detection (implementation detail). + slot_generation: u32, +} + +/// Builder for `RigidBody2D`. +#[derive(Debug, Clone, Copy)] +pub struct RigidBody2DBuilder { + body_type: RigidBodyType, + position: [f32; 2], + rotation: f32, + velocity: [f32; 2], + dynamic_mass_kg: Option, +} + +impl RigidBody2DBuilder { + /// Creates a builder for the given body type with stable defaults. + pub fn new(body_type: RigidBodyType) -> Self; + + /// Sets the initial position, in meters. + pub fn with_position(self, x: f32, y: f32) -> Self; + + /// Sets the initial rotation, in radians. + pub fn with_rotation(self, radians: f32) -> Self; + + /// Sets the initial linear velocity, in meters per second. + pub fn with_velocity(self, vx: f32, vy: f32) -> Self; + + /// Sets the mass, in kilograms, for dynamic bodies. + pub fn with_dynamic_mass_kg(self, mass_kg: f32) -> Self; + + /// Inserts the body into the given world. + pub fn build( + self, + world: &mut PhysicsWorld2D, + ) -> Result; +} + +impl RigidBody2D { + /// Returns the body type. + pub fn body_type( + self, + world: &PhysicsWorld2D, + ) -> Result; + + /// Returns the current position, in meters. + pub fn position( + self, + world: &PhysicsWorld2D, + ) -> Result<[f32; 2], RigidBody2DError>; + + /// Returns the current rotation, in radians. + pub fn rotation( + self, + world: &PhysicsWorld2D, + ) -> Result; + + /// Returns the current linear velocity, in meters per second. + pub fn velocity( + self, + world: &PhysicsWorld2D, + ) -> Result<[f32; 2], RigidBody2DError>; + + /// Sets position, in meters. + pub fn set_position( + self, + world: &mut PhysicsWorld2D, + x: f32, + y: f32, + ) -> Result<(), RigidBody2DError>; + + /// Sets rotation, in radians. + pub fn set_rotation( + self, + world: &mut PhysicsWorld2D, + radians: f32, + ) -> Result<(), RigidBody2DError>; + + /// Sets linear velocity, in meters per second. + pub fn set_velocity( + self, + world: &mut PhysicsWorld2D, + vx: f32, + vy: f32, + ) -> Result<(), RigidBody2DError>; + + /// Applies a force, in Newtons, at the center of mass. + pub fn apply_force( + self, + world: &mut PhysicsWorld2D, + fx: f32, + fy: f32, + ) -> Result<(), RigidBody2DError>; + + /// Applies an impulse, in Newton-seconds, at the center of mass. + pub fn apply_impulse( + self, + world: &mut PhysicsWorld2D, + ix: f32, + iy: f32, + ) -> Result<(), RigidBody2DError>; +} + +/// Errors for rigid body construction and operations. +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum RigidBody2DError { + InvalidHandle, + WorldMismatch, + BodyNotFound, + InvalidPosition { x: f32, y: f32 }, + InvalidRotation { radians: f32 }, + InvalidVelocity { x: f32, y: f32 }, + InvalidForce { x: f32, y: f32 }, + InvalidImpulse { x: f32, y: f32 }, + InvalidMassKg { mass_kg: f32 }, + UnsupportedOperation { body_type: RigidBodyType }, +} +``` + +Notes +- `RigidBody2D` is a handle and MUST NOT own simulation state. +- This work item introduces no removal API. `BodyNotFound` and `InvalidHandle` + exist to support handle validation and future body destruction work. + +### Behavior + +Coordinate system and units +- Positions and velocities MUST be interpreted as meters and meters per second. +- Rotation MUST be interpreted as radians around the 2D Z axis, with positive + values rotating counterclockwise. +- World gravity MUST follow `PhysicsWorld2D::gravity()` (default `(0.0, -9.81)` + meters per second squared). + +Observable state +- `position()` MUST return the rigid body translation at the end of the most + recent `step()` or after the most recent explicit teleport mutation. +- `rotation()` MUST return the rigid body rotation at the end of the most + recent `step()` or after the most recent explicit teleport mutation. +- `velocity()` MUST return the current linear velocity used for integration. + +Insertion +- `RigidBody2DBuilder::build()` MUST insert a new body into the given world and + return a world-scoped `RigidBody2D` handle. +- The builder MUST provide stable defaults: + - `position`: `(0.0, 0.0)` + - `rotation`: `0.0` + - `velocity`: `(0.0, 0.0)` + - `dynamic_mass_kg`: unset (dynamic bodies default to `1.0`) + +Mutation timing +- `set_position`, `set_rotation`, and `set_velocity` MUST take effect + immediately and MUST be observable by subsequent queries before the next + `PhysicsWorld2D::step()`. +- `set_position` and `set_rotation` MUST NOT modify linear velocity or the + accumulated force state for the body. +- `apply_impulse` MUST take effect immediately by updating velocity and MUST be + observable by `velocity()` before the next `PhysicsWorld2D::step()`. +- `apply_force` MUST affect integration starting with the next + `PhysicsWorld2D::step()`. + +Stepping +- `PhysicsWorld2D::step()` MUST integrate dynamic and kinematic rigid bodies. +- Integration MUST respect `PhysicsWorld2D` substeps (each substep applies + gravity and applies accumulated forces for the substep duration). +- Accumulated forces MUST be applied for the full duration of the outer step + (across all substeps) and MUST be cleared after the outer `step()` completes. + +Integration model (normative) +- Dynamic and kinematic translation integration MUST be equivalent to + symplectic Euler for linear motion: + - Dynamic: + - `a = gravity + (force / mass_kg)` + - `v = v + a * dt` + - `x = x + v * dt` + - Kinematic: + - `x = x + v * dt` +- `dt` MUST be the world timestep divided by `substeps` for each internal + substep. +- `force` MUST be the accumulated per-body force vector in Newtons, computed as + the linear sum of all `apply_force()` calls since the end of the previous + outer `PhysicsWorld2D::step()`. +- Force and impulse APIs in this work item MUST apply at the center of mass and + MUST NOT introduce torque. +- `apply_force()` MUST be additive. Multiple `apply_force()` calls before a + `PhysicsWorld2D::step()` MUST sum linearly into the force accumulator. + +Rotation semantics +- During `step()`, rigid body rotation MUST remain unchanged in this work item. + - Rationale: Angular dynamics are out of scope and the public API does not + expose angular velocity or torque. +- `set_rotation()` MUST update rotation immediately for all body types. + +Static bodies +- Static bodies MUST NOT be affected by gravity, forces, or impulses. +- Static bodies MUST NOT change position or rotation during `step()` unless + explicitly mutated via `set_position` or `set_rotation`. +- Applying forces/impulses to static bodies MUST return + `RigidBody2DError::UnsupportedOperation { body_type: Static }`. +- Setting velocity on static bodies MUST return + `RigidBody2DError::UnsupportedOperation { body_type: Static }`. + +Dynamic bodies +- Dynamic bodies MUST be affected by gravity each step. +- `apply_force()` MUST accumulate a force that influences acceleration on the + next `step()`. +- `apply_impulse()` MUST modify linear velocity immediately by: + - `velocity += impulse / mass_kg` +- `set_velocity()` MUST override the current linear velocity. +- `set_position()` and `set_rotation()` MUST update the transform and MUST NOT + panic. These operations MUST NOT implicitly clear velocity or accumulated + forces. + +Kinematic bodies +- Kinematic bodies MUST NOT be affected by gravity, forces, or impulses. +- Kinematic bodies MUST integrate using their linear velocity: + - `position += velocity * dt` +- `set_velocity()` MUST set the kinematic integration velocity. +- Applying forces/impulses to kinematic bodies MUST return + `RigidBody2DError::UnsupportedOperation { body_type: Kinematic }`. + +Bodies without colliders +- Bodies MUST be able to move and be queried without requiring collision + shapes. +- Bodies without colliders MUST NOT collide, generate contacts, or constrain + motion in this work item. + +### Validation and Errors + +Handle validation +- All `RigidBody2D` operations MUST validate that the handle is well-formed and + belongs to the provided world. +- `InvalidHandle` MUST be returned when the handle encoding is invalid (for + example, a zero `world_id`). +- Using a handle from a different world MUST return + `RigidBody2DError::WorldMismatch`. +- Using an unknown handle MUST return `RigidBody2DError::BodyNotFound`. + Unknown includes a slot that is out of range, empty, or has a mismatched + generation. + +Input validation rules +- Position components MUST be finite. +- Rotation MUST be finite. +- Velocity components MUST be finite. +- Force components MUST be finite. +- Impulse components MUST be finite. +- For `Dynamic` bodies, mass MUST be finite and `> 0.0`. + - If `dynamic_mass_kg` is unset, mass MUST default to `1.0`. +- For `Static` and `Kinematic` bodies, setting a dynamic mass MUST be rejected + with `UnsupportedOperation { body_type }`. + +Error reporting +- Errors MUST be backend-agnostic and MUST NOT expose vendor types. +- Errors SHOULD include offending values where doing so materially improves + actionability (for example, `InvalidMassKg { mass_kg }`). + +### Cargo Features + +- This work item MUST be gated by the existing `lambda-rs/physics-2d` feature + and MUST NOT add new physics Cargo features. +- `lambda-rs/physics-2d` MUST continue to enable + `lambda-rs-platform/physics-2d`. +- The implementation pull request SHOULD update `docs/features.md` to include + the rigid body APIs as part of the `physics-2d` summary. + +## Constraints and Rules + +- Public APIs MUST remain backend-agnostic and MUST NOT expose vendor types. +- Public APIs MUST avoid panic in library code; invalid operations MUST return + actionable errors. +- This work item MUST NOT introduce collisions, shapes, or collision response. + +## Performance Considerations + +Recommendations +- Prefer using kinematic bodies for directly-authoritative gameplay movement. + - Rationale: Avoids force tuning and solver costs when collisions are not + required. +- Use dynamic bodies only when gravity and force-based movement is required. + - Rationale: Dynamic integration adds per-step acceleration updates and + force accumulation handling. + +## Requirements Checklist + +Functionality +- [ ] Static, dynamic, and kinematic bodies can be created in a world. +- [ ] Bodies can be queried for position, rotation, and velocity. +- [ ] Bodies can be mutated for position, rotation, and velocity. +- [ ] Gravity affects dynamic bodies and does not affect static/kinematic. +- [ ] Forces and impulses affect dynamic bodies and error on other types. + +API Surface +- [ ] `RigidBody2D`, `RigidBody2DBuilder`, and `RigidBodyType` are public in + `lambda-rs`. +- [ ] Public API is backend-agnostic and does not expose vendor types. +- [ ] Handle validation prevents cross-world misuse. +- [ ] Handles are world-scoped and generational. + +Validation and Errors +- [ ] Builder validation rejects non-finite inputs and invalid dynamic mass. +- [ ] Unsupported operations return descriptive errors (no panics). + +Documentation and Examples +- [ ] `docs/features.md` updated to reflect rigid body support. +- [ ] Minimal example added or updated (optional for this work item). + +## Verification and Testing + +Unit tests +- Construct each body type and validate default state. +- Verify static bodies do not move under gravity/forces. +- Verify dynamic bodies accelerate under gravity and respond to force/impulse + with assertions that use explicit tolerances. +- Verify kinematic bodies move when a velocity is set. +- Verify handle validation rejects cross-world operations. +- Recommended numeric scenarios (tolerance: `1e-4`) + - Dynamic gravity integration + - `dt = 1.0`, `gravity = (0.0, -10.0)`, `mass_kg = 1.0`, `v0 = (0.0, 0.0)`. + - After one step: `v1.y == -10.0`, `y1 == -10.0`. + - After two steps: `v2.y == -20.0`, `y2 == -30.0`. + - Dynamic impulse + - `mass_kg = 2.0`, `apply_impulse((2.0, 0.0))` results in `vx += 1.0`. + - Dynamic force (no gravity) + - `dt = 1.0`, `gravity = (0.0, 0.0)`, `mass_kg = 2.0`, + `apply_force((10.0, 0.0))` once before a step results in `vx == 5.0` and + `x == 5.0` after the step. + - Kinematic velocity integration + - `dt = 1.0`, `v = (2.0, 0.0)` results in `x += 2.0` after one step. +- Commands: `cargo test -p lambda-rs --features physics-2d -- --nocapture` + +Integration tests +- None required for this work item. + +## Compatibility and Migration + +- If the physics module remains behind `physics-2d`, no behavior changes occur + unless the feature is enabled. +- No migration steps are required. + +## Changelog + +- 2026-02-12 (v0.1.0) — Initial draft. +- 2026-02-12 (v0.2.0) — Add handle rules, timing, and integration semantics. diff --git a/docs/tutorials/README.md b/docs/tutorials/README.md index 85867831..b028e241 100644 --- a/docs/tutorials/README.md +++ b/docs/tutorials/README.md @@ -3,13 +3,13 @@ title: "Tutorials Index" document_id: "tutorials-index-2025-10-17" status: "living" created: "2025-10-17T00:20:00Z" -last_updated: "2026-02-07T00:00:00Z" -version: "0.8.0" +last_updated: "2026-02-13T00:00:00Z" +version: "0.9.0" engine_workspace_version: "2023.1.30" wgpu_version: "28.0.0" shader_backend_default: "naga" winit_version: "0.29.10" -repo_commit: "4b0c5abf6743788596177b3c10c3214db20ad6b1" +repo_commit: "6a3b507eedddc39f568ed73cfadf34011d57b9a3" owners: ["lambda-sh"] reviewers: ["engine", "rendering"] tags: ["index", "tutorials", "docs"] @@ -44,11 +44,13 @@ Browse all tutorials under `rendering/`. ### Basics - Physics 2D: Falling Quad (Kinematic) — [physics/basics/falling-quad-kinematic.md](physics/basics/falling-quad-kinematic.md) +- Physics 2D: Rigid Bodies (No Collisions) — [physics/basics/rigid-bodies-2d.md](physics/basics/rigid-bodies-2d.md) Browse all tutorials under `physics/`. Changelog +- 0.9.0 (2026-02-13): Add rigid bodies physics tutorial. - 0.8.0 (2026-02-07): Add physics tutorial section and first physics demo. - 0.7.1 (2026-02-07): Group tutorials by feature area in the index. - 0.7.0 (2026-01-05): Rename push constants tutorial to immediates for wgpu v28; update metadata. diff --git a/docs/tutorials/physics/basics/rigid-bodies-2d.md b/docs/tutorials/physics/basics/rigid-bodies-2d.md new file mode 100644 index 00000000..bc3ad9fb --- /dev/null +++ b/docs/tutorials/physics/basics/rigid-bodies-2d.md @@ -0,0 +1,281 @@ +--- +title: "Physics 2D: Rigid Bodies (No Collisions)" +document_id: "physics-rigid-bodies-2d-no-collisions-2026-02-13" +status: "draft" +created: "2026-02-13T00:00:00Z" +last_updated: "2026-02-13T00:00:00Z" +version: "0.1.0" +engine_workspace_version: "2023.1.30" +wgpu_version: "28.0.0" +shader_backend_default: "naga" +winit_version: "0.29.10" +repo_commit: "6a3b507eedddc39f568ed73cfadf34011d57b9a3" +owners: ["lambda-sh"] +reviewers: ["engine", "rendering"] +tags: ["tutorial", "physics", "2d", "rigid-bodies", "fixed-timestep", "uniform-buffers"] +--- + +## Overview + +This tutorial builds a render demo that showcases 2D rigid bodies in +`PhysicsWorld2D`. The simulation does not define collision shapes, so bodies do +not collide. Instead, simple boundary rules clamp and bounce bodies within the +viewport to keep the demo visible. + +Reference implementation: `demos/physics/src/bin/physics_rigid_bodies_2d.rs`. + +## Table of Contents + +- [Overview](#overview) +- [Goals](#goals) +- [Prerequisites](#prerequisites) +- [Requirements and Constraints](#requirements-and-constraints) +- [Data Flow](#data-flow) +- [Implementation Steps](#implementation-steps) + - [Step 1 — Add a Demo Binary Entry](#step-1) + - [Step 2 — Define Shader and Uniform Contract](#step-2) + - [Step 3 — Define Component State](#step-3) + - [Step 4 — Create the Physics World and Bodies](#step-4) + - [Step 5 — Build GPU Resources and Per-Body Uniform Buffers](#step-5) + - [Step 6 — Implement Fixed-Timestep Stepping and Controls](#step-6) + - [Step 7 — Write Uniforms and Render Bodies](#step-7) +- [Validation](#validation) +- [Notes](#notes) +- [Conclusion](#conclusion) +- [Exercises](#exercises) +- [Changelog](#changelog) + +## Goals + +- Create three rigid body types: + - Dynamic bodies (gravity, forces, impulses). + - Kinematic body (user-provided velocity and rotation). + - Static body (fixed reference). +- Step physics with a fixed timestep accumulator. +- Apply forces and impulses to dynamic bodies. +- Query position and rotation each frame and render via uniform buffers. + +## Prerequisites + +- The workspace builds: `cargo build --workspace`. +- The physics demo crate builds: `cargo build -p lambda-demos-physics`. + +## Requirements and Constraints + +- The demo MUST enable `lambda-rs` feature `physics-2d`. +- The update loop MUST step the simulation using a fixed timestep accumulator. + Rationale: reduces variance across machines. +- The demo MUST NOT rely on collision shapes, collision detection, or collision + response. Boundary behavior MUST be implemented in user code. +- Uniform structs in Rust MUST match shader uniform blocks in size and + alignment. + +## Data Flow + +- Fixed ticks apply: + - A constant wind force (dynamic bodies). + - A pending impulse on input (dynamic bodies). + - A kinematic rotation update and kinematic velocity (kinematic body). + - One `PhysicsWorld2D::step()`. + - Manual boundary bounce and clamp logic (dynamic and kinematic bodies). +- Each render frame queries rigid body transforms and writes them to per-body + uniform buffers used by the vertex shader. + +ASCII diagram + +``` +Variable frame time + │ + ▼ +Accumulator (seconds) + │ while >= fixed_dt + ▼ +Fixed tick: + ├─ apply_force / apply_impulse (dynamic) + ├─ set_rotation (kinematic) + ├─ PhysicsWorld2D::step() + └─ boundary clamp + bounce (user code) + │ + ▼ +Per-frame: + ├─ query body position/rotation + ├─ write per-body uniforms + └─ draw the same quad mesh per body +``` + +## Implementation Steps + +### Step 1 — Add a Demo Binary Entry + +Add a new binary to the physics demos crate. + +Update `demos/physics/Cargo.toml`: + +```toml +[[bin]] +name = "physics_rigid_bodies_2d" +path = "src/bin/physics_rigid_bodies_2d.rs" +required-features = ["physics-2d"] +``` + +After this step, `cargo build -p lambda-demos-physics` SHOULD still succeed. + +### Step 2 — Define Shader and Uniform Contract + +Define a vertex shader that: + +- Rotates a quad in 2D using a uniform rotation in radians. +- Translates the quad by a uniform `(x, y)` offset. +- Applies a per-body tint color for readability. + +Define a matching Rust uniform: + +```rust +#[repr(C)] +#[derive(Debug, Clone, Copy)] +pub struct QuadGlobalsUniform { + pub offset_rotation: [f32; 4], + pub tint: [f32; 4], +} +``` + +After this step, the shader and uniform contract represent a complete per-body +transform and color payload. + +### Step 3 — Define Component State + +Define a component that stores: + +- A `PhysicsWorld2D`. +- A fixed timestep accumulator. +- `RigidBody2D` handles for each body in the demo. +- Basic input state (for an impulse trigger). +- GPU resources: shaders, mesh, pipeline, render pass. +- One uniform buffer and bind group per rigid body. + +After this step, the demo has a concrete place to store both simulation state +and render state. + +### Step 4 — Create the Physics World and Bodies + +Construct the physics world using `PhysicsWorld2DBuilder`, then create four +bodies: + +- Two dynamic bodies with different masses. +- One kinematic body with an initial velocity. +- One static body as a fixed reference. + +Example: + +```rust +let mut physics_world = PhysicsWorld2DBuilder::new() + .with_gravity(0.0, -1.6) + .build() + .expect("Failed to create PhysicsWorld2D"); + +let dynamic_light_body = RigidBody2DBuilder::new(RigidBodyType::Dynamic) + .with_position(-0.35, 0.75) + .with_dynamic_mass_kg(0.5) + .build(&mut physics_world) + .expect("Failed to create dynamic body (light)"); +``` + +After this step, the demo has simulation objects whose state can be queried and +stepped. + +### Step 5 — Build GPU Resources and Per-Body Uniform Buffers + +In `on_attach`: + +- Build a quad mesh. +- Build a uniform bind group layout. +- For each rigid body: + - Query initial position and rotation. + - Create a CPU-visible uniform buffer with `QuadGlobalsUniform`. + - Create a bind group for that buffer. +- Build a render pipeline using the shared layout and shared mesh buffer. + +After this step, each body has a uniform buffer and bind group that can be +updated independently while sharing a single mesh and pipeline. + +### Step 6 — Implement Fixed-Timestep Stepping and Controls + +Implement: + +- Keyboard handling that sets an impulse flag when Space is pressed. +- A fixed timestep loop in `on_update`: + - Apply a constant wind force to dynamic bodies each tick. + - Apply an upward impulse when the impulse flag is set. + - Increment and set a kinematic rotation value each tick. + - Call `PhysicsWorld2D::step()`. + - Apply manual boundary bounce and clamp logic: + - Floor and ceiling bounce for dynamic bodies. + - Left and right wall bounce for dynamic bodies. + - Left and right wall clamp (and velocity flip) for the kinematic body. + +After this step, the demo shows steady movement regardless of frame rate, and +input can inject instantaneous velocity changes into the dynamic bodies. + +### Step 7 — Write Uniforms and Render Bodies + +In `on_render`: + +- For each body: + - Query position and rotation from the physics world. + - Write `QuadGlobalsUniform` to the corresponding uniform buffer. +- Record draw commands: + - Begin a render pass, set the pipeline, set viewport/scissor, bind the shared + vertex buffer. + - For each body, set its bind group and draw the quad. + +After this step, the rendered quads track the simulated bodies each frame. + +## Validation + +Build and run: + +```bash +cargo run -p lambda-demos-physics --bin physics_rigid_bodies_2d +``` + +Expected behavior: + +- Two dynamic bodies fall under gravity and drift from a constant wind force. +- Dynamic bodies bounce off the floor, ceiling, and side walls. +- The kinematic body moves horizontally, rotates continuously, and clamps at + the walls. +- The static body remains fixed. +- Pressing Space applies an upward impulse to both dynamic bodies. + +## Notes + +- This demo intentionally does not use collision shapes. Any “bouncing” behavior + is implemented by clamping positions and mutating velocities in user code. +- `apply_force` and `apply_impulse` are intended for dynamic bodies. Calls on + static or kinematic bodies SHOULD return an error. +- Fixed timestep stepping is implemented with an accumulator. The demo MUST NOT + advance simulation directly from variable frame deltas. +- The rotation shown in the demo is explicitly set each tick. Angular dynamics + are not required for this tutorial. + +## Conclusion + +This tutorial demonstrates how to create and step 2D rigid bodies using +`PhysicsWorld2D` and render them by writing per-body uniform buffers. The demo +uses dynamic, kinematic, and static bodies and applies forces and impulses to +validate basic rigid body integration without relying on collision shapes. + +## Exercises + +- Add a configurable drag force that reduces dynamic body velocity over time. +- Replace constant wind with a time-varying sinusoidal force. +- Add a toggle key that enables or disables gravity at runtime. +- Render a simple velocity indicator (for example, a line in the direction of + velocity) using additional geometry. +- Spawn N dynamic bodies at startup and randomize initial positions and masses. +- Apply a force proportional to body mass and observe acceleration differences. +- Add a soft “camera” offset uniform and pan the view with arrow keys. + +## Changelog + +- 0.1.0 (2026-02-13): Initial tutorial for `physics_rigid_bodies_2d`.