Figuring Out Godot 2D Off-Screen Pointers and Bonus Picture-In-Picture

I’ve been exploring Godot (Version 4.2) quite a bit lately, and it’s been a real pleasure. I’ve been thinking about making some posts about things I’ve learned. I’m sure little or none of this will be surprising to someone having a lot of experience with Godot or game development generally, but I hope that other newcomers will find some helpful and interesting bits. I have been coding in C#, but the conversion to GDScript is self-explanatory in most cases. (Just remember to convert uppercase object names like “CanvasTransform” to snake case “canvas_transform.”)

I’m going to link to the pages in the docs that I have found most helpful, and here is my first (and certainly not last) plug for the Godot documentation; they really do an excellent job, so if you’re a beginning and you haven’t yet started trying to use the docs, it’s worth getting used to working with them.

In this post, my main goal is to talk about making an “edge-of-screen” pointer that indicates the direction of objects that aren’t in the visible screen. Since this involves showing where objects are when those objects aren’t visible actually visible on the screen, I’m going to kill two CharacterBody2D’s with one stone and also show how you can use a “picture-in-picture” screen to test that the pointer works as intended. As is so often the case, both of these things turn out to be very natural and straightforward once you know what to look for. But it’s not at all obvious what to look for when you’re starting out!

Creating an “Edge-of-Screen” Pointer

I’m working on with a 2D top-down game and I realized I want to have on-screen pointers that indicate the directions toward other nodes on the canvas that are not currently in the visible, because they are beyond the view of the camera. Imagine a top-down shooter in which some enemies are so far away from the player that they don’t fit in the screen. You want some “indicator” icon for each enemy to point toward it, and maybe even have that be accurate enough that if you aimed toward the indicator, you would be assured to hit the enemy.

A source object (in blue) on screen, with a yellow line pointing toward a target object (off screen), and a red indicator icon at the edge of the screen.

So, we need to answer the questions:

  1. When is a Node not within the visible portion of the screen for a given Camera2D?
  2. How do we determine “good” representative coordinates at the edge of the screen to place an indicator toward an off-screen object? (And how do we angle that indicator?)

Before I start, I’ll note that I’m convinced that a good solution using RayCasts also exists, but I did not go that route. In this post, I am relying purely on vector calculations.

To answer the first question, we need to navigate the conversion between the coordinates as represented on your computer screen and the “world” coordinates of the canvas on which the objects are drawn. There are a number of coordinate systems in play in even a 2D game, so this could get really confusing at first. I’m going to skip the details here, but for digging into all that, there are two very useful pages in the Godot Docs: Viewports and Canvas Transforms and 2D Coordinate Systems and 2D Transforms. A key detail is that in 2D, trying to figure out what is off-screen is not really about the camera, because the camera “sees” everything on the canvas; it is only the interaction of the camera’s position and zoom with the size of the viewport that determines what is, or is not, visible.

Changing from Screen Coordinates to World Coordinates

We’ll do all our calculations in the “world” / canvas coordinates. So our first question is what portion of the world is actually visible, in canvas coordinates? We will need the following lines:

        Transform2D InvCanvasTransform = GetTree().Root.CanvasTransform.AffineInverse();
        Rect2 viewportRect = GetViewport().GetVisibleRect();
        Vector2 upperLeft = InvCanvasTransform * Vector2.Zero;
        Vector2 lowerRight = InvCanvasTransform * viewportRect.Size;

Interestingly, the crucial piece here is the CanvasTransform, a Transform2D property in GetTree().Root, which in is the root/default Window (a sublcass of Viewport) of the SceneTree. Transforms are just functions that take vectors as input and spit vectors as output; but unlike methods in code, these functions can operate on the vectors by multiplying. (If you’ve learned linear algebra, it may help to be aware that this is just matrix multiplication. The Godot docs have a good tutorial on Matrices and Transforms, but you don’t need those details now.)

