1use gizmo_physics_core::{Collider, Transform};
2use gizmo_physics_rigid::components::{RigidBody, Velocity};
3use gizmo_physics_core::raycast::{Ray, Raycast, RaycastHit};
4use gizmo_core::entity::Entity;
5use gizmo_math::{Quat, Vec3};
6
7#[derive(Clone, Debug)]
14pub struct PacejkaParams {
15 pub b: f32, pub c: f32, pub d: f32, pub e: f32, }
20
21impl Default for PacejkaParams {
22 fn default() -> Self {
23 Self {
24 b: 10.0,
25 c: 1.9,
26 d: 1.0,
27 e: 0.97,
28 }
29 }
30}
31
32impl PacejkaParams {
33 pub fn calculate_force(&self, slip: f32, normal_load: f32) -> f32 {
35 let bx = self.b * slip;
36 let d = self.d * normal_load;
37 let inner = self.c * (bx - self.e * (bx - bx.atan())).atan();
38 d * inner.sin()
39 }
40
41 fn weighting_lorentzian(&self, sigma_other: f32) -> f32 {
44 let k = self.b * sigma_other;
45 1.0 / (1.0 + k * k).sqrt() }
47}
48
49pub type PacejkaLat = PacejkaParams;
51
52pub fn pacejka_combined(
55 long: &PacejkaParams,
56 lat: &PacejkaParams,
57 slip_ratio: f32, slip_angle: f32, normal_load: f32,
60) -> (f32, f32) {
61 let fx_pure = long.calculate_force(slip_ratio, normal_load);
62 let fy_pure = lat.calculate_force(slip_angle, normal_load);
63
64 let gx = long.weighting_lorentzian(slip_angle);
66 let gy = lat.weighting_lorentzian(slip_ratio);
67
68 let fx = fx_pure * gx;
69 let fy = fy_pure * gy;
70
71 let mu_peak = long.d.max(lat.d) * 1.2;
73 let limit = normal_load * mu_peak;
74 let mag = (fx * fx + fy * fy).sqrt();
75 if mag > limit && mag > 0.0 {
76 let scale = limit / mag;
77 (fx * scale, fy * scale)
78 } else {
79 (fx, fy)
80 }
81}
82
83#[derive(Clone, Debug, PartialEq)]
88pub enum Axle {
89 Front,
90 Rear,
91}
92
93#[derive(Clone, Debug)]
94pub struct Wheel {
95 pub attachment_local_pos: Vec3,
96 pub direction_local: Vec3,
97 pub axle_type: Axle,
98 pub is_left: bool,
99
100 pub radius: f32,
101 pub suspension_rest_length: f32,
102 pub suspension_max_travel: f32,
103 pub suspension_stiffness: f32,
104 pub suspension_damping: f32,
105
106 pub pacejka_long: PacejkaParams,
107 pub pacejka_lat: PacejkaLat,
108 pub wheel_mass: f32,
109
110 pub steering_angle: f32,
112 pub drive_torque: f32,
113 pub brake_torque: f32,
114
115 pub is_grounded: bool,
117 pub ground_hit: Option<RaycastHit>,
118 pub suspension_length: f32,
119 pub rotation_angle: f32,
120 pub angular_velocity: f32,
121 pub suspension_force: f32,
122}
123
124impl Default for Wheel {
125 fn default() -> Self {
126 Self {
127 attachment_local_pos: Vec3::ZERO,
128 direction_local: Vec3::new(0.0, -1.0, 0.0),
129 axle_type: Axle::Front,
130 is_left: true,
131 radius: 0.35,
132 suspension_rest_length: 0.5,
133 suspension_max_travel: 0.2,
134 suspension_stiffness: 25000.0,
135 suspension_damping: 3000.0,
136 pacejka_long: PacejkaParams::default(),
137 pacejka_lat: PacejkaLat::default(),
138 wheel_mass: 20.0,
139 steering_angle: 0.0,
140 drive_torque: 0.0,
141 brake_torque: 0.0,
142 is_grounded: false,
143 ground_hit: None,
144 suspension_length: 0.5,
145 rotation_angle: 0.0,
146 angular_velocity: 0.0,
147 suspension_force: 0.0,
148 }
149 }
150}
151
152impl Wheel {
154 #[inline]
155 pub fn pacejka(&self) -> &PacejkaParams {
156 &self.pacejka_long
157 }
158}
159
160#[derive(Clone, Debug)]
162pub struct AeroPackage {
163 pub drag_coefficient: f32, pub lift_coefficient: f32, pub frontal_area: f32, pub center_of_pressure: Vec3, pub ground_effect_height: f32, pub ground_effect_multiplier: f32,
169}
170
171impl Default for AeroPackage {
172 fn default() -> Self {
173 Self {
174 drag_coefficient: 0.32,
175 lift_coefficient: -0.8, frontal_area: 2.2, center_of_pressure: Vec3::new(0.0, 0.3, 0.2),
178 ground_effect_height: 0.15,
179 ground_effect_multiplier: 1.8,
180 }
181 }
182}
183
184#[derive(Clone, Debug)]
185pub struct VehicleTuning {
186 pub idle_rpm: f32,
187 pub max_rpm: f32,
188 pub gear_ratios: Vec<f32>,
190 pub final_drive_ratio: f32,
191 pub upshift_rpm: f32,
193 pub downshift_rpm: f32,
195 pub wheelbase: f32,
196 pub track_width: f32,
197 pub anti_roll_stiffness: f32,
198 pub max_engine_torque: f32,
199 pub max_brake_torque: f32,
200 pub aero: AeroPackage,
201}
202
203impl Default for VehicleTuning {
204 fn default() -> Self {
205 Self {
206 idle_rpm: 800.0,
207 max_rpm: 7000.0,
208 gear_ratios: vec![-2.5, 0.0, 3.0, 2.0, 1.4, 1.0, 0.75],
209 final_drive_ratio: 3.73,
210 upshift_rpm: 6200.0,
211 downshift_rpm: 2200.0,
212 wheelbase: 2.8,
213 track_width: 1.6,
214 anti_roll_stiffness: 3000.0,
215 max_engine_torque: 350.0,
216 max_brake_torque: 1500.0,
217 aero: AeroPackage::default(),
218 }
219 }
220}
221
222#[derive(Clone)]
223pub struct VehicleController {
224 pub wheels: Vec<Wheel>,
225 pub tuning: VehicleTuning,
226
227 pub throttle_input: f32, pub brake_input: f32, pub steering_input: f32, pub reverse_input: bool,
231 pub auto_shift: bool, pub current_gear: usize, pub max_steering_angle: f32,
235 pub shift_cooldown: f32, pub engine_rpm: f32,
238 pub current_speed_kmh: f32,
239 pub engine_angular_vel: f32, pub flywheel_inertia: f32, }
242
243impl gizmo_core::component::Component for VehicleController {}
244
245impl Default for VehicleController {
246 fn default() -> Self {
247 Self {
248 wheels: Vec::new(),
249 tuning: VehicleTuning::default(),
250 throttle_input: 0.0,
251 brake_input: 0.0,
252 steering_input: 0.0,
253 reverse_input: false,
254 auto_shift: true,
255 current_gear: 2,
256 max_steering_angle: 0.52,
257 shift_cooldown: 0.0,
258 engine_rpm: 800.0,
259 current_speed_kmh: 0.0,
260 engine_angular_vel: 800.0 / 9.549,
261 flywheel_inertia: 0.25, }
263 }
264}
265
266impl VehicleController {
267 pub fn new() -> Self {
268 Self::default()
269 }
270 pub fn add_wheel(&mut self, wheel: Wheel) {
271 self.wheels.push(wheel);
272 }
273
274 pub fn get_engine_torque(&self) -> f32 {
276 let t = &self.tuning;
277 let ratio = (self.engine_rpm - t.idle_rpm).max(0.0) / (t.max_rpm - t.idle_rpm).max(1.0);
278 let curve = (1.0 - (ratio - 0.4).powi(2) * 2.5).clamp(0.05, 1.0);
279 t.max_engine_torque * curve * self.throttle_input.abs()
280 }
281
282 pub fn set_reverse(&mut self, on: bool) {
283 self.current_gear = if on {
284 0
285 } else if self.current_gear == 0 {
286 2
287 } else {
288 self.current_gear
289 };
290 self.reverse_input = on;
291 }
292
293 pub fn auto_shift_tick(&mut self, dt: f32) {
295 if !self.auto_shift || self.reverse_input {
296 return;
297 }
298 self.shift_cooldown = (self.shift_cooldown - dt).max(0.0);
299 if self.shift_cooldown > 0.0 {
300 return;
301 }
302 let max_gear = self.tuning.gear_ratios.len() - 1;
303 if self.engine_rpm > self.tuning.upshift_rpm && self.current_gear < max_gear {
304 self.current_gear += 1;
305 self.shift_cooldown = 0.4; } else if self.engine_rpm < self.tuning.downshift_rpm && self.current_gear > 2 {
307 self.current_gear -= 1;
308 self.shift_cooldown = 0.3;
309 }
310 }
311}
312
313pub fn update_vehicle(
318 vehicle_entity: Entity,
319 vehicle: &mut VehicleController,
320 vehicle_rb: &mut RigidBody,
321 vehicle_transform: &Transform,
322 vehicle_vel: &mut Velocity,
323 all_colliders: &[(Entity, Transform, Collider)],
324 dt: f32,
325) {
326 if vehicle_rb.is_static() {
327 return;
328 }
329
330 let up = vehicle_transform
332 .rotation
333 .mul_vec3(Vec3::new(0.0, 1.0, 0.0));
334 let forward = vehicle_transform
335 .rotation
336 .mul_vec3(Vec3::new(0.0, 0.0, -1.0));
337 let right = vehicle_transform
338 .rotation
339 .mul_vec3(Vec3::new(1.0, 0.0, 0.0));
340
341 let v_com = vehicle_vel.linear;
342 let forward_speed = v_com.dot(forward);
343 vehicle.current_speed_kmh = forward_speed * 3.6;
344
345 let gear_ratio = vehicle
349 .tuning
350 .gear_ratios
351 .get(vehicle.current_gear)
352 .copied()
353 .unwrap_or(0.0);
354 let total_ratio = gear_ratio * vehicle.tuning.final_drive_ratio;
355
356 let mut avg_rear_ω = 0.0f32;
358 let mut rear_count = 0.0f32;
359 for w in &vehicle.wheels {
360 if w.axle_type == Axle::Rear {
361 avg_rear_ω += w.angular_velocity;
362 rear_count += 1.0;
363 }
364 }
365 if rear_count > 0.0 {
366 avg_rear_ω /= rear_count;
367 }
368
369 let wheel_rpm = avg_rear_ω.abs() * 9.549; vehicle.engine_rpm =
371 (wheel_rpm * total_ratio.abs()).clamp(vehicle.tuning.idle_rpm, vehicle.tuning.max_rpm);
372
373 let engine_torque = vehicle.get_engine_torque();
374 let torque_sign = if total_ratio < 0.0 { -1.0 } else { 1.0 };
376 let drive_torque_total = engine_torque * total_ratio.abs() * torque_sign;
377
378 vehicle.auto_shift_tick(dt);
382
383 const AIR_DENSITY: f32 = 1.225; let spd = v_com.length();
388 let spd_sq = spd * spd;
389 let a = &vehicle.tuning.aero;
390 let q = 0.5 * AIR_DENSITY * spd_sq; let height_above_ground = vehicle
394 .wheels
395 .iter()
396 .filter(|w| w.is_grounded)
397 .filter_map(|w| w.ground_hit.as_ref().map(|hit| hit.distance - 0.5)) .fold(f32::MAX, f32::min);
399 let ge_factor = if height_above_ground < a.ground_effect_height {
400 a.ground_effect_multiplier
401 } else {
402 1.0
403 };
404
405 let drag_dir = if spd > 0.1 { -v_com / spd } else { Vec3::ZERO };
406 let drag_force = drag_dir * (a.drag_coefficient * a.frontal_area * q);
407 let lift_force = up * (a.lift_coefficient * a.frontal_area * q * ge_factor);
408
409 let cop_world =
411 vehicle_transform.position + vehicle_transform.rotation.mul_vec3(a.center_of_pressure);
412 let com = vehicle_transform.position
413 + vehicle_transform
414 .rotation
415 .mul_vec3(vehicle_rb.center_of_mass);
416 apply_force_at_point(
417 vehicle_rb,
418 vehicle_vel,
419 com,
420 vehicle_transform.rotation,
421 drag_force + lift_force,
422 cop_world,
423 dt,
424 );
425
426 let steer_angle = vehicle.steering_input * vehicle.max_steering_angle;
430 let turn_radius = if steer_angle.abs() > 0.01 {
431 vehicle.tuning.wheelbase / steer_angle.tan()
432 } else {
433 f32::MAX
434 };
435
436 let rear_count_f = rear_count.max(1.0);
440
441 for wheel in &mut vehicle.wheels {
442 let attach_world = vehicle_transform.position
443 + vehicle_transform
444 .rotation
445 .mul_vec3(wheel.attachment_local_pos);
446 let ray_dir = vehicle_transform
447 .rotation
448 .mul_vec3(wheel.direction_local)
449 .normalize();
450
451 let ray_origin_offset = 0.5;
454 let ray_start = attach_world - ray_dir * ray_origin_offset;
455 let ray_max = wheel.suspension_rest_length
456 + wheel.radius
457 + wheel.suspension_max_travel
458 + ray_origin_offset;
459 let ray = Ray::new(ray_start, ray_dir);
460
461 let mut closest_hit: Option<RaycastHit> = None;
463 let mut closest_dist = ray_max;
464
465 for (other_ent, other_trans, other_col) in all_colliders {
466 if *other_ent == vehicle_entity || other_col.is_trigger {
467 continue;
468 }
469 let aabb = other_col.compute_aabb(other_trans.position, other_trans.rotation);
470 if Raycast::ray_aabb(&ray, &aabb).is_none() {
471 continue;
472 }
473 if let Some((dist, normal)) = Raycast::ray_shape(&ray, &other_col.shape, other_trans) {
474 if dist < closest_dist {
475 closest_dist = dist;
476 closest_hit = Some(RaycastHit {
477 entity: *other_ent,
478 point: ray.point_at(dist),
479 normal,
480 distance: dist,
481 });
482 }
483 }
484 }
485
486 if let Some(hit) = closest_hit {
487 wheel.is_grounded = true;
488 wheel.ground_hit = Some(hit);
489
490 let actual_dist = closest_dist - ray_origin_offset;
492
493 let raw_len = (actual_dist - wheel.radius).clamp(
495 wheel.suspension_rest_length - wheel.suspension_max_travel,
496 wheel.suspension_rest_length + wheel.suspension_max_travel,
497 );
498 wheel.suspension_length = raw_len;
499 } else {
500 wheel.is_grounded = false;
501 wheel.ground_hit = None;
502 wheel.suspension_length = wheel.suspension_rest_length;
503 wheel.suspension_force = 0.0;
504 }
505
506 if wheel.axle_type == Axle::Front {
508 let sign = if wheel.is_left { 1.0 } else { -1.0 };
509 wheel.steering_angle = if turn_radius.abs() < 1e4 {
510 (vehicle.tuning.wheelbase / (turn_radius + sign * vehicle.tuning.track_width * 0.5))
511 .atan()
512 } else {
513 steer_angle
514 };
515 }
516
517 wheel.drive_torque = if wheel.axle_type == Axle::Rear {
519 drive_torque_total / rear_count_f
520 } else {
521 0.0
522 };
523
524 let bias = if wheel.axle_type == Axle::Front {
526 0.6
527 } else {
528 0.4
529 };
530 wheel.brake_torque = vehicle.brake_input * vehicle.tuning.max_brake_torque * bias;
531 }
532
533 let (mut fl, mut fr, mut rl, mut rr) = (0.0f32, 0.0f32, 0.0f32, 0.0f32);
537 for w in &vehicle.wheels {
538 let travel = w.suspension_rest_length - w.suspension_length;
539 match (&w.axle_type, w.is_left) {
540 (Axle::Front, true) => fl = travel,
541 (Axle::Front, false) => fr = travel,
542 (Axle::Rear, true) => rl = travel,
543 (Axle::Rear, false) => rr = travel,
544 }
545 }
546 let front_diff = fl - fr;
547 let rear_diff = rl - rr;
548
549 for wheel in &mut vehicle.wheels {
554 let attach_world = vehicle_transform.position
555 + vehicle_transform
556 .rotation
557 .mul_vec3(wheel.attachment_local_pos);
558 let ray_dir = vehicle_transform
559 .rotation
560 .mul_vec3(wheel.direction_local)
561 .normalize();
562
563 let wheel_inertia = 0.5 * wheel.wheel_mass * wheel.radius.powi(2);
566
567 if wheel.is_grounded {
568 if let Some(hit) = wheel.ground_hit.as_ref() {
569 let point_rel = attach_world - vehicle_transform.position;
571 let point_vel = vehicle_vel.linear + vehicle_vel.angular.cross(point_rel);
572 let susp_vel = point_vel.dot(ray_dir); let compression = wheel.suspension_rest_length - wheel.suspension_length;
574
575 let spring_force = wheel.suspension_stiffness * compression;
576
577 let damper_coeff = if susp_vel > 0.0 {
579 wheel.suspension_damping } else {
581 wheel.suspension_damping * 2.5 };
583 let damper_force = damper_coeff * susp_vel;
584
585 let bump_stop_travel = wheel.suspension_max_travel * 0.1;
587 let bump_excess = compression - (wheel.suspension_max_travel - bump_stop_travel);
588 let bump_stop_force = if bump_excess > 0.0 {
589 bump_excess * wheel.suspension_stiffness * 8.0
590 } else {
591 0.0
592 };
593
594 let arb_force = match wheel.axle_type {
596 Axle::Front => {
597 if wheel.is_left {
598 -front_diff
599 } else {
600 front_diff
601 }
602 }
603 Axle::Rear => {
604 if wheel.is_left {
605 -rear_diff
606 } else {
607 rear_diff
608 }
609 }
610 } * vehicle.tuning.anti_roll_stiffness;
611
612 wheel.suspension_force =
613 (spring_force + damper_force + bump_stop_force + arb_force).max(0.0);
614 let susp_impulse = (-ray_dir) * wheel.suspension_force;
615 apply_force_at_point(
616 vehicle_rb,
617 vehicle_vel,
618 com,
619 vehicle_transform.rotation,
620 susp_impulse,
621 attach_world,
622 dt,
623 );
624
625 let steering_rot = Quat::from_axis_angle(up, wheel.steering_angle);
627 let wheel_forward = steering_rot.mul_vec3(forward).normalize();
628 let wheel_right = steering_rot.mul_vec3(right).normalize();
629
630 let v_long = point_vel.dot(wheel_forward);
631 let v_lat = point_vel.dot(wheel_right);
632
633 let ref_vel = v_long.abs().max(0.5);
635
636 let wheel_linear_vel = wheel.angular_velocity * wheel.radius;
638 let slip_ratio = (wheel_linear_vel - v_long) / ref_vel;
639
640 let slip_angle = -(v_lat / ref_vel).atan();
642
643 let normal_load = wheel.suspension_force;
644
645 let (final_long, final_lat) = pacejka_combined(
647 &wheel.pacejka_long,
648 &wheel.pacejka_lat,
649 slip_ratio,
650 slip_angle,
651 normal_load,
652 );
653
654 let tire_force = wheel_forward * final_long + wheel_right * final_lat;
656 let contact_pt = hit.point;
657 apply_force_at_point(
658 vehicle_rb,
659 vehicle_vel,
660 com,
661 vehicle_transform.rotation,
662 tire_force,
663 contact_pt,
664 dt,
665 );
666
667 let reaction_torque = final_long * wheel.radius;
670
671 let brake_dir = if wheel.angular_velocity.abs() > 0.01 {
673 -wheel.angular_velocity.signum()
674 } else {
675 0.0
676 };
677 let effective_brake = wheel.brake_torque * brake_dir;
678
679 let net_torque = wheel.drive_torque + effective_brake - reaction_torque;
681
682 wheel.angular_velocity += (net_torque / wheel_inertia) * dt;
684
685 let max_brake_decel = wheel.brake_torque / wheel_inertia * dt;
687 if vehicle.brake_input > 0.01 && wheel.angular_velocity.abs() < max_brake_decel {
688 wheel.angular_velocity = 0.0;
689 }
690 }
691 } else {
692 wheel.suspension_force = 0.0;
694
695 let brake_dir = if wheel.angular_velocity.abs() > 0.01 {
696 -wheel.angular_velocity.signum()
697 } else {
698 0.0
699 };
700
701 let effective_brake = wheel.brake_torque * brake_dir;
702 let net_torque = wheel.drive_torque + effective_brake;
703 wheel.angular_velocity += (net_torque / wheel_inertia) * dt;
704
705 let max_brake_decel = wheel.brake_torque / wheel_inertia * dt;
707 if vehicle.brake_input > 0.01 && wheel.angular_velocity.abs() < max_brake_decel {
708 wheel.angular_velocity = 0.0;
709 }
710 }
711
712 let damping_coeff = 2.0; wheel.angular_velocity *= (1.0 - damping_coeff * dt).max(0.0);
715
716 if wheel.angular_velocity.abs() < 0.05
718 && wheel.drive_torque.abs() < 1.0
719 && wheel.brake_torque < 1.0
720 {
721 wheel.angular_velocity = 0.0;
722 }
723
724 wheel.rotation_angle += wheel.angular_velocity * dt;
726 wheel.rotation_angle %= std::f32::consts::TAU;
727 }
728}
729
730#[allow(dead_code)]
736fn apply_force_central(rb: &RigidBody, vel: &mut Velocity, force: Vec3, dt: f32) {
737 if rb.is_static() {
738 return;
739 }
740 vel.linear += force * rb.inv_mass() * dt;
741}
742
743fn apply_force_at_point(
745 rb: &RigidBody,
746 vel: &mut Velocity,
747 center_of_mass: Vec3,
748 rotation: Quat,
749 force: Vec3,
750 point: Vec3,
751 dt: f32,
752) {
753 if rb.is_static() {
754 return;
755 }
756 vel.linear += (force * rb.inv_mass()) * dt;
757 let torque = (point - center_of_mass).cross(force);
758 vel.angular += (rb.inv_world_inertia_tensor(rotation) * torque) * dt;
759}
760
761#[cfg(test)]
762mod tests {
763 use super::*;
764
765 #[test]
766 fn test_suspension_spring_and_damper_math() {
767 let stiffness = 25000.0; let compression = 0.1; let spring_force = stiffness * compression;
771
772 assert_eq!(spring_force, 2500.0, "Hooke's Law spring force failed");
774
775 let damping_compression = 3000.0; let susp_vel_compressing = 1.0; let damper_force = damping_compression * susp_vel_compressing;
781 assert_eq!(damper_force, 3000.0, "Damper force calculation failed");
782
783 let total_suspension_force = spring_force + damper_force;
785 assert_eq!(
786 total_suspension_force, 5500.0,
787 "Total suspension force calculation failed"
788 );
789 }
790
791 #[test]
792 fn test_pacejka_combined_slip() {
793 let long = PacejkaParams::default();
794 let lat = PacejkaLat::default();
795 let normal_load = 5000.0; let (fx1, fy1) = pacejka_combined(&long, &lat, 0.0, 0.0, normal_load);
799 assert!(
800 fx1.abs() < 1e-4,
801 "Expected zero longitudinal force at zero slip"
802 );
803 assert!(fy1.abs() < 1e-4, "Expected zero lateral force at zero slip");
804
805 let (fx2, fy2) = pacejka_combined(&long, &lat, 0.15, 0.0, normal_load);
807 let expected_fx2 = long.calculate_force(0.15, normal_load);
808 assert!(
809 (fx2 - expected_fx2).abs() < 1e-4,
810 "Expected combined force to match pure force when no lateral slip is present"
811 );
812 assert!(
813 fy2.abs() < 1e-4,
814 "Expected zero lateral force when purely accelerating straight"
815 );
816
817 let (fx3, fy3) = pacejka_combined(&long, &lat, 0.15, 0.15, normal_load);
820
821 assert!(
823 fx3 < fx2,
824 "Combined slip should reduce longitudinal grip (Friction Circle violated)"
825 );
826 assert!(
827 fy3 > 1000.0,
828 "Expected significant lateral force during cornering"
829 );
830 }
831}