Physics Playground
Live Demo: Physics
An interactive physics sandbox demonstrating rigid bodies, colliders, joints, and forces.
Complete Example
use nightshade::prelude::*; use nightshade::ecs::physics::commands::{ spawn_static_physics_cube_with_material, spawn_dynamic_physics_cube_with_material, spawn_dynamic_physics_sphere_with_material, spawn_dynamic_physics_cylinder_with_material, }; use nightshade::ecs::physics::{ RigidBodyType, SphericalJoint, create_spherical_joint, }; struct PhysicsPlayground { spawn_mode: SpawnMode, selected_entity: Option<Entity>, holding_entity: Option<Entity>, grab_distance: f32, } #[derive(Default, Clone, Copy)] enum SpawnMode { #[default] Cube, Sphere, Cylinder, Chain, Ragdoll, } impl Default for PhysicsPlayground { fn default() -> Self { Self { spawn_mode: SpawnMode::Cube, selected_entity: None, holding_entity: None, grab_distance: 5.0, } } } impl State for PhysicsPlayground { fn initialize(&mut self, world: &mut World) { let camera = spawn_camera(world, Vec3::new(0.0, 5.0, 10.0), "Camera".to_string()); world.resources.active_camera = Some(camera); self.setup_environment(world); self.setup_ui(world); world.set_cursor_visible(false); world.set_cursor_locked(true); } fn run_systems(&mut self, world: &mut World) { fly_camera_system(world); self.update_held_object(world); run_physics_systems(world); sync_transforms_from_physics_system(world); } fn on_keyboard_input(&mut self, world: &mut World, key: KeyCode, state: ElementState) { if state == ElementState::Pressed { match key { KeyCode::Digit1 => self.spawn_mode = SpawnMode::Cube, KeyCode::Digit2 => self.spawn_mode = SpawnMode::Sphere, KeyCode::Digit3 => self.spawn_mode = SpawnMode::Cylinder, KeyCode::Digit4 => self.spawn_mode = SpawnMode::Chain, KeyCode::Digit5 => self.spawn_mode = SpawnMode::Ragdoll, KeyCode::KeyR => self.reset_scene(world), KeyCode::KeyF => self.apply_explosion(world), KeyCode::KeyG => self.toggle_gravity(world), _ => {} } } } fn on_mouse_input(&mut self, world: &mut World, state: ElementState, button: MouseButton) { match (button, state) { (MouseButton::Left, ElementState::Pressed) => { self.spawn_object(world); } (MouseButton::Right, ElementState::Pressed) => { self.grab_object(world); } (MouseButton::Right, ElementState::Released) => { self.release_object(world); } (MouseButton::Middle, ElementState::Pressed) => { self.delete_at_cursor(world); } _ => {} } } } impl PhysicsPlayground { fn setup_environment(&mut self, world: &mut World) { spawn_static_physics_cube_with_material( world, Vec3::zeros(), Vec3::new(100.0, 0.2, 100.0), Material { base_color: [0.3, 0.3, 0.35, 1.0], roughness: 0.8, ..Default::default() }, ); self.spawn_walls(world); spawn_sun(world); world.resources.graphics.ambient_light = [0.2, 0.2, 0.2, 1.0]; } fn spawn_walls(&mut self, world: &mut World) { let wall_positions = [ (Vec3::new(25.0, 2.5, 0.0), Vec3::new(1.0, 5.0, 100.0)), (Vec3::new(-25.0, 2.5, 0.0), Vec3::new(1.0, 5.0, 100.0)), (Vec3::new(0.0, 2.5, 25.0), Vec3::new(100.0, 5.0, 1.0)), (Vec3::new(0.0, 2.5, -25.0), Vec3::new(100.0, 5.0, 1.0)), ]; for (position, size) in wall_positions { spawn_static_physics_cube_with_material( world, position, size, Material { base_color: [0.4, 0.4, 0.45, 1.0], roughness: 0.9, ..Default::default() }, ); } } fn setup_ui(&mut self, world: &mut World) { let help_text = "Controls:\n\ 1-5: Select spawn mode\n\ Left Click: Spawn object\n\ Right Click: Grab/throw\n\ Middle Click: Delete\n\ R: Reset scene\n\ F: Explosion\n\ G: Toggle gravity"; spawn_ui_text(world, help_text, Vec2::new(20.0, 20.0)); spawn_ui_text(world, "Mode: Cube", Vec2::new(700.0, 20.0)); } fn spawn_object(&mut self, world: &mut World) { let Some(camera) = world.resources.active_camera else { return }; let Some(transform) = world.core.get_global_transform(camera) else { return }; let spawn_position = transform.translation() + transform.forward_vector() * 5.0; match self.spawn_mode { SpawnMode::Cube => { self.spawn_cube(world, spawn_position); } SpawnMode::Sphere => { self.spawn_sphere(world, spawn_position); } SpawnMode::Cylinder => { self.spawn_cylinder(world, spawn_position); } SpawnMode::Chain => self.spawn_chain(world, spawn_position), SpawnMode::Ragdoll => self.spawn_ragdoll(world, spawn_position), } } fn spawn_cube(&self, world: &mut World, position: Vec3) -> Entity { spawn_dynamic_physics_cube_with_material( world, position, Vec3::new(1.0, 1.0, 1.0), 1.0, Material { base_color: random_color(), roughness: 0.7, metallic: 0.1, ..Default::default() }, ) } fn spawn_sphere(&self, world: &mut World, position: Vec3) -> Entity { spawn_dynamic_physics_sphere_with_material( world, position, 0.5, 1.0, Material { base_color: random_color(), roughness: 0.3, metallic: 0.8, ..Default::default() }, ) } fn spawn_cylinder(&self, world: &mut World, position: Vec3) -> Entity { spawn_dynamic_physics_cylinder_with_material( world, position, 0.5, 0.3, 1.0, Material { base_color: random_color(), roughness: 0.5, metallic: 0.3, ..Default::default() }, ) } fn spawn_chain(&self, world: &mut World, start_position: Vec3) { let link_count = 10; let link_spacing = 0.8; let mut previous_link: Option<Entity> = None; for index in 0..link_count { let position = start_position + Vec3::new(0.0, -(index as f32 * link_spacing), 0.0); let link = spawn_dynamic_physics_cylinder_with_material( world, position, 0.15, 0.1, if index == 0 { 0.0 } else { 0.5 }, Material { base_color: [0.7, 0.7, 0.75, 1.0], roughness: 0.3, metallic: 0.9, ..Default::default() }, ); if index == 0 { if let Some(body) = world.core.get_rigid_body_mut(link) { *body = RigidBodyComponent::new_static() .with_translation(position.x, position.y, position.z); } } if let Some(prev) = previous_link { create_spherical_joint( world, prev, link, SphericalJoint::new() .with_local_anchor1(Vec3::new(0.0, -link_spacing / 2.0, 0.0)) .with_local_anchor2(Vec3::new(0.0, link_spacing / 2.0, 0.0)), ); } previous_link = Some(link); } } fn spawn_ragdoll(&self, world: &mut World, position: Vec3) { let torso = self.spawn_body_part(world, position, Vec3::new(0.6, 0.8, 0.4), [0.8, 0.6, 0.5, 1.0]); let head = self.spawn_body_part(world, position + Vec3::new(0.0, 0.6, 0.0), Vec3::new(0.3, 0.3, 0.3), [0.9, 0.7, 0.6, 1.0]); let left_arm = self.spawn_body_part(world, position + Vec3::new(-0.5, 0.2, 0.0), Vec3::new(0.5, 0.16, 0.16), [0.8, 0.6, 0.5, 1.0]); let right_arm = self.spawn_body_part(world, position + Vec3::new(0.5, 0.2, 0.0), Vec3::new(0.5, 0.16, 0.16), [0.8, 0.6, 0.5, 1.0]); let left_leg = self.spawn_body_part(world, position + Vec3::new(-0.15, -0.6, 0.0), Vec3::new(0.2, 0.6, 0.2), [0.3, 0.3, 0.5, 1.0]); let right_leg = self.spawn_body_part(world, position + Vec3::new(0.15, -0.6, 0.0), Vec3::new(0.2, 0.6, 0.2), [0.3, 0.3, 0.5, 1.0]); create_spherical_joint(world, torso, head, SphericalJoint::new() .with_local_anchor1(Vec3::new(0.0, 0.4, 0.0)) .with_local_anchor2(Vec3::new(0.0, -0.15, 0.0))); create_spherical_joint(world, torso, left_arm, SphericalJoint::new() .with_local_anchor1(Vec3::new(-0.3, 0.2, 0.0)) .with_local_anchor2(Vec3::new(0.25, 0.0, 0.0))); create_spherical_joint(world, torso, right_arm, SphericalJoint::new() .with_local_anchor1(Vec3::new(0.3, 0.2, 0.0)) .with_local_anchor2(Vec3::new(-0.25, 0.0, 0.0))); create_spherical_joint(world, torso, left_leg, SphericalJoint::new() .with_local_anchor1(Vec3::new(-0.15, -0.4, 0.0)) .with_local_anchor2(Vec3::new(0.0, 0.3, 0.0))); create_spherical_joint(world, torso, right_leg, SphericalJoint::new() .with_local_anchor1(Vec3::new(0.15, -0.4, 0.0)) .with_local_anchor2(Vec3::new(0.0, 0.3, 0.0))); } fn spawn_body_part(&self, world: &mut World, position: Vec3, size: Vec3, color: [f32; 4]) -> Entity { let mass = size.x * size.y * size.z * 8.0; spawn_dynamic_physics_cube_with_material( world, position, size, mass, Material { base_color: color, roughness: 0.8, ..Default::default() }, ) } fn grab_object(&mut self, world: &mut World) { let Some(camera) = world.resources.active_camera else { return }; let Some(transform) = world.core.get_global_transform(camera) else { return }; let origin = transform.translation(); let direction = transform.forward_vector(); for entity in world.core.query_entities(RIGID_BODY | GLOBAL_TRANSFORM) { let Some(entity_transform) = world.core.get_global_transform(entity) else { continue }; let to_entity = entity_transform.translation() - origin; let distance = to_entity.magnitude(); let dot = direction.dot(&to_entity.normalize()); if distance < 20.0 && dot > 0.95 { self.holding_entity = Some(entity); self.grab_distance = distance; break; } } } fn release_object(&mut self, world: &mut World) { if let Some(entity) = self.holding_entity.take() { let Some(camera) = world.resources.active_camera else { return }; let Some(transform) = world.core.get_global_transform(camera) else { return }; let throw_direction = transform.forward_vector(); if let Some(body) = world.core.get_rigid_body_mut(entity) { body.linvel = [ throw_direction.x * 20.0, throw_direction.y * 20.0, throw_direction.z * 20.0, ]; } } } fn update_held_object(&mut self, world: &mut World) { let Some(entity) = self.holding_entity else { return }; let Some(camera) = world.resources.active_camera else { return }; let Some(camera_transform) = world.core.get_global_transform(camera) else { return }; let target = camera_transform.translation() + camera_transform.forward_vector() * self.grab_distance; if let Some(transform) = world.core.get_local_transform(entity) { let to_target = target - transform.translation; if let Some(body) = world.core.get_rigid_body_mut(entity) { body.linvel = [to_target.x * 20.0, to_target.y * 20.0, to_target.z * 20.0]; } } } fn delete_at_cursor(&mut self, world: &mut World) { let Some(camera) = world.resources.active_camera else { return }; let Some(transform) = world.core.get_global_transform(camera) else { return }; let origin = transform.translation(); let direction = transform.forward_vector(); let mut closest: Option<(Entity, f32)> = None; for entity in world.core.query_entities(RIGID_BODY | GLOBAL_TRANSFORM) { let Some(entity_transform) = world.core.get_global_transform(entity) else { continue }; let to_entity = entity_transform.translation() - origin; let distance = to_entity.magnitude(); let dot = direction.dot(&to_entity.normalize()); if distance < 50.0 && dot > 0.95 { if closest.map_or(true, |(_, closest_distance)| distance < closest_distance) { closest = Some((entity, distance)); } } } if let Some((entity, _)) = closest { world.despawn_entities(&[entity]); } } fn apply_explosion(&self, world: &mut World) { let Some(camera) = world.resources.active_camera else { return }; let Some(transform) = world.core.get_global_transform(camera) else { return }; let explosion_center = transform.translation() + transform.forward_vector() * 5.0; let explosion_radius = 10.0; let explosion_force = 50.0; for entity in world.core.query_entities(RIGID_BODY | GLOBAL_TRANSFORM) { if let (Some(body), Some(entity_transform)) = ( world.core.get_rigid_body_mut(entity), world.core.get_global_transform(entity), ) { let to_entity = entity_transform.translation() - explosion_center; let distance = to_entity.magnitude(); if distance < explosion_radius && distance > 0.1 { let falloff = 1.0 - (distance / explosion_radius); let force = to_entity.normalize() * explosion_force * falloff; body.linvel = [ body.linvel[0] + force.x, body.linvel[1] + force.y, body.linvel[2] + force.z, ]; } } } } fn toggle_gravity(&self, world: &mut World) { let gravity = &mut world.resources.physics.gravity; if gravity.y < 0.0 { *gravity = Vec3::zeros(); } else { *gravity = Vec3::new(0.0, -9.81, 0.0); } } fn reset_scene(&mut self, world: &mut World) { let entities_to_remove: Vec<Entity> = world.core .query_entities(RIGID_BODY) .filter(|entity| { world.core.get_rigid_body(*entity) .map(|body| body.body_type == RigidBodyType::Dynamic) .unwrap_or(false) }) .collect(); world.despawn_entities(&entities_to_remove); self.holding_entity = None; self.selected_entity = None; } } fn random_color() -> [f32; 4] { static COUNTER: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(12345); let mut seed = COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed); let mut next = || -> f32 { seed = seed.wrapping_mul(1103515245).wrapping_add(12345); seed as f32 / u32::MAX as f32 }; [ 0.3 + 0.7 * next(), 0.3 + 0.7 * next(), 0.3 + 0.7 * next(), 1.0, ] } fn main() -> Result<(), Box<dyn std::error::Error>> { nightshade::launch(PhysicsPlayground::default()) }
Features Demonstrated
Object Spawning
Spawn various physics primitives with random colors using the convenience functions:
#![allow(unused)] fn main() { use nightshade::ecs::physics::commands::{ spawn_dynamic_physics_cube_with_material, spawn_dynamic_physics_sphere_with_material, spawn_dynamic_physics_cylinder_with_material, spawn_static_physics_cube_with_material, }; spawn_dynamic_physics_cube_with_material(world, position, size, mass, material); spawn_dynamic_physics_sphere_with_material(world, position, radius, mass, material); spawn_dynamic_physics_cylinder_with_material(world, position, half_height, radius, mass, material); spawn_static_physics_cube_with_material(world, position, size, material); }
Joint Systems
Chain: A series of capsules connected by spherical joints, anchored at the top.
Ragdoll: A humanoid figure made of box body parts connected by joints:
- Head connected to torso
- Arms connected to torso
- Legs connected to torso
Joints are created using the SphericalJoint builder:
#![allow(unused)] fn main() { use nightshade::ecs::physics::{SphericalJoint, create_spherical_joint}; create_spherical_joint(world, parent, child, SphericalJoint::new() .with_local_anchor1(Vec3::new(0.0, 0.4, 0.0)) .with_local_anchor2(Vec3::new(0.0, -0.15, 0.0))); }
Object Manipulation
Grab: Right-click to grab objects and move them with the camera.
Throw: Release right-click to throw grabbed objects.
Delete: Middle-click to delete objects.
Physics Effects
Explosion: Press F to apply radial force to nearby objects.
Gravity Toggle: Press G to toggle between normal gravity and zero gravity.
Cargo.toml
[package]
name = "physics-playground"
version = "0.1.0"
edition = "2024"
[dependencies]
nightshade = { git = "...", features = ["engine", "wgpu", "physics"] }