Move car to the ground normal

Godot Version

4.3

Question

How do i make my car’s model rotate so all wheels are always on the ground? i started it but it only works when the car is going up slopes.


func update_model(delta : float) -> void:
	if not is_on_floor(): 
		$Model.rotation.x = lerp_angle($Model.rotation.x, velocity.normalized().y, 4 * delta)
	else:
		var target_angle = get_floor_normal().angle_to(Vector3(0, 1, 0))
		$Model.rotation.x = lerp_angle($Model.rotation.x, target_angle, 7 * delta)

	$Model/WheelTopL.rotation.y = -steer_input * 0.5
	$Model/WheelTopR.rotation.y = -steer_input * 0.5

	$Model/WheelTopL.rotation.x += current_speed * delta
	$Model/WheelTopR.rotation.x += current_speed * delta
	$Model/WheelBottomL.rotation.x += current_speed * delta
	$Model/WheelBottomR.rotation.x += current_speed * delta

This might work in 2D, but not in 3D. Rotations cannot be reduced to one float. Given how you are doing this I would recommend using look_at with the normal as the “up vector”.

var forward_target: Vector3 = global_position - global_transform.basis.z 
$Model.look_at(forward_target, get_floor_normal())

Using lerp you may need to create a new variable with transform.basis.looking_at(forward, up) so you can interpolate the effect.

2 Likes

Thank you! Though i dont think that’s worked. I changed it to

func update_model(delta : float) -> void:
	if not is_on_floor(): 
		$Model.rotation.x = lerp_angle($Model.rotation.x, velocity.normalized().y, 4 * delta)
	else:
		var forward_target: Vector3 = global_position - global_transform.basis.z 
		$Model.look_at(forward_target, get_floor_normal())
	
	$Model/WheelTopL.rotation.y = -steer_input * 0.5
	$Model/WheelTopR.rotation.y = -steer_input * 0.5
	
	$Model/WheelTopL.rotation.x += current_speed * delta
	$Model/WheelTopR.rotation.x += current_speed * delta
	$Model/WheelBottomL.rotation.x += current_speed * delta
	$Model/WheelBottomR.rotation.x += current_speed * delta

but all i get is the car pointing towards downwards at weird angles.
image
image

Seem flipped, maybe the “forward” is backwards, try adding the basis.z or using “model front”

var forward_target: Vector3 = global_position + global_transform.basis.z

Doing that just makes the model backwards, and model front doesnt rotate at all.

I was just reading the documentation for look_at() and its behaviour prioritizes alignment with the target point.

Perhaps this is why look_at() doesn’t work – at least not with the current set of parameter values. I think making look_at() consistently orient a node towards an up-axis requires the target point to be perpendicular to the up vector.

Try using the normalized cross product of the object’s x-axis and the contact normal instead.

var normal = get_floor_normal()
var x_cross_normal = global_basis.x.cross(normal).normalized()
var forward_target = global_position + x_cross_normal

$Model.look_at(forward_target, normal)

I hope that does it!

Its definately close! the model def seems to be snapping to the slope, but the rotation is all messed up. Ill see if adding something fixes it.


Perhaps we need to rewind a bit so we can discuss how your current system actually works.

  1. How does your car move?
    • Which object rotates when you are turning?
  2. What does your node hierarchy look like?
  3. What exactly do you mean by having all wheels on the ground?

I have my own assumptions but those sometimes lead to confusion. It would be great if you could clarify these points before discussing further.

Thanks! The car moves by pretty much just setting the velocity to the main nodes rotation times the current speed, and this is my hierarchy:
image
by having all wheels on the ground, i just mean so that the car’s model is rotated to look like its actually placed on the ground, instead of just floating above or clipping into slopes. This is the main script if you need it!

extends CharacterBody3D

@onready var boost_level_3l: GPUParticles3D = $Model/Particles/BoostLevel3L
@onready var boost_level_3r: GPUParticles3D = $Model/Particles/BoostLevel3R
@onready var boost_level_2l: GPUParticles3D = $Model/Particles/BoostLevel2L
@onready var boost_level_2r: GPUParticles3D = $Model/Particles/BoostLevel2R
@onready var boost_level_1l: GPUParticles3D = $Model/Particles/BoostLevel1L
@onready var boost_level_1r: GPUParticles3D = $Model/Particles/BoostLevel1R

