Help getting Camera to Rotate Using the Shortest Path

Godot Version

Godot 4.7 beta 1

The Problem

I’m currently working on a Zelda 64-style third-person camera. Right now I’m having trouble getting the SpringArm to rotate properly when recentering the camera, or “soft targeting” as we call it. Most of the time it works fine, but there are certain angles where it will rotate the longest path instead of the shortest path.

Here is the full code that handles the main camera behavior.

extends Node


@export var toggle_invert_horiz: bool = true
@export var toggle_invert_verti: bool = false
@export_range(1, 200, 0.1) var fov_targeting: float = 60.0
@export_range(1, 200, 0.1) var fov_follow: float = 75.0
@export_range(0.1, 10, 0.1) var cam_rotate_spd: float = 3:
	set(value):
		if value == 0.0:
			value = 1.0
		cam_rotate_spd = value
@export var cam_lerp_weight_following: float = 0.15:
	set(value): cam_lerp_weight_following = clampf(value, 0.0, 1.0)
@export var cam_lerp_weight_targeting: float = 0.20:
	set(value): cam_lerp_weight_targeting = clampf(value, 0.0, 1.0)
@export var cam_vertical_offset: Vector3 = Vector3(0, 1, 0) ## The offset to allow the player to be better positioned on-screen.


@onready var arm: SpringArm3D = $"../SpringArm3D"
@onready var cam: Camera3D = $"../Cam"



var midpoint_global_position: Vector3

var cam_rotate_dir: Vector3
var cam_state: CameraState = CameraState.FOLLOW

var input_stick_r: Vector2:
	get: return Global.player.input.r_stick_input
var invert_horiz: int:
	get: return 1 if toggle_invert_horiz else -1
var invert_verti: int:
	get: return 1 if toggle_invert_verti else -1


enum CameraState { ## Used to control the behavior of the Main Camera.
	FOLLOW, ## The default state. When the player is idling or moving without camera movement input.
	MANUAL, ## When the camera is being moved.
	TARGETING_SOFT, 
	TARGETING_HARD,
}







func _process(delta: float) -> void:
	cam_state = CameraState.FOLLOW

	if input_stick_r != Vector2.ZERO:
		cam_state = CameraState.MANUAL
	if Global.player.input.l_trigger_input != 0:
		cam_state = CameraState.TARGETING_SOFT

	arm.spring_length = clampf(arm.spring_length, 2, 100)

	match cam_state:


		CameraState.FOLLOW:
			arm.global_position = lerp(arm.global_position, Global.player.global_position + cam_vertical_offset, cam_lerp_weight_following)
			cam.look_at(Global.player.global_position)
			cam.fov = lerpf(cam.fov, fov_follow, 0.1)


		CameraState.MANUAL:
			arm.global_position = lerp(arm.global_position, Global.player.global_position + cam_vertical_offset, cam_lerp_weight_following)
			cam.look_at(Global.player.global_position)
			cam.fov = lerpf(cam.fov, fov_follow, 0.1)

# TODO: Look into quaternions for a solution to replace this. May be necessary to avoid camera going into weird directions...
			arm.global_rotation.x += input_stick_r.normalized().y * input_stick_r.length() * invert_horiz * cam_rotate_spd * delta
			arm.global_rotation.y += input_stick_r.normalized().x * input_stick_r.length() * invert_verti * cam_rotate_spd * delta
			arm.global_rotation.z = 0.0

			arm.global_rotation.x = clampf(arm.rotation.x, deg_to_rad(-75), deg_to_rad(30))


		CameraState.TARGETING_SOFT:
			arm.global_position = lerp(arm.global_position, Global.player.global_position + cam_vertical_offset, cam_lerp_weight_targeting)

			arm.global_rotation.x = lerpf(arm.global_rotation.x, -0.3, cam_lerp_weight_targeting)
			arm.global_rotation.y = lerpf(arm.global_rotation.y, Math.get_shortest_rotation(Global.player.rotation.y), cam_lerp_weight_targeting)
			arm.global_rotation.z = 0.0

			cam.look_at(Global.player.global_position)
			cam.fov = lerpf(cam.fov, fov_targeting, 0.1)


		CameraState.TARGETING_HARD:
			arm.global_position = lerp(arm.global_position, Global.player.global_position + cam_vertical_offset, cam_lerp_weight_targeting)

			midpoint_global_position = Global.player.get_node("LambStuff/ObjMidpoint").global_position

			cam.look_at(midpoint_global_position)
			cam.fov = lerpf(cam.fov, fov_targeting, 0.1)

