Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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"] }