@export var MAX_STEP_UP := 0.5			# Maximum height in meters the player can step up.
@export var MAX_STEP_DOWN := -0.5		# Maximum height in meters the player can step down.

enum STATE {
	REGULAR,
	DRIFTING,
	SPINOUT
}

const SPEED := 15.0
const JUMP_VELOCITY := 9.5
const ACCEL_SPEED := 2.0
const DECEL_SPEED := 3.5
const TURN_SPEED := 6.0
const TURN_ACCEL_SPEED := 5.0
const DRIFT_TURN_ACCEL_SPEED := 5.0
const GRAVITY = -18.0
const DRIFT_TURN_FORCE := 85.0
const DRIFT_BOOST_DAMPING := 15.0
const DRIFT_BOOST_MULTIPLIER := 6.0

var current_speed := 0.0

var steer_input := 0.0
var accel_input := 0.0

var drift_direction := 0
var drift_boost := 0.0
var boost_velocity := Vector3.ZERO

var cur_state = STATE.REGULAR

var wish_dir := Vector3.ZERO			# Player input (WASD) direction
var vertical := Vector3(0, 1, 0)		# Shortcut for converting vectors to vertical
var horizontal := Vector3(1, 0, 1)		# Shortcut for converting vectors to horizontal

func _physics_process(delta: float) -> void:
	update_model(delta)
	
	match cur_state:
		STATE.REGULAR:
			regular_state(delta)
		STATE.DRIFTING:
			drift_state(delta)
			
	
	wish_dir = (transform.basis.z * current_speed).normalized()
	move_and_step(delta)

func move_and_step(delta : float):
	stair_step_up()
	move_and_slide()

func enter_state(new_state : STATE):
	cur_state = new_state
	match new_state:
		STATE.DRIFTING:
			drift_direction = -sign(Input.get_axis("left", "right"))

func regular_state(delta : float) -> void:
	
	handle_gravity(delta)
	handle_floor_collision()
	handle_jump()

	update_inputs(delta)

	var rotation_amount = steer_input * sign(current_speed) * TURN_SPEED * Vector2(velocity.x, velocity.z).length()
	
	rotation_degrees.y += rotation_amount * delta
	$Model.rotation_degrees.y = rad_to_deg(
		lerp_angle(
			deg_to_rad($Model.rotation_degrees.y), 
			(rotation_amount * 0.003), 
			delta * 5
		)
	)
	
	handle_drift_boost()

	if accel_input != 0:
		current_speed = lerp(current_speed, SPEED * accel_input, ACCEL_SPEED * delta)
	else:
		current_speed = lerp(current_speed, 0.0, DECEL_SPEED * delta)

	var direction := transform.basis.z
	velocity.x = direction.x * current_speed
	velocity.z = direction.z * current_speed

	velocity += boost_velocity
	boost_velocity = boost_velocity.move_toward(Vector3.ZERO, DRIFT_BOOST_DAMPING * delta)
	
	if Input.is_action_pressed("drift") and abs(current_speed) > SPEED/1.5 and sign(Input.get_axis("left", "right")) != 0 and is_on_floor() and accel_input == -1:
		enter_state(STATE.DRIFTING)

func drift_state(delta : float) -> void:
	handle_gravity(delta)
	handle_floor_collision()
	handle_jump()

	update_inputs(delta)

	var rotation_amount = (drift_direction*DRIFT_TURN_FORCE) + (steer_input * sign(current_speed) * TURN_SPEED * 7)
	if not is_on_floor(): rotation_amount *= 0.8
	
	rotation_degrees.y += rotation_amount * delta
	
	$Model.rotation_degrees.y = rad_to_deg(
		lerp_angle(
			deg_to_rad($Model.rotation_degrees.y), 
			deg_to_rad((drift_direction * 3) + (rotation_amount * 0.3)), 
			delta * 5
		)
	)
	
	drift_boost = move_toward(drift_boost, 3, (0.04 + abs(steer_input)) * delta)

	current_speed = lerp(current_speed, -SPEED, ACCEL_SPEED * delta)
	
	if not Input.is_action_pressed("drift"):
		enter_state(STATE.REGULAR)

	var direction := transform.basis.z
	velocity.x = direction.x * current_speed
	velocity.z = direction.z * current_speed
	
	velocity += boost_velocity
	boost_velocity = boost_velocity.lerp(Vector3.ZERO, DRIFT_BOOST_DAMPING * delta)

