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?