The isolated problem part is here:

		CameraState.TARGETING_SOFT:
			arm.global_position = lerp(arm.global_position, Global.player.global_position + cam_vertical_offset, cam_lerp_weight_targeting)

			arm.global_rotation.x = lerpf(arm.global_rotation.x, -0.3, cam_lerp_weight_targeting)
			arm.global_rotation.y = lerpf(arm.global_rotation.y, Math.get_shortest_rotation(Global.player.rotation.y), cam_lerp_weight_targeting)
			arm.global_rotation.z = 0.0

			cam.look_at(Global.player.global_position)
			cam.fov = lerpf(cam.fov, fov_targeting, 0.1)

The issue seems to be in the arm.global_rotation.y = ... part. Sometimes it uses the short path, sometimes it rotates the long path. For example, if we’re at 100° and I want it to rotate to 90°, if the conditions are right, instead of rotating -10° it will rotate past 360°, return to 0°, and to 90°. (That’s an exaggerated example, but it gets the point across, I hope.)

The Math.get_shortest_rotation() function is here:

func get_shortest_rotation(angle: float) -> float:
	var new_angle: float = fmod(angle, TAU)
	if abs(new_angle) > PI:
		if new_angle > 0.0:
			new_angle -= TAU
		else:
			new_angle += TAU
	return new_angle

Any ideas on how to fix this?

Don’t lerp individual euler angles. Instead, slerp() the whole basis.

I did try slerp, but the shortest path problem persisted and also it would rotate in very odd ways on the X axis.

What did you slerp between?

		CameraState.TARGETING_SOFT:
			arm.global_position = lerp(arm.global_position, Global.player.global_position + cam_vertical_offset, cam_lerp_weight_targeting)

			arm.global_rotation = arm.global_rotation.slerp(Global.player.global_rotation, cam_lerp_weight_targeting)

			#arm.global_rotation.x = lerpf(arm.global_rotation.x, -0.3, cam_lerp_weight_targeting)
			#arm.global_rotation.y = lerpf(arm.global_rotation.y, Math.get_shortest_rotation(Global.player.rotation.y), cam_lerp_weight_targeting)
			#arm.global_rotation.z = 0.0

			cam.look_at(Global.player.global_position)
			cam.fov = lerpf(cam.fov, fov_targeting, 0.1)

I also tried doing arm.global_rotation.y = arm.global_rotation.slerp(Global.player.global_rotation, 0.1).y when I tried slerp earlier and it did not fix it either.

s64_cam_2

You’re slerping euler rotation vectors. That doesn’t make much sense You need to slerp the bases.

2 Likes

I didn’t know there was a difference between Euler axes and the basis. I will look into this later tonight, thank you!

1 Like

Other than using basis as stated, angles should use lerp_angle instead of lerpf

1 Like

This seems to have fixed the issue (thank you!!), but I’m going to look into what basis is and how I can use it to accomplish the same thing as rotating it.

		arm.global_rotation.x = lerp_angle(arm.global_rotation.x, -0.30, cam_lerp_weight_targeting)
		arm.global_rotation.y = lerp_angle(arm.global_rotation.y, Global.player.global_rotation.y, cam_lerp_weight_targeting)
		arm.global_rotation.z = 0.0

I’ve watched a few videos on Transform3D and read the documentation yet I’m really at a loss at what basis is and how to use it. Are the XYZ properties of Transform3D basis separate from the Node3D properties? Are they like an offset added onto the Node3D properties? If not, what is the benefit to using transforms over just changing the Node3D properties? How do you use transforms in code?

Basis in a collection of three vectors that represent x, y and z axes of object’s local coordinate system. Think of it as those three red, green and blue arrows you see in editor gizmos when in local transform mode.