func handle_gravity(delta : float) -> void:
	if is_on_floor(): return
	velocity.y += GRAVITY * delta

func handle_drift_boost():
	if not is_on_floor() or drift_boost == 0: return
	boost_velocity = velocity.normalized() * snapped(drift_boost, 1) * DRIFT_BOOST_MULTIPLIER
	boost_velocity.y = 0
	steer_input = 0
	drift_boost = 0
	
func handle_jump() -> void:
	if Input.is_action_just_pressed("jump") and is_on_floor():
		velocity.y = JUMP_VELOCITY

func handle_floor_collision() -> void:
	if is_on_floor():
		velocity.y = 0
		velocity = velocity.slide(get_floor_normal())

func update_model(delta : float) -> void:
	if not is_on_floor(): 
		$Model.rotation.x = lerp_angle($Model.rotation.x, velocity.normalized().y, 4 * delta)
	else:
		var normal = get_floor_normal()
		var x_cross_normal = global_basis.x.cross(normal).normalized()
		var forward_target = global_position + x_cross_normal

		$Model.look_at(forward_target, normal)
	
	$Model/WheelTopL.rotation.y = -steer_input * 0.5
	$Model/WheelTopR.rotation.y = -steer_input * 0.5
	
	$Model/WheelTopL.rotation.x += current_speed * delta
	$Model/WheelTopR.rotation.x += current_speed * delta
	$Model/WheelBottomL.rotation.x += current_speed * delta
	$Model/WheelBottomR.rotation.x += current_speed * delta
	
	if boost_velocity.length() <= 5:
		boost_level_3l.emitting = false
		boost_level_3r.emitting = false
		boost_level_2l.emitting = false
		boost_level_2r.emitting = false
		boost_level_1l.emitting = false
		boost_level_1r.emitting = false
	if drift_boost >= 3:
		boost_level_3l.emitting = true
		boost_level_3r.emitting = true
	if drift_boost >= 2:
		boost_level_2l.emitting = true
		boost_level_2r.emitting = true
	if drift_boost >= 1:
		boost_level_1l.emitting = true
		boost_level_1r.emitting = true

func update_inputs(delta : float) -> void:
	if cur_state == STATE.DRIFTING:
		steer_input = lerp(steer_input, Input.get_axis("left", "right"), DRIFT_TURN_ACCEL_SPEED * delta)
	else:
		steer_input = lerp(steer_input, Input.get_axis("left", "right"), TURN_ACCEL_SPEED * delta)
	accel_input = Input.get_axis("up", "down")

