Mathematically find orthographic camera bound knowing size, angle, and position

Godot Version

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

Question

I have an orthographic Camera3D set up according to the typical pattern in which a Node3D acts as a pivot point and is parented to a SpringArm3D, which is parented to a Camera3D. The Camera3D is allowed to move only on the XZ plane.

I’d like to make the Camera3D unable to show the outside of my rectangular room. Is there any way to calculate where I can put a CollisionShape3D to do this? I’d like to set the bounds programmatically, rather than guessing and checking every time I decide the Camera3D.Size ought to be different.

Here’s a diagram explaining what I’d like to solve for:

The pivot point is directly above the player character, and the camera should be unable to show anything to the left of the stop sign (which would be below it from the camera’s POV).* The Camera3D.Size can be either of two predetermined values. The SpringArm3D.SpringLength is determined mathematically from the desired height and angle of the camera, rather than being input before runtime.

* Technically I’d like to be able to prevent it showing anything outside any side of the room, but one thing at a time.

If the stop-sign point is at the same height as the camera, that gap is:
float margin = camera.Size * 0.5f / Mathf.Sin(fixedAngleRadians);

So the camera’s horizontal distance to the wall should stay >= margin. That’s your ? m.
–Size is the full ortho height, so half of it sits on each side of the look axis. Sin shows up because that half-extent is along the camera’s up axis, which is tilted.

If the wall top isn’t at Fixed_Height, include the height difference:

float margin = (camera.Size * 0.5f - (wallTopY - fixedHeight) * Mathf.Cos(fixedAngleRadians))
	/ Mathf.Sin(fixedAngleRadians);

For a CollisionShape3D, take the room rect on XZ and shrink it by that margin on the wall the camera looks toward. Side walls use the horizontal half-extent instead:
float halfWidth = camera.Size * 0.5f * aspect;
Shrink those sides by halfWidth.

If you clamp the pivot instead of the camera, shift the look-axis margin by the spring length (Fixed_Height / Tan(Fixed_Angle)), since the camera sits that far in front of the pivot.

I might be doing something wrong, but this equation isn’t getting me the result I need for the first use case (the one where the camera is moving backward, like in the diagram). Here’s my code:

float margin = (myCamera.Size / 2f - (your_RoomPoints[0].GlobalPosition.Y - Fixed_Height) * Mathf.Cos(Fixed_Angle)) / Mathf.Sin(Fixed_Angle);
float pos_south = your_RoomPoints[3].GlobalPosition.Z + margin;

yourBounds_Meshes[3].GlobalPosition = new Vector3(0, GlobalPosition.Y, pos_south);
//this is run from the pivot point, so using its own GlobalPosition.Y is fine

your_Roompoints is a List of Marker3Ds that I added to basically be the stop sign from the diagram, marking the corners of the (cardinally aligned) room. The one at index 3 is the southeast corner of the room: [32, 7, 32]. The camera is at GlobalPosition.Y = 24.85.

The result of this calculation is margin = 27.3, putting the south CollisionShape3D at GlobalPosition.Z = 59.3. From a quick guess and check, it looks like the output I need is around margin = 7.5.

looking at your diagram again: circle over the player is the pivot, triangle is the camera, and ? m is camera, that cyan square. So that gap is the camera margin, not the spring.

Also I had the height term signed wrong earlier. Stop sign sits on the wall top (below Fixed_Height), so it should be:

float wallTopY = your_RoomPoints[3].GlobalPosition.Y;
float marginCam = (myCamera.Size * 0.5f
	- (Fixed_Height - wallTopY) * Mathf.Cos(Fixed_Angle))
	/ Mathf.Sin(Fixed_Angle);

(wallTopY - Fixed_Height) the other way around makes the number too big; that lines up with your 27.3 vs the ~7.5 you found by hand.

And for the south wall at Z = 32, subtract the margin, don’t add it:

float pos_south = your_RoomPoints[3].GlobalPosition.Z - marginCam;

+ margin putting it at 59 is outside the room.