Basis is the best way to unambiguously represent object’s orientation in 3D space, which euler angles are incapable of. It’s also easy to mentally visualize the orientation using the image of those 3 axes, aka basis vectors.

2 Likes

Is my understanding correct here, that the original OP issue is a gimbal lock ?
And that Basis is always mathematically well-defined, in contrast to Euler?

1 Like

Not necessarily the gimbal lock. There’s a number of problems with interpolating euler angles, gimbal lock is only one of them. Bases are far easier and more elegant to work with, especially if you need to handle any general case. You can get away with eulers when you only rotate around one or two fixed axes, but once arbitrary aiming enters the picture, they become very complicated to handle to the point of being useless.
And yeah, bases mathematically fully define the orientation while 3 euler angles don’t have enough “bandwidth” to store it and their proper functioning depends on additional “outside” information in the form of rotation order.

4 Likes
		CameraState.TARGETING_SOFT:
			arm.global_position = lerp(arm.global_position, Global.player.global_position + cam_vertical_offset, cam_lerp_weight_targeting)

			arm.global_rotation += arm.global_transform.basis.y * 2 * delta

# NOTE: Thank you to @gertkeno for helping here! https://forum.godotengine.org/t/help-getting-camera-to-rotate-using-the-shortest-path/138307/8
			#arm.global_rotation.x = lerp_angle(arm.global_rotation.x, cam_recenter_angle_x, cam_lerp_weight_targeting)
			#arm.global_rotation.y = lerp_angle(arm.global_rotation.y, Global.player.global_rotation.y, cam_lerp_weight_targeting)
			#arm.global_rotation.z = 0.0

			cam.look_at(Global.player.global_position)
			cam.fov = lerpf(cam.fov, fov_targeting, 0.1)

This is kinda approaching what I’m looking to do, but I’m still having a lot of trouble wrapping my head around this. From what I can tell, the arm.global_transform.basis.y is telling arm.global_rotation to rotate around the Y axis at a speed of 2. There’s also a warble to the rotation that I don’t understand what could be causing it.

s64_cam_3

I’m having trouble figuring out how to use lerp (or slerp) with it.

#arm.global_rotation.y = arm.global_transform.basis.y.slerp(Global.player.global_rotation, 0.01).y

You should abandon thinking in terms or “rotation”. Think only in terms of orientation. The current basis of your node’s transform defines its current orientation in 3d space. When you want to change that orientation, you construct a basis that represents that new orientation and just slerp between those two bases. No “rotation” involvement at all and you never touch any “rotation” property in the process.

var basis_wanted = arm.global_transform.looking_at(...).basis # or some other method of constructing the wanted basis

...

arm.global_basis = arm.global_basis.slerp(basis_wanted, ...) 

That’s all there is to it.

2 Likes
var basis_wanted: Basis = arm.global_transform.looking_at(Global.player.global_basis.x).basis
arm.global_basis = arm.global_basis.slerp(basis_wanted, 0.1)

s64_cam_4

Would something like var basis_wanted: Basis = arm.global_transform.looking_at(Vector3.FORWARD.rotated(Vector3.UP, Global.player.global_basis.x.y)) be closer to being correct?

What exactly you want to do? What triggers the basis re-orientation?

The goal is to rotate the SpringArm (and its Camera) directly behind the player character’s back, like recentering the camera in a third-person action-adventure game. This happens when pressing and holding the left trigger button.


(the RemoteTransform3D is connected to the Cam node)

If you want to better understand what a change of basis is, I’d highly recommend reading up on at least the basics of linear algebra. For visuals, 3Blue1Brown on YouTube has a series that beautifully explains the subject. Freya Holmer is another good one that’s more game dev focused. It’s super useful stuff that will solve a lot of problems for you.

1 Like

If arm’s pitch stays the same then the best way is to manually construct the wanted basis vectors using cross products.

  • y stays the same (as pitch is retained)
  • x is normalized cross product of y and the vector that points away from the player (presumably player’s global basis z)
  • z is a normalized cross product of x and y