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}