RichTextLabel meta_hover_* signals from a 3D GUI

Godot Version

4.7.2, dotNet

Question

I have a RichTextLabel with [url] BBCode tags and meta_hover_started / meta_hover_ended signals connected to another node (added in C#). When I test this set up in a 2D scene, it all works. I can trigger feedback when the mouse rolls over the [url]-tagged “link”. So far, so good.

The issue comes up when I put it in a 3D scene.

I’m using a SubViewport, and a packed scene with C# code very similar to the 3D GUI demo, to transform the mouse position in a mouse event and push it on the viewport. I know this is working, because I can play the 3D scene, click the link in the 3D GUI and trigger the meta_clicked signal. However, the meta_hover_* signals don’t trigger from the mouse hovering the link: they ONLY if the mouse button is held down while moving over the link.

To be clear, it works fine in 2D for hovers and clicks, and it works fine in 3D for clicks, but not hovers. And mouse_entered and mouse_exited work as expected in 3D, for the RichTextLabel as a whole. My guess is that maybe the InputEventMouseMotion events aren’t reaching the RichTextLabel node unless some behaviour in between is responding to the click, but I can’t figure out what. Anyone got any ideas?

Post the code and the scene structure

Thanks for the nudge. Here are the relevant scenes.
Instances of VerbosityNodePanel are added to VNodePanelContainer.


(Don’t worry about “Verbosity” or “VNode”, those are just named after the project.)

VNodeText extends RichTextLabel. It’s signals are connected in VerbosityNodePanel.cs, like so:

	public void SetVNode(VerbosityMgr verbosityMgr, VNode node)
	{
		CheckNodeRefs();

		_vNodeText.MetaClicked += verbosityMgr.OnMetaClick;
		_vNodeText.MetaHoverStarted += verbosityMgr.OnMetaHoverStarted;
		_vNodeText.MetaHoverEnded += verbosityMgr.OnMetaHoverEnded;
		_vNodeText.MouseEntered += verbosityMgr.OnTextHoverStarted;
		_vNodeText.MouseExited += verbosityMgr.OnTextHoverEnded;

		_vNode = node;
		_vNodeFoldable.Title = _vNode.Title;
		RefreshVNodeText();
	}

And here’s the whole of GuiPanel3d, where _UnhandledInput and MouseInputEvent are probably the relevant bits.

public partial class GuiPanel3d : Node3D
{
	bool _isMouseOverGui = false;
	Vector2? _lastEventPos = null;
	double? _lastEventTime = null;

	SubViewport? _nodeViewport;
	MeshInstance3D? _nodeQuad;
	Area3D? _nodeArea;

	[MemberNotNull(nameof(_nodeViewport), nameof(_nodeQuad), nameof(_nodeArea))]
	protected void CheckNodeRefs()
	{
		_nodeViewport ??= GetNode<SubViewport>("GuiViewport");
		_nodeQuad ??= GetNode<MeshInstance3D>("GuiQuad");
		_nodeArea ??= GetNode<Area3D>("GuiQuad/GuiArea");

		Debug.Assert(_nodeViewport is not null && _nodeQuad is not null && _nodeArea is not null, "Failed to assign a node.");
	}

	public override void _Ready()
	{
		SetProcess(false);
		CheckNodeRefs();

		_nodeArea.MouseEntered += () => SetIsMouseOverGui(true);
		_nodeArea.MouseExited += () => SetIsMouseOverGui(false);
		_nodeArea.InputEvent += MouseInputEvent;
	}

	public override void _Process(double delta)
	{
		base._Process(delta);

		if (_isMouseOverGui)
		{
			// TODO
		}
		else SetProcess(false);
	}

	protected void SetIsMouseOverGui(bool over)
	{
		_isMouseOverGui = over;
		//GD.Print($"GuiPanel3d._isMouseOverGui = {_isMouseOverGui}");
		SetProcess(_isMouseOverGui);
	}

	public override void _UnhandledInput(InputEvent inputEvent)
	{
		if (inputEvent is InputEventMouseButton || inputEvent is InputEventMouseMotion || inputEvent is InputEventScreenDrag || inputEvent is InputEventScreenTouch) return;
		CheckNodeRefs();
		_nodeViewport.PushInput(inputEvent);
	}

	private void MouseInputEvent(Node camera, InputEvent inputEvent, Vector3 worldPos3d, Vector3 normal, long shapeIdx)
	{
		CheckNodeRefs();
		double now = Time.GetTicksMsec() / 1000.0;
		double delta = (_lastEventTime is null) ? 0.0 : (now - (_lastEventTime ?? 0.0));

		//Transform3D cameraTransform = (camera is Node3D nodeCamera) ? nodeCamera.GlobalTransform : Transform3D.Identity;
		Vector2 quadSize2d = (((_nodeQuad.Mesh as QuadMesh)?.Size ?? Vector2.One).Append() * _nodeQuad.GlobalTransform.AffineInverse()).Truncate();
		Vector3 guiPos3d = worldPos3d * _nodeQuad.GlobalTransform.AffineInverse();
		Vector2 guiPos2d, translation, velocity;

		if (_isMouseOverGui)
		{
			guiPos2d = ((new Vector2(guiPos3d.X, -guiPos3d.Y) / quadSize2d) + new Vector2(0.5f, 0.5f)) * _nodeViewport.Size;
			translation = guiPos2d - (_lastEventPos ?? Vector2.Zero);
			velocity = translation / (float)delta;
		}
		else
		{
			guiPos2d = _lastEventPos ?? Vector2.Zero;
			translation = Vector2.Zero;
			velocity= Vector2.Zero;
		}

		if (inputEvent is InputEventMouseMotion motionEvent)
		{
			if (_lastEventPos is null)
			{
				motionEvent.Relative = Vector2.Zero;
				motionEvent.Velocity = Vector2.Zero;
			}
			else
			{
				motionEvent.Relative = translation;
				motionEvent.Velocity = velocity;
			}
		}
		else if (inputEvent is InputEventScreenDrag touchEvent)
		{
			if (_lastEventPos is null)
			{
				touchEvent.Relative = Vector2.Zero;
				touchEvent.Velocity = Vector2.Zero;
			}
			else
			{
				touchEvent.Relative = translation;
				touchEvent.Velocity = velocity;
			}
			touchEvent.Position = guiPos2d;
			_lastEventPos = guiPos2d;
		}
		
		if (inputEvent is InputEventMouse mouseEvent)
		{
			mouseEvent.Position = guiPos2d;
			_lastEventPos = guiPos2d;
			mouseEvent.GlobalPosition = guiPos2d;
		}

		_lastEventTime = now;
		//GD.Print($"GuiPanel3D.MouseInputEvent: worldPos3d = {worldPos3d}, guiPos2d = {guiPos2d}");
		_nodeViewport.PushInput(inputEvent);
	}
}

Transforming from screen to world to in-world GUI definitely works. Using breakpoints, I can see that the MouseInputEvent is executed and the event pushed, even when the signal isn’t fired, regardless of whether the mouse button is pressed, so it seems like the “blockage” is after this.

I’m wondering if it’s something to do with how SubViewport passes input. If I was a bit more confident with Godot, I’d take a look at source myself.

What happens if you forward all events to the subviewport from _UnhandledInput()?

Good idea, but that just sends duplicates of the non-working events.
I solved it though, answer to follow.

Got it. Turns out I had to call Viewport.NotifyMouseEntered and Viewport.NotifyMouseExited manually.

	public override void _Ready()
	{
		...
		NodeArea.MouseEntered += () => SetIsMouseOverGui(true);
		NodeArea.MouseExited += () => SetIsMouseOverGui(false);
		NodeArea.InputEvent += MouseInputEvent;
	}

	...

	protected void SetIsMouseOverGui(bool over)
	{
		_isMouseOverGui = over;
		
		// This is the fix:
		if (_isMouseOverGui) NodeViewport.NotifyMouseEntered();
		else NodeViewport.NotifyMouseExited();

		SetProcess(_isMouseOverGui);
	}

The documentation for Viewport.NotifyMouseEntered has this note:

In most cases, it is not necessary to call this function because Godot.SubViewport nodes that are children of Godot.SubViewportContainer are notified automatically. This is only necessary when interacting with viewports in non-default ways, for example as textures in Godot.TextureRect or with an Godot.Area3D.