One more thing: even if this script lives on the pivot, ? m is still a camera distance. Either place the bound from the camera, or if the collider has to stop the pivot, do marginCam + Fixed_Height / Tan(Fixed_Angle). Mixing those is another way to blow past ~7.5.

(And Fixed_Angle in radians, just in case.)

I’m not done yet, and I have to log off about now, but I wanted to let you know ASAP that this might solve an issue I’ve been going crazy over for like a year on and off. :partying_face:

Like I said, it’s not complete, and I might have some other questions, but here’s what it looks like now. The code:

private void Update_Bounds()
{
    //set the sizes of the bounds
    for (int i = 0; i < 4; i ++)
    {
        float scale_x = 1f / 65536f;
        float scale_z = 1f / 65536f;

        switch(i)
        {
            case 0:

                scale_z = your_RoomPoints[3].GlobalPosition.Z - your_RoomPoints[0].GlobalPosition.Z;
                break;

            case 1:

                scale_x = your_RoomPoints[0].GlobalPosition.X - your_RoomPoints[1].GlobalPosition.X;
                break;

            case 2:

                scale_z = your_RoomPoints[2].GlobalPosition.Z - your_RoomPoints[1].GlobalPosition.Z;
                break;

            case 3:

                scale_x = your_RoomPoints[3].GlobalPosition.X - your_RoomPoints[2].GlobalPosition.X;
                break;

        }

        BoxShape3D new_shape = new()
        {
            Size = new Vector3(scale_x, 1, scale_z)
        };
        yourBounds_Colliders[i].Shape = new_shape;
    }

//set the initial positions of the bounds
float wall_height = yourBounds_Colliders[0].GlobalPosition.Y;
float margin = (myCamera.Size / 2 - (GlobalPosition.Y - wall_height) * Mathf.Cos(Fixed_Angle)) / Mathf.Sin(Fixed_Angle) / 2;
//I'm still fuzzy on this math, but the margin was coming out eerily close to twice ~7.5, so I added another divide by two

yourBounds_Colliders[1].GlobalPosition = new Vector3
(
    (your_RoomPoints[0].GlobalPosition.X - your_RoomPoints[1].GlobalPosition.X) / 2,
    yourBounds_StaticBody.GlobalPosition.Y,
    your_RoomPoints[0].GlobalPosition.Z - margin
);

yourBounds_Colliders[3].GlobalPosition = new Vector3
(
    (your_RoomPoints[3].GlobalPosition.X - your_RoomPoints[2].GlobalPosition.X) / 2,
    yourBounds_StaticBody.GlobalPosition.Y,
    your_RoomPoints[2].GlobalPosition.Z - margin
);

yourBounds_StaticBody.GlobalPosition = new Vector3(0, GlobalPosition.Y, 0);
}

And here’s a quick video of it in action, and again at 30° to prove it’s scalable:

Part of the reason I’m giving you a progress update instead of waiting until the whole thing is implemented is that I can’t get the CharacterBody3D (I changed the pivot point from Node3D to that so it can use MoveAndSlide() to prevent it from going OOB) to actually collide with the StaticBody3D. The SpringArm3D works fine (a little jittery, but that’s a for-later issue), but the pivot point can slip right past.

I was asking for help in the Discord and didn’t get anywhere tonight, so I was wondering if you know what might be causing that? The CharacterBody3D and StaticBody3D are only on collision layer and mask 32, and the SpringArm3D is only on mask 32.

Glad the margin is in the ballpark. Two likely reasons MoveAndSlide walks through the walls while the SpringArm still stops:

  1. Paper-thin boxes
    You start with scale_x/z = 1/65536 and only thicken one axis. The other stays ~0, so the wall is a film. SpringArm rays can still hit it; a CharacterBody often tunnels. Give walls real thickness on the inward axis (e.g. 0.51), not 1/65536.

  2. You move the StaticBody after placing the shapes

yourBounds_Colliders[1].GlobalPosition = ...
yourBounds_Colliders[3].GlobalPosition = ...
yourBounds_StaticBody.GlobalPosition = new Vector3(0, GlobalPosition.Y, 0); // moves the kids

