Skip to main content

vimp_engine_core/client/
rigid_body.rs

1//! Минимальный двухтельный решатель контактов (sequential impulse) для
2//! клиентского предсказания — порт src/lib/rigidBody.js (срез tank-battle
3//! 2026-08): предсказанный актор, динамика карты и стены живут в одной
4//! симуляции, поэтому нарисованные тела не выдавливают друг друга.
5//! Это приближение Rapier, а не его копия: остаток расхождения прячет
6//! реконсиляция игрового предиктора.
7//!
8//! Здесь же — материальные свойства контакта (`MAP_SURFACE`,
9//! `combine_surfaces`) и масс-инерционные свойства прямоугольного тела
10//! (`box_mass_properties`): их обязаны видеть одинаково обе стороны, иначе
11//! реплика тихо разъедется с хостом.
12//!
13//! `Body::x`/`y` — ЦЕНТР бокса, не «угол объекта» снапшота
14//! (см. `client::collision::box_center_from_origin`).
15//! Статика (стена) — `inv_mass: 0.0, inv_inertia: 0.0`.
16
17use super::collision::Contact;
18use crate::map::{DEFAULT_FRICTION, DEFAULT_RESTITUTION};
19use crate::physics::{clamp, normalize_angle};
20
21/// Материальные свойства поверхности.
22#[derive(Clone, Copy, Debug, PartialEq)]
23pub struct Surface {
24    pub friction: f32,
25    pub restitution: f32,
26}
27
28/// Поверхность геометрии карты (стены и динамика) — те же дефолты
29/// коллайдеров, с которыми хост строит карту (`map.rs`).
30pub const MAP_SURFACE: Surface = Surface {
31    friction: DEFAULT_FRICTION,
32    restitution: DEFAULT_RESTITUTION,
33};
34
35/// Тело клиентской реплики.
36#[derive(Clone, Copy, Debug, PartialEq)]
37pub struct Body {
38    pub x: f32,
39    pub y: f32,
40    pub angle: f32,
41    pub vx: f32,
42    pub vy: f32,
43    pub angvel: f32,
44    pub inv_mass: f32,
45    pub inv_inertia: f32,
46    pub linear_damping: f32,
47    pub angular_damping: f32,
48}
49
50impl Default for Body {
51    fn default() -> Self {
52        Self {
53            x: 0.0,
54            y: 0.0,
55            angle: 0.0,
56            vx: 0.0,
57            vy: 0.0,
58            angvel: 0.0,
59            inv_mass: 0.0,
60            inv_inertia: 0.0,
61            linear_damping: 0.0,
62            angular_damping: 0.0,
63        }
64    }
65}
66
67/// Обратные масса и момент инерции прямоугольного тела.
68#[derive(Clone, Copy, Debug, PartialEq)]
69pub struct MassProperties {
70    pub inv_mass: f32,
71    pub inv_inertia: f32,
72}
73
74/// Комбинирует материальные свойства двух тел в параметры контакта.
75/// Rapier сводит коэффициенты правилом среднего
76/// (`CoefficientCombineRule::Average`) — реплика делает так же.
77pub fn combine_surfaces(a: &Surface, b: &Surface) -> Surface {
78    Surface {
79        friction: (a.friction + b.friction) / 2.0,
80        restitution: (a.restitution + b.restitution) / 2.0,
81    }
82}
83
84/// Обратные масса и момент инерции прямоугольника.
85/// Нулевая плотность или габарит дают статику (нулевые обратные величины),
86/// а не деление на ноль.
87pub fn box_mass_properties(width: f32, height: f32, density: f32) -> MassProperties {
88    let mass = density * width * height;
89    let inertia = (mass * (width * width + height * height)) / 12.0;
90
91    MassProperties {
92        inv_mass: if mass > 0.0 { 1.0 / mass } else { 0.0 },
93        inv_inertia: if inertia > 0.0 { 1.0 / inertia } else { 0.0 },
94    }
95}
96
97/// Один шаг интеграции тела.
98/// Порядок обязан совпадать с шагом игрового предиктора: позиция
99/// интегрируется скоростью ДО демпфирования, хранится задемпфированная
100/// скорость. Порядок эмпирический, повторяет Rapier.
101pub fn integrate(body: &mut Body, dt: f32) {
102    body.x += body.vx * dt;
103    body.y += body.vy * dt;
104    body.angle = normalize_angle(body.angle + body.angvel * dt);
105
106    let linear = 1.0 / (1.0 + dt * body.linear_damping);
107    let angular = 1.0 / (1.0 + dt * body.angular_damping);
108
109    body.vx *= linear;
110    body.vy *= linear;
111    body.angvel *= angular;
112}
113
114/// Разводит тела на глубину проникновения пропорционально обратным массам.
115/// Вызывается ОДИН раз на контакт за шаг: повторное применение той же глубины
116/// на каждой итерации решателя расталкивало бы тела кратно числу итераций.
117/// Нормаль контакта направлена ОТ тела `a`.
118pub fn separate_bodies(a: &mut Body, b: &mut Body, contact: &Contact) {
119    let total = a.inv_mass + b.inv_mass;
120
121    // два статических тела — разводить нечего
122    if total == 0.0 {
123        return;
124    }
125
126    a.x -= (contact.nx * contact.depth * a.inv_mass) / total;
127    a.y -= (contact.ny * contact.depth * a.inv_mass) / total;
128    b.x += (contact.nx * contact.depth * b.inv_mass) / total;
129    b.y += (contact.ny * contact.depth * b.inv_mass) / total;
130}
131
132/// Нормальный импульс с отскоком плюс кулоновское трение по касательной.
133/// Итерируется решателем (sequential impulse) по всем контактам шага.
134/// Мутирует скорости обоих тел, позиций не трогает.
135/// Нормаль контакта направлена ОТ тела `a`.
136pub fn apply_contact_impulse(a: &mut Body, b: &mut Body, contact: &Contact, surface: &Surface) {
137    let Contact { nx, ny, cx, cy, .. } = *contact;
138
139    if a.inv_mass + b.inv_mass == 0.0 {
140        return;
141    }
142
143    // плечи от центров тел до точки контакта
144    let rax = cx - a.x;
145    let ray = cy - a.y;
146    let rbx = cx - b.x;
147    let rby = cy - b.y;
148
149    // скорости материальных точек контакта (v + ω × r)
150    let vax = a.vx - a.angvel * ray;
151    let vay = a.vy + a.angvel * rax;
152    let vbx = b.vx - b.angvel * rby;
153    let vby = b.vy + b.angvel * rbx;
154
155    let vrel_x = vbx - vax;
156    let vrel_y = vby - vay;
157    let vn = vrel_x * nx + vrel_y * ny;
158
159    // точки уже расходятся — импульс не нужен, коррекция позиции остаётся
160    if vn >= 0.0 {
161        return;
162    }
163
164    let rn_a = rax * ny - ray * nx;
165    let rn_b = rbx * ny - rby * nx;
166    let eff_n = a.inv_mass + b.inv_mass + rn_a * rn_a * a.inv_inertia + rn_b * rn_b * b.inv_inertia;
167
168    if eff_n == 0.0 {
169        return;
170    }
171
172    let jn = (-(1.0 + surface.restitution) * vn) / eff_n;
173
174    a.vx -= nx * jn * a.inv_mass;
175    a.vy -= ny * jn * a.inv_mass;
176    a.angvel -= rn_a * jn * a.inv_inertia;
177    b.vx += nx * jn * b.inv_mass;
178    b.vy += ny * jn * b.inv_mass;
179    b.angvel += rn_b * jn * b.inv_inertia;
180
181    // трение по касательной, зажатое кулоновским конусом
182    let tx = -ny;
183    let ty = nx;
184    let vt = vrel_x * tx + vrel_y * ty;
185
186    let rt_a = rax * ty - ray * tx;
187    let rt_b = rbx * ty - rby * tx;
188    let eff_t = a.inv_mass + b.inv_mass + rt_a * rt_a * a.inv_inertia + rt_b * rt_b * b.inv_inertia;
189
190    if eff_t == 0.0 {
191        return;
192    }
193
194    let max_friction = surface.friction * jn;
195    let jt = clamp(-vt / eff_t, -max_friction, max_friction);
196
197    a.vx -= tx * jt * a.inv_mass;
198    a.vy -= ty * jt * a.inv_mass;
199    a.angvel -= rt_a * jt * a.inv_inertia;
200    b.vx += tx * jt * b.inv_mass;
201    b.vy += ty * jt * b.inv_mass;
202    b.angvel += rt_b * jt * b.inv_inertia;
203}
204
205#[cfg(test)]
206mod tests {
207    use super::*;
208
209    // разрешение одиночного контакта ровно в том порядке, в каком его делает
210    // решатель игрового предиктора
211    fn resolve_contact(a: &mut Body, b: &mut Body, contact: &Contact, surface: &Surface) {
212        separate_bodies(a, b, contact);
213        apply_contact_impulse(a, b, contact, surface);
214    }
215
216    fn body() -> Body {
217        Body {
218            inv_mass: 1.0,
219            inv_inertia: 1.0,
220            ..Body::default()
221        }
222    }
223
224    fn static_body() -> Body {
225        Body::default()
226    }
227
228    fn smooth(restitution: f32) -> Surface {
229        Surface {
230            friction: 0.0,
231            restitution,
232        }
233    }
234
235    // лобовой контакт по X: точка контакта на линии центров, плеча нет
236    fn head_on() -> Contact {
237        Contact {
238            nx: 1.0,
239            ny: 0.0,
240            depth: 2.0,
241            cx: 5.0,
242            cy: 0.0,
243        }
244    }
245
246    #[test]
247    fn integrate_moves_by_undamped_velocity() {
248        let dt = 1.0 / 120.0;
249        let mut b = Body {
250            vx: 100.0,
251            vy: -40.0,
252            angvel: 2.0,
253            linear_damping: 3.0,
254            angular_damping: 100.0,
255            ..body()
256        };
257
258        integrate(&mut b, dt);
259
260        // позиция интегрируется скоростью ДО демпфирования
261        assert!((b.x - 100.0 * dt).abs() < 1e-6);
262        assert!((b.y + 40.0 * dt).abs() < 1e-6);
263        assert!((b.angle - 2.0 * dt).abs() < 1e-6);
264
265        // хранится уже задемпфированная скорость
266        assert!((b.vx - 100.0 / (1.0 + dt * 3.0)).abs() < 1e-4);
267        assert!((b.vy + 40.0 / (1.0 + dt * 3.0)).abs() < 1e-4);
268        assert!((b.angvel - 2.0 / (1.0 + dt * 100.0)).abs() < 1e-6);
269    }
270
271    #[test]
272    fn integrate_normalizes_the_angle() {
273        let mut b = Body {
274            angle: 3.1,
275            angvel: 10.0,
276            ..body()
277        };
278
279        integrate(&mut b, 0.1);
280
281        assert!(b.angle >= -core::f32::consts::PI);
282        assert!(b.angle <= core::f32::consts::PI);
283    }
284
285    #[test]
286    fn zero_damping_keeps_velocity() {
287        let mut b = Body {
288            vx: 7.0,
289            angvel: 3.0,
290            ..body()
291        };
292
293        integrate(&mut b, 0.5);
294
295        assert_eq!(b.vx, 7.0);
296        assert_eq!(b.angvel, 3.0);
297    }
298
299    #[test]
300    fn equal_bodies_conserve_momentum() {
301        let mut a = Body { vx: 10.0, ..body() };
302        let mut b = Body { x: 8.0, ..body() };
303        let before = a.vx + b.vx;
304
305        resolve_contact(&mut a, &mut b, &head_on(), &smooth(0.0));
306
307        assert!((a.vx + b.vx - before).abs() < 1e-5);
308    }
309
310    #[test]
311    fn restitution_zero_kills_the_closing_velocity() {
312        let mut a = Body { vx: 10.0, ..body() };
313        let mut b = Body { x: 8.0, ..body() };
314
315        resolve_contact(&mut a, &mut b, &head_on(), &smooth(0.0));
316
317        assert!((b.vx - a.vx).abs() < 1e-5);
318    }
319
320    #[test]
321    fn restitution_one_swaps_velocities() {
322        let mut a = Body { vx: 10.0, ..body() };
323        let mut b = Body { x: 8.0, ..body() };
324
325        resolve_contact(&mut a, &mut b, &head_on(), &smooth(1.0));
326
327        assert!(a.vx.abs() < 1e-5);
328        assert!((b.vx - 10.0).abs() < 1e-5);
329    }
330
331    #[test]
332    fn static_body_neither_moves_nor_accelerates() {
333        let mut a = Body { vx: 10.0, ..body() };
334        let mut wall = Body {
335            x: 8.0,
336            ..static_body()
337        };
338
339        resolve_contact(&mut a, &mut wall, &head_on(), &smooth(0.0));
340
341        assert_eq!(wall.x, 8.0);
342        assert_eq!(wall.vx, 0.0);
343        assert_eq!(wall.angvel, 0.0);
344
345        // всё выталкивание досталось подвижному телу
346        assert!((a.x + 2.0).abs() < 1e-5);
347        assert!(a.vx.abs() < 1e-5);
348    }
349
350    #[test]
351    fn two_static_bodies_are_a_no_op() {
352        let mut a = static_body();
353        let mut b = Body {
354            x: 8.0,
355            ..static_body()
356        };
357
358        resolve_contact(&mut a, &mut b, &head_on(), &smooth(0.0));
359
360        assert_eq!(a, static_body());
361    }
362
363    #[test]
364    fn separating_bodies_get_position_correction_but_no_impulse() {
365        let mut a = Body { vx: -5.0, ..body() };
366        let mut b = Body {
367            x: 8.0,
368            vx: 5.0,
369            ..body()
370        };
371
372        resolve_contact(&mut a, &mut b, &head_on(), &smooth(0.0));
373
374        assert_eq!(a.vx, -5.0);
375        assert_eq!(b.vx, 5.0);
376        // позиционная коррекция делится поровну между равными массами
377        assert!((a.x + 1.0).abs() < 1e-5);
378        assert!((b.x - 9.0).abs() < 1e-5);
379    }
380
381    #[test]
382    fn position_correction_scales_with_inverse_mass() {
383        let mut light = Body {
384            inv_mass: 4.0,
385            ..body()
386        };
387        let mut heavy = Body { x: 8.0, ..body() };
388
389        resolve_contact(&mut light, &mut heavy, &head_on(), &smooth(0.0));
390
391        // лёгкое тело уезжает вчетверо дальше тяжёлого
392        assert!((light.x + 1.6).abs() < 1e-5);
393        assert!((heavy.x - 8.4).abs() < 1e-5);
394    }
395
396    #[test]
397    fn friction_is_clamped_by_the_coulomb_cone() {
398        // сильное касательное движение при слабом нормальном сближении
399        let contact = Contact {
400            depth: 0.1,
401            ..head_on()
402        };
403        let mut a = Body {
404            vx: 1.0,
405            vy: 100.0,
406            inv_inertia: 0.0,
407            ..body()
408        };
409        let mut b = Body {
410            x: 8.0,
411            inv_inertia: 0.0,
412            ..body()
413        };
414
415        resolve_contact(
416            &mut a,
417            &mut b,
418            &contact,
419            &Surface {
420                friction: 0.2,
421                restitution: 0.0,
422            },
423        );
424
425        // нормальный импульс равных масс: jn = -vn / (im + im) = 0.5
426        let jn = 0.5;
427
428        assert!(b.vy.abs() <= 0.2 * jn + 1e-6);
429        assert!(b.vy > 0.0);
430    }
431
432    #[test]
433    fn zero_friction_keeps_the_tangential_velocity() {
434        let contact = Contact {
435            depth: 0.1,
436            ..head_on()
437        };
438        let mut a = Body {
439            vx: 1.0,
440            vy: 100.0,
441            inv_inertia: 0.0,
442            ..body()
443        };
444        let mut b = Body {
445            x: 8.0,
446            inv_inertia: 0.0,
447            ..body()
448        };
449
450        resolve_contact(&mut a, &mut b, &contact, &smooth(0.0));
451
452        assert_eq!(b.vy, 0.0);
453        assert_eq!(a.vy, 100.0);
454    }
455
456    #[test]
457    fn off_center_hit_spins_both_bodies() {
458        // контакт смещён по Y — появляется плечо
459        let contact = Contact {
460            depth: 1.0,
461            cy: 4.0,
462            ..head_on()
463        };
464        let mut a = Body { vx: 10.0, ..body() };
465        let mut b = Body { x: 8.0, ..body() };
466
467        resolve_contact(&mut a, &mut b, &contact, &smooth(0.0));
468
469        assert_ne!(a.angvel, 0.0);
470        assert_ne!(b.angvel, 0.0);
471    }
472
473    #[test]
474    fn head_on_hit_does_not_spin() {
475        let contact = Contact {
476            depth: 1.0,
477            ..head_on()
478        };
479        let mut a = Body { vx: 10.0, ..body() };
480        let mut b = Body { x: 8.0, ..body() };
481
482        resolve_contact(&mut a, &mut b, &contact, &smooth(0.0));
483
484        assert!(a.angvel.abs() < 1e-6);
485        assert!(b.angvel.abs() < 1e-6);
486    }
487
488    #[test]
489    fn separate_bodies_touches_positions_only() {
490        let mut a = Body { vx: 10.0, ..body() };
491        let mut b = Body { x: 8.0, ..body() };
492
493        separate_bodies(&mut a, &mut b, &head_on());
494
495        assert!((a.x + 1.0).abs() < 1e-6);
496        assert!((b.x - 9.0).abs() < 1e-6);
497        assert_eq!(a.vx, 10.0);
498        assert_eq!(b.vx, 0.0);
499    }
500
501    #[test]
502    fn apply_contact_impulse_touches_velocities_only() {
503        let mut a = Body { vx: 10.0, ..body() };
504        let mut b = Body { x: 8.0, ..body() };
505
506        apply_contact_impulse(&mut a, &mut b, &head_on(), &smooth(0.0));
507
508        assert_eq!(a.x, 0.0);
509        assert_eq!(b.x, 8.0);
510        assert!((a.vx - 5.0).abs() < 1e-5);
511        assert!((b.vx - 5.0).abs() < 1e-5);
512    }
513
514    // регресс: повтор развода на каждой итерации решателя расталкивал тела
515    // кратно числу итераций — отсюда брался рывок рисуемой позиции
516    #[test]
517    fn solver_iterations_do_not_multiply_the_correction() {
518        let mut a = Body { vx: 10.0, ..body() };
519        let mut b = Body { x: 8.0, ..body() };
520
521        separate_bodies(&mut a, &mut b, &head_on());
522
523        let (ax, bx) = (a.x, b.x);
524
525        for _ in 0..4 {
526            apply_contact_impulse(&mut a, &mut b, &head_on(), &smooth(0.0));
527        }
528
529        assert_eq!(a.x, ax);
530        assert_eq!(b.x, bx);
531    }
532
533    #[test]
534    fn rectangle_mass_and_inertia() {
535        let props = box_mass_properties(8.0, 6.0, 200.0);
536        let mass = 200.0 * 8.0 * 6.0;
537
538        assert!((1.0 / props.inv_mass - mass).abs() < 1e-1);
539        assert!((1.0 / props.inv_inertia - (mass * (64.0 + 36.0)) / 12.0).abs() < 1.0);
540    }
541
542    #[test]
543    fn zero_density_or_size_is_static() {
544        let zero = MassProperties {
545            inv_mass: 0.0,
546            inv_inertia: 0.0,
547        };
548
549        assert_eq!(box_mass_properties(8.0, 6.0, 0.0), zero);
550        assert_eq!(box_mass_properties(0.0, 0.0, 200.0), zero);
551    }
552
553    #[test]
554    fn surfaces_combine_by_the_average_rule() {
555        let tank = Surface {
556            friction: 0.5,
557            restitution: 0.1,
558        };
559
560        assert_eq!(
561            combine_surfaces(&tank, &MAP_SURFACE),
562            Surface {
563                friction: 0.35,
564                restitution: 0.05,
565            }
566        );
567    }
568}