Help with continuous look-at rotation that maintains horizontal orientation without gimbal lock

Godot Version

4.4.1 stable

Question

Hello, I’m working on a simple test project where one node orbits around a target node using a sphere curves motion, and continuously looks at the target. I’m encountering a classic problem that I can’t seem to solve: achieving smooth, continuous rotation that maintains horizontal orientation without gimbal lock.

The Setup:

One node orbits around a target node in a complex 3D spiral pattern
The orbiting node should always look at the target
The node should maintain horizontal orientation (its “up” vector should stay aligned with world Y-axis)

The Problem:

I have two methods in my update_continuous_look_at() function, but each has issues:

Method 1 (Current implementation):

method1-ezgif.com-optimize

var target_direction = (target.global_transform.origin - global_transform.origin).normalized()
var current_forward = current_rotation * (Vector3.FORWARD)
var delta_rotation = get_rotation_between_directions(current_forward, target_direction)
current_rotation = delta_rotation * current_rotation
current_rotation = current_rotation.normalized()
global_transform.basis = Basis(current_rotation)
func get_rotation_between_directions(from: Vector3, to: Vector3) -> Quaternion:
	from = from.normalized()
	to = to.normalized()
	
	if from.is_equal_approx(to):
		return Quaternion.IDENTITY
	
	if from.is_equal_approx(-to):
		# Find perpendicular axis for 180-degree rotation
		var perpendicular = Vector3.UP
		if abs(from.dot(perpendicular)) > 0.9:
			perpendicular = Vector3.RIGHT
		var axis = from.cross(perpendicular).normalized()
		return Quaternion(axis, PI)
	
	# General case: calculate rotation axis and angle
	var axis = from.cross(to).normalized()
	var angle = from.angle_to(to)
	
	return Quaternion(axis, angle)

:white_check_mark: Smooth, continuous rotation without gimbal lock
:cross_mark: Doesn’t maintain horizontal orientation - the node can roll/tilt

Method 2 (Commented out):

method2-ezgif.com-optimize

var target_direction = (target.global_transform.origin - global_transform.origin).normalized()
global_transform.basis = Basis.looking_at(target_direction)

:white_check_mark: Maintains horizontal orientation perfectly
:cross_mark: Suffers from gimbal lock - when the node passes directly above or below the target, it flips/snaps

What I’m trying to achieve:

  • Continuous, smooth rotation without sudden flips or snaps
  • The node’s “up” vector should always point toward world Y+ (maintain horizontal level)
  • No gimbal lock when the node passes above or below the target
  • When the node is directly above the target looking down, its “forward” direction should naturally rotate without the node rolling

I’ve tried various approaches including quaternion SLERP, decomposing rotations into yaw/pitch components, and different basis construction methods, but I always end up with either gimbal lock or loss of horizontal orientation.
Has anyone solved this problem before? Any suggestions for maintaining both continuous rotation AND horizontal orientation?

Here’s my minimal Godot project for reference: https://drive.google.com/file/d/1a4Pf0cUqFLXx47PMfKQX8AzYpbQkbGN5/view?usp=sharing

Thank you for any help!

Small correction: The node’s up vector should point to Y+, I meant it should stay parallel to the Y-axis (either +Y or -Y). The key is maintaining horizontal orientation without any roll, not necessarily pointing up.