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. The scene is a floor, four walls, a sun, and whatever the user spawns into it. Left-click spawns the current primitive (cube, sphere, cylinder, chain, ragdoll). Right-click grabs an object and throws it on release. Middle-click deletes the object under the crosshair. F detonates a radial explosion. G toggles gravity. R clears every dynamic body.

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

The single-shape spawners cover cubes, spheres, and cylinders. Each takes a position, a shape parameter (size for cubes, radius for spheres, half-height and radius for cylinders), a mass, and a material:

#![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);
}

Dynamic bodies fall under gravity and respond to forces. Static bodies are immovable and serve as level geometry.

Joint systems

The chain is a column of small capsules connected by spherical joints, with the top capsule pinned to the world. Gravity pulls the bottom links down. The joint constraints stop them from drifting apart.

The ragdoll is six box body parts (torso, head, two arms, two legs) connected to the torso by spherical joints. Drop it and it crumples in a believable way because each joint resolves its own angular constraint while the bodies fall.

Joints are created through the SphericalJoint builder, with one local anchor on each end:

#![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)));
}

The anchor on each side is the point on that body's local frame where the joint attaches.

Object manipulation

Right-click grabs the object the camera is pointed at. While held, update_held_object drives the body's linear velocity toward a point five units in front of the camera, so the object follows the cursor without snapping. Releasing the button hands the object a forward impulse, which throws it.

Middle-click deletes the closest object under the crosshair via despawn_entities.

Physics effects

The explosion is a radial loop. Every dynamic body within ten units of the explosion center receives an impulse along the vector from the center to the body, scaled by distance falloff so closer bodies fly farther. The gravity toggle flips between Earth gravity and zero gravity by writing into world.resources.physics.gravity. The reset path queries every dynamic body and despawns it in a single batch.

Cargo.toml

[package]
name = "physics-playground"
version = "0.1.0"
edition = "2024"

[dependencies]
nightshade = { git = "...", features = ["engine", "wgpu", "physics"] }