Skip to main content

concinnity_core/physics/sim/
config.rs

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