VisibleOnScreenNotifier3D not being properly transformed with parent Node3D

Godot Version

v4.6.2.stable.mono.official [71f334935]

Question

I’ve been working on a system that will keep my Camera3D within a box that changes depending on the camera’s rotation. To do this, I have four Node3Ds that each contain a VisibleOnScreenNotifier3D, along with a MeshInstance3D to keep track of them while debugging.

The way the code works is that a manager Node3D calculates the rectangle that circumscribes the walls of the room, then transforms each of the four Node3Ds to match, like this:

public partial class OwCamBoundManager : Node3D
{
    //...

    public void Bounds_Apply()
	{
		//this can't take Bounds_recalculate as an argument or else the camera won't be able to call it properly
		var square_circum = Bounds_Recalculate();

		//update bounds
		for(var i = 0; i < 4; i ++)
		{
			var centroid = (square_circum[i] + square_circum[(i + 1) % 4]) / 2;
			my_Bound_Groups[i].GlobalPosition = new Vector3(centroid.X, your_Bound_Markers[i].GlobalPosition.Y, centroid.Y);

			my_Bound_Groups[i].GlobalRotation = new Vector3(0, GetViewport().GetCamera3D().GlobalRotation.Y, 0);

			Vector2 new_scale;
			BaseMaterial3D new_material = my_Bound_Meshes[i].GetSurfaceOverrideMaterial(0).Duplicate() as BaseMaterial3D;
			if (i % 2 == 0)
			{
				new_scale.X = square_circum[i].DistanceTo(square_circum[i + 1]);
				new_scale.Y = your_Bound_Markers[i].Scale.Z;
			}
			else
			{
				new_scale.X = your_Bound_Markers[i].Scale.X;
				new_scale.Y = square_circum[i].DistanceTo(square_circum[(i + 1) % 4]);
			}

			my_Bound_Groups[i].Scale = new Vector3(new_scale.X, float.Epsilon, new_scale.Y);

			new_material.Uv1Scale = new Vector3(new_scale.X, new_scale.Y, new_material.Uv1Scale.Z);
			my_Bound_Meshes[i].SetSurfaceOverrideMaterial(0, new_material);
		}
	}

   //...
}

Please NB:

  • I’ve omitted the code Bounds_Recalculate() because I don’t believe it’s relevant to this issue, but I can supply it if necessary.
  • The code that keeps the camera within these bounds was disabled to get these screenshots.
  • The dark purple and green markers that define the rectangle to be circumscribed (one is circled green).
  • That weird tearing: I don’t know what it is but it goes away if I pull back the camera and I was planning to ask about it some other time but I left it in in case it’s the reason my stuff isn’t working.

I’m getting behavior that indicates the notifiers aren’t being tracked properly when the camera is moved in a non-cardinal direction, i.e. not on a Vector3 of (±1f, 0f, ±1f). For example, the List that keeps track of how many are on screen using the following logic will report too many or too few: I had to move right well past the point at which the marker circled in green in the screenshot was no longer in-frame for it to report 3 instead of 4.

public partial class OwCamBoundManager : Node3D
{
    //...
    public List<VisibleOnScreenNotifier3D> Bounds_Visible = [];

	public async override void _Ready()
	{
        //...

        for (var i = 0; i < 4; i ++)
		{
			var current = my_Bound_Notifs[i];

			current.ScreenEntered += () => Bound_OnScreen(current);
			current.ScreenExited += () => Bound_OffScreen(current);
		}
    }

	private void Bound_OnScreen(VisibleOnScreenNotifier3D emitter)
	{
		if (!Bounds_Visible.Contains(emitter))
		{
			Bounds_Visible.Add(emitter);
			GD.Print(Bounds_Visible.Count);
		}
		else
		{
			throw new Exception("Camera Bound Manager attempting to add redundant bound to list.");
		}
	}

	private void Bound_OffScreen(VisibleOnScreenNotifier3D emitter)
	{
		if (Bounds_Visible.Contains(emitter))
		{
			Bounds_Visible.Remove(emitter);
			GD.Print(Bounds_Visible.Count);
		}
		else
		{
			throw new Exception("Camera Bound Manager attempting to remove absent bound from list.");
		}
	}
}

Am I not correctly applying the transforms to the notifiers? I’ve had a similar issue before, which prompted me to move toward this system, so I’m worried I’m not understanding the node correctly.

To clarify, the meshes are behaving exactly as intended, minus the tearing I mentioned.

Someone in the Godot Discord server, who asked to remain anonymous, showed me how to do this a different way! Here is the camera code they shared:

extends Camera3D

var camera_speed: float = 5.0

func _process(delta: float) -> void:
	var move_input = delta*camera_speed* Input.get_vector("move_left", "move_right", "move_up", "move_down")
	var ortho_basis: Basis = Basis.from_euler(Vector3(0, rotation.y, 0))
	var motion:Vector3 = ortho_basis * Vector3(move_input.x, 0, move_input.y)
	global_position += motion

	%MeshInstance3D.rotation.y = fmod(%MeshInstance3D.rotation.y + Input.get_axis("ui_home", "ui_end")*delta*PI*0.5, TAU)
	size = clamp(size + Input.get_axis("ui_page_down", "ui_page_up")*delta, 1, 10)

	var scr_rect: Rect2
	scr_rect = Rect2(unproject_position(%Marker3D1.global_position), Vector2.ZERO)
	scr_rect = scr_rect.expand(unproject_position(%Marker3D2.global_position))
	scr_rect = scr_rect.expand(unproject_position(%Marker3D3.global_position))
	scr_rect = scr_rect.expand(unproject_position(%Marker3D4.global_position))
	var visible_rect = get_viewport().get_visible_rect()

	%Rect.position = scr_rect.position
	%Rect.size = scr_rect.size

	if scr_rect.size < visible_rect.size:
		## Since we fit completely in the screen, undo the movement
		## and center it on the rect
		var move_offset = unproject_position(global_position - motion) - visible_rect.size*0.5
		## Correct for clamping movement
		%Rect.position -= move_offset

		move_offset.x *= 0.0 ## Again, why is this? why does x act differently from y?
		## I'm sure I most be doing something stupid, but I'm not seeing it.

		var center_pos := project_position(scr_rect.get_center() + move_offset, 0)
		global_position.x = center_pos.x
		global_position.z = center_pos.z
		return

	var offset: Vector2
	if visible_rect.end.x > scr_rect.end.x:
		offset.x = scr_rect.end.x - visible_rect.end.x
	elif visible_rect.position.x < scr_rect.position.x:
		offset.x = scr_rect.position.x - visible_rect.position.x
	if visible_rect.end.y > scr_rect.end.y:
		offset.y = scr_rect.end.y - visible_rect.end.y
	elif visible_rect.position.y < scr_rect.position.y:
		offset.y = scr_rect.position.y - visible_rect.position.y

	## Correct for clamping movement
	%Rect.position -= offset

	offset.y *= 2.0 ## Again...

	var new_pos := project_position(visible_rect.size*0.5 + offset, 0)
	global_position.x = new_pos.x
	global_position.z = new_pos.z

Here is a transcription of their scene tree:

Node3D
├── WorldEnvironment3D
├── DirectionalLight3D
├── Camera3D           📜
├── MeshInstance3D      %
│   ├── Marker3D1       %
│   ├── Marker3D2       %
│   ├── Marker3D3       %
│   ├── Marker3D4       %
│   ├── MeshInstance3D2
│   ├── MeshInstance3D3
│   ├── MeshInstance3D4
│   └── MeshInstance3D5
└── Rect                %

And here is a video they provided of their camera in action!