# Function: Handle walking up stairs
func stair_step_up():
	if wish_dir == Vector3.ZERO:
		return
	if velocity.y > 0.1: return

	# 0. Initialize testing variables
	var body_test_params = PhysicsTestMotionParameters3D.new()
	var body_test_result = PhysicsTestMotionResult3D.new()

	var test_transform = global_transform				## Storing current global_transform for testing
	var distance = wish_dir * 0.1						## Distance forward we want to check
	body_test_params.from = self.global_transform		## Self as origin point
	body_test_params.motion = distance					## Go forward by current distance

	# Pre-check: Are we colliding?
	if !PhysicsServer3D.body_test_motion(self.get_rid(), body_test_params, body_test_result):

		## If we don't collide, return
		return

	# 1. Move test_transform to collision location
	var remainder = body_test_result.get_remainder()							## Get remainder from collision
	test_transform = test_transform.translated(body_test_result.get_travel())	## Move test_transform by distance traveled before collision

	# 2. Move test_transform up to ceiling (if any)
	var step_up = MAX_STEP_UP * vertical
	body_test_params.from = test_transform
	body_test_params.motion = step_up
	PhysicsServer3D.body_test_motion(self.get_rid(), body_test_params, body_test_result)
	test_transform = test_transform.translated(body_test_result.get_travel())

	# 3. Move test_transform forward by remaining distance
	body_test_params.from = test_transform
	body_test_params.motion = remainder
	PhysicsServer3D.body_test_motion(self.get_rid(), body_test_params, body_test_result)
	test_transform = test_transform.translated(body_test_result.get_travel())

	# 3.5 Project remaining along wall normal (if any)
	## So you can walk into wall and up a step
	if body_test_result.get_collision_count() != 0:
		remainder = body_test_result.get_remainder().length()

		### Uh, there may be a better way to calculate this in Godot.
		var wall_normal = body_test_result.get_collision_normal()
		var dot_div_mag = wish_dir.dot(wall_normal) / (wall_normal * wall_normal).length()
		var projected_vector = (wish_dir - dot_div_mag * wall_normal).normalized()

		body_test_params.from = test_transform
		body_test_params.motion = remainder * projected_vector
		PhysicsServer3D.body_test_motion(self.get_rid(), body_test_params, body_test_result)
		test_transform = test_transform.translated(body_test_result.get_travel())


	# 4. Move test_transform down onto step
	body_test_params.from = test_transform
	body_test_params.motion = MAX_STEP_UP * -vertical

	# Return if no collision
	if !PhysicsServer3D.body_test_motion(self.get_rid(), body_test_params, body_test_result):

		return

	test_transform = test_transform.translated(body_test_result.get_travel())

	# 5. Check floor normal for un-walkable slope
	var surface_normal = body_test_result.get_collision_normal()
	if (snappedf(surface_normal.angle_to(vertical), 0.001) > floor_max_angle):

		return

	# 6. Move player up
	var global_pos = global_position
	var step_up_dist = test_transform.origin.y - global_pos.y

	#velocity.y = 0
	global_pos.y = test_transform.origin.y
	global_position = global_pos

Okay. So you have 3 different places where the $Model’s rotation is modified.

  • In regular_state()
  • In drift_state()
  • In update_model()

Presumably, the y-rotation done in regular_state() and drift_state() is meant to further convey the user´s steering input.

Having read the script, I can see why your initial approach only modified the x-rotation. The root node (CharacterBody3D) moves while the transform of the mesh (Model) is adjusted based on the underlying terrain.

However, I still believe that utilizing look_at(), in combination with the previously described cross product, is the right approach.

Is it possible that the mesh simply hasn’t got a local position of (0,0,0) i.e. is offset from the center of the CharacterBody3D? This would cause the forward_target point to be incorrectly placed relative to the Model.

In any case, using the global_position of the Model rather than the root’s when computing the forward_target should fix this. In addition, you may use Transform3D’s method, looking_at(), if you wish to avoid overriding the y-rotation set in regular_state() and drift_state().

var normal = get_floor_normal()
var x_cross_normal = global_basis.x.cross(normal).normalized()
var forward_target = $Model.global_position + x_cross_normal # This changed.

# Calculate a new transform and apply its x-rotation to the Model
var new_transform = $Model.global_transform.looking_at(forward_target, normal)
$Model.rotation.x = new_transform.basis.get_euler().x

I hope it works this time!

2 Likes

Thank you! Thats perfect, all i had to do was make it negative and it works amazing!!

func update_model(delta : float) -> void:
	if not is_on_floor(): 
		$Model.rotation.x = lerp_angle($Model.rotation.x, velocity.normalized().y, 4 * delta)
	else:
		var normal = get_floor_normal()
		var x_cross_normal = global_basis.x.cross(normal).normalized()
		var forward_target = $Model.global_position + x_cross_normal # This changed.

		# Calculate a new transform and apply its x-rotation to the Model
		var new_transform = $Model.global_transform.looking_at(forward_target, normal)
		$Model.rotation.x = -new_transform.basis.get_euler().x 
1 Like

This topic was automatically closed 30 days after the last reply. New replies are no longer allowed.