How do I lock the linear movement of the rigid body simulating the wheel in the X and Z axis? In the following post are images depicting how I've set the chassis up so far. Here's the code I'm currently using:

extends RigidBody3D

@export var wheel_max_compression = 0.5
@export var suspension_stiffness = 20

@onready var fr_ray_cast: RayCast3D = $RayCastFrontRight 
@onready var fl_ray_cast: RayCast3D = $RayCastFrontLeft
@onready var rr_ray_cast: RayCast3D = $RayCastRearRight
@onready var rl_ray_cast: RayCast3D = $RayCastRearLeft

@onready var fr_wheel: RigidBody3D = $RayCastFrontRight/Wheel
@onready var fl_wheel: RigidBody3D = $RayCastFrontLeft/Wheel
@onready var rr_wheel: RigidBody3D = $RayCastRearRight/Wheel
@onready var rl_wheel: RigidBody3D = $RayCastRearLeft/Wheel

func _physics_process(delta):
	fr_ray_cast.force_raycast_update()
	fl_ray_cast.force_raycast_update()
	rr_ray_cast.force_raycast_update()
	rl_ray_cast.force_raycast_update()

	var fr_comp = wheel_max_compression - fr_ray_cast.get_collision_point().y
	var fl_comp = wheel_max_compression - fl_ray_cast.get_collision_point().y
	var rr_comp = wheel_max_compression - rr_ray_cast.get_collision_point().y
	var rl_comp = wheel_max_compression - rl_ray_cast.get_collision_point().y

	var fr_force = suspension_stiffness * fr_comp
	var fl_force = suspension_stiffness * fl_comp 
	var rr_force = suspension_stiffness * rr_comp
	var rl_force = suspension_stiffness * rl_comp

	apply_central_force(fr_force * fr_ray_cast.global_transform.basis.y)
	apply_central_force(fl_force * fl_ray_cast.global_transform.basis.y)
	apply_central_force(rr_force * rr_ray_cast.global_transform.basis.y)
	apply_central_force(rl_force * rl_ray_cast.global_transform.basis.y)

RigidBodies have axis lock properties, but I'm not sure if it will work in this case. If it doesn't, you can try using one of the availabe joint nodes.