That last line shifts the colliders you just placed. Set the StaticBody position first, then set each CollisionShape3D (prefer Local position).

Also check: CharacterBody has its own CollisionShape3D, both bodies on layer 32, and the CharacterBody mask includes 32 (StaticBody mask can be empty). SpringArm only proves ray hits, not that MoveAndSlide is using a solid shape.

On the / 2 in margin: if it’s consistently 2x too big, wall_height is probably wrong (e.g. after moving the StaticBody to camera Y). Use the stop-sign / room marker Y, and drop the extra / 2 once that’s correct:

float margin = (myCamera.Size * 0.5f - (Fixed_Height - wallTopY) * Mathf.Cos(Fixed_Angle))
	/ Mathf.Sin(Fixed_Angle);

So here’s the good news: the collision problem was happening because…

(your_RoomPoints[3].GlobalPosition.X - your_RoomPoints[2].GlobalPosition.X) / 2,

I forgot that you add numbers to average them, not subtract :man_facepalming:. The thickness of the CollisionShape3Ds wasn’t the problem (although that thin is certainly overkill).

The bad news is I cannot get the bounds to work properly with the equation. I did realize that it’s probably better to have Fixed_Height be the distance between the camera and the floor, so I’ve made some adjustments for that, but it’s still overshooting the margin. I copied in your formula and only changed the variable names, so I’m not sure what’s up.

This is the entire code of Initialize_Nodes(), which is run at the end of the _Ready() method, with nothing left out except a custom exception for if some node-linking signals don’t fire, which has never happened:

private void Initialize_Nodes()
{
    //reparent the node to the first instance of the player character node
    Reparent(Player_Overworld_CharacterBody3D.ourPlayers[0]);

    //move pivot point directly above player
    Vector3 pos_player = GetParent<Player_Overworld_CharacterBody3D>().GlobalPosition;
    GlobalPosition = new Vector3
    (
        pos_player.X,
        your_RoomPoints[0].GlobalPosition.Y + Fixed_Height_Above_Wall,
        pos_player.Z
    );
    GlobalRotation = new Vector3(0, 0, 0);

    //set the properties of the springarm and camera
    mySpringArm.SpringLength = (GlobalPosition.Y - your_RoomPoints[4].GlobalPosition.Y) / MathF.Tan(Fixed_Angle);
    myCamera.GlobalRotate(Vector3.Left, Fixed_Angle);

    Update_Bounds();
}

private void Update_Bounds()
{
    //set the sizes of the bounds
    for (int i = 0; i < 4; i ++)
    {
        float scale_x = 1f / 16f;
        float scale_z = 1f / 16f;

        switch(i)
        {
            case 0: scale_z = your_RoomPoints[3].GlobalPosition.Z - your_RoomPoints[0].GlobalPosition.Z; break;
            case 1: scale_x = your_RoomPoints[0].GlobalPosition.X - your_RoomPoints[1].GlobalPosition.X; break;
            case 2: scale_z = your_RoomPoints[2].GlobalPosition.Z - your_RoomPoints[1].GlobalPosition.Z; break;
            case 3: scale_x = your_RoomPoints[3].GlobalPosition.X - your_RoomPoints[2].GlobalPosition.X; break;
        }

        BoxShape3D new_shape = new() {Size = new Vector3(scale_x, 1, scale_z)};
        yourBounds_Colliders[i].Shape = new_shape;
    }

    //set the initial positions of the bounds
    float wall_top_y = yourBounds_Colliders[0].GlobalPosition.Y;
    float cam_height_above_floor = GlobalPosition.Y - your_RoomPoints[4].GlobalPosition.Y;
    //your_RoomPoints 0-3 are the NE/NW/SW/SE wall top corners, 4 is the center of the floor
    float margin = (myCamera.Size * 0.5f - (cam_height_above_floor - wall_top_y) * Mathf.Cos(Fixed_Angle)) / Mathf.Sin(Fixed_Angle);]

    yourBounds_Colliders[3].GlobalPosition = new Vector3
    (
        (your_RoomPoints[2].GlobalPosition.X + your_RoomPoints[3].GlobalPosition.X) / 2,
        GlobalPosition.Y,
        your_RoomPoints[2].GlobalPosition.Z - margin
    );
}

