HUD element scrolling based on player's pitch value breaks upon any roll input

Godot Version

4.8.dev2.mono

Question

I am attempting to implement an Aircraft HUD with angle-to-ground indicators that scroll based on the player’s rotation.x value (pitch). Akin to the first person view in any Ace Combat game. The markers fine… if I don’t roll. The rotation of them works great, but upon receiving a roll input, my player.rotation.x value alters downward (i.e. player.rotation.x = 120 → 60) and the HUD element no longer works for the remainder of the scene. Specifically, the HUD begins to cap between 70-80 degrees in the positive pitch axis and around 60 degrees in the negative. I suspect this is due to some error with initial values, but trying various combinatorics of them returned no notable improvements.

Controls for the player use rather standard roll/pitch/yaw inputs

The scrolling hud element uses nested control nodes, notably a clipping mask whose child is HUDRotation, which is the element that rotates, and HUDScrolling, a VSplitContainer which holds individual children nodes that make up the sprites and labels that form the full scrolling apparatus from -95 degrees to 95 degrees.

The following is the relevant code used for the HUDController in the main scene, including my troubleshooting aid comments:

func _ready():
	#Obtain child node references for scrolling, rotating, and the +90 pitch degree marker
	HudScrolling = FirstPersonHudNode.get_node("FirstPersonOnlyHud/ClippingMask/HUDRotation/HUDScrolling")
	HudRotation = FirstPersonHudNode.get_node("FirstPersonOnlyHud/ClippingMask/HUDRotation")
	hud90Degree = FirstPersonHudNode.get_node("FirstPersonOnlyHud/ClippingMask/HUDRotation/HUDScrolling/+90")
	
	#Establish baseline hudPosition.
	hudPositionInitial = HudScrolling.position.y
	#Find location of the 90 degree marker, create position factor based on said location
	hudHeight = hud90Degree.global_position.y
	hudPositionFactor = hudHeight / 90

	
func _physics_process(_delta):
	#obtain player pitch value
	var convertedRotation : float
	if player.rotation_degrees.x >= 180:
		convertedRotation = player.rotation_degrees.x - 360
		#convertedRotation = absf(fmod(player.rotation_degrees.x, 180)) - 360 no difference noticed
	else:
		convertedRotation = player.rotation_degrees.x

	#change hud position based on player pitch value, change angle based on roll value

	HudScrolling.position.y = (convertedRotation * hudPositionFactor *-1) + hudPositionInitial  
	HudRotation.rotation = angle_difference(0, player.rotation.z)
	#print(str("HudScrolling y pos: " + str(convertedRotation*hudPositionFactor)))

	#print("player.rotation.x: " + str(rad_to_deg(player.rotation.x)) + ", hudPosInit: " + str(hudPositionInitial) + ", hudScrolling.y: " + str(HudScrolling.position.y) + \
	#", convertedRotation: " + str(convertedRotation) + ", HudScrollingFactor: " + str(convertedRotation*hudPositionFactor*-1))

In the process of writing this out and copying this over, I have a suspicion that my tree hierarchy of:

-First Person Only Hud

  • Clipping Mask
    • HUDRotation
      • HUD Scrolling
        • 21 HUD textures
          • 2 Labels inside each HUD element

is then resulting in HUDRotation correctly rotating upon a roll input, but somehow this is changing the Y values required by my HUDScrolling to work. That makes me think

hudHeight = hud90Degree.global_position.y is part of what’s causing it to fail, but just using plain ol’ position.y is giving results 5000 pixels off and resulting in zero functionality. It’s strange how it tends to mostly work when pitch is the only input and then consistently fail the moment I roll, most noticeably when rolling during a straight vert ascent, resulting in a max displayed pitch (in both the inspector reading rad_to_deg(player.rotation.x) and with the on-screen tool) of around 70-80 degrees.

Well, half-fixed. Gimbal lock, gimbal lock, gimbal lock. For future generations with a similar problem, turns out the .rotate ain’t a good place to rotate. Who’d have thunk? This fixed the “roll screwing everything up” issue.