There's also built-in VehicleBody3D node, which might suit your needs, without you having to implement those things.

    LoipesMas without you having to implement those things.

    I wanted to go through the exercise of doing it this way to learn more about how to run an advanced suspension simulation.

    LoipesMas If it doesn't, you can try using one of the availabe joint nodes.

    Was hoping to figure out the raycast method, but here's an attempt at implementing the built int joint nodes:

    Try out this tutorial:

    It's in Unity but it seems like it should translate to Godot just fine. The big thing to note for this method is that the wheels AREN'T physics objects. You are controlling their transforms entirely procedurally, and using the raycast calculations to manually apply forces to the body of the vehicle.

    I'll start with your tutorial, but I found the following tutorial series to be a more complete demonstration:

    The following code produces this:

    extends RigidBody3D
    
    @export var wheel_count: int = 4
    @export var suspension_rest_length: float = 0.5 
    @export var suspension_max_length: float = 1.0
    @export var suspension_stiffness: float = 500.0
    @export var dampening_sensitivity: float = 300.0
    @export var dampening_max: float = 60.0
    
    @onready var fr: RayCast3D = $FR
    @onready var fl: RayCast3D = $FL
    @onready var rr: RayCast3D = $RR
    @onready var rl: RayCast3D = $RL
    
    @onready var fr_wheel_pos: Marker3D = $FR/Wheel
    @onready var fl_wheel_pos: Marker3D = $FL/Wheel
    @onready var rr_wheel_pos: Marker3D = $RR/Wheel
    @onready var rl_wheel_pos: Marker3D = $RL/Wheel
    
    func _physics_process(delta):
    	if fr.is_colliding():
    		var dist = fr.get_collision_point().distance_to(fr.global_transform.origin)
    		var dampening = clamp(linear_velocity.length() * dampening_sensitivity, 0, dampening_max)
    		self.apply_central_force(-fr.global_transform.basis.y * (suspension_stiffness * (dist - suspension_rest_length) + dampening))
    		fr_wheel_pos.global_transform.origin = fr.get_collision_point()
       
    	if fl.is_colliding():
    		var dist = fl.get_collision_point().distance_to(fl.global_transform.origin)
    		var dampening = clamp(linear_velocity.length() * dampening_sensitivity, 0, dampening_max)
    		self.apply_central_force(-fl.global_transform.basis.y * (suspension_stiffness * (dist - suspension_rest_length) + dampening))
    		fl_wheel_pos.global_transform.origin = fl.get_collision_point()
      
    	if rr.is_colliding():
    		var dist = rr.get_collision_point().distance_to(rr.global_transform.origin)
    		var dampening = clamp(linear_velocity.length() * dampening_sensitivity, 0, dampening_max)
    		self.apply_central_force(-rr.global_transform.basis.y * (suspension_stiffness * (dist - suspension_rest_length) + dampening))
    		rr_wheel_pos.global_transform.origin = rr.get_collision_point()
      
    	if rl.is_colliding():
    		var dist = rl.get_collision_point().distance_to(rl.global_transform.origin)
    		var dampening = clamp(linear_velocity.length() * dampening_sensitivity, 0, dampening_max)
    		self.apply_central_force(-rl.global_transform.basis.y * (suspension_stiffness * (dist - suspension_rest_length) + dampening))
    		rl_wheel_pos.global_transform.origin = rl.get_collision_point()

    How do I get the linear velocity of a Marker3D?

    extends Marker3D
    
    var new_pos
    var old_pos
    
    # Called when the node enters the scene tree for the first time.
    func _ready():
    	old_pos = transform.origin
    
    
    # Called every frame. 'delta' is the elapsed time since the previous frame.
    func _process(delta):
    	new_pos = transform.origin
    	old_pos - new_pos

      Lousifr How do I get the linear velocity of a Marker3D?

      (new_pos - old_pos) / delta

      print(fr_wheel.get_velocity()) returns (0, 0, 0,) despite the car moving all around with wheel attached.

      wheel.gd:

      extends Marker3D
      
      var new_pos
      var old_pos
      var velocity
      
      func _ready():
      	old_pos = global_transform.origin
      
      
      func _process(delta):
      	new_pos = global_transform.origin
      	velocity = (new_pos - old_pos) / delta
      	old_pos = new_pos
      
      func get_velocity():
      	return velocity

      vehicle.gd:

      extends RigidBody3D
      
      @export var wheel_count: int = 4
      @export var suspension_rest_length: float = 0.5 
      @export var suspension_max_length: float = 0.5
      @export var suspension_stiffness: float = 500.0
      @export var dampening_sensitivity: float = 200.0
      @export var dampening_max: float = 50.0
      
      @onready var fr: RayCast3D = $FR
      @onready var fl: RayCast3D = $FL
      @onready var rr: RayCast3D = $RR
      @onready var rl: RayCast3D = $RL
      
      @onready var fr_wheel: Marker3D = $FR/Wheel
      @onready var fl_wheel: Marker3D = $FL/Wheel
      @onready var rr_wheel: Marker3D = $RR/Wheel
      @onready var rl_wheel: Marker3D = $RL/Wheel
      
      func _physics_process(delta):
      	if fr.is_colliding():
      		var dist = fr.get_collision_point().distance_to(fr.global_transform.origin)
      		var dampening = clamp(linear_velocity.length() * dampening_sensitivity, 0, dampening_max)
      		self.apply_central_force(-fr.global_transform.basis.y * (suspension_stiffness * (dist - suspension_rest_length) + dampening))
      		fr_wheel.global_transform.origin = fr.get_collision_point()
         
      	if fl.is_colliding():
      		var dist = fl.get_collision_point().distance_to(fl.global_transform.origin)
      		var dampening = clamp(linear_velocity.length() * dampening_sensitivity, 0, dampening_max)
      		self.apply_central_force(-fl.global_transform.basis.y * (suspension_stiffness * (dist - suspension_rest_length) + dampening))
      		fl_wheel.global_transform.origin = fl.get_collision_point()
        
      	if rr.is_colliding():
      		var dist = rr.get_collision_point().distance_to(rr.global_transform.origin)
      		var dampening = clamp(linear_velocity.length() * dampening_sensitivity, 0, dampening_max)
      		self.apply_central_force(-rr.global_transform.basis.y * (suspension_stiffness * (dist - suspension_rest_length) + dampening))
      		rr_wheel.global_transform.origin = rr.get_collision_point()
        
      	if rl.is_colliding():
      		var dist = rl.get_collision_point().distance_to(rl.global_transform.origin)
      		var dampening = clamp(linear_velocity.length() * dampening_sensitivity, 0, dampening_max)
      		self.apply_central_force(-rl.global_transform.basis.y * (suspension_stiffness * (dist - suspension_rest_length) + dampening))
      		rl_wheel.global_transform.origin = rl.get_collision_point()

        That's weird. If you just print(global_transform.origin), does it ever change?

          award It does.

          Do I need to calculate the forces applied to each wheel in wheel.gd then relay that back to vehicle.gd? Maybe encapsulate the variables in vehicle.gd, then call setter functions from wheel.gd?
          Edit: Nvm, stupid idea.

          Lousifr var dampening = clamp(linear_velocity.length() * dampening_sensitivity, 0, dampening_max)

          Why do you damp the strength of the suspension based on the total velocity of the car? I haven't watched the tutorial, so maybe it's explained there, but it doesn't seem right.

          Also that damp is added to force multiplier, which is also counterintuitive to me.
          For dist = 0.4 and suspension_rest_length=0.5 the result would be a scalar of -0.1 (ignoring stiffness). But if you add dampening (which is in range 0..50), the scalar can change direction, i.e., become positive in this case.
          Again, maybe it's explained in the tutorial, but it feels off.

            LoipesMas I have no idea what I was doing, just kinda shot in the dark.

            I've rewritten the _physics_process to actually make sense according to this tutorial:

            extends RigidBody3D
            
            @export var suspension_rest_length: float = 0.5 
            @export var suspension_max_length: float = 1.0
            @export var suspension_stiffness: float = 250.0
            
            @export var dampening_sensitivity: float = 200.0
            @export var dampening_max: float = 50.0
            
            @onready var fr: RayCast3D = $FR
            @onready var fl: RayCast3D = $FL
            @onready var rr: RayCast3D = $RR
            @onready var rl: RayCast3D = $RL
            
            @onready var fr_wheel: Marker3D = $FR/Wheel
            @onready var fl_wheel: Marker3D = $FL/Wheel
            @onready var rr_wheel: Marker3D = $RR/Wheel
            @onready var rl_wheel: Marker3D = $RL/Wheel
            
            func _physics_process(delta):
            	if fr.is_colliding():
            		var velocity = fr_wheel.get_velocity()
            		var dist = fr.get_collision_point().distance_to(fr.global_transform.origin)
            		var offset = dist - suspension_rest_length
            		var force = (offset * suspension_stiffness) - (velocity.y * dampening_sensitivity)
            		self.apply_central_force(fr.global_transform.basis.y * force)
            		fr_wheel.global_transform.origin = fr.get_collision_point()
               
            	if fl.is_colliding():
            		var velocity = fl_wheel.get_velocity()
            		var dist = fl.get_collision_point().distance_to(fl.global_transform.origin)
            		var offset = dist - suspension_rest_length
            		var force = (offset * suspension_stiffness) - (velocity.y * dampening_sensitivity)
            		self.apply_central_force(fl.global_transform.basis.y * force)
            		fl_wheel.global_transform.origin = fl.get_collision_point()
              
            	if rr.is_colliding():
            		var velocity = rr_wheel.get_velocity()
            		var dist = rr.get_collision_point().distance_to(rr.global_transform.origin)
            		var offset = dist - suspension_rest_length
            		var force = (offset * suspension_stiffness) - (velocity.y * dampening_sensitivity)
            		self.apply_central_force(rr.global_transform.basis.y * force)
            		rr_wheel.global_transform.origin = rr.get_collision_point()
              
            	if rl.is_colliding():
            		var velocity = rl_wheel.get_velocity()
            		var dist = rl.get_collision_point().distance_to(rl.global_transform.origin)
            		var offset = dist - suspension_rest_length
            		var force = (offset * suspension_stiffness) - (velocity.y * dampening_sensitivity)
            		self.apply_central_force(rl.global_transform.basis.y * force)
            		rl_wheel.global_transform.origin = rl.get_collision_point()

            Having this effect:

            Migrating suspension code to wheel.gd:

            extends Marker3D
            
            @onready var vehicle_body: RigidBody3D = get_parent().get_parent()
            @onready var ray_cast: RayCast3D = get_parent()
            
            @export var spring_rest_length: float = 0.5
            @export var spring_travel: float = 0.5 
            @export var spring_stiffness: float = 30000.0
            var spring_max_length: float
            var spring_min_length: float
            var spring_last_length: float
            var spring_force: float
            var spring_velocity: float
            
            @export var damper_stiffness: float = 10000.0
            var damper_force: float
            
            var velocity: Vector3 = Vector3.ZERO
            var last_position = global_transform.origin
            
            func _ready():
            	spring_max_length = spring_rest_length + spring_travel
            	spring_min_length = spring_rest_length - spring_travel
            
            func _physics_process(delta):
            	if ray_cast.is_colliding():
            		var distance = global_transform.origin.distance_to(ray_cast.get_collision_point())
            		var extension = distance - spring_rest_length
            		spring_velocity = (distance - spring_last_length) / delta
            		spring_last_length = distance
            		spring_force = (spring_stiffness * extension) - (damper_stiffness * spring_velocity)
            		
            		var force = -global_transform.basis.y * spring_force
            		vehicle_body.apply_central_force(force)
            			
            	velocity = (global_transform.origin - last_position) / delta
            	last_position = global_transform.origin


            The RigidBody3D "Vehicle" ways 1470kg


            Beginning at 12:27 (The video should start at that time), the steering needs the dot product of each tires velocity. I have no idea what a dot product is, nor how to use it. Could I get some help using dot products in this case?

            • xyz replied to this.

              Lousifr You may have very hard time making custom car physics if you don't know what a dot product is. It is special kind of multiplication of two vector's components resulting in a scalar value. That's why it's often also called the scalar product. This operation is one of the very fundamental things in vector math. In Godot's api it's implemented viaVector3::dot()

              Yeah, this is getting to be too much of a challenge. Does VehicleBody have parameters I can tweak in code?

              • xyz replied to this.

                Lousifr Yeah, this is getting to be too much of a challenge. Does VehicleBody have parameters I can tweak in code?

                Well, take a look at the class reference. All class properties can be tweaked via code. VehicleBody3D has relatively small number of parameters. However it's meant to be used with some VehicleWheel3D objects. They have a lot of adjustable properties.

                I haven't used this system myself so I don't know how good it is. But you can give it a try and see if it fits your needs.

                I don't want to give up. I'm gonna go back to when the VehicleController made sense to me. Condensing code to be in wheel.gd:

                extends Marker3D
                
                @export var spring_rest_length: float = 0.5
                @export var spring_travel_length: float = 0.5
                @export var suspension_max_length: float = 1.0
                @export var suspension_stiffness: float = 150 
                @export var dampening: float = 20.0
                
                @onready var ray: RayCast3D = get_parent()
                @onready var body: RigidBody3D = get_parent().get_parent()
                
                var new_pos
                var old_pos
                var velocity
                
                func _ready():
                	old_pos = global_transform.origin 
                
                func _physics_process(delta):
                	if ray.is_colliding():
                		new_pos = ray.get_collision_point()
                		velocity = (new_pos - old_pos) / delta
                		old_pos = new_pos
                		print(velocity)
                	
                		var dist = new_pos.distance_to(global_transform.origin)
                		var offset = dist - spring_rest_length
                		var force = (offset * suspension_stiffness) - (velocity.y * dampening)
                		body.apply_central_force(global_transform.basis.y * force)