Skip to main content

viewport_lib/plugins/physics_lite/
plugin.rs

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