The CanvasTransform converts a vector in world coordinates to a vector in screen coordinates. We want to go the opposite direction, from screen to world. So, we are lucky that it comes equipped with the AffineInverse(), which does just that. So, that first line simply gives a transform that will convert screen coordinates to worlds coordinates. Interestingly, this is not mentioned in any of the tutorials linked above, but you can find it in the Viewport class page. The second line obtains the rectangle of the Viewport, which is in screen coordinates. Lines 3 and 4 then perform the conversions to obtain the world coordinates for the upper-left and lower-right points on the screen.

Side Note: I used Vector2.Zero for the upper right, since the GetVisibleRect() function always returns that as its position. I definitely had (or still have) some confusion here, since the documentation says it returns it in “global screen coordinates” — but this is just coordinates relative to the upper left of the game’s window and not to be confused with “absolute screen coordinates” which I believe are the coordinates relative to your operating system’s display.

Figuring out if the target is off-screen, and how to point to it when it is

In this discussion, I have 3 objects, all of which either are Node2Ds.

  • The Source is the player’s character/icon/etc, expected to always be on screen. I won’t assume it is at the center, since this doesn’t really make the code worse.
  • The Target is the the object of interest that we want to track, which may go off-screen since that is the whole point of this endeavor.
  • The Pointer is the icon that should always be positioned along the vector from the Source to the Target very near the edge of the screen, and angled in such a way that it “points” toward the target.

Obviously, the first thing I did when trying to solve this problem was ask whether someone else had already solved it for me and made the solution available. I found a helpful reddit thread where a commenter noted that a good first attempt would be to simply choose the Pointer’s position to be the clamp of the Target’s position within the visible coordinate rectangle. Indeed that is a good first attempt, and makes a great rough estimate in a lot of cases, but it can also get very inaccurate. So, for example, aiming and shooting toward that would usually miss. The problem is that the Vector2 clamp is simply performing two linear clamps independently, so generally the vector resulting from the clamp doesn’t point in the same direction as the vector from the Source to the Target. We will use that clamp to check whether the Target is in-bounds or not. If not, we simply make the Pointer invisible and return.

Important Note: The following discussion and code are all fine if the Source, Target, and Pointer nodes are all siblings of each other in the tree, since I am using Position, which is local coordinates; so they are all in the same space. To make it work with nodes from anywhere in the scene tree, just change each “Position” reference to “GlobalPosition,” and change the “Rotation” to “GlobalRotation.” With that, everything is in the same global coordinate system and it should work no matter where the nodes are in the tree.

Our approach to get the accurate placement of the Pointer is to simply take the vector Delta pointing from the Source to the Target, which is given by Delta = Target.Position – Source.Position, and scale that to the right length, so that when we start the rescaled vector at the Source’s position, it ends right at the window’s edge. The elegant geometric solution to the problem is to simply say “similar triangles” and walk away: take the right triangle with Delta as hypotenuse and having legs that are vetical and horizontal, and just squish that down. The actual calculation, however, is a bit more tedious, because we don’t know whether we’re going off the one of the vertical sides or one of the horizontal sides, and Delta doesn’t directly tell us how Target is positioned with regard to the vertical or horizontal boundaries of the screen. But it turns out, we can just look at the two potential similar triangle ratios and take whichever one is worse (smaller):

  • (vertical distance from Source to intervening screen edge) / |Delta.Y|
  • (horizontal distance from Source to intervening screen edge) / |Delta.X|

If the Target is in-bounds either vertically or horizontally, then we are not going to need to squish in that direction, and we just set it to 1. All that squishing, is done in defining xScale and yScale below, which are each done in nested ternary operators in order to get the subtractions correct. Then minScale simply chooses the “bigger squish.” Then set the position of the Pointer to the Source position + the rescaled Delta. The code below is in the _Process function for a custom Pointer class inheriting from Node2D. I made my Pointer have a sprite as a child, but it could just be a Sprite2D itself. Making the Sprite2D a child enabled me to set the relative positions so that the “point” of the Pointer is exactly on the screen’s edge, and I can adjust how far the Pointer is from the edge within the editor instead of in code. The desired rotation is simply the angle formed by Delta, for which Godot gives us a convenient helper function, Delta.Angle().

     // Would love to just make this the position of the pointer, but it can be rather inaccurate; usually not a terrible rough estimate, though.
     Vector2 ClampedTargetPosition = Target.Position.Clamp(upperLeft,lowerRight);

     // But the clamped position is still an easy way to check if the target is off-screen. 
     if (ClampedTargetPosition != Target.Position)
     {
         // The vector from the source to the target
         Vector2 Delta = Target.Position - Source.Position;

         float xScale = (Target.Position.X < upperLeft.X) ? (Source.Position.X - upperLeft.X) / Mathf.Abs(Delta.X) : (Target.Position.X > lowerRight.X ? (lowerRight.X - Source.Position.X) / Mathf.Abs(Delta.X) : 1);
         float yScale = (Target.Position.Y < upperLeft.Y) ? (Source.Position.Y - upperLeft.Y) / Mathf.Abs(Delta.Y) : (Target.Position.Y > lowerRight.Y ? (lowerRight.Y - Source.Position.Y) / Mathf.Abs(Delta.Y) : 1);
         float minScale = Mathf.Min(xScale,yScale);
         
         Position = Source.Position + Delta * minScale;            
         Rotation = Delta.Angle();
         Visible = true;
     }
     else
     {
         Visible = false;
     }

Testing the Code and Snazzy Picture-In-Picture

I figured I would write a separate scene to test this out, and before it occurred to me to draw a nice line from the Source to the Target, I realized I’d have trouble knowing if the Pointer was doing its just or not, given that the whole point is that Target is not visible. The line from Source to Target does show us that the pointer is in the right place, but it was worth it to learn the picture-in-picture trick.

Again, it’s all about the Viewport. Viewports are a weird kind of thing. You often don’t have to think about them at all as a learner. Then when you do have to start thinking about them, you kind of feel annoyed at the hassle. Then you look at the documentation and think “They’re literally nothing … they don’t do anything. You can’t even view something with a viewport! You need a special texture or container to do that! What’s the point?” and then after a while you realize you don’t know what you’re talking about because you’re still learning. Somewhere along the way you watch this amazing GodotCon 2023 video of a talk by Rafael Picca “Viewports: An Overview,” and you realize “wow, viewports are powerful.” And you probably still don’t get them. They can capture a view of the screen (or even audio), which can then be rendered when and where you need it. Check out this awesome Reddit post by user archiekatt, which provides a much more compelling demonstration than what I’m about to show. Archiekatt’s explanation in the comments is right on point as well. There was only one detail that caused me about a half-hour of grief, which I didn’t realize until I found the illuminating line the in the documentation: By default, each Viewport contains its own World2D. So you can’t place some things in a tree and drop a viewport in, and expect it to show your those things. By default, it shows exactly those things which are its children in the scene tree, and nothing else; and those things which are in its scene tree are then not rendered to the default viewport. So, to get the picture-in-picture effect of some object(s) showing up in two places at once, we simply need to set the Viewport’s World2D to be the same as the default, with the following line:

          SubViewport.World2D = GetViewport().World2D;

That’s it; that’s literally the only line of code needed. The rest is just placing a Viewport in the SceneTree and also some way to actually view it.

The game screen with another window inside that has a smaller, zoomed out, version of the same screen, showing how far the Pointer is along the vector from the Source to the Target.

Here is the SceneTree I used in my test project:

The SubViewport gets its own camera with its own Zoom. The TextureRect is set to render the SubViewport.

I wanted to test that the point works correctly even when the source is not at the center of the screen, so I placed the camera under a separate node. In my test, the camera and Source node move around independently.

Anyway, I hope some of this is helpful or interesting to you!