The resulting behavior has margin about 7 less than what it should be when Fixed_Angle = 45, and about 12.5 m less when Fixed_Angle = 30.

Nice catch on the average.

On the margin still coming up short: two things in that snippet.

  1. wall_top_y shouldn’t come from yourBounds_Colliders[0].GlobalPosition.Y. Use the room marker (your_RoomPoints[0] / stop-sign Y). And don’t mix cam_height_above_floor (a relative height) with an absolute Y in the same subtraction. Both sides need to be world Y:
float wallTopY = your_RoomPoints[0].GlobalPosition.Y;
float marginCam = (myCamera.Size * 0.5f
	- (GlobalPosition.Y - wallTopY) * Mathf.Cos(Fixed_Angle))
	/ Mathf.Sin(Fixed_Angle);

You’re colliding the pivot, but marginCam is the gap for the camera. Camera sits a spring-length behind the pivot, so the pivot bound needs that added:

float marginPivot = marginCam + mySpringArm.SpringLength;
// then use marginPivot when placing yourBounds_Colliders[3]

That matches the “~7 short at 45 degrees, ~12.5 at 30 degrees” pattern if your height-above-floor is around ~7 (SpringLength = H / tan(angle)).

(There’s also a stray ] after Sin(Fixed_Angle) in the paste.)

Try printing marginCam, SpringLength, and marginPivot once at init and compare to the hand-tuned value.

It lives :partying_face: !!! Thank you so much for all of your help and detailed explanation; I’ve been trying to figure this out for a very long time.

Again, my understanding of this math is hazy at best, but it seems like this is unnecessary; the system wasn’t working when I did that, but once I got rid of that adjustment it worked.

Here’s the entire class :blush: :

public partial class Camera_Overworld : CharacterBody3D
{
    //VARIABLES THAT LINK NODES TOGETHER
    public static Camera_Overworld Instance { get; private set; }
    private SpringArm3D mySpringArm;
    private Camera3D myCamera;
    private List<CollisionShape3D> yourBounds_Colliders = [];
    private List<Marker3D> your_RoomPoints = [];

    //VARIABLES THAT DESCRIBE THE FIXED PROPERTIES OF THE CAMERA
    private readonly float Fixed_Height_Above_Wall = 7;
    //the camera bound system will STOP WORKING if this is changed!!!
    [Export] private float Fixed_Angle = 37.5f;
    [Export] private int[] Fixed_Sizes = [16, 48];
    [Export] private int Fixed_Zoom_Seconds = 1;
    [Export] private int[] Fixed_Speeds = [512, 1024, 2048];

    //VARIABLES THAT DESCRIBE THE INITIAL STATE OF THE CAMERA
    [Export] private bool Init_Zoomed = true;
    [Export] private byte Init_Speed = 1;

    //VARIABLES THAT DESCRIBE THE CURRENT STATE OF THE CAMERA
    private bool[] States_Zoom = [false, false];
    private float Counter_Zoom = 0;
    private byte State_Move_Speed;

    public override void _Ready()
    {
        Instance = this;
        Initialize_Link("");

        Fixed_Angle = Fixed_Angle * MathHandler.DegToRad;
        States_Zoom[1] = Init_Zoomed;

        if (Init_Zoomed)    {myCamera.Size = Fixed_Sizes[0];}
        else                {myCamera.Size = Fixed_Sizes[1];}

        State_Move_Speed = Init_Speed;

        CallDeferred(MethodName.Initialize_Nodes);
    }

    public override void _PhysicsProcess(double delta)
    {
        if (Logic_Zoom((float) delta))
        {
            return;
        }
        Logic_Move((float) delta);
    }

