concinnity_core/physics/fanout/mod.rs
1// How a host lends the simulation its threads.
2//
3// `crate::physics::Fanout` is generic in the item and the body so a step's
4// work units are monomorphised into it rather than boxed, which is what a
5// system holding one as a trait object cannot be. So the seam is drawn one
6// level up: a host answers "advance this simulation by dt", and the fan-out it
7// reaches for on the way stays inside its own answer.
8//
9// The ordering contract is not this seam's to keep. A step hands out
10// independent work and loads every result back in manifold order afterwards,
11// so which thread solved what is not observable in the world state either way;
12// what a fan-out decides is only how long the step takes.
13
14mod inline;
15
16pub use inline::{Fanout, Inline};
17
18use crate::physics::Simulation;
19
20use crate::ecs::ScheduleMode;
21
22/// A host's way of lending the simulation the threads it has.
23///
24/// A world with none runs its steps on the calling thread through
25/// [`SerialFanout`], which is the default a [`PhysicsSystem`] is built with.
26///
27/// [`PhysicsSystem`]: crate::physics::PhysicsSystem
28pub trait PhysicsFanout: core::fmt::Debug + Send {
29 /// Workers the step's per-worker scratch is reserved for under `mode`.
30 /// Read once, at world start.
31 fn worker_count(&self, mode: ScheduleMode) -> usize;
32
33 /// Advance `sim` by `dt`, lending it whatever `mode` names.
34 fn step(&self, sim: &mut Simulation, dt: f32, mode: ScheduleMode);
35}
36
37/// The fan-out for a host with no threads to lend: every step runs on the
38/// calling thread.
39#[derive(Debug, Clone, Copy, Default)]
40pub struct SerialFanout;
41
42impl PhysicsFanout for SerialFanout {
43 fn worker_count(&self, _mode: ScheduleMode) -> usize {
44 1
45 }
46
47 fn step(&self, sim: &mut Simulation, dt: f32, _mode: ScheduleMode) {
48 sim.step_with(dt, &Inline);
49 }
50}
51
52#[cfg(test)]
53mod tests {
54 use super::*;
55 use crate::physics::{ColliderShape, DynamicParams, LayerMask};
56
57 fn falling() -> (Simulation, crate::physics::BodyHandle) {
58 let mut sim = Simulation::with_capacity(4);
59 let ball = sim
60 .add_dynamic(
61 &ColliderShape::Ball { radius: 0.5 },
62 [0.0, 10.0, 0.0],
63 [0.0; 3],
64 DynamicParams {
65 mass: 1.0,
66 friction: 0.5,
67 restitution: 0.0,
68 gravity_scale: 1.0,
69 linear_damping: 0.0,
70 },
71 LayerMask::ALL,
72 )
73 .expect("room for one body");
74 (sim, ball)
75 }
76
77 // The serial fan-out reserves one worker's scratch and advances the world,
78 // which is the whole of what a host with no pool needs from it.
79 #[test]
80 fn the_serial_fanout_lends_one_worker_and_steps() {
81 assert_eq!(SerialFanout.worker_count(ScheduleMode::Serial), 1);
82 assert_eq!(SerialFanout.worker_count(ScheduleMode::Parallel), 1);
83
84 let (mut sim, ball) = falling();
85 for _ in 0..10 {
86 SerialFanout.step(&mut sim, 1.0 / 60.0, ScheduleMode::Serial);
87 }
88 assert!(
89 sim.body_pose_quat(ball).expect("a live body").0[1] < 10.0,
90 "the fan-out advanced the simulation"
91 );
92 }
93
94 // The mode a serial fan-out is asked for changes nothing: it has one place
95 // to run the step, so both land on identical state.
96 #[test]
97 fn the_schedule_mode_does_not_change_a_serial_step() {
98 let run = |mode| {
99 let (mut sim, ball) = falling();
100 for _ in 0..30 {
101 SerialFanout.step(&mut sim, 1.0 / 60.0, mode);
102 }
103 sim.body_pose_quat(ball).expect("a live body").0
104 };
105 assert_eq!(run(ScheduleMode::Serial), run(ScheduleMode::Parallel));
106 }
107}