Skip to main content

viewport_lib/runtime/plugins/physics_lite/
plugin.rs

1//! PhysicsLitePlugin: simple velocity integration, gravity, and bounded collision.
2
3use crate::interaction::selection::NodeId;
4use crate::runtime::context::RuntimeStepContext;
5use crate::runtime::output::ContactEvent;
6use crate::runtime::plugin::{RuntimePlugin, phase};
7use crate::scene::aabb::Aabb;
8
9/// A single physics body managed by [`PhysicsLitePlugin`].
10#[derive(Debug, Clone)]
11pub struct PhysicsBody {
12    /// Scene node this body drives.
13    pub node_id: NodeId,
14    /// Linear velocity in world space (m/s).
15    pub velocity: glam::Vec3,
16    /// Multiplier on the plugin's global gravity vector.
17    pub gravity_scale: f32,
18    /// Bounce restitution coefficient (0 = no bounce, 1 = perfectly elastic).
19    pub restitution: f32,
20    /// Optional world-space bounding box. The body reflects off the faces of
21    /// this box and a [`ContactEvent`] is emitted on each bounce.
22    pub bounds: Option<Aabb>,
23}
24
25impl PhysicsBody {
26    /// Create a body at rest with default gravity scale and restitution.
27    pub fn new(node_id: NodeId) -> Self {
28        Self {
29            node_id,
30            velocity: glam::Vec3::ZERO,
31            gravity_scale: 1.0,
32            restitution: 0.7,
33            bounds: None,
34        }
35    }
36
37    /// Set the initial velocity.
38    pub fn with_velocity(mut self, v: glam::Vec3) -> Self {
39        self.velocity = v;
40        self
41    }
42
43    /// Set the gravity scale.
44    pub fn with_gravity_scale(mut self, s: f32) -> Self {
45        self.gravity_scale = s;
46        self
47    }
48
49    /// Set the restitution coefficient.
50    pub fn with_restitution(mut self, r: f32) -> Self {
51        self.restitution = r;
52        self
53    }
54
55    /// Constrain the body inside a world-space bounding box.
56    pub fn with_bounds(mut self, bounds: Aabb) -> Self {
57        self.bounds = Some(bounds);
58        self
59    }
60}
61
62/// A plugin that integrates velocity, applies gravity, and reflects bodies off
63/// bounding box walls.
64///
65/// Runs in the [`RuntimePhase::Simulate`] phase. Pairs well with
66/// [`crate::FixedTimestep`] for stable integration.
67///
68/// World-bounds collisions produce [`ContactEvent`]s in [`crate::RuntimeOutput`]
69/// with `node_b` set to `NodeId::MAX` (a sentinel for world geometry).
70///
71/// # Example
72///
73/// ```rust,ignore
74/// use viewport_lib::{Aabb, FixedTimestep, PhysicsBody, PhysicsLitePlugin, ViewportRuntime};
75///
76/// let bounds = Aabb { min: glam::Vec3::splat(-5.0), max: glam::Vec3::splat(5.0) };
77/// let mut physics = PhysicsLitePlugin::new()
78///     .with_gravity(glam::Vec3::new(0.0, 0.0, -9.81));
79/// physics.add_body(
80///     PhysicsBody::new(node_id)
81///         .with_velocity(glam::Vec3::new(2.0, 1.0, 4.0))
82///         .with_bounds(bounds),
83/// );
84///
85/// let runtime = ViewportRuntime::new()
86///     .with_fixed_timestep(FixedTimestep::new(60.0))
87///     .with_plugin(physics);
88/// ```
89pub struct PhysicsLitePlugin {
90    /// All bodies managed by this plugin.
91    pub bodies: Vec<PhysicsBody>,
92    /// Global gravity acceleration vector (world space).
93    pub gravity: glam::Vec3,
94}
95
96impl Default for PhysicsLitePlugin {
97    fn default() -> Self {
98        Self::new()
99    }
100}
101
102impl PhysicsLitePlugin {
103    /// Create with default downward gravity (-Z) and no bodies.
104    pub fn new() -> Self {
105        Self {
106            bodies: Vec::new(),
107            gravity: glam::Vec3::new(0.0, 0.0, -9.81),
108        }
109    }
110
111    /// Set the gravity vector.
112    pub fn with_gravity(mut self, gravity: glam::Vec3) -> Self {
113        self.gravity = gravity;
114        self
115    }
116
117    /// Add a body.
118    pub fn add_body(&mut self, body: PhysicsBody) {
119        self.bodies.push(body);
120    }
121
122    /// Get a mutable reference to the body for `node_id`, if it exists.
123    pub fn body_mut(&mut self, node_id: NodeId) -> Option<&mut PhysicsBody> {
124        self.bodies.iter_mut().find(|b| b.node_id == node_id)
125    }
126}
127
128impl RuntimePlugin for PhysicsLitePlugin {
129    fn priority(&self) -> i32 {
130        phase::SIMULATE
131    }
132
133    fn step(&mut self, ctx: &mut RuntimeStepContext<'_>) {
134        for body in &mut self.bodies {
135            let Some(node) = ctx.scene.node(body.node_id) else {
136                continue;
137            };
138            let world = node.world_transform();
139            let (scale, rotation, mut pos) = world.to_scale_rotation_translation();
140
141            // Apply gravity.
142            body.velocity += ctx.dt * body.gravity_scale * self.gravity;
143
144            // Integrate position.
145            pos += body.velocity * ctx.dt;
146
147            // Reflect off bounding box faces.
148            if let Some(ref bounds) = body.bounds {
149                let mut normal = glam::Vec3::ZERO;
150                let mut bounced = false;
151
152                if pos.x < bounds.min.x && body.velocity.x < 0.0 {
153                    pos.x = bounds.min.x;
154                    body.velocity.x = -body.velocity.x * body.restitution;
155                    normal = glam::Vec3::X;
156                    bounced = true;
157                } else if pos.x > bounds.max.x && body.velocity.x > 0.0 {
158                    pos.x = bounds.max.x;
159                    body.velocity.x = -body.velocity.x * body.restitution;
160                    normal = -glam::Vec3::X;
161                    bounced = true;
162                }
163
164                if pos.y < bounds.min.y && body.velocity.y < 0.0 {
165                    pos.y = bounds.min.y;
166                    body.velocity.y = -body.velocity.y * body.restitution;
167                    normal = glam::Vec3::Y;
168                    bounced = true;
169                } else if pos.y > bounds.max.y && body.velocity.y > 0.0 {
170                    pos.y = bounds.max.y;
171                    body.velocity.y = -body.velocity.y * body.restitution;
172                    normal = -glam::Vec3::Y;
173                    bounced = true;
174                }
175
176                if pos.z < bounds.min.z && body.velocity.z < 0.0 {
177                    pos.z = bounds.min.z;
178                    body.velocity.z = -body.velocity.z * body.restitution;
179                    normal = glam::Vec3::Z;
180                    bounced = true;
181                } else if pos.z > bounds.max.z && body.velocity.z > 0.0 {
182                    pos.z = bounds.max.z;
183                    body.velocity.z = -body.velocity.z * body.restitution;
184                    normal = -glam::Vec3::Z;
185                    bounced = true;
186                }
187
188                if bounced {
189                    ctx.output.contact_events.push(ContactEvent {
190                        node_a: body.node_id,
191                        node_b: NodeId::MAX,
192                        world_normal: normal,
193                        impulse: body.velocity.length() * (1.0 + body.restitution),
194                        contact_point: pos,
195                    });
196                }
197            }
198
199            let new_t = glam::Affine3A::from_scale_rotation_translation(scale, rotation, pos);
200            ctx.writeback.set(body.node_id, new_t);
201        }
202    }
203}