Apply_torque on two axes, going insane :(

Upon further inspection, I appear to have missed the fact that your wheel’s default transform has the wheel lying on its side instead of standing on its circumference (which I expected). This means that the fundamental reference frame – which are the axes of the wheel – are different between us. I believe that is why you have yet to make it work. We have not been talking about the same axes.

I realize now that this is plainly visible in the GIF of your original post.

I suggest you rotate the mesh and collision shape of your wheel such that the wheel is seen standing in the editor. The x-axis of the physics body should point right, the y-axis up, and the z-axis backwards. You can read more about Godot’s axes and their directionality here.

In addition, I think I might have confused myself after trying to explain the concept of space and the difference between local and global space.

As you might recall, I said that:

I just want to make clear that what I actually meant to say was: the space of the object, and the space of the world. This goes for the related code as well.

I fucked up! Sorry about that.


Aside from this unfortunate miscommunication, I also looked at the code.

I believe I have revised the script such that it works as you want.

Forum_SelfBalancingWheel_Demo

I’ve made some changes to the script but the overall approach is still the same. The script can be seen at the end of this post.

Walkthrough of changes

Change #1: Variable naming
Your current code defines a OFFSET_ANGLE that is used as an angular threshold in related if-statements. However, since the new approach uses the dot product to compute the uprightness, calling it an angle is misleading. Therefore, I’ve opted to rename it to UPRIGHT_THRESHOLD.
I’m not too satisfied with the term “uprightness” so if you think of a better way to communicate the concept, feel free to rename stuff to that.

Change #2: Upright-related conditions
Currently, you have a few conditions in your inputDirection if-block. The first is

if uprightness < TARGET_ANGLE or uprightness > TARGET_ANGLE:

which I have changed to

if abs(uprightness) > UPRIGHT_TRESHOLD:

I think you’re forgetting that the value range of uprightness that denotes an upright orientation is around 0. The condition should only evaluate to true when a certain threshold is crossed i.e. when the wheel is no longer upright enough. This threshold is controlled with UPRIGHT_THRESHOLD. Since uprightness is negative when the wheel falls on its right side, we use its absolute value. We don’t care about left/right, we just want the normalized distance from an upright orientation.

The other condition is

if uprightness <= TARGET_ANGLE + OFFSET_ANGLE and uprightness >= TARGET_ANGLE - OFFSET_ANGLE:

which has been changed to

if abs(uprightness) < UPRIGHT_TRESHOLD:

This is very similar to the other change, we just use < instead of >. We do this because you, presumably, only want the wheel to rotate when it is upright enough.

The code that computes the forward vector is also changed in response to the axis-misunderstanding.

	# Create a forward-vector from the object's x-axis and the global y-axis
	var object_x = global_basis.x
	var world_y = Vector3.UP
	var forward_vector = world_y.cross(object_x).normalized()

Finally, I decided to not use the PID controller. I don’t have much experience with PID controllers so I don’t know whether it’s actually a good use case for it. Instead, the balancing torque is now scaled by the uprightness as well as an @export variable named BALANCING_TORQUE_STRENGTH.

apply_torque(forward_vector * uprightness * BALANCE_TORQUE_STRENGTH)
extends RigidBody3D

@onready var pivot = $SpringArmPivot
@onready var arm = $SpringArmPivot/SpringArm3D
@export var sens = 0.005

var _pid1D := Pid1D.new(1, 1, 1)
var pid
@export_range(0, 1) var UPRIGHT_TRESHOLD = 0.2
@export_range(0,45) var TARGET_ANGLE = 0
@export var BALANCE_TORQUE_STRENGTH = 10.0
@export var ACCELERATION_STRENGTH = 3.0

var mouse_captured: bool
var input_direction : Vector2 = Vector2(0, 0)

func _input(event):
	if event is InputEventMouseMotion:
		pivot.rotate_y(-event.relative.x * sens)
		arm.rotate_x(-event.relative.y * sens)

func _ready() -> void:
	can_sleep = false
	Input.set_mouse_mode(Input.MOUSE_MODE_CAPTURED)
	mouse_captured = true

func _physics_process(delta: float) -> void:
	# Put the camera near the object
	pivot.global_transform.origin = global_transform.origin
	
	# Create a forward-vector from the object's x-axis and the global y-axis
	var object_x = global_basis.x
	var world_y = Vector3.UP
	var forward_vector = world_y.cross(object_x).normalized()
	
	# Compute the normalized distance from an 'upright' orientation
	var uprightness = object_x.dot(world_y)
	pid = _pid1D.update(uprightness - (TARGET_ANGLE / 90.0), delta) * 3
	
	# Get direction input from player
	input_direction = Input.get_vector("steer_left", "steer_right", "brake", "accelerate")
	var debug_direction = ""
	if input_direction:
		# If the wheel is far from an upright position, apply balancing torque.
		if abs(uprightness) > UPRIGHT_TRESHOLD:
			apply_torque(forward_vector * uprightness * BALANCE_TORQUE_STRENGTH)
			debug_direction = "rising"
		# If we're upright we roll forward
		if abs(uprightness) < UPRIGHT_TRESHOLD:
			if input_direction.y != 0:
				# Apply torque on the x-axis scaled by the user's input
				apply_torque(object_x * ACCELERATION_STRENGTH * input_direction.y)
				debug_direction = "forward"
	
	# DELETE THIS - it's project-specific debugging code.
	DebugGeo.draw_debug_line(delta, global_position, global_position + forward_vector, 0.01, Color.BLUE)
	DebugGeo.draw_debug_line(delta, global_position, global_position + basis.x * (0.15 + uprightness), 0.01, Color.RED)


This better work for you as well! Let me know if anything is incorrect or you have any questions.

1 Like