    private void Initialize_Link(NodePath thisNode)
    {
        //this is a little janky but at a small number of nodes it's fine
        if (thisNode == "")
        {
            mySpringArm = GetNode<SpringArm3D>("SpringArm3D");
            myCamera = mySpringArm.GetNode<Camera3D>("Camera3D");
        }

        else
        {
            string node_path = Owner.GetPath() + thisNode.ToString()[2..];

            if (node_path.Contains("Camera_Bounds"))
            {
                yourBounds_Colliders.Add(GetNode<CollisionShape3D>(node_path));
            }
            else if (node_path.Contains("Camera_Bound_Points"))
            {
                your_RoomPoints.Add(GetNode<Marker3D>(node_path));
            }
            else
            {
                throw new Exception("Camera_Overworld: An unexpected Node is emitting a signal toward Initialize_Link.");
            }
        }
    }

    private void Initialize_Nodes()
    {
        if (yourBounds_Colliders.Count != 4 || your_RoomPoints.Count != 5)
        {
        throw new Exception("Camera_Overworld: One or more linked Nodes have not been set.");
        }

        //reparent the node to the first instance of the player character node
        Reparent(Player_Overworld_CharacterBody3D.ourPlayers[0]);

        //move pivot point directly above player
        Vector3 pos_player = GetParent<Player_Overworld_CharacterBody3D>().GlobalPosition;
        GlobalPosition = new Vector3
        (
            pos_player.X,
            your_RoomPoints[0].GlobalPosition.Y + Fixed_Height_Above_Wall,
            pos_player.Z
        );
        GlobalRotation = new Vector3(0, 0, 0);

        //set the properties of the springarm and camera
        mySpringArm.SpringLength = (GlobalPosition.Y - your_RoomPoints[4].GlobalPosition.Y) / MathF.Tan(Fixed_Angle);
        myCamera.GlobalRotate(Vector3.Left, Fixed_Angle);

        Update_Bounds();
    }

    private void Update_Bounds()
    {
        //set the sizes of the bounds
        for (int i = 0; i < 4; i ++)
        {
            float scale_x = 1f / 16f;
            float scale_z = 1f / 16f;

            switch(i)
            {
                case 0: scale_z = your_RoomPoints[3].GlobalPosition.Z - your_RoomPoints[0].GlobalPosition.Z; break;
                case 1: scale_x = your_RoomPoints[0].GlobalPosition.X - your_RoomPoints[1].GlobalPosition.X; break;
                case 2: scale_z = your_RoomPoints[2].GlobalPosition.Z - your_RoomPoints[1].GlobalPosition.Z; break;
                case 3: scale_x = your_RoomPoints[3].GlobalPosition.X - your_RoomPoints[2].GlobalPosition.X; break;
            }

            BoxShape3D new_shape = new() {Size = new Vector3(scale_x, 1, scale_z)};
            yourBounds_Colliders[i].Shape = new_shape;
        }

        //set the initial positions of the bounds
        //https://forum.godotengine.org/t/mathematically-find-orthographic-camera-bound-knowing-size-angle-and-position/142503
        float[] margins = [0, 0];
        margins[0] = myCamera.Size / 2 * ViewHandler.GetAspectRatio(GetViewport());
        margins[1] = (myCamera.Size / 2 - (GlobalPosition.Y - your_RoomPoints[0].GlobalPosition.Y) * Mathf.Cos(Fixed_Angle)) / Mathf.Sin(Fixed_Angle);

        for (byte i = 0; i < 4; i ++)
        {
            float pos_x;
            float pos_z;

            if (i % 2 == 0)
            {
                pos_x = your_RoomPoints[i].GlobalPosition.X + margins[0] * (-1 + i);
                pos_z = (your_RoomPoints[i / 2].GlobalPosition.Z + your_RoomPoints[3 - i / 2].GlobalPosition.Z) / 2;
            }
            else
            {
                pos_x = (your_RoomPoints[i - 1].GlobalPosition.X + your_RoomPoints[i].GlobalPosition.X) / 2;
                pos_z = your_RoomPoints[i].GlobalPosition.Z + margins[1] * (2 - i);
            }

            yourBounds_Colliders[i].GlobalPosition = new Vector3(pos_x, GlobalPosition.Y, pos_z);
        }
    }

    private bool Logic_Zoom(float delta)
    {
        if(!States_Zoom[0] && Input.IsActionJustPressed("cam_zoom_toggle"))
        {
            States_Zoom[0] = true;

            if (States_Zoom[1])
            {
                SoundHandler.PlaySfx(this, "UI/Camera/Camera_ZoomFar");
            }

            else
            {
                SoundHandler.PlaySfx(this, "UI/Camera/Camera_ZoomClose");
            }

            return true;
        }

        else if (States_Zoom[0])
        {
            if (Counter_Zoom >= 1)
            {
                States_Zoom[0] = false;
                States_Zoom[1] = !States_Zoom[1];
                Counter_Zoom = 0;

                return false;
            }
            else
            {
                Counter_Zoom += delta / Fixed_Zoom_Seconds;

                if (States_Zoom[1])
                {
                    myCamera.Size = Mathf.Lerp(Fixed_Sizes[0], Fixed_Sizes[1], Counter_Zoom);
                }
                else
                {
                    myCamera.Size = Mathf.Lerp(Fixed_Sizes[1], Fixed_Sizes[0], Counter_Zoom);
                }

                return true;
            }
        }

        return false;
    }

    private void Logic_Move(float delta)
    {
        Vector2 input = Input.GetVector("cam_move_left", "cam_move_right", "cam_move_forward", "cam_move_backward");
        input = input.Normalized();
        input *= Fixed_Speeds[State_Move_Speed] * delta;
        Velocity = new Vector3(input.X, 0, input.Y);

        MoveAndSlide();
    }
}

The only weird thing is how the SpringArm3D behaves with the bottom (+Z) CollisionShape3D: you can see it only jitters on yourBounds_Colliders[3]. Once I get rotation in 90° increments implemented and working (fingers crossed), I’ll mark one of your replies as the solution, but let me know if you know what that jitter is about.

Here’s hoping it still works once I implement camera rotation at 45° increments: my plan is to keep yourBounds_Colliders in cardinal directions relative to the camera, and make it so the area you’re allowed to view is the rectangle at angle N that circumscribes the rectangle formed by your_RoomPoints. There’s a screenshot of an earlier attempt at that system in this thread.

Glad it’s alive

Dropping the spring-length add-on is fine if the margin already matches what you see. That term only matters when the thing you’re clamping and the camera are clearly offset; your setup can land without it.

The +Z jitter is very likely the SpringArm colliding with yourBounds_Colliders[3]. Pivot hits the wall with MoveAndSlide, the arm ray also hits the same shape and keeps shortening/lengthening → shake. Side walls don’t sit on that ray, so they look fine.

Likely fix is: put the camera-bound shapes on a layer the SpringArm does not mask. Only the pivot CharacterBody3D should collide with them. SpringArm can keep masking real level geometry.

Also, Logic_Zoom changes Size but never calls Update_Bounds(), so margins go stale while zooming. Call Update_Bounds() when size changes (or at the end of the zoom).

For 90 degrees / 45 degrees rotation later, rotating the bound offsets with the camera (or rebuilding margins in camera-forward/right) is the right idea; circumscribed rectangle works, just rebuild after each snap.

I’m very happy to report that I’ve gotten the bounds into their correct positions at every rotation on a 45° rotation. :star_struck: Again, thank you so much for your detailed and patient responses; I’ve tried several other approaches to implementing this behavior (as you can see in my post history), going back a long time now, so I’m thrilled to be done with this.

I’ve marked your last message as the solution, since it answers the scope of the problem described in my OP, but if you’d like, there’s one thing left I need to fix: getting the camera back within the bounds if it goes OOB. This will most commonly be when it is flush against a bound and rotates, causing the bounds to recalculate.

I’ve adapted this user’s solution into a method, but it just doesn’t work correctly and I don’t understand why, since I’ve triple-checked it. It triggers when it shouldn’t when the camera moves closer to the viewer, and doesn’t trigger when it should after the camera escapes.

These are my movement and checking methods. I should note that I actually realized a SpringArm3D was the wrong tool for this job: now the controller node has one CollisionShape3D at its origin, and places another N meters +Z of it, where N was the SpringArm3D’s SpringLength. Accounting for thickness is done to make it so the camera has to move a bit further than it should to get back into bounds, to prevent it from stopping and starting again due to slight movement.

private void Logic_Move(float delta)
{
    if (State_Move_FromOOB)
    {
        //push toward center of room if OOB and override movement until done
        //just do it at a constant speed bc to lerp it you'd need to find out which bound is closest
        Velocity = Current_Move_FromOOB_Dir * Fixed_Move_FromOOB_Speed;
        MoveAndSlide();

        //check whether it's done
        if (Check_IsWithinBounds(true))
        {
            State_Move_FromOOB = false;
            Enable_Collision(true);
        }
    }
    else if (Check_IsWithinBounds(false))
    {
        //get normalized input and convert to fixed speed
        Vector2 input = Input.GetVector("cam_move_left", "cam_move_right", "cam_move_forward", "cam_move_backward").Normalized() * Fixed_Speeds[State_Move_Speed] * delta;

        //convert prior result into relative direction
        Velocity = new Vector3(input.X, 0, input.Y) * Basis.FromEuler(new Vector3(0, -Rotation.Y, 0));

        MoveAndSlide();

        return;
    }
    else
    {
        State_Move_FromOOB = true;
        Enable_Collision(false);

        //find the direction the pivot point needs to move
        Vector3 centroid_xz = new(Current_Bound_Centroid.X, GlobalPosition.Y, Current_Bound_Centroid.Z);
        Current_Move_FromOOB_Dir = (centroid_xz - myCamera.GlobalPosition).Normalized();
    }
}

private void Enable_Collision(bool enabled)
{
    //do both sets of CollisionShape3Ds so this method can feel more like it's contributing
    GetNode<CollisionShape3D>("Collider_Origin").Disabled = !enabled;
	GetNode<CollisionShape3D>("Collider_Front").Disabled = !enabled;

	foreach(CollisionShape3D bound in yourBounds_Colliders)
	{
		bound.Disabled = !enabled;
	}
}

private bool Check_IsWithinBounds(bool account_for_thickness)
{
    Vector3[] corners_xz = new Vector3[4];
    Array.Copy(Current_Corners_CameraAligned, corners_xz, 4);

    if (account_for_thickness)
    {
        for (byte i = 1; i < 3; i ++)
        {
            //assume each bound is closer to the centroid by its thickness
            corners_xz[i].X -= BakedIn_Bound_Thickness * Math.Sign(corners_xz[i].X - Current_Bound_Centroid.X);
            corners_xz[i].Z -= BakedIn_Bound_Thickness * Math.Sign(corners_xz[i].Z - Current_Bound_Centroid.Z);
        }
    }

    //https://stackoverflow.com/a/53907763
    float sin = MathF.Sin(- GlobalRotation.Y);
    float cos = MathF.Cos(- GlobalRotation.Y);

    Vector2 point = new(myCamera.GlobalPosition.X - Current_Bound_Centroid.X, myCamera.GlobalPosition.Z - Current_Bound_Centroid.Z);
    point = new Vector2(point.X * cos - point.Y * sin, point.X * sin + point.Y * cos);
    point += new Vector2(Current_Bound_Centroid.X, Current_Bound_Centroid.Z);

    Vector2 mins = new(float.MaxValue, float.MaxValue);
    Vector2 maxs = new(float.MinValue, float.MinValue);
    foreach (Vector3 corner in corners_xz)
    {
        if (corner.X < mins.X) mins.X = corner.X;
        if (corner.X > maxs.X) maxs.X = corner.X;
        if (corner.Z < mins.Y) mins.Y = corner.Z;
        if (corner.Z > maxs.Y) maxs.Y = corner.Z;
    }

    return point.X >= mins.X && point.X <= maxs.X && point.Y >= mins.Y && point.Y <= maxs.Y;
}