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}