physics_3d/
lib.rs

1//! Flat-shaded 3D rigid bodies, interpolated fixed-tick physics, and an orbit camera.
2use ferravex_core::{FixedClock, FrameStats};
3use ferravex_physics::d3::{PhysicsWorld, RigidBodyHandle};
4use ferravex_render::{
5    mesh::Mesh3d,
6    prelude::*,
7    shaders::{LitMaterial, LitSettings},
8    ui,
9};
10
11const MAX_BODIES: usize = 180;
12const CONCRETE: Color = Color::from_rgba(106, 116, 116, 255);
13const CRATE: Color = Color::from_rgba(173, 125, 68, 255);
14const PAINT: Color = Color::from_rgba(102, 143, 141, 255);
15
16struct Body {
17    handle: RigidBodyHandle,
18    ball: bool,
19    previous_position: Vec3,
20    previous_rotation: Quat,
21}
22
23struct Sandbox {
24    physics: PhysicsWorld,
25    bodies: Vec<Body>,
26    sequence: u32,
27}
28
29impl Sandbox {
30    fn new() -> Self {
31        let mut sandbox = Self {
32            physics: PhysicsWorld::new([0.0, -9.81, 0.0]),
33            bodies: Vec::new(),
34            sequence: 0,
35        };
36        sandbox
37            .physics
38            .add_box([0.0, -0.4, 0.0], [10.0, 0.4, 8.0], false);
39        // Low perimeter curbs catch rolling objects without obscuring the scene.
40        for (position, half) in [
41            ([0.0, 0.25, -8.0], [10.25, 0.25, 0.25]),
42            ([0.0, 0.25, 8.0], [10.25, 0.25, 0.25]),
43            ([-10.0, 0.25, 0.0], [0.25, 0.25, 8.0]),
44            ([10.0, 0.25, 0.0], [0.25, 0.25, 8.0]),
45        ] {
46            sandbox.physics.add_box(position, half, false);
47        }
48        for x in [-9.4, 9.4] {
49            for z in [-7.4, 7.4] {
50                sandbox
51                    .physics
52                    .add_box([x, 0.4, z], [0.29, 0.4, 0.29], false);
53            }
54        }
55        // A compact crate stack built at physical scale: one unit is one meter.
56        for y in 0..5 {
57            for x in 0..3 {
58                for z in 0..2 {
59                    sandbox.spawn(
60                        vec3(
61                            -1.8 + x as f32 * 1.22,
62                            0.6 + y as f32 * 1.21,
63                            -0.7 + z as f32 * 1.22,
64                        ),
65                        false,
66                    );
67                }
68            }
69        }
70        for index in 0..5 {
71            sandbox.spawn(
72                vec3(-5.0 + index as f32 * 0.9, 7.5 + index as f32 * 0.45, 2.5),
73                true,
74            );
75        }
76        sandbox
77    }
78
79    fn spawn(&mut self, position: Vec3, ball: bool) {
80        if self.bodies.len() >= MAX_BODIES {
81            return;
82        }
83        let handle = if ball {
84            self.physics.add_ball(position.to_array(), 0.58, true)
85        } else {
86            self.physics.add_box(position.to_array(), [0.58; 3], true)
87        };
88        let collider = self.physics.bodies[handle].colliders()[0];
89        self.physics.colliders[collider].set_restitution(if ball { 0.45 } else { 0.05 });
90        self.physics.bodies[handle].enable_ccd(true);
91        self.bodies.push(Body {
92            handle,
93            ball,
94            previous_position: position,
95            previous_rotation: Quat::IDENTITY,
96        });
97    }
98
99    fn drop_body(&mut self, ball: bool) {
100        self.sequence = self.sequence.wrapping_add(1);
101        let phase = self.sequence as f32 * 2.399_963;
102        self.spawn(vec3(phase.cos() * 2.3, 10.0, phase.sin() * 2.3), ball);
103    }
104
105    fn step(&mut self, dt: f32) {
106        for body in &mut self.bodies {
107            let rigid_body = &self.physics.bodies[body.handle];
108            body.previous_position = Vec3::from_array(rigid_body.translation().to_array());
109            body.previous_rotation = Quat::from_array(rigid_body.rotation().to_array());
110        }
111        self.physics.step(dt);
112    }
113}
114
115fn window_conf() -> Conf {
116    ferravex_render::window_conf("Ferravex / low-poly physics laboratory")
117}
118
119ferravex_render::game!(window_conf, game);
120async fn game() {
121    let mut demo = ferravex_render::demo::Demo::new();
122    let mut sandbox = Sandbox::new();
123    let mut clock = FixedClock::new(120.0, 8);
124    let mut stats = FrameStats::<90>::default();
125    let material = LitMaterial::new()
126        .expect("The built-in Ferravex material must compile on the selected GPU backend");
127    let crate_mesh = Mesh3d::cuboid(vec3(1.16, 1.16, 1.16), CRATE);
128    let ball_mesh = Mesh3d::sphere(0.58, PAINT, 10, 6);
129    let floor_mesh = Mesh3d::cuboid(vec3(20.0, 0.8, 16.0), Color::from_rgba(71, 81, 86, 255));
130    let curb_x = Mesh3d::cuboid(vec3(20.5, 0.5, 0.5), CONCRETE);
131    let curb_z = Mesh3d::cuboid(vec3(0.5, 0.5, 16.0), CONCRETE);
132    let floor_mark = Mesh3d::cuboid(
133        vec3(0.12, 0.012, 14.0),
134        Color::from_rgba(152, 146, 117, 255),
135    );
136    let cross_mark = Mesh3d::cuboid(
137        vec3(18.0, 0.012, 0.12),
138        Color::from_rgba(152, 146, 117, 255),
139    );
140    let pedestal = Mesh3d::cuboid(vec3(0.55, 0.8, 0.55), Color::from_rgba(82, 92, 98, 255));
141    let marker = Mesh3d::cuboid(vec3(0.58, 0.13, 0.58), Color::from_rgba(208, 163, 74, 255));
142    let mut paused = false;
143    let mut yaw: f32 = 0.68;
144    let mut pitch: f32 = 0.48;
145    let mut radius: f32 = 25.0;
146    let mut previous_mouse = Vec2::from(mouse_position());
147    loop {
148        ui::warm_up_font();
149        stats.record(get_frame_time());
150        let pointer = Vec2::from(mouse_position());
151        let footer_y = screen_height() - 98.0;
152        let in_scene = pointer.y > 126.0 && pointer.y < footer_y;
153        if is_mouse_button_down(MouseButton::Left)
154            && !is_mouse_button_pressed(MouseButton::Left)
155            && in_scene
156        {
157            let delta = pointer - previous_mouse;
158            yaw -= delta.x * 0.006;
159            pitch = (pitch + delta.y * 0.004).clamp(0.12, 1.35);
160        }
161        previous_mouse = pointer;
162        if in_scene {
163            radius = (radius - mouse_wheel().1 * 0.8).clamp(12.0, 42.0);
164        }
165        let rotate_speed = get_frame_time() * 1.2;
166        if is_key_down(KeyCode::Left) {
167            yaw -= rotate_speed;
168        }
169        if is_key_down(KeyCode::Right) {
170            yaw += rotate_speed;
171        }
172        if is_key_down(KeyCode::Up) {
173            pitch = (pitch + rotate_speed).min(1.35);
174        }
175        if is_key_down(KeyCode::Down) {
176            pitch = (pitch - rotate_speed).max(0.12);
177        }
178        if is_key_pressed(KeyCode::Space) {
179            paused = !paused;
180        }
181        if is_key_pressed(KeyCode::R) {
182            sandbox = Sandbox::new();
183            clock.reset();
184        }
185        if is_key_pressed(KeyCode::Key1) {
186            sandbox.drop_body(false);
187        }
188        if is_key_pressed(KeyCode::Key2) {
189            sandbox.drop_body(true);
190        }
191        let frame = clock.advance(if paused { 0.0 } else { get_frame_time() as f64 });
192        for _ in 0..frame.steps {
193            sandbox.step(frame.dt);
194        }
195        let alpha = if paused { 1.0 } else { frame.alpha };
196        clear_background(ui::BACKGROUND);
197        let target = vec3(0.0, 2.2, 0.0);
198        let eye = target
199            + vec3(
200                yaw.sin() * pitch.cos(),
201                pitch.sin(),
202                yaw.cos() * pitch.cos(),
203            ) * radius;
204        set_camera(&Camera3D {
205            position: eye,
206            target,
207            up: Vec3::Y,
208            fovy: 43.0_f32.to_radians(),
209            z_near: 0.1,
210            z_far: 100.0,
211            ..Default::default()
212        });
213        material.begin(LitSettings {
214            camera_position: eye,
215            fog_start: 22.0,
216            fog_end: 65.0,
217            ambient: 0.48,
218            ..Default::default()
219        });
220        floor_mesh.draw(vec3(0.0, -0.4, 0.0), Quat::IDENTITY);
221        for z in [-8.0, 8.0] {
222            curb_x.draw(vec3(0.0, 0.25, z), Quat::IDENTITY);
223        }
224        for x in [-10.0, 10.0] {
225            curb_z.draw(vec3(x, 0.25, 0.0), Quat::IDENTITY);
226        }
227        for x in [-5.8, 5.8] {
228            floor_mark.draw(vec3(x, 0.008, 0.0), Quat::IDENTITY);
229        }
230        for z in [-4.5, 4.5] {
231            cross_mark.draw(vec3(0.0, 0.008, z), Quat::IDENTITY);
232        }
233        for x in [-9.4, 9.4] {
234            for z in [-7.4, 7.4] {
235                pedestal.draw(vec3(x, 0.4, z), Quat::IDENTITY);
236                marker.draw(vec3(x, 0.65, z), Quat::IDENTITY);
237            }
238        }
239        for body in &sandbox.bodies {
240            let rigid_body = &sandbox.physics.bodies[body.handle];
241            let position = body
242                .previous_position
243                .lerp(Vec3::from_array(rigid_body.translation().to_array()), alpha);
244            let rotation = body
245                .previous_rotation
246                .slerp(Quat::from_array(rigid_body.rotation().to_array()), alpha);
247            if body.ball {
248                ball_mesh.draw(position, rotation);
249            } else {
250                crate_mesh.draw(position, rotation);
251            }
252        }
253        material.end();
254        // Wire detail belongs only to crates: it reads as a shipping frame and
255        // keeps geometry simple rather than requiring a texture atlas.
256        for body in &sandbox.bodies {
257            if body.ball {
258                continue;
259            }
260            let rigid_body = &sandbox.physics.bodies[body.handle];
261            let position = body
262                .previous_position
263                .lerp(Vec3::from_array(rigid_body.translation().to_array()), alpha);
264            let rotation = body
265                .previous_rotation
266                .slerp(Quat::from_array(rigid_body.rotation().to_array()), alpha);
267            let points = [
268                vec3(-0.582, -0.582, -0.582),
269                vec3(0.582, -0.582, -0.582),
270                vec3(0.582, 0.582, -0.582),
271                vec3(-0.582, 0.582, -0.582),
272                vec3(-0.582, -0.582, 0.582),
273                vec3(0.582, -0.582, 0.582),
274                vec3(0.582, 0.582, 0.582),
275                vec3(-0.582, 0.582, 0.582),
276            ]
277            .map(|v| position + rotation * v);
278            for (a, b) in [
279                (0, 1),
280                (1, 2),
281                (2, 3),
282                (3, 0),
283                (4, 5),
284                (5, 6),
285                (6, 7),
286                (7, 4),
287                (0, 4),
288                (1, 5),
289                (2, 6),
290                (3, 7),
291            ] {
292                draw_line_3d(points[a], points[b], Color::from_rgba(84, 64, 43, 255));
293            }
294        }
295        set_default_camera();
296        ui::header(
297            "3D RIGID BODIES",
298            "The loading yard.",
299            if screen_width() > 660.0 {
300                "Drag or use arrow keys to orbit  /  Scroll: zoom  /  1, 2: drop bodies  /  Space: pause"
301            } else {
302                "Drag to orbit. Drop bodies below."
303            },
304        );
305        ui::panel(Rect::new(20.0, footer_y, screen_width() - 40.0, 78.0));
306        let button_width = ((screen_width() - 92.0) / 4.0).clamp(38.0, 90.0);
307        let button_rect = |index: f32| {
308            Rect::new(
309                34.0 + index * (button_width + 8.0),
310                footer_y + 20.0,
311                button_width,
312                36.0,
313            )
314        };
315        if ui::button(button_rect(0.0), "+ CRATE", false) {
316            sandbox.drop_body(false);
317        }
318        if ui::button(button_rect(1.0), "+ BALL", false) {
319            sandbox.drop_body(true);
320        }
321        if ui::button(
322            button_rect(2.0),
323            if paused { "RESUME" } else { "PAUSE" },
324            paused,
325        ) {
326            paused = !paused;
327        }
328        if ui::button(button_rect(3.0), "RESET", false) {
329            sandbox = Sandbox::new();
330            clock.reset();
331        }
332        if screen_width() > 760.0 {
333            let awake = sandbox
334                .bodies
335                .iter()
336                .filter(|b| !sandbox.physics.bodies[b.handle].is_sleeping())
337                .count();
338            ui::stat(
339                450.0,
340                footer_y + 25.0,
341                "DYNAMIC BODIES",
342                &format!("{} / {MAX_BODIES}", sandbox.bodies.len()),
343            );
344            ui::stat(625.0, footer_y + 25.0, "AWAKE", &awake.to_string());
345        }
346        if screen_width() > 940.0 {
347            ui::stat(
348                765.0,
349                footer_y + 25.0,
350                "FRAME AVERAGE",
351                &format!("{:.1} ms", stats.mean_ms()),
352            );
353        }
354        if screen_width() > 1100.0 {
355            ui::stat(955.0, footer_y + 25.0, "SHADING", "Flat + fog");
356        }
357        if demo.finish_frame() {
358            break;
359        }
360        next_frame().await;
361    }
362}
363
364#[cfg(test)]
365mod tests {
366    use super::*;
367
368    #[test]
369    fn recovered_sandbox_settles_without_floor_tunneling() {
370        let mut sandbox = Sandbox::new();
371        for _ in 0..960 {
372            sandbox.step(1.0 / 120.0);
373            for body in &sandbox.bodies {
374                let rigid = &sandbox.physics.bodies[body.handle];
375                assert!(rigid.translation().is_finite());
376                assert!(rigid.linvel().is_finite());
377                assert!(rigid.rotation().is_finite());
378                assert!(rigid.translation().y > 0.3);
379            }
380        }
381        assert!(
382            sandbox
383                .bodies
384                .iter()
385                .any(|body| sandbox.physics.bodies[body.handle].is_sleeping())
386        );
387    }
388
389    #[test]
390    fn dropping_is_bounded_and_rebuild_replaces_the_world() {
391        let mut sandbox = Sandbox::new();
392        for i in 0..MAX_BODIES + 10 {
393            sandbox.drop_body(i % 2 == 0);
394        }
395        assert_eq!(sandbox.bodies.len(), MAX_BODIES);
396        assert_eq!(sandbox.physics.bodies.len(), MAX_BODIES + 9);
397        sandbox = Sandbox::new();
398        assert_eq!(sandbox.bodies.len(), 35);
399        assert_eq!(sandbox.physics.bodies.len(), 44);
400    }
401}