var basis = player.global_transform.basis
#calc true pitch
var forward_y = -basis.z.y
var pitch_rad = asin(forward_y)
var pitch_deg = rad_to_deg(pitch_rad)

#calc true roll
var roll_rad = atan2(basis.x.y, basis.y.y)
HudScrolling.position.y = (pitch_deg * hudPositionFactor * -1) + hudPositionInitial
HudRotation.rotation = -roll_rad

Leading to just the exciting second half of the issue - my HUDScrolling VSplitContainer node being scaled to 0.2 in X&Y gives it a pretty great shape for my HUD. Sadly, it also gives it a max angle of only 75 degrees on my rotating friend, even when pointing straight up with a (now-correctly calculated) angle of 90 degrees straight vert. Removing the scaling gives lets it go back to 90, and also fill the entire canvas so that it’s ugly and useless. Like looking into a mirror.

I could probably reduce the canvas size to fix it without needing to scale the VSplitContainer, but I’m not convinced that would actually help. I don’t seem to be able to scale the elements within the VSplitContainer to any avail. At least, not all of them at once. And the offset transforms simply make them smaller within rather large boxes, which is not the intended appearance.

Instead, I chose to do the math on how much 15 degrees out of 90 was, and see that multiplying my HudHeight by (6/5) would fix the issue. I thought perhaps maybe it’d be something fun like (1 + HUDScale.y), but trying other HUDScales and that little factor didn’t work.

If anyone has ideas that are saleable (heh) without a magic number, such that arbitrary scale value for a VSplitContainer that has more elements in it than god(ot) ever intended will still allow the functionality to all work, feel free to share your sick knowledge and/or mad scientist ideas.

On the scale thing: hudHeight = hud90Degree.global_position.y is a bad calibration. That’s absolute screen Y, not “how far 90 degrees is from the horizon,” and it gets messy once parents are scaled/rotated.

Measure the ladder in local space, then convert with the scale you actually apply when you move the node:

# marker0 / marker90 = children of HUDScrolling (0° and +90°)
var local_span = marker90.position.y - marker0.position.y
hud_position_factor = (local_span * HudScrolling.scale.y) / 90.0
HudScrolling.position.y = hud_position_initial - pitch_deg * hud_position_factor

If scale is on HUDScrolling, moving position.y is in the parent’s space, while child positions are in unscaled local space, so you need the * scale.y. That replaces the 6/5 fudge; it should track whatever scale you set.

Cleaner layout: leave HUDScrolling at scale 1, put the 0.2 on a parent wrapper, and do all scroll math on the unscaled child. Control + VSplitContainer + non-1 scale fights you; a plain Control/Node2D pitch ladder is less painful than a split container full of markers.

Agree with having HUDScaling as its own control node, so I’ve implemented that above HUDScrolling, giving me a node tree of HUDRotation → HUDScaling → HUDScrolling → Markers.
Now HUDScaling is scaled to 0.2 and HUDScrolling to 1.

I also agree the span idea would be great, but for whatever reason both y values are returning 0.

marker90.position.y and marker0.position.y (in my code obtained from hud90Degree and hud0Degree:

hud90Degree = FirstPersonHudNode.get_node(“FirstPersonOnlyHud/ClippingMask/HUDRotation/HUDScaling/HUDScrolling/+90”)
hud0Degree = FirstPersonHudNode.get_node(“FirstPersonOnlyHud/ClippingMask/HUDRotation/HUDScaling/HUDScrolling/Center”)

Both return values of 0 in output, resulting in a local_span of 0 and no movement. I’ve been having that issue since the beginning, which is why I initially went with global_position.

This issue persists even when changing the VSplitContainer to other types of containers.

In the inspector, 90 is at y= 412 and Center (0) is at y= 7828 for my FirstPersonOnlyHud scene, and the scene is not marked as “Make Local” or “Editable Children” in this scene. It is a child of the node which runs the script, HUDController. Not sure why at runtime I’m getting different results than the inspector is showing. Could it be due to timing with the ready() node, since HUDController is a parent of FirstPersonOnlyHud in the scene I’m testing in? Are the values from FirstPersonOnlyHud’s scene not yet propagated along when the ready() call is made?

edit: just tested quickly and yep, waiting a frame at the beginning of the ready() function gets span to correctly display a value of -7416.

edit2: even more confounding, hudPositionInitial = HudScrolling.position.y is returning 0. global_position returns a value ~400. Manually setting hudPositionInitial = -7831.5 allows the following code to give intended results:

ready()
...
var local_span = hud90Degree.position.y - hud0Degree.position.y
hudPositionFactor = (local_span) / 90.0
physics_process()
HudScrolling.position.y = hudPositionInitial - (pitch_deg * hudPositionFactor)  

Without even including the hudScaleY = HudScaling.scale.y anywhere in the calculation. It’s just… not grabbing the initial HudScrolling.position.y, even after having it wait frames before continuing with the ready function. Throwing in a print statement for the value of HudScrolling.position.y in both ready and in physics process reveals some true insanity:

With no awaits in the ready function, HudScrolling.position.y returns the correct value for both ready() and physics_process() consistently. However, I return 0 for my Hud90 and Hud0 degrees.

With 1 await in the ready funciton, I return the correct value for the 1st frame of phsyics_process(), 0 for ready(), and then 0 for the next 97 frames of physics check — ah, I see what’s happened. Physics_process is overwriting my values if I wait to grab them after a frame in ready.

So… janky workaround is that I can grab my initial HudScrolling.position.y before my await call, then grab my 90 and 0 degrees markers after the await call. That feels bad, to be frank. But this is technically working without any magic numbers:

	#Obtain child node references for scrolling, rotating, and the +90 pitch degree marker
	HudScrolling = FirstPersonHudNode.get_node("FirstPersonOnlyHud/ClippingMask/HUDRotation/HUDScaling/HUDScrolling")
	HudScaling = FirstPersonHudNode.get_node("FirstPersonOnlyHud/ClippingMask/HUDRotation/HUDScaling")
	HudRotation = FirstPersonHudNode.get_node("FirstPersonOnlyHud/ClippingMask/HUDRotation")
	hud90Degree = FirstPersonHudNode.get_node("FirstPersonOnlyHud/ClippingMask/HUDRotation/HUDScaling/HUDScrolling/+90")
	hud0Degree = FirstPersonHudNode.get_node("FirstPersonOnlyHud/ClippingMask/HUDRotation/HUDScaling/HUDScrolling/Center")

	hudPositionInitial = HudScrolling.position.y
	await get_tree().process_frame

	var hudScaleY = HudScaling.scale.y
	var local_span = hud90Degree.position.y - hud0Degree.position.y
	hudPositionFactor = (local_span) / 90.0

Then in Physics Process:
HudScrolling.position.y = hudPositionInitial - (pitch_deg * hudPositionFactor)

Yeah, that’s normal Control pain. Containers haven’t laid out yet in _ready, so child position is still 0. await get_tree().process_frame (or call_deferred) is the right idea.

What bites you is _physics_process running in that gap with a bad/zero factor and writing HudScrolling.position.y before you finish calibrating. So it isn’t that hudPositionInitial “won’t read”. You’re overwriting the real layout position first.

Cleaner than splitting the grabs:

var _hud_ready := false
func _ready() -> void:
	# get node refs...
	await get_tree().process_frame
	var local_span = hud90Degree.position.y - hud0Degree.position.y
	hudPositionFactor = local_span / 90.0
	hudPositionInitial = HudScrolling.position.y
	_hud_ready = true
func _physics_process(_delta: float) -> void:
	if not _hud_ready:
		return
	HudScrolling.position.y = hudPositionInitial - pitch_deg * hudPositionFactor

With scale on HUDScaling and HUDScrolling at 1, you can drop * scale.y; parent space and child locals match for the scroll math.

If you want zero awaits:
call_deferred(“_calibrate_hud”) and do the span/hudPositionInitial work there, same _hud_ready gate.

Your workaround works; the flag just makes the “don’t touch position until layout exists” part explicit.