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}