Skip to main content

concinnity_physics/sim/
config.rs

1// concinnity-physics/src/sim/config.rs
2//
3// The knobs a step is tuned by, in one place so a caller can reason about them
4// together and so every default is stated once.
5//
6// The contact stiffness is expressed as a frequency and a damping ratio rather
7// than as a bias factor: a frequency keeps its meaning when the tick rate or
8// the substep count changes, where a raw bias factor does not.
9
10use crate::GRAVITY;
11
12/// Tuning for one simulation.
13///
14/// [`SimConfig::default`] is what the engine runs with; the fields are public
15/// so a caller reproducing a recorded step, or trading stability for speed,
16/// can set them explicitly.
17#[derive(Debug, Clone, Copy, PartialEq)]
18pub struct SimConfig {
19    /// Downward acceleration in world units per second squared.
20    pub gravity: f32,
21    /// Velocity/position passes per step. More substeps buy stiffer stacks at
22    /// a near-linear cost; below `1` the step does nothing.
23    pub substeps: u32,
24    /// Contact stiffness as a frequency in hertz. Clamped against the substep
25    /// rate, since a contact cannot be stiffer than the rate it is solved at.
26    pub contact_hertz: f32,
27    /// Contact damping ratio. Above `1` the contact is overdamped, which is
28    /// what stops a resting stack from breathing.
29    pub contact_damping_ratio: f32,
30    /// Joint stiffness as a frequency in hertz, clamped against the substep
31    /// rate the same way the contact one is. Joints are held stiffer than
32    /// contacts because a contact that sinks a millimetre is still right and
33    /// a joint that gives a millimetre reads as broken.
34    pub joint_hertz: f32,
35    /// Joint damping ratio. Far lower than the contact one, and damped at all
36    /// only so a joint built out of place settles rather than ringing as it
37    /// closes: the correction's damping is what bleeds a pendulum's swing, so
38    /// every part of it that is not needed is energy a joint gives back.
39    pub joint_damping_ratio: f32,
40    /// Ceiling on the speed penetration is pushed out at, so a deep overlap
41    /// resolves over several steps instead of launching.
42    pub max_push_velocity: f32,
43    /// Penetration the solver leaves alone. Resting contacts settle just
44    /// inside the surface, which keeps them from being lost and remade.
45    pub linear_slop: f32,
46    /// Gap within which a contact is still created, so an approaching body is
47    /// slowed before it overlaps rather than after.
48    pub speculative_margin: f32,
49    /// Approach speed below which a contact does not bounce, whatever the
50    /// restitution. Without it a bouncy body never comes to rest.
51    pub restitution_threshold: f32,
52    /// Whether a settled island stops being simulated.
53    pub allow_sleep: bool,
54    /// Speed below which a body counts as still, in world units per second.
55    pub sleep_linear_velocity: f32,
56    /// Spin below which a body counts as still, in radians per second.
57    pub sleep_angular_velocity: f32,
58    /// How long a whole island must be still before it sleeps, in seconds.
59    pub time_to_sleep: f32,
60    /// Slack added to a body's bounds, so small motion does not invalidate
61    /// them.
62    pub bounds_margin: f32,
63    /// Whether a body that outruns the step's own contact test is caught by
64    /// a sweep. On for every freely simulated and position-driven body, since
65    /// without it one passes straight through thin geometry.
66    pub ccd_enabled: bool,
67    /// Fraction of a body's thinnest dimension its motion over one step has
68    /// to exceed before that sweep runs.
69    ///
70    /// At or below `1` nothing can outrun the gate: passing through anything
71    /// takes more motion than the mover's own width, so the sweep is already
72    /// armed by the time tunnelling is possible. The margin below `1` covers
73    /// the rotation the sweep does not model.
74    pub ccd_motion_ratio: f32,
75}
76
77impl Default for SimConfig {
78    fn default() -> Self {
79        SimConfig {
80            gravity: GRAVITY,
81            substeps: 4,
82            contact_hertz: 30.0,
83            contact_damping_ratio: 10.0,
84            joint_hertz: 60.0,
85            joint_damping_ratio: 0.5,
86            max_push_velocity: 3.0,
87            linear_slop: 0.005,
88            speculative_margin: 0.02,
89            restitution_threshold: 1.0,
90            allow_sleep: true,
91            sleep_linear_velocity: 0.05,
92            sleep_angular_velocity: 0.1,
93            time_to_sleep: 0.5,
94            bounds_margin: 0.05,
95            ccd_enabled: true,
96            ccd_motion_ratio: 0.5,
97        }
98    }
99}
100
101impl SimConfig {
102    /// Substeps as a positive count, so a caller cannot configure a step that
103    /// integrates nothing.
104    pub(crate) fn substep_count(&self) -> u32 {
105        self.substeps.max(1)
106    }
107}
108
109/// The three coefficients a soft constraint is solved with, derived once per
110/// step from a frequency, a damping ratio, and the substep timestep.
111///
112/// This is the implicit-spring formulation: `bias_rate` converts a position
113/// error into a velocity, and the two scales split each impulse between
114/// correcting the error now and remembering it for the next iteration, which
115/// is what keeps the correction from injecting energy.
116#[derive(Debug, Clone, Copy, PartialEq)]
117pub(crate) struct Softness {
118    pub(crate) bias_rate: f32,
119    pub(crate) mass_scale: f32,
120    pub(crate) impulse_scale: f32,
121}
122
123impl Softness {
124    pub(crate) fn new(hertz: f32, damping_ratio: f32, h: f32) -> Self {
125        if hertz <= 0.0 || h <= 0.0 {
126            return Softness {
127                bias_rate: 0.0,
128                mass_scale: 1.0,
129                impulse_scale: 0.0,
130            };
131        }
132        let omega = 2.0 * core::f32::consts::PI * hertz;
133        let a1 = 2.0 * damping_ratio + h * omega;
134        let a2 = h * omega * a1;
135        let a3 = 1.0 / (1.0 + a2);
136        Softness {
137            bias_rate: omega / a1,
138            mass_scale: a2 * a3,
139            impulse_scale: a3,
140        }
141    }
142
143    /// A constraint solved with no bias at all: the relax pass, which removes
144    /// the velocity the biased pass added without reopening the penetration.
145    pub(crate) const RIGID: Softness = Softness {
146        bias_rate: 0.0,
147        mass_scale: 1.0,
148        impulse_scale: 0.0,
149    };
150}
151
152#[cfg(test)]
153mod tests {
154    use super::*;
155
156    #[test]
157    fn the_default_matches_the_engine_gravity_and_simulates_something() {
158        let c = SimConfig::default();
159        assert_eq!(c.gravity, GRAVITY);
160        assert!(c.substep_count() >= 1);
161        assert!(c.speculative_margin > c.linear_slop);
162        assert!(
163            c.joint_hertz > c.contact_hertz,
164            "a joint has to be held stiffer than a contact"
165        );
166        assert!(c.ccd_enabled, "a fast body must not pass through geometry");
167        assert!(
168            c.ccd_motion_ratio > 0.0 && c.ccd_motion_ratio <= 1.0,
169            "past one width of motion a body can tunnel, so the gate has to \
170             be armed before then"
171        );
172    }
173
174    #[test]
175    fn a_zero_substep_config_still_takes_one_pass() {
176        let c = SimConfig {
177            substeps: 0,
178            ..SimConfig::default()
179        };
180        assert_eq!(c.substep_count(), 1);
181    }
182
183    // The coefficients must stay in the range the solver assumes: a positive
184    // bias rate, a mass scale in [0, 1], and an impulse scale in [0, 1].
185    #[test]
186    fn softness_coefficients_stay_in_the_solvers_range() {
187        for hertz in [1.0, 30.0, 240.0] {
188            for zeta in [0.5, 1.0, 10.0] {
189                let s = Softness::new(hertz, zeta, 1.0 / 240.0);
190                assert!(s.bias_rate > 0.0, "{hertz} {zeta}: {s:?}");
191                assert!((0.0..=1.0).contains(&s.mass_scale), "{hertz} {zeta}: {s:?}");
192                assert!(
193                    (0.0..=1.0).contains(&s.impulse_scale),
194                    "{hertz} {zeta}: {s:?}"
195                );
196                assert!(
197                    (s.mass_scale + s.impulse_scale - 1.0).abs() < 1.0e-5,
198                    "the two scales partition one impulse: {s:?}"
199                );
200            }
201        }
202    }
203
204    // A stiffer contact pushes harder for the same error.
205    #[test]
206    fn a_higher_frequency_raises_the_bias_rate() {
207        let h = 1.0 / 240.0;
208        let soft = Softness::new(10.0, 10.0, h);
209        let stiff = Softness::new(60.0, 10.0, h);
210        assert!(stiff.bias_rate > soft.bias_rate, "{soft:?} {stiff:?}");
211    }
212
213    #[test]
214    fn a_disabled_frequency_degrades_to_the_rigid_solve() {
215        assert_eq!(Softness::new(0.0, 1.0, 1.0 / 240.0), Softness::RIGID);
216        assert_eq!(Softness::new(30.0, 1.0, 0.0), Softness::RIGID);